feat(gastown): add UI-aware mayor with dashboard context injection#1123
feat(gastown): add UI-aware mayor with dashboard context injection#1123
Conversation
Code Review SummaryStatus: 2 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Other Observations (not in diff)Issues found in unchanged code that cannot receive inline comments:
Files Reviewed (21 files)
Fix these issues in Kilo Cloud Reviewed by gpt-5.4-20260305 · 3,572,298 tokens |
…d UI control Implement Phase 1 of the UI-Aware Mayor feature from #447: the mayor receives dashboard navigation context alongside user messages and can trigger UI actions (open drawers, navigate pages) in the user's dashboard via WebSocket broadcast. Key changes: - Accept uiContext on mayor message endpoints (tRPC + HTTP), inject as system-reminder block into the mayor's prompt - Add gt_ui_action tool letting the mayor control the dashboard - Add broadcastUiAction on TownDO to push ui_action frames over the status WebSocket - Extend mayor system prompt with Dashboard Context and UI Control sections - Add UiActionInput type to the container plugin for the tool client
These directories contain generated type declarations and agent runtime artifacts that should not be checked by prettier.
…text sync - Handle ui_action WebSocket frames in useAlarmStatusWs, dispatching to DrawerStack (open_bead_drawer, open_convoy_drawer, open_agent_drawer) and Next.js router (navigate) so gt_ui_action tool calls from the mayor actually open drawers and navigate pages in the browser - Add useGastownUiContext hook that tracks page navigation and drawer opens, builds a <user-context> XML string, and syncs it to the TownDO via POST /api/towns/:townId/mayor/dashboard-context - Add setDashboardContext() RPC on TownDO to store the latest dashboard context in memory; sendMayorMessage falls back to this when no explicit uiContext is passed - Add handleSetDashboardContext handler and route - Export getToken from gastown trpc client for authenticated fetch calls
…action WS Two root-cause fixes: 1. Dashboard context was never reaching the mayor because the PTY path bypasses sendMayorMessage entirely. Fix: the gastown plugin's experimental.chat.system.transform hook now fetches the latest dashboard context from GET /api/mayor/:townId/tools/dashboard-context on every LLM call and injects it as a system block. This works for both PTY and HTTP message paths since the hook runs inside the kilo serve process before each LLM request. 2. gt_ui_action tool calls succeeded but nothing happened in the browser because the status WebSocket connection lived inside AlarmStatusPane, which only mounts when the Status tab is active and the bar is expanded. Fix: lift useAlarmStatusWs and the UI action dispatcher up to the TerminalBar component so the WebSocket stays connected and ui_action frames are handled regardless of which tab is active. Backend additions: - getDashboardContext() RPC on TownDO - GET /api/mayor/:townId/tools/dashboard-context endpoint - fetchDashboardContext() on MayorGastownClient
…ching per-call Replace the pull-based approach (plugin fetches GET /dashboard-context on every LLM call) with a push-based one: 1. TownDO.setDashboardContext() now pushes the context XML to the running container via POST /dashboard-context on the control-server. 2. The control-server writes to an in-memory ring buffer (capped at 5 snapshots) in dashboard-context.ts. Oldest entries are evicted. 3. The plugin reads from this same-process store — zero network cost at prompt time. The ring buffer provides a short navigation history so the mayor sees breadcrumbs of recent user activity without unbounded growth. The buildContextBlock() function emits the latest snapshot as full XML plus one-line summaries of older snapshots. Removed: GET /dashboard-context endpoint, fetchDashboardContext client method, and the per-call HTTP fetch in the plugin's system.transform.
The plugin imported buildContextBlock from ../src/dashboard-context.ts, pulling a container/src/ module into the kilo serve process (which loads the plugin). The plugin and control-server run in separate processes — sharing module-level state doesn't work, and the cross-boundary import caused the kilo serve plugin loader to fail, making session.create return the web UI HTML page instead of JSON. Fix: the plugin now has its own readDashboardContextBlock() that reads the shared JSON file at /tmp/gastown-dashboard-context.json directly. The control-server writes snapshots there via pushContext(); the plugin reads them via readFileSync(). Both processes share the container filesystem so this is a cheap local read with no cross-process import.
- Move /api/mayor/:townId/tools/ui-action route after mayorAuthMiddleware so it's properly auth-guarded (was registered before the middleware) - Remove open_create_rig_dialog from UiAction schema, tool description, system prompt, and type — it was advertised but never handled - Fix activity ref timing in useGastownUiContext: track navigation changes synchronously during render instead of in effects, so the XML always includes the current state - Fix lastSyncedRef: only mark context as synced after a successful POST response, so failed syncs are retried on the next debounce tick
…label - Fix unsafe return of any[] in readSnapshots() by validating with z.array(ContextSnapshotSchema).parse() instead of a bare Array.isArray - Include the rigId in the rig-detail page label so the mayor knows which rig the user is viewing when no drawer is open
- Fix regression: bead/convoy WS frames were overwriting AlarmStatus data after lifting the WebSocket to TerminalBar. Now only frames with an 'alarm' key are stored as AlarmStatus; bead, convoy, and other channel frames are silently ignored. - Fix navigate action leaving stale drawer open: call closeAll() before router.push() so old drawers don't cover the new page. - Fix context sync never retrying on failure: add a 10s retry timer that re-attempts the POST if it fails, independent of contextXml changes. - Fix file write race between control-server and plugin: use atomic write-to-temp-then-rename pattern so the plugin can't read truncated JSON. - Add town-ownership check (verifyTownOwner) to handleSetDashboardContext and handleBroadcastUiAction — the two new HTTP handlers now verify the kilo-auth'd user owns the townId before proceeding.
…re setDashboardContext - Add verifyTownOwner() call to handleSendMayorMessage so the uiContext field (injected as <system-reminder>) cannot be exploited by an authenticated user who doesn't own the town. - Add setTownId() call before setDashboardContext() in the handler so the TownDO has a reliable townId when pushDashboardContext() forwards to the container.
…ll town routes Replace per-handler verifyTownOwner() calls with a reusable townOwnershipMiddleware that chains after kiloAuthMiddleware. Applied to all /api/towns/:townId/* routes via a single app.use() registration, so every town-scoped HTTP handler now has ownership verification. This consolidates the 5 separate kiloAuthMiddleware registrations for town sub-routes (convoys, escalations, config, container, mayor) into one combined auth+ownership middleware, and removes the inline verifyTownOwner helper and its 3 call sites from mayor.handler.ts.
Add normalizeUiAction() that overwrites any townId field in the action with the route-scoped params.townId. Applied in both handleBroadcastUiAction (kilo auth route) and handleMayorUiAction (mayor tool route) so callers cannot reference resources outside the authenticated town.
| app.use('/api/towns/:townId/mayor/*', async (c: Context<GastownEnv, string>, next) => | ||
| c.env.ENVIRONMENT === 'development' ? next() : kiloAuthMiddleware(c, next) | ||
| ); | ||
| // Town routes: kilo auth + town ownership check (skipped in dev for auth only) |
There was a problem hiding this comment.
WARNING: Admin-only town config APIs are now blocked by owner checks
This broad app.use('/api/towns/:townId/*', ...) also wraps /api/towns/:townId/config. src/routers/admin/gastown-router.ts:584 and src/routers/admin/gastown-router.ts:704 call that endpoint with an admin Kilo JWT, but townOwnershipMiddleware only lets the town owner through, so admins will start getting 403s for every town they do not personally own. If the inspector/update flows still need admin access, exempt admins here or scope the ownership middleware away from those routes.
There was a problem hiding this comment.
Hrmmmm I think ideally we'd totally separate internal/admin routes from user-facing routes (i.e. put all admin routes under /admin/api) and protect that whole endpoint with Cloudflare Access. I know that seems like a large ask that's totally out of scope of this task, but it's the right way to go
| * Overwrite any `townId` field in the action with the route-scoped townId | ||
| * so callers can't reference resources outside the authenticated town. | ||
| */ | ||
| export function normalizeUiAction(action: UiAction, townId: string): UiAction { |
There was a problem hiding this comment.
WARNING: normalizeUiAction() still allows cross-town rigId targets
This only rewrites townId. open_bead_drawer and open_agent_drawer still carry whatever rigId came in, and the frontend uses that rigId directly in src/components/gastown/drawer-panels/BeadPanel.tsx:80, src/components/gastown/drawer-panels/AgentPanel.tsx:68, and src/components/gastown/drawer-panels/BeadPanel.tsx:82. Because those tRPC procedures only verify that the rig belongs to the current user, a crafted or hallucinated action from town A can still open data from another town the same user owns. This needs server-side rig validation or normalization before broadcasting.
Summary
Implements Phase 1 of the UI-Aware Mayor: Dashboard Context Injection feature from #447. Two capabilities:
Dashboard Context Injection — The frontend tracks which page/drawer the user is viewing, builds a
<user-context>XML string, and syncs it to the TownDO viaPOST /api/towns/:townId/mayor/dashboard-context. When the mayor session starts (or restarts), this context is injected as a<system-reminder>block so the mayor knows what the user is looking at.UI Action Control — The mayor can trigger UI actions in the user's browser via the
gt_ui_actiontool. Actions are broadcast over the existing status WebSocket asui_actionframes. The frontend handles these by opening DrawerStack drawers (bead, convoy, agent), navigating to pages, or highlighting beads.Backend changes:
uiContextaccepted on mayor message endpoints (tRPC + HTTP)setDashboardContext()RPC on TownDO stores context in memory;sendMayorMessageuses it as fallbackgt_ui_actiontool +broadcastUiAction()on TownDO pushesui_actionframes over WebSocketUiActionSchemaZod schema centralized intypes.tshandleSetDashboardContexthandler + route for context syncFrontend changes:
useGastownUiContexthook tracks pathname + DrawerStack changes, debounces context sync to TownDOuseAlarmStatusWsextended to detectchannel: 'ui_action'WebSocket framesAlarmStatusPanedispatches UI actions to DrawerStack (open) and Next.js router (push)MayorTerminalBarwires the context hookgetTokenexported from gastown trpc client for authenticated fetch callsVerification
pnpm --filter cloudflare-gastown run typecheck— passespnpm --filter gastown-container run typecheck— passesnpx tsgo --noEmit --incremental false(root) — passesprettier --checkon all modified files — passesVisual Changes
Reviewer Notes
UiActiontype is defined in three places by necessity: the worker'stypes.ts(Zod schema, single source of truth), the plugin'stypes.ts(plain TS type for the container client), and inferred from Zod inTown.do.ts. The plugin can't import from the worker'stypes.tssince it's a separate package./api/towns/:townId/mayor/ui-action(kilo JWT) and/api/mayor/:townId/tools/ui-action(container JWT).open_create_rig_dialogaction type is defined but not handled in the frontend yet — it would need a dedicated dialog state hook. Other action types are fully wired.useGastownUiContextuses a directfetchwithgetToken()rather than tRPC because the endpoint is a simple fire-and-forget POST that doesn't need query caching or invalidation.