diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index 93b972854..e90634ba5 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -137,13 +137,6 @@ export class GastownClient { }); } - async updateAgentStatusMessage(message: string): Promise { - await this.request(this.agentPath('/status'), { - method: 'POST', - body: JSON.stringify({ message }), - }); - } - // -- Rig-scoped endpoints -- async getBead(beadId: string): Promise { @@ -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 { await this.request(this.mayorPath(`/convoys/${convoyId}`), { method: 'PATCH', diff --git a/cloudflare-gastown/container/plugin/tools.ts b/cloudflare-gastown/container/plugin/tools.ts index 1c360c0f2..1bc40ed2f 100644 --- a/cloudflare-gastown/container/plugin/tools.ts +++ b/cloudflare-gastown/container/plugin/tools.ts @@ -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 ' + diff --git a/cloudflare-gastown/container/src/process-manager.ts b/cloudflare-gastown/container/src/process-manager.ts index befba0a05..4891b997d 100644 --- a/cloudflare-gastown/container/src/process-manager.ts +++ b/cloudflare-gastown/container/src/process-manager.ts @@ -33,6 +33,20 @@ const eventAbortControllers = new Map(); 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>(); +// Tracks last H1 status posted per agent to deduplicate status updates +const lastStatusForAgent = new Map(); +// 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(); + +/** 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(); @@ -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 = { + '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}` + ); + } + } + } + } + } } /** @@ -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'); @@ -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', { @@ -666,6 +779,8 @@ export async function stopAgent(agentId: string): Promise { // Cancel any pending idle timer clearIdleTimer(agentId); + lastStatusForAgent.delete(agentId); + clearAccumulatedText(agentId); // Abort event subscription const controller = eventAbortControllers.get(agentId); @@ -762,6 +877,8 @@ export async function stopAll(): Promise { clearTimeout(timer); } idleTimers.clear(); + lastStatusForAgent.clear(); + accumulatedPartText.clear(); // Abort all event subscriptions for (const [, controller] of eventAbortControllers) { diff --git a/cloudflare-gastown/src/prompts/polecat-system.prompt.ts b/cloudflare-gastown/src/prompts/polecat-system.prompt.ts index ea57e1077..3a441d65b 100644 --- a/cloudflare-gastown/src/prompts/polecat-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/polecat-system.prompt.ts @@ -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 @@ -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 diff --git a/cloudflare-gastown/src/prompts/triage-system.prompt.ts b/cloudflare-gastown/src/prompts/triage-system.prompt.ts index dc4feb861..a8f9d59aa 100644 --- a/cloudflare-gastown/src/prompts/triage-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/triage-system.prompt.ts @@ -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.