Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ export class GastownClient {
body: JSON.stringify({ summary }),
});
}

/**
* Resolve a triage_request bead with the chosen action and notes.
* The TownDO closes the triage request and executes any side effects.
*/
async resolveTriage(input: {
triage_request_bead_id: string;
action: string;
resolution_notes: string;
}): Promise<Bead> {
return this.request<Bead>(this.rigPath('/triage/resolve'), {
method: 'POST',
body: JSON.stringify(input),
});
}
}

/**
Expand Down
27 changes: 27 additions & 0 deletions cloudflare-gastown/container/plugin/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,5 +184,32 @@ export function createTools(client: GastownClient) {
return `Advanced to step ${result.currentStep + 1} of ${result.totalSteps}.`;
},
}),

gt_triage_resolve: tool({
description:
'Resolve a triage request with your chosen action. The TownDO will execute ' +
'the action (restart agent, close bead, escalate, etc.) and close the triage request.',
args: {
triage_request_bead_id: tool.schema
.string()
.describe('The UUID of the triage_request bead to resolve'),
action: tool.schema
.string()
.describe(
'The chosen action from the available options (e.g. RESTART, ESCALATE_TO_MAYOR, CLOSE_BEAD)'
),
resolution_notes: tool.schema
.string()
.describe('Brief explanation of your reasoning for choosing this action'),
},
async execute(args) {
const bead = await client.resolveTriage({
triage_request_bead_id: args.triage_request_bead_id,
action: args.action,
resolution_notes: args.resolution_notes,
});
return `Triage request ${args.triage_request_bead_id} resolved with action: ${args.action}`;
},
}),
};
}
2 changes: 1 addition & 1 deletion cloudflare-gastown/container/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type Bead = {
closed_at: string | null;
};

export type AgentRole = 'polecat' | 'refinery' | 'mayor' | 'witness';
export type AgentRole = 'polecat' | 'refinery' | 'mayor';
export type AgentStatus = 'idle' | 'working' | 'stalled' | 'dead';

export type Agent = {
Expand Down
118 changes: 109 additions & 9 deletions cloudflare-gastown/container/src/agent-runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Config } from '@kilocode/sdk';
import { z } from 'zod';
import { writeFile } from 'node:fs/promises';
import { cloneRepo, createWorktree } from './git-manager';
import { cloneRepo, createWorktree, setupRigBrowseWorktree } from './git-manager';
import { startAgent } from './process-manager';
import { getCurrentTownConfig } from './control-server';
import type { ManagedAgent, StartAgentRequest } from './types';
Expand Down Expand Up @@ -210,15 +210,16 @@ async function configureGitCredentials(
* is available, call the Next.js server to resolve fresh credentials.
* Returns the (potentially enriched) envVars.
*/
async function resolveGitCredentialsIfMissing(
request: StartAgentRequest
): Promise<Record<string, string>> {
const envVars = { ...(request.envVars ?? {}) };
export async function resolveGitCredentials(params: {
envVars?: Record<string, string>;
platformIntegrationId?: string;
}): Promise<Record<string, string>> {
const envVars = { ...(params.envVars ?? {}) };
const hasToken = !!(envVars.GIT_TOKEN || envVars.GITHUB_TOKEN || envVars.GITLAB_TOKEN);

if (hasToken) return envVars;

const integrationId = request.platformIntegrationId;
const integrationId = params.platformIntegrationId;
const kiloToken = envVars.KILOCODE_TOKEN;
// The Next.js server URL — in dev it's localhost:3000, in prod it's the main app URL.
// We derive it from KILO_API_URL (the gateway URL) or fall back to localhost.
Expand Down Expand Up @@ -360,6 +361,58 @@ async function createMayorWorkspace(rigId: string): Promise<string> {
return dir;
}

/**
* Write the mayor's system prompt to AGENTS.md in the workspace.
*
* kilo/opencode reads AGENTS.md from the project root for ALL sessions,
* including built-in sub-agents (explore, general). By writing the full
* system prompt here instead of passing it via the session.prompt API,
* the mayor and all its sub-agents share the exact same instructions.
*
* The system prompt comes from the TownDO (buildMayorSystemPrompt) and
* is the single source of truth. When it changes (gastown updates,
* user customization), the TownDO sends the updated prompt and we
* rewrite this file.
*/
async function writeMayorSystemPromptToAgentsMd(
workspaceDir: string,
systemPrompt: string
): Promise<void> {
const { writeFile, readdir, stat } = await import('node:fs/promises');
const path = await import('node:path');

// Append a dynamic section listing discovered browse worktrees so
// sub-agents know where to find rig codebases.
const rigsRoot = '/workspace/rigs';
let rigDirs: string[] = [];
try {
rigDirs = await readdir(rigsRoot);
} catch {
// No rigs directory yet
}

const browseEntries: string[] = [];
for (const entry of rigDirs) {
if (entry.startsWith('mayor-')) continue;
const browseDir = path.join(rigsRoot, entry, 'browse');
try {
const s = await stat(browseDir);
if (s.isDirectory()) {
browseEntries.push(`- **${entry}**: \`${browseDir}\``);
}
} catch {
// No browse worktree yet
}
}

const browseSuffix =
browseEntries.length > 0
? `\n\n## Discovered Browse Worktrees\n\n${browseEntries.join('\n')}`
: '';

await writeFile(path.join(workspaceDir, 'AGENTS.md'), systemPrompt + browseSuffix);
}

/**
* Run the full agent startup sequence:
* 1. Clone/fetch the rig's git repo (or create minimal workspace for mayor)
Expand All @@ -368,17 +421,54 @@ async function createMayorWorkspace(rigId: string): Promise<string> {
* 4. Start a kilo serve instance for the worktree (or reuse existing)
* 5. Create a session and send the initial prompt via HTTP API
*/
export async function runAgent(request: StartAgentRequest): Promise<ManagedAgent> {
export async function runAgent(originalRequest: StartAgentRequest): Promise<ManagedAgent> {
let request = originalRequest;
let workdir: string;

if (request.role === 'mayor') {
// Mayor doesn't need a repo clone — just a git-initialized directory
workdir = await createMayorWorkspace(request.rigId);

// On fresh containers the browse worktrees won't exist yet. Set them
// up for all known rigs before writing AGENTS.md so the mayor (and its
// sub-agents) can immediately browse codebases.
if (request.rigs?.length) {
const envVars = await resolveGitCredentials(request);
await Promise.allSettled(
request.rigs.map(async rig => {
try {
await setupRigBrowseWorktree({
rigId: rig.rigId,
gitUrl: rig.gitUrl,
defaultBranch: rig.defaultBranch,
envVars,
});
} catch (err) {
const msg = err instanceof Error ? err.message.split('\n')[0] : String(err);
console.warn(`[runAgent] browse worktree setup failed for rig=${rig.rigId}: ${msg}`);
}
})
);
}

// Write the system prompt to AGENTS.md so the mayor AND its built-in
// sub-agents (explore, general) all share the same instructions.
// The system prompt is NOT passed via the session.prompt API — AGENTS.md
// is the sole source of truth for the mayor's instructions.
if (request.systemPrompt) {
await writeMayorSystemPromptToAgentsMd(workdir, request.systemPrompt);
}
} else {
// Resolve git credentials if missing. When the town config doesn't have
// a token (common on first dispatch after rig creation), fetch one from
// the Next.js server using the platform_integration_id.
const envVars = await resolveGitCredentialsIfMissing(request);
const envVars = await resolveGitCredentials(request);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Resolved git credentials never reach the spawned agent

resolveGitCredentials() enriches envVars here, but runAgent() still builds the child process environment from the original request later on. For rigs that rely on platformIntegrationId, startup can clone and verify the repo successfully while the agent session itself still launches without GIT_TOKEN/GH_TOKEN, so in-session git push and gh commands fail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. After resolveGitCredentials enriches envVars, the request is now reassigned with the resolved envVars (request = { ...request, envVars }) so buildAgentEnv picks up GIT_TOKEN/GH_TOKEN for the spawned process.


// Merge resolved credentials back into the request so buildAgentEnv
// can propagate GIT_TOKEN/GH_TOKEN to the spawned kilo serve process.
// Without this, rigs using platformIntegrationId would clone successfully
// but the agent session itself would lack git push / gh credentials.
request = { ...request, envVars };

await cloneRepo({
rigId: request.rigId,
Expand All @@ -390,6 +480,8 @@ export async function runAgent(request: StartAgentRequest): Promise<ManagedAgent
workdir = await createWorktree({
rigId: request.rigId,
branch: request.branch,
startPoint: request.startPoint,
defaultBranch: request.defaultBranch,
});

// Set up git credentials so the agent can push
Expand All @@ -401,5 +493,13 @@ export async function runAgent(request: StartAgentRequest): Promise<ManagedAgent

const env = buildAgentEnv(request);

return startAgent(request, workdir, env);
// For the mayor, the system prompt lives in AGENTS.md (written above)
// so all sessions — including sub-agents — share it. Don't also pass
// it via the session.prompt API to avoid duplication. Setting to
// undefined (not '') so the SDK omits it entirely and kilo serve
// uses its default system prompt + AGENTS.md, rather than treating
// an empty string as an explicit override.
const startRequest = request.role === 'mayor' ? { ...request, systemPrompt: undefined } : request;

return startAgent(startRequest, workdir, env);
}
55 changes: 51 additions & 4 deletions cloudflare-gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { runAgent } from './agent-runner';
import { runAgent, resolveGitCredentials } from './agent-runner';
import {
stopAgent,
sendMessage,
Expand All @@ -13,8 +13,8 @@ import {
registerEventSink,
} from './process-manager';
import { startHeartbeat, stopHeartbeat } from './heartbeat';
import { mergeBranch } from './git-manager';
import { StartAgentRequest, SendMessageRequest, MergeRequest } from './types';
import { mergeBranch, setupRigBrowseWorktree } from './git-manager';
import { StartAgentRequest, SendMessageRequest, MergeRequest, SetupRepoRequest } from './types';
import type {
AgentStatusResponse,
HealthResponse,
Expand Down Expand Up @@ -105,7 +105,7 @@ app.post('/agents/start', async c => {
console.log(
`[control-server] /agents/start: role=${parsed.data.role} name=${parsed.data.name} rigId=${parsed.data.rigId} agentId=${parsed.data.agentId}`
);
console.log(`[control-server] system prompt length: ${parsed.data.systemPrompt.length}`);
console.log(`[control-server] system prompt length: ${parsed.data.systemPrompt?.length ?? 0}`);

try {
const agent = await runAgent(parsed.data);
Expand Down Expand Up @@ -228,6 +228,53 @@ export function consumeStreamTicket(ticket: string): string | null {
return entry.agentId;
}

// POST /repos/setup
// Proactively clone a rig's repo and create a browse worktree so the
// mayor (and future agents) have immediate access to the codebase.
// Called by the TownDO when a new rig is added.
app.post('/repos/setup', async c => {
const body: unknown = await c.req.json().catch(() => null);
const parsed = SetupRepoRequest.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'Invalid request body', issues: parsed.error.issues }, 400);
}

const req = parsed.data;
console.log(`[control-server] /repos/setup: rigId=${req.rigId} gitUrl=${req.gitUrl}`);

// Run in background so we return 202 immediately.
// Errors are caught and logged — never propagated as unhandled rejections.
const doSetup = async () => {
try {
// Resolve git credentials from platformIntegrationId if no token
// is present in envVars (e.g. rigs using GitHub App installations).
const envVars = await resolveGitCredentials({
envVars: req.envVars,
Comment thread
jrf0110 marked this conversation as resolved.
platformIntegrationId: req.platformIntegrationId,
});

const browseDir = await setupRigBrowseWorktree({
rigId: req.rigId,
gitUrl: req.gitUrl,
defaultBranch: req.defaultBranch,
envVars,
});
console.log(`[control-server] /repos/setup: done rigId=${req.rigId} browse=${browseDir}`);
} catch (err) {
// Log as a warning, not an error — this is a best-effort background
// operation. The mayor and agents can still function without the
// browse worktree; it will be retried on the next agent dispatch.
const message = err instanceof Error ? err.message : String(err);
console.warn(
`[control-server] /repos/setup: FAILED for rigId=${req.rigId}: ${message.split('\n')[0]}`
);
}
};
doSetup().catch(() => {});

return c.json({ status: 'accepted', message: 'Repo setup started' }, 202);
});

// POST /git/merge
// Deterministic merge of a polecat branch into the target branch.
// Called by the Rig DO's processReviewQueue → startMergeInContainer.
Expand Down
Loading