Skip to content
Open
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
12 changes: 4 additions & 8 deletions cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,6 @@ export class GastownClient {
});
}

async updateAgentStatusMessage(message: string): Promise<void> {
await this.request<void>(this.agentPath('/status'), {
method: 'POST',
body: JSON.stringify({ message }),
});
}

// -- Rig-scoped endpoints --

async getBead(beadId: string): Promise<Bead> {
Expand Down Expand Up @@ -443,7 +436,10 @@ export class MayorGastownClient {

async updateConvoy(
convoyId: string,
input: { merge_mode?: 'review-then-land' | 'review-and-merge'; feature_branch?: string }
input: {
merge_mode?: 'review-then-land' | 'review-and-merge';
feature_branch?: string;
}
): Promise<void> {
await this.request<void>(this.mayorPath(`/convoys/${convoyId}`), {
method: 'PATCH',
Expand Down
17 changes: 0 additions & 17 deletions cloudflare-gastown/container/plugin/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,23 +244,6 @@ export function createTools(client: GastownClient) {
},
}),

gt_status: tool({
description:
'Emit a plain-language status update visible on the dashboard. ' +
'Call this when starting a new phase of work (e.g. "Installing dependencies", ' +
'"Writing tests", "Fixing lint errors"). Write it as a brief sentence for a teammate, ' +
'not a log line. Do NOT call this on every tool use â only at meaningful phase transitions.',
args: {
message: tool.schema
.string()
.describe('A 1-2 sentence plain-language description of what you are currently doing.'),
},
async execute(args) {
await client.updateAgentStatusMessage(args.message);
return 'Status updated.';
},
}),

gt_nudge: tool({
description:
'Send a real-time nudge to another agent. Unlike gt_mail_send (which queues a formal ' +
Expand Down
117 changes: 117 additions & 0 deletions cloudflare-gastown/container/src/process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ const eventAbortControllers = new Map<string, AbortController>();
const eventSinks = new Set<(agentId: string, event: string, data: unknown) => void>();
// Per-agent idle timers — fires exit when no nudges arrive
const idleTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Tracks last H1 status posted per agent to deduplicate status updates
const lastStatusForAgent = new Map<string, string>();
// Accumulates streaming text deltas per "agentId:partId" key so we can scan for
// H1 headers. SDK events send part.text as empty during delta streaming; the
// actual content arrives incrementally in the `delta` field.
const accumulatedPartText = new Map<string, string>();
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: accumulatedPartText never gets pruned for long-lived agents

This cache is only cleared when an agent stops, but mayor sessions intentionally stay alive across many batches. Every completed text part stays retained for the life of the container, so a busy mayor will keep full streamed responses in memory and grow this map without bound. Please drop entries when a part/message finishes (for example via part.time.end, message.completed, or message.part.removed).


/** Remove all accumulated part text entries for a given agent. */
function clearAccumulatedText(agentId: string): void {
const prefix = `${agentId}:`;
for (const key of accumulatedPartText.keys()) {
if (key.startsWith(prefix)) accumulatedPartText.delete(key);
}
}

let nextPort = 4096;
const startTime = Date.now();
Expand Down Expand Up @@ -143,6 +157,101 @@ function broadcastEvent(agentId: string, event: string, data: unknown): void {
// Best-effort persistence — don't block live streaming
});
}

// Parse H1 markdown headers from streaming text parts and post as agent status.
// This gives dashboard visibility into what an agent is doing — agents write
// natural H1 headers like "# Installing dependencies" which become status updates.
//
// During streaming, the SDK sends part.text as empty and the actual content in
// the `delta` field. We accumulate deltas per part ID so we can scan the full
// text for completed H1 headers (those followed by a newline).
if (event === 'message.part.updated' || event === 'message_part.updated') {
const dataObj = data != null && typeof data === 'object' ? data : undefined;
const part =
dataObj && 'part' in dataObj && dataObj.part != null && typeof dataObj.part === 'object'
? dataObj.part
: undefined;
if (
part &&
'type' in part &&
part.type === 'text' &&
'id' in part &&
typeof part.id === 'string'
) {
const partKey = `${agentId}:${part.id}`;
const delta =
dataObj && 'delta' in dataObj && typeof dataObj.delta === 'string'
? dataObj.delta
: undefined;
// Accumulate text: if delta is present, append it; otherwise use part.text
// as the full snapshot (non-streaming mode).
let fullText: string;
if (delta !== undefined) {
const prev = accumulatedPartText.get(partKey) ?? '';
fullText = prev + delta;
accumulatedPartText.set(partKey, fullText);
} else if ('text' in part && typeof part.text === 'string') {
fullText = part.text;
accumulatedPartText.set(partKey, fullText);
} else {
fullText = accumulatedPartText.get(partKey) ?? '';
}

// Use last H1 match — most current status when agent writes multiple headers.
// Require a trailing newline so we only match completed headings; without it,
// every streaming delta would match the partial heading being typed and spam
// the /status endpoint with incremental fragments.
const matches = [...fullText.matchAll(/(?:^|\n)# (.+)\n/g)];
const lastMatch = matches.length > 0 ? matches[matches.length - 1] : null;
if (lastMatch) {
const statusText = lastMatch[1].slice(0, 120);
if (statusText !== lastStatusForAgent.get(agentId)) {
lastStatusForAgent.set(agentId, statusText);
const agentMeta = agents.get(agentId);
const statusAuthToken =
process.env.GASTOWN_CONTAINER_TOKEN ??
agentMeta?.gastownContainerToken ??
agentMeta?.gastownSessionToken;
if (agentMeta?.gastownApiUrl && statusAuthToken) {
const statusUrl = `${agentMeta.gastownApiUrl}/api/towns/${agentMeta.townId ?? '_'}/rigs/${agentMeta.rigId ?? '_'}/agents/${agentId}/status`;
const statusHeaders: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${statusAuthToken}`,
};
if (process.env.GASTOWN_CONTAINER_TOKEN || agentMeta.gastownContainerToken) {
statusHeaders['X-Gastown-Agent-Id'] = agentId;
if (agentMeta.rigId) statusHeaders['X-Gastown-Rig-Id'] = agentMeta.rigId;
}
console.log(
`${MANAGER_LOG} H1 status for agent ${agentId}: "${statusText}" → POST ${statusUrl}`
);
fetch(statusUrl, {
method: 'POST',
headers: statusHeaders,
body: JSON.stringify({ message: statusText }),
})
.then(resp => {
if (!resp.ok) {
console.warn(
`${MANAGER_LOG} H1 status POST failed: ${resp.status} ${resp.statusText}`
);
}
})
.catch(err => {
console.warn(
`${MANAGER_LOG} H1 status POST error:`,
err instanceof Error ? err.message : err
);
});
} else {
console.warn(
`${MANAGER_LOG} H1 status: cannot post for agent ${agentId} — missing apiUrl=${!!agentMeta?.gastownApiUrl} authToken=${!!statusAuthToken}`
);
}
}
}
}
}
}

/**
Expand Down Expand Up @@ -423,6 +532,8 @@ async function subscribeToEvents(
});
agent.status = 'exited';
agent.exitReason = 'completed';
lastStatusForAgent.delete(agent.agentId);
clearAccumulatedText(agent.agentId);
broadcastEvent(agent.agentId, 'agent.exited', { reason: 'completed' });
void reportAgentCompleted(agent, 'completed');

Expand Down Expand Up @@ -505,6 +616,8 @@ async function subscribeToEvents(
});
if (agent.status === 'running') {
clearIdleTimer(agent.agentId);
lastStatusForAgent.delete(agent.agentId);
clearAccumulatedText(agent.agentId);
agent.status = 'failed';
agent.exitReason = 'Event stream error';
broadcastEvent(agent.agentId, 'agent.exited', {
Expand Down Expand Up @@ -666,6 +779,8 @@ export async function stopAgent(agentId: string): Promise<void> {

// Cancel any pending idle timer
clearIdleTimer(agentId);
lastStatusForAgent.delete(agentId);
clearAccumulatedText(agentId);

// Abort event subscription
const controller = eventAbortControllers.get(agentId);
Expand Down Expand Up @@ -762,6 +877,8 @@ export async function stopAll(): Promise<void> {
clearTimeout(timer);
}
idleTimers.clear();
lastStatusForAgent.clear();
accumulatedPartText.clear();

// Abort all event subscriptions
for (const [, controller] of eventAbortControllers) {
Expand Down
5 changes: 1 addition & 4 deletions cloudflare-gastown/src/prompts/polecat-system.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ You have these tools available. Use them to coordinate with the Gastown orchestr
- **gt_mail_check** — Check for new mail from other agents. Call this periodically or when you suspect coordination messages.
- **gt_escalate** — Escalate a problem you cannot solve. Creates an escalation bead. Use this when you are stuck, blocked, or need human intervention.
- **gt_checkpoint** — Write crash-recovery data. Call this after significant progress so work can be resumed if the container restarts.
- **gt_status** — Emit a plain-language status update visible on the dashboard. Call this at meaningful phase transitions.

## Workflow

Expand Down Expand Up @@ -79,9 +78,7 @@ If you are stuck for more than a few attempts at the same problem:

## Status Updates

Periodically call gt_status with a brief, plain-language description of what you are doing. Write it for a teammate watching the dashboard — not a log line, not a stack trace. One or two sentences. Examples: "Installing dependencies and setting up the project structure.", "Writing unit tests for the API endpoints.", "Fixing 3 TypeScript errors before committing."

Call gt_status when you START a new meaningful phase of work: beginning a new file, running tests, installing packages, pushing a branch. Do NOT call it on every tool use.
Use markdown H1 headers (e.g. \`# Installing dependencies\`) at the start of each new phase of work. These headers are automatically parsed from your output and displayed on the dashboard as status updates. Write them as brief, plain-language descriptions for a teammate — not log lines or stack traces. Examples: \`# Installing dependencies\`, \`# Writing unit tests for the API endpoints\`, \`# Fixing TypeScript errors\`.

## Important

Expand Down
3 changes: 1 addition & 2 deletions cloudflare-gastown/src/prompts/triage-system.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,11 @@ This will close the triage batch, unhook you, and return you to idle.
- **Prefer least-disruptive actions.** RESTART over CLOSE_BEAD. NUDGE over ESCALATE.
- **Escalate genuinely hard problems.** If a situation requires human context you don't have, escalate rather than guess.
- **Never skip a triage request.** Every pending request must be resolved.
- **Post status updates.** Call gt_status before starting the batch (e.g. "Triaging 3 requests") and after finishing (e.g. "Triage complete — 2 restarted, 1 escalated"). This keeps the dashboard informed.
- **Post status updates.** Use markdown H1 headers (e.g. \`# Triaging 3 requests\`) to indicate your current phase. These are automatically parsed and displayed on the dashboard.

## Available Tools

- **gt_triage_resolve** — Resolve a triage request. Provide the triage_request_bead_id, chosen action, and brief notes.
- **gt_status** — Post a plain-language status update visible on the dashboard. Call this at the start and end of your triage batch.
- **gt_mail_send** — Send guidance to a stuck agent.
- **gt_escalate** — Forward a problem to the Mayor or human operators.
- **gt_bead_close** — Close your hooked bead when all triage requests have been processed.
Expand Down
Loading