Skip to content

feat(gastown): add UI-aware mayor with dashboard context injection#1123

Open
jrf0110 wants to merge 13 commits intomainfrom
ui-aware-mayor
Open

feat(gastown): add UI-aware mayor with dashboard context injection#1123
jrf0110 wants to merge 13 commits intomainfrom
ui-aware-mayor

Conversation

@jrf0110
Copy link
Contributor

@jrf0110 jrf0110 commented Mar 16, 2026

Summary

Implements Phase 1 of the UI-Aware Mayor: Dashboard Context Injection feature from #447. Two capabilities:

  1. 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 via POST /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.

  2. UI Action Control — The mayor can trigger UI actions in the user's browser via the gt_ui_action tool. Actions are broadcast over the existing status WebSocket as ui_action frames. The frontend handles these by opening DrawerStack drawers (bead, convoy, agent), navigating to pages, or highlighting beads.

Backend changes:

  • uiContext accepted on mayor message endpoints (tRPC + HTTP)
  • setDashboardContext() RPC on TownDO stores context in memory; sendMayorMessage uses it as fallback
  • gt_ui_action tool + broadcastUiAction() on TownDO pushes ui_action frames over WebSocket
  • Two HTTP routes for UI actions: dashboard-facing (kilo auth) and container-facing (mayor auth)
  • UiActionSchema Zod schema centralized in types.ts
  • Mayor system prompt extended with "Dashboard Context" and "UI Control" sections
  • handleSetDashboardContext handler + route for context sync

Frontend changes:

  • useGastownUiContext hook tracks pathname + DrawerStack changes, debounces context sync to TownDO
  • useAlarmStatusWs extended to detect channel: 'ui_action' WebSocket frames
  • AlarmStatusPane dispatches UI actions to DrawerStack (open) and Next.js router (push)
  • MayorTerminalBar wires the context hook
  • getToken exported from gastown trpc client for authenticated fetch calls

Verification

  • pnpm --filter cloudflare-gastown run typecheck — passes
  • pnpm --filter gastown-container run typecheck — passes
  • npx tsgo --noEmit --incremental false (root) — passes
  • prettier --check on all modified files — passes

Visual Changes

image image

Reviewer Notes

  • The UiAction type is defined in three places by necessity: the worker's types.ts (Zod schema, single source of truth), the plugin's types.ts (plain TS type for the container client), and inferred from Zod in Town.do.ts. The plugin can't import from the worker's types.ts since it's a separate package.
  • The two HTTP routes for UI actions serve different auth contexts: /api/towns/:townId/mayor/ui-action (kilo JWT) and /api/mayor/:townId/tools/ui-action (container JWT).
  • Dashboard context is stored in TownDO memory (not SQLite) — it's ephemeral and only useful for the current session. If the DO restarts, the context is lost, which is fine since the frontend will resync on next navigation.
  • The open_create_rig_dialog action type is defined but not handled in the frontend yet — it would need a dedicated dialog state hook. Other action types are fully wired.
  • useGastownUiContext uses a direct fetch with getToken() rather than tRPC because the endpoint is a simple fire-and-forget POST that doesn't need query caching or invalidation.

@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Mar 16, 2026

Code Review Summary

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
cloudflare-gastown/src/gastown.worker.ts 395 Broad town-ownership middleware now blocks admin-only /api/towns/:townId/config reads and updates used by src/routers/admin/gastown-router.ts.
cloudflare-gastown/src/types.ts 337 normalizeUiAction() rewrites townId but still trusts rigId, so drawer actions can open data from another town the same user owns.
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
N/A N/A N/A
Files Reviewed (21 files)
  • .prettierignore - 0 issues
  • cloudflare-gastown/container/plugin/client.ts - 0 issues
  • cloudflare-gastown/container/plugin/index.ts - 0 issues
  • cloudflare-gastown/container/plugin/mayor-tools.ts - 0 issues
  • cloudflare-gastown/container/plugin/types.ts - 0 issues
  • cloudflare-gastown/container/src/control-server.ts - 0 issues
  • cloudflare-gastown/container/src/dashboard-context.ts - 0 issues
  • cloudflare-gastown/gastown-grafana-dash-1.json - 0 issues
  • cloudflare-gastown/src/dos/Town.do.ts - 0 issues
  • cloudflare-gastown/src/dos/town/container-dispatch.ts - 0 issues
  • cloudflare-gastown/src/gastown.worker.ts - 1 issue
  • cloudflare-gastown/src/handlers/mayor-tools.handler.ts - 0 issues
  • cloudflare-gastown/src/handlers/mayor.handler.ts - 0 issues
  • cloudflare-gastown/src/middleware/town-ownership.middleware.ts - 0 issues
  • cloudflare-gastown/src/prompts/mayor-system.prompt.ts - 0 issues
  • cloudflare-gastown/src/trpc/router.ts - 0 issues
  • cloudflare-gastown/src/types.ts - 1 issue
  • src/app/(app)/gastown/[townId]/MayorTerminalBar.tsx - 0 issues
  • src/components/gastown/TerminalBar.tsx - 0 issues
  • src/components/gastown/useGastownUiContext.ts - 0 issues
  • src/lib/gastown/trpc.ts - 0 issues

Fix these issues in Kilo Cloud


Reviewed by gpt-5.4-20260305 · 3,572,298 tokens

jrf0110 added 11 commits March 16, 2026 14:45
…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.
jrf0110 added 2 commits March 16, 2026 15:01
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)
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants