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:
@@ -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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user