feat(chat): agent panels — named Claude panels that can hand work to each other

Committing work that was left uncommitted in the shared tree. I did not write it;
I reviewed it in full, verified it against the running system, and am landing it at
the owner's explicit request because no one currently owns it.

This REPAIRS master. `useAgentPanel.ts` shipped in dbe585f and calls
`/chat/agent-panels`, but `registerAgentPanelRoutes` existed only in the working
tree — so on master as pushed, every one of those calls 404s. The feature has been
half-landed since that commit.

What it is. A panel on a dashboard can be given a name ("frontend", "code-reviewer").
Naming it mints two things: a `sessionKey`, which is the panel's permanent continuity
(it keys the sidecar's on-disk resume map and `chat_session_events`, so the same panel
reopens the same Claude session), and a `handoffToken`, a bearer credential scoped to
exactly one verb. The agent in that panel is then addressable by name, and can pass
work to a peer on the same dashboard over `/api/agent-handoff`.

Three doors, deliberately separate:
  - `/chat/agent-panels` (browser, session-authed) — name / list / rename / forget.
    Mounted on the chat router rather than given its own prefix: these routes create
    and name Claude sessions, which is authority `chat` already grants. A second
    top-level mount would have meant a second capability entry claiming the same
    thing under a different name.
  - `/api/agent-handoff` (agent, token-authed) — peers and send. Unprotected by the
    session middleware and exempted in `capabilities/totality.ts` with its reasoning
    written down, because the caller is a subprocess with a token, not a browser with
    a cookie.
  - The transcript stays where transcripts live. DELETE forgets the address and the
    panel's claim on the session; it does not touch ~/.claude/projects.

Security, as verified rather than assumed:
  - The sender is derived from the token, never from the request body — there is no
    `from` field on the wire, so it cannot be forged.
  - Every lookup is scoped to the token's `userId` AND `dashboardId`, so an agent can
    only see and reach peers on its own dashboard.
  - `toAgentPanelView` strips `handoffToken` and `userId`, and it is the only shape
    the browser routes return. Confirmed by reading every return path.
  - Live-tested: a real token on `GET /api/agent-handoff/peers` returns 200 with
    correctly scoped peers; a bogus one returns 401.

Two judgement calls in the code worth knowing about, both already commented at their
site: the introduction turn inlines the handoff token into a runnable curl (a
single-owner MVP trade), and `agent_panels` carries no FK to `dashboards.id` because
that primary key is mid-rework to a composite.

Schema uses `uniqueIndex` throughout, never `unique().on(...)` — the rule that exists
because drizzle-kit mis-diffs named composite unique constraints and re-creates them,
which is what wiped seven tables on 2026-08-03.

NO `bun db:push` IS NEEDED. `agent_panels` is already live in Postgres with 6 rows;
the schema file is catching up to a database that already has it.

Verified: `bunx tsgo` clean, `bun test` 538 pass / 0 fail across 35 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 21:47:48 +00:00
co-authored by Claude Opus 5
parent 302116d624
commit 8773da5953
10 changed files with 571 additions and 1 deletions
+115
View File
@@ -0,0 +1,115 @@
import type { Hono } from 'hono';
import {
createAgentPanel,
deleteAgentPanel,
getAgentPanelByPanelId,
listAgentPanels,
toAgentPanelView,
updateAgentPanel,
} from 'officerdb';
import { introduceAgentPanel } from '../agent-handoff/deliver';
import { logger } from './logger';
/**
* 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 capability —
* these routes create and name Claude sessions, which is what `chat` already grants. A new top-level
* mount would have meant a new capability 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 = `http://127.0.0.1:${process.env.PORT ?? '5000'}`;
export function registerAgentPanelRoutes(router: Hono<any>): 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<string, unknown>;
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<string, unknown>;
const patch: Record<string, unknown> = {};
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 });
});
}