diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index daf504f3e8..d539341117 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -125,6 +125,13 @@ 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 { diff --git a/cloudflare-gastown/container/plugin/tools.ts b/cloudflare-gastown/container/plugin/tools.ts index ead4bf4cea..c3769f5056 100644 --- a/cloudflare-gastown/container/plugin/tools.ts +++ b/cloudflare-gastown/container/plugin/tools.ts @@ -203,7 +203,7 @@ export function createTools(client: GastownClient) { .describe('Brief explanation of your reasoning for choosing this action'), }, async execute(args) { - const bead = await client.resolveTriage({ + await client.resolveTriage({ triage_request_bead_id: args.triage_request_bead_id, action: args.action, resolution_notes: args.resolution_notes, @@ -211,5 +211,22 @@ export function createTools(client: GastownClient) { return `Triage request ${args.triage_request_bead_id} resolved with action: ${args.action}`; }, }), + + 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.'; + }, + }), }; } diff --git a/cloudflare-gastown/src/db/tables/agent-metadata.table.ts b/cloudflare-gastown/src/db/tables/agent-metadata.table.ts index cc4064cce9..c618ca0900 100644 --- a/cloudflare-gastown/src/db/tables/agent-metadata.table.ts +++ b/cloudflare-gastown/src/db/tables/agent-metadata.table.ts @@ -29,6 +29,8 @@ export const AgentMetadataRecord = z.object({ }) .pipe(z.unknown()), last_activity_at: z.string().nullable(), + agent_status_message: z.string().nullable(), + agent_status_updated_at: z.string().nullable(), }); export type AgentMetadataRecord = z.output; @@ -49,5 +51,15 @@ export function createTableAgentMetadata(): string { dispatch_attempts: `integer not null default 0`, checkpoint: `text`, last_activity_at: `text`, + agent_status_message: `text`, + agent_status_updated_at: `text`, }); } + +/** Idempotent ALTER statements for existing databases. */ +export function migrateAgentMetadata(): string[] { + return [ + `ALTER TABLE agent_metadata ADD COLUMN agent_status_message text`, + `ALTER TABLE agent_metadata ADD COLUMN agent_status_updated_at text`, + ]; +} diff --git a/cloudflare-gastown/src/db/tables/bead-events.table.ts b/cloudflare-gastown/src/db/tables/bead-events.table.ts index 10d1ca28f0..1ea22b4c07 100644 --- a/cloudflare-gastown/src/db/tables/bead-events.table.ts +++ b/cloudflare-gastown/src/db/tables/bead-events.table.ts @@ -17,6 +17,7 @@ export const BeadEventType = z.enum([ 'agent_exited', 'pr_created', 'pr_creation_failed', + 'agent_status', ]); export type BeadEventType = z.infer; diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 4f34fde7c6..19bec72015 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -93,6 +93,8 @@ function formatEventMessage(row: Record): string { return `PR creation failed for ${target}`; case 'escalation_created': return `Escalation created: ${target}`; + case 'agent_status': + return `${actor}: ${newValue ?? 'status update'}`; default: return `${eventType}: ${target}`; } @@ -297,6 +299,29 @@ export class TownDO extends DurableObject { } } + /** + * Broadcast a lightweight agent_status event to all connected status + * WebSocket clients. Called whenever an agent updates its status message. + */ + private broadcastAgentStatus(agentId: string, message: string): void { + const sockets = this.ctx.getWebSockets('status'); + if (sockets.length === 0) return; + + const payload = JSON.stringify({ + type: 'agent_status', + agentId, + message, + timestamp: now(), + }); + for (const ws of sockets) { + try { + ws.send(payload); + } catch { + // Client disconnected — will be cleaned up by webSocketClose + } + } + } + // ── Initialization ────────────────────────────────────────────────── private async ensureInitialized(): Promise { @@ -653,6 +678,29 @@ export class TownDO extends DurableObject { await this.armAlarmIfNeeded(); } + async updateAgentStatusMessage(agentId: string, message: string): Promise { + await this.ensureInitialized(); + agents.updateAgentStatusMessage(this.sql, agentId, message); + const agent = agents.getAgent(this.sql, agentId); + if (agent?.current_hook_bead_id) { + const rig = agent.rig_id ? rigs.getRig(this.sql, agent.rig_id) : null; + beadOps.logBeadEvent(this.sql, { + beadId: agent.current_hook_bead_id, + agentId, + eventType: 'agent_status', + newValue: message, + metadata: { + agentId, + message, + agent_name: agent.name, + rig_id: agent.rig_id, + rig_name: rig?.name, + }, + }); + } + this.broadcastAgentStatus(agentId, message); + } + // ══════════════════════════════════════════════════════════════════ // Mail // ══════════════════════════════════════════════════════════════════ @@ -2270,7 +2318,8 @@ export class TownDO extends DurableObject { ${agent_metadata.status} AS status, ${agent_metadata.current_hook_bead_id}, ${agent_metadata.dispatch_attempts}, ${agent_metadata.last_activity_at}, - ${agent_metadata.checkpoint} + ${agent_metadata.checkpoint}, + ${agent_metadata.agent_status_message}, ${agent_metadata.agent_status_updated_at} FROM ${beads} INNER JOIN ${agent_metadata} ON ${beads.bead_id} = ${agent_metadata.bead_id} WHERE ${agent_metadata.status} = 'idle' @@ -2294,6 +2343,8 @@ export class TownDO extends DurableObject { last_activity_at: row.last_activity_at, checkpoint: row.checkpoint, created_at: row.created_at, + agent_status_message: row.agent_status_message, + agent_status_updated_at: row.agent_status_updated_at, })); console.log(`${TOWN_LOG} schedulePendingWork: found ${pendingAgents.length} pending agents`); diff --git a/cloudflare-gastown/src/dos/town/agents.ts b/cloudflare-gastown/src/dos/town/agents.ts index 79d6eaacb8..ad7fcfae92 100644 --- a/cloudflare-gastown/src/dos/town/agents.ts +++ b/cloudflare-gastown/src/dos/town/agents.ts @@ -65,6 +65,8 @@ function toAgent(row: AgentBeadRecord): Agent { last_activity_at: row.last_activity_at, checkpoint: row.checkpoint, created_at: row.created_at, + agent_status_message: row.agent_status_message, + agent_status_updated_at: row.agent_status_updated_at, }; } @@ -81,7 +83,8 @@ const AGENT_JOIN = /* sql */ ` ${agent_metadata.status} AS status, ${agent_metadata.current_hook_bead_id}, ${agent_metadata.dispatch_attempts}, ${agent_metadata.last_activity_at}, - ${agent_metadata.checkpoint} + ${agent_metadata.checkpoint}, + ${agent_metadata.agent_status_message}, ${agent_metadata.agent_status_updated_at} FROM ${beads} INNER JOIN ${agent_metadata} ON ${beads.bead_id} = ${agent_metadata.bead_id} `; @@ -241,7 +244,9 @@ export function hookBead(sql: SqlStorage, agentId: string, beadId: string): void SET ${agent_metadata.columns.current_hook_bead_id} = ?, ${agent_metadata.columns.status} = 'idle', ${agent_metadata.columns.dispatch_attempts} = 0, - ${agent_metadata.columns.last_activity_at} = ? + ${agent_metadata.columns.last_activity_at} = ?, + ${agent_metadata.columns.agent_status_message} = NULL, + ${agent_metadata.columns.agent_status_updated_at} = NULL WHERE ${agent_metadata.bead_id} = ? `, [beadId, now(), agentId] @@ -453,6 +458,21 @@ export function readCheckpoint(sql: SqlStorage, agentId: string): unknown { return agent?.checkpoint ?? null; } +// ── Status Message ─────────────────────────────────────── + +export function updateAgentStatusMessage(sql: SqlStorage, agentId: string, message: string): void { + query( + sql, + /* sql */ ` + UPDATE ${agent_metadata} + SET ${agent_metadata.columns.agent_status_message} = ?, + ${agent_metadata.columns.agent_status_updated_at} = ? + WHERE ${agent_metadata.bead_id} = ? + `, + [message, now(), agentId] + ); +} + // ── Touch (heartbeat helper) ──────────────────────────────────────── export function touchAgent(sql: SqlStorage, agentId: string): void { diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 9e6581b451..03e0abd974 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -16,7 +16,11 @@ import { createTableBeadDependencies, getIndexesBeadDependencies, } from '../../db/tables/bead-dependencies.table'; -import { agent_metadata, createTableAgentMetadata } from '../../db/tables/agent-metadata.table'; +import { + agent_metadata, + createTableAgentMetadata, + migrateAgentMetadata, +} from '../../db/tables/agent-metadata.table'; import { review_metadata, createTableReviewMetadata } from '../../db/tables/review-metadata.table'; import { escalation_metadata, @@ -59,7 +63,7 @@ export function initBeadTables(sql: SqlStorage): void { query(sql, createTableConvoyMetadata(), []); // Migrations: add columns to existing tables (idempotent) - for (const stmt of migrateConvoyMetadata()) { + for (const stmt of [...migrateConvoyMetadata(), ...migrateAgentMetadata()]) { try { query(sql, stmt, []); } catch { diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 4abe264405..11520c7a85 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -37,6 +37,7 @@ import { handleHeartbeat, handleGetOrCreateAgent, handleDeleteAgent, + handleUpdateAgentStatusMessage, } from './handlers/rig-agents.handler'; import { handleSendMail } from './handlers/rig-mail.handler'; import { handleAppendAgentEvent, handleGetAgentEvents } from './handlers/rig-agent-events.handler'; @@ -227,6 +228,9 @@ app.get('/api/towns/:townId/rigs/:rigId/agents/:agentId/mail', c => app.post('/api/towns/:townId/rigs/:rigId/agents/:agentId/heartbeat', c => handleHeartbeat(c, c.req.param()) ); +app.post('/api/towns/:townId/rigs/:rigId/agents/:agentId/status', c => + handleUpdateAgentStatusMessage(c, c.req.param()) +); // ── Agent Events ───────────────────────────────────────────────────────── diff --git a/cloudflare-gastown/src/handlers/rig-agents.handler.ts b/cloudflare-gastown/src/handlers/rig-agents.handler.ts index d7e77de8cf..02813a7b56 100644 --- a/cloudflare-gastown/src/handlers/rig-agents.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-agents.handler.ts @@ -33,6 +33,10 @@ const WriteCheckpointBody = z.object({ data: z.unknown(), }); +const UpdateAgentStatusMessageBody = z.object({ + message: z.string().trim().min(1).max(280), +}); + export async function handleRegisterAgent(c: Context, params: { rigId: string }) { const parsed = RegisterAgentBody.safeParse(await parseJsonBody(c)); if (!parsed.success) { @@ -225,6 +229,23 @@ export async function handleGetOrCreateAgent(c: Context, params: { r return c.json(resSuccess(agent)); } +export async function handleUpdateAgentStatusMessage( + c: Context, + params: { rigId: string; agentId: string } +) { + const parsed = UpdateAgentStatusMessageBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + const townId = c.get('townId'); + const town = getTownDOStub(c.env, townId); + await town.updateAgentStatusMessage(params.agentId, parsed.data.message); + return c.json(resSuccess({ ok: true })); +} + export async function handleDeleteAgent( c: Context, params: { rigId: string; agentId: string } diff --git a/cloudflare-gastown/src/prompts/polecat-system.prompt.ts b/cloudflare-gastown/src/prompts/polecat-system.prompt.ts index c12b074776..508f1de80c 100644 --- a/cloudflare-gastown/src/prompts/polecat-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/polecat-system.prompt.ts @@ -29,6 +29,7 @@ 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 @@ -57,6 +58,12 @@ If you are stuck for more than a few attempts at the same problem: - If you need input from another agent, use gt_mail_send. - Keep messages concise and actionable. +## 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. + ## Important - Do NOT create pull requests or merge requests. Your job is to write code on your branch. The Refinery handles merging and PR creation. diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index c849dd84c9..3071ee4610 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -62,6 +62,8 @@ export const AgentOutput = z.object({ last_activity_at: z.string().nullable(), checkpoint: z.unknown().optional(), created_at: z.string(), + agent_status_message: z.string().nullable().optional().default(null), + agent_status_updated_at: z.string().nullable().optional().default(null), }); // BeadEvent (output shape, after transforms) diff --git a/cloudflare-gastown/src/types.ts b/cloudflare-gastown/src/types.ts index 5cd6c6d6fb..391d8a87da 100644 --- a/cloudflare-gastown/src/types.ts +++ b/cloudflare-gastown/src/types.ts @@ -74,6 +74,8 @@ export type Agent = { // eslint-disable-next-line @typescript-eslint/no-explicit-any checkpoint: any; created_at: string; + agent_status_message: string | null; + agent_status_updated_at: string | null; }; export type RegisterAgentInput = { diff --git a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx index b7bdc4fe70..226696ab9d 100644 --- a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx @@ -27,6 +27,7 @@ import { ChevronRight, ChevronDown, Layers, + MessageSquare, } from 'lucide-react'; import { toast } from 'sonner'; import { formatDistanceToNow } from 'date-fns'; @@ -341,7 +342,20 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) townId={townId} events={events.slice(0, 80)} isLoading={townEventsQuery.isLoading} - onEventClick={event => openDrawer({ type: 'event', event })} + onEventClick={event => { + if (event.event_type === 'agent_status' && event.agent_id != null) { + // rig_id is not on the bead_events row — resolve it from + // the already-fetched agentsByRig map instead. + const rigId = Object.entries(agentsByRig).find(([, agents]) => + agents.some(a => a.id === event.agent_id) + )?.[0]; + if (rigId) { + openDrawer({ type: 'agent', agentId: event.agent_id, rigId, townId }); + return; + } + } + openDrawer({ type: 'event', event }); + }} /> @@ -439,6 +453,19 @@ export function TownOverviewPageClient({ townId }: TownOverviewPageClientProps) {recentAgents.map((agent, i) => { const RoleIcon = ROLE_ICONS[agent.role] ?? Bot; + const showStatusBubble = + agent.status === 'working' && + agent.agent_status_message != null && + agent.agent_status_message.length > 0; + const isStale = + showStatusBubble && + agent.agent_status_updated_at != null && + Date.now() - new Date(agent.agent_status_updated_at).getTime() > + 10 * 60 * 1000; + const truncatedMsg = + agent.agent_status_message && agent.agent_status_message.length > 80 + ? `${agent.agent_status_message.slice(0, 80)}…` + : (agent.agent_status_message ?? ''); return ( -
- - -
-
-
- - {agent.name} - - - {agent.role} - +
+
+ +
-
- {(agent as Agent & { rigName: string }).rigName} - · - - {agent.last_activity_at - ? formatDistanceToNow(new Date(agent.last_activity_at), { - addSuffix: true, - }) - : 'no activity'} - +
+
+ + {agent.name} + + + {agent.role} + +
+
+ {(agent as Agent & { rigName: string }).rigName} + · + + {agent.last_activity_at + ? formatDistanceToNow(new Date(agent.last_activity_at), { + addSuffix: true, + }) + : 'no activity'} + +
+
- + + + {showStatusBubble && ( + + +
+

+ {truncatedMsg} +

+ {agent.agent_status_updated_at && ( +

+ {formatDistanceToNow( + new Date(agent.agent_status_updated_at), + { addSuffix: true } + )} +

+ )} +
+
+ )} +
); })} diff --git a/src/app/(app)/gastown/[townId]/agents/AgentsPageClient.tsx b/src/app/(app)/gastown/[townId]/agents/AgentsPageClient.tsx index ace11a4396..03b72c406b 100644 --- a/src/app/(app)/gastown/[townId]/agents/AgentsPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/agents/AgentsPageClient.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import { useQuery, useQueries } from '@tanstack/react-query'; import { useGastownTRPC } from '@/lib/gastown/trpc'; import { useDrawerStack } from '@/components/gastown/DrawerStack'; -import { Bot, Crown, Shield, Eye, Clock, Hexagon } from 'lucide-react'; +import { Bot, Crown, Shield, Eye, Clock, Hexagon, MessageSquare } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { motion, AnimatePresence } from 'motion/react'; import type { GastownOutputs } from '@/lib/gastown/trpc'; @@ -79,6 +79,18 @@ export function AgentsPageClient({ townId }: { townId: string }) { {allAgents.map((agent, i) => { const RoleIcon = ROLE_ICONS[agent.role] ?? Bot; const rigId = (agent as Agent & { rigId: string }).rigId; + const showStatusBubble = + agent.status === 'working' && + agent.agent_status_message != null && + agent.agent_status_message.length > 0; + const isStale = + showStatusBubble && + agent.agent_status_updated_at != null && + Date.now() - new Date(agent.agent_status_updated_at).getTime() > 10 * 60 * 1000; + const truncatedMsg = + agent.agent_status_message && agent.agent_status_message.length > 80 + ? `${agent.agent_status_message.slice(0, 80)}…` + : (agent.agent_status_message ?? ''); return ( {(agent as Agent & { rigName: string }).rigName}
+ + + {showStatusBubble && ( + + +
+

+ {truncatedMsg} +

+ {agent.agent_status_updated_at && ( +

+ {formatDistanceToNow(new Date(agent.agent_status_updated_at), { + addSuffix: true, + })} +

+ )} +
+
+ )} +
); })} diff --git a/src/components/gastown/ActivityFeed.tsx b/src/components/gastown/ActivityFeed.tsx index eef8e471d8..5dc5cbe140 100644 --- a/src/components/gastown/ActivityFeed.tsx +++ b/src/components/gastown/ActivityFeed.tsx @@ -13,6 +13,7 @@ import { PlayCircle, PauseCircle, Mail, + MessageSquare, ChevronRight, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; @@ -27,6 +28,7 @@ const EVENT_ICONS: Record = { review_submitted: GitMerge, review_completed: GitMerge, mail_sent: Mail, + agent_status: MessageSquare, }; const EVENT_COLORS: Record = { @@ -39,6 +41,7 @@ const EVENT_COLORS: Record = { review_submitted: 'text-indigo-500', review_completed: 'text-green-600', mail_sent: 'text-sky-500', + agent_status: 'text-white/50', }; type TownEvent = GastownOutputs['gastown']['getTownEvents'][number]; @@ -73,6 +76,16 @@ function eventDescription(event: { return `${rigPrefix}Review ${event.new_value ?? 'completed'}`; case 'mail_sent': return `${rigPrefix}Mail sent`; + case 'agent_status': { + const msg = event.new_value ?? (event.metadata?.message as string | undefined); + const agentName = event.metadata?.agent_name as string | undefined; + const rigName = event.metadata?.rig_name as string | undefined; + const body = msg ?? 'Agent status update'; + // Prefer metadata rig_name over the top-level rig_name (which is + // never populated for bead_events rows). + const prefix = rigName ? `[${rigName}] ` : rigPrefix; + return agentName ? `${prefix}${agentName}: ${body}` : `${prefix}${body}`; + } default: return `${rigPrefix}${event.event_type}`; } diff --git a/src/components/gastown/AgentCard.tsx b/src/components/gastown/AgentCard.tsx index 80469a9b32..0003f60573 100644 --- a/src/components/gastown/AgentCard.tsx +++ b/src/components/gastown/AgentCard.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { Bot, Crown, Shield, Eye, Trash2 } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; +import { motion, AnimatePresence } from 'motion/react'; type Agent = { id: string; @@ -16,6 +17,8 @@ type Agent = { last_activity_at: string | null; checkpoint?: unknown; created_at: string; + agent_status_message?: string | null; + agent_status_updated_at?: string | null; }; type AgentCardProps = { @@ -39,9 +42,26 @@ const statusColors: Record = { dead: 'bg-red-500', }; +const TEN_MINUTES_MS = 10 * 60 * 1000; + export function AgentCard({ agent, isSelected, onSelect, onDelete }: AgentCardProps) { const Icon = roleIcons[agent.role] ?? Bot; + const showStatusBubble = + agent.status === 'working' && + agent.agent_status_message != null && + agent.agent_status_message.length > 0; + + const isStale = + showStatusBubble && + agent.agent_status_updated_at != null && + Date.now() - new Date(agent.agent_status_updated_at).getTime() > TEN_MINUTES_MS; + + const truncatedMessage = + agent.agent_status_message && agent.agent_status_message.length > 80 + ? `${agent.agent_status_message.slice(0, 80)}…` + : (agent.agent_status_message ?? ''); + return ( )}
+ + + {showStatusBubble && ( + +

+ {truncatedMessage} +

+ {agent.agent_status_updated_at && ( +

+ {formatDistanceToNow(new Date(agent.agent_status_updated_at), { + addSuffix: true, + })} +

+ )} +
+ )} +
); diff --git a/src/components/gastown/TerminalBar.tsx b/src/components/gastown/TerminalBar.tsx index e9df061f5b..6078f0b9e7 100644 --- a/src/components/gastown/TerminalBar.tsx +++ b/src/components/gastown/TerminalBar.tsx @@ -158,12 +158,25 @@ type AlarmStatus = { recentEvents: Array<{ time: string; type: string; message: string }>; }; +type AgentStatusEvent = { + type: 'agent_status'; + agentId: string; + message: string; + timestamp: string; +}; + /** * Hook that connects to the TownDO status WebSocket and returns the * latest alarm status snapshot. Falls back to tRPC polling if the * WebSocket fails or disconnects. + * + * The optional `onAgentStatus` callback is invoked for `agent_status` + * events so callers can react in real time (e.g. invalidate listAgents). */ -function useAlarmStatusWs(townId: string): { +function useAlarmStatusWs( + townId: string, + onAgentStatus?: (event: AgentStatusEvent) => void +): { data: AlarmStatus | null; connected: boolean; error: string | null; @@ -174,6 +187,8 @@ function useAlarmStatusWs(townId: string): { const wsRef = useRef(null); const reconnectTimerRef = useRef | null>(null); const mountedRef = useRef(true); + const onAgentStatusRef = useRef(onAgentStatus); + onAgentStatusRef.current = onAgentStatus; const connect = useCallback(() => { if (!mountedRef.current) return; @@ -190,7 +205,19 @@ function useAlarmStatusWs(townId: string): { ws.onmessage = (e: MessageEvent) => { if (!mountedRef.current || typeof e.data !== 'string') return; try { - setData(JSON.parse(e.data) as AlarmStatus); + const parsed: unknown = JSON.parse(e.data); + if ( + parsed !== null && + typeof parsed === 'object' && + 'type' in parsed && + (parsed as Record).type === 'agent_status' + ) { + // Lightweight agent_status event — dispatch to callback, don't + // overwrite the alarm status snapshot. + onAgentStatusRef.current?.(parsed as AgentStatusEvent); + } else { + setData(parsed as AlarmStatus); + } } catch { // Ignore malformed messages } @@ -224,8 +251,33 @@ function useAlarmStatusWs(townId: string): { } function AlarmStatusPane({ townId }: { townId: string }) { - const { data: wsData, connected: wsConnected, error: wsError } = useAlarmStatusWs(townId); const trpc = useGastownTRPC(); + const queryClient = useQueryClient(); + + // Invalidate listAgents for all rigs in this town when an agent_status + // event arrives over the WebSocket, so agent cards update immediately + // without waiting for the next 5s poll cycle. + // tRPC @tanstack/react-query v11 query keys have the shape: + // [['gastown', 'listAgents'], { input: ..., type: 'query' }] + const handleAgentStatus = useCallback( + (_event: AgentStatusEvent) => { + void queryClient.invalidateQueries({ + predicate: query => { + const key = query.queryKey; + if (!Array.isArray(key) || !Array.isArray(key[0])) return false; + const path = key[0] as string[]; + return path.includes('listAgents'); + }, + }); + }, + [queryClient] + ); + + const { + data: wsData, + connected: wsConnected, + error: wsError, + } = useAlarmStatusWs(townId, handleAgentStatus); // Fall back to polling when WebSocket is unavailable (blocked, errored, // or never connected). The tRPC query is disabled while the WS is diff --git a/src/components/gastown/drawer-panels/AgentPanel.tsx b/src/components/gastown/drawer-panels/AgentPanel.tsx index 99645f3a1f..c80b0e7bf1 100644 --- a/src/components/gastown/drawer-panels/AgentPanel.tsx +++ b/src/components/gastown/drawer-panels/AgentPanel.tsx @@ -18,6 +18,7 @@ import { Zap, Activity, ChevronRight, + MessageSquare, } from 'lucide-react'; const ROLE_ICONS: Record = { @@ -102,6 +103,26 @@ export function AgentPanel({
+ {/* Current status message */} + {agent.agent_status_message && agent.status === 'working' && ( +
+ +
+

Status

+

+ {agent.agent_status_message} +

+ {agent.agent_status_updated_at && ( +

+ {formatDistanceToNow(new Date(agent.agent_status_updated_at), { + addSuffix: true, + })} +

+ )} +
+
+ )} + {/* Actions */}