import type { Hono } from 'hono'; import { createAgentPanel, deleteAgentPanel, getAgentPanelByPanelId, listAgentPanels, toAgentPanelView, updateAgentPanel, } from 'officerdb'; import { introduceAgentPanel } from '../agent-handoff/deliver'; import { logger } from './logger'; import { OFFICER_API_URL } from '../../officer-url.mjs'; /** * The browser's half of the address book: name a panel, look up what a panel is, rename, remove. * * Mounted on the chat router rather than given its own prefix, because it is the same permission — * these routes create and name Claude sessions, which is what `chat` already grants. A new top-level * mount would have meant a new permission entry claiming the same authority under a second name. * * The agent-facing door is separate and deliberately so: `servers/api/agent-handoff/router.ts`. */ /** A name has to survive being typed into a prompt and into a shell, so keep it boring. */ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$|^[a-z0-9]$/; const API_ORIGIN = OFFICER_API_URL; export function registerAgentPanelRoutes(router: Hono): void { // GET /chat/agent-panels?dashboardId=… — the address book for one dashboard. router.get('/agent-panels', async (ctx) => { const user = ctx.get('user'); const dashboardId = ctx.req.query('dashboardId')?.trim(); if (!dashboardId) return ctx.text('dashboardId is required', 400); const panels = await listAgentPanels(user.id, dashboardId); return ctx.json({ agents: panels.map(toAgentPanelView) }); }); // POST /chat/agent-panels — name a panel, minting its permanent sessionKey. // // Idempotent on (dashboardId, panelId): a double-submit or a remount returns the existing agent // rather than a second one. It has to be — the sessionKey is the panel's whole continuity, and // minting a second one would abandon the first session with no way back to it. router.post('/agent-panels', async (ctx) => { const user = ctx.get('user'); const body = (ctx.get('body') ?? {}) as Record; const dashboardId = typeof body.dashboardId === 'string' ? body.dashboardId.trim() : ''; const panelId = typeof body.panelId === 'string' ? body.panelId.trim() : ''; const name = typeof body.name === 'string' ? body.name.trim().toLowerCase() : ''; const cwd = typeof body.cwd === 'string' && body.cwd.trim() ? body.cwd.trim() : null; const rolePrompt = typeof body.rolePrompt === 'string' && body.rolePrompt.trim() ? body.rolePrompt.trim() : null; if (!dashboardId || !panelId) return ctx.text('dashboardId and panelId are required', 400); if (!NAME_RE.test(name)) { return ctx.text('Name must be lowercase letters, digits and dashes — e.g. "frontend", "code-reviewer"', 400); } const existing = await getAgentPanelByPanelId(user.id, dashboardId, panelId); if (existing) return ctx.json({ agent: toAgentPanelView(existing), created: false }); let created; try { created = await createAgentPanel({ userId: user.id, dashboardId, panelId, name, cwd, rolePrompt }); } catch (err) { // uq_agent_panels_address — the address must resolve to one agent or a handoff is ambiguous. if (String(err).includes('uq_agent_panels_address')) { return ctx.text(`There is already an agent named "${name}" on this dashboard`, 409); } throw err; } // The introduction is the session's first turn: it is how the agent learns its own name, its peers // and the command it uses to reach them. Fire-and-forget on purpose — the panel should render // immediately, and if this fails the agent is still addressable, just uninformed. Logged, not // swallowed, so "it never introduced itself" is diagnosable. void listAgentPanels(user.id, dashboardId) .then((peers) => introduceAgentPanel(created, peers, API_ORIGIN)) .catch((err) => logger.error('Failed to introduce agent panel', { name, dashboardId, error: String(err) })); return ctx.json({ agent: toAgentPanelView(created), created: true }); }); // PATCH /chat/agent-panels/:id — rename, re-scope, or record that the panel moved. router.patch('/agent-panels/:id', async (ctx) => { const user = ctx.get('user'); const id = Number(ctx.req.param('id')); if (!Number.isFinite(id)) return ctx.text('Bad id', 400); const body = (ctx.get('body') ?? {}) as Record; const patch: Record = {}; if (typeof body.name === 'string') { const name = body.name.trim().toLowerCase(); if (!NAME_RE.test(name)) return ctx.text('Name must be lowercase letters, digits and dashes', 400); patch.name = name; } if ('cwd' in body) patch.cwd = typeof body.cwd === 'string' && body.cwd.trim() ? body.cwd.trim() : null; if ('rolePrompt' in body) patch.rolePrompt = typeof body.rolePrompt === 'string' ? body.rolePrompt : null; if (typeof body.panelId === 'string' && body.panelId.trim()) patch.panelId = body.panelId.trim(); const updated = await updateAgentPanel(user.id, id, patch); if (!updated) return ctx.text('No such agent', 404); return ctx.json({ agent: toAgentPanelView(updated) }); }); // DELETE /chat/agent-panels/:id — forget the agent. The Claude transcript is NOT deleted: it lives // in ~/.claude/projects and is readable from /chat like any other conversation. Only the address // and the panel's claim on that session go away. router.delete('/agent-panels/:id', async (ctx) => { const user = ctx.get('user'); const id = Number(ctx.req.param('id')); if (!Number.isFinite(id)) return ctx.text('Bad id', 400); await deleteAgentPanel(user.id, id); return ctx.json({ ok: true }); }); }