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
+129
View File
@@ -0,0 +1,129 @@
import { getUserById, markAgentPanelIntroduced, type AgentPanel } from 'officerdb';
import * as sidecar from '@@/sidecar-registry';
import { resolveBaseCwd } from '../chat/websocket';
import { logger } from '../chat/logger';
/**
* Push a turn into an agent panel's session — with or without a browser attached.
*
* This is the whole delivery mechanism, and it is four lines because the hard parts already existed:
*
* - `spawnClaudeStreaming` on an EXISTING sessionKey adopts the live session and queues the prompt
* (`claude/claude-manager.ts:379-391`). On a reaped one it re-creates the session and resumes the
* same Claude transcript from the sidecar's write-through `sessionKey → claudeSessionId` map
* (`claude-manager.ts:258`). So we never have to know, or care, whether the target is warm.
* - the sidecar commits the resulting output to `chat_session_events` itself, keyed by sessionKey
* (`claude/session-log.ts`, `durable` defaults to true). A browser that opens the dashboard later
* replays it from its cursor via `resume-cursor`.
*
* Which is why no browser needs to be open, and why nothing here subscribes to the output: the point
* of the durable log is that officer is not on the delivery path.
*
* NOT awaited to completion — `spawnClaudeStreaming` resolves when the turn has been *accepted*, not
* when it finishes (`sidecar/user-instance.ts:169` acks before doing the work). A handoff is fire-and-
* observe: the sender learns nothing about the outcome except by being told in turn.
*/
export async function deliverToAgentPanel(target: AgentPanel, prompt: string): Promise<void> {
const user = await getUserById(target.userId);
if (!user) throw new Error(`agent panel ${target.id} belongs to a user that no longer exists`);
await sidecar.spawnClaudeStreaming({
userId: user.id,
email: user.email,
username: user.name ?? user.email,
prompt,
sessionKey: target.sessionKey,
cwd: resolveBaseCwd(user.email, target.cwd ?? undefined),
// Deliberately no `model`: a live session ignores it anyway, and a resumed one keeps whatever the
// panel started with. Passing one here would only look like it worked.
durable: true,
});
logger.info('Delivered a turn to an agent panel', {
dashboardId: target.dashboardId,
to: target.name,
sessionKey: target.sessionKey,
});
}
/**
* The envelope. The receiving agent is a language model reading its own conversation, so the sender
* has to be *in the text* — there is no out-of-band "from" header it could see. Fenced so a message
* containing its own markdown cannot be mistaken for the frame around it.
*/
export function composeHandoff(fromName: string, message: string): string {
return [
`[handoff from \`${fromName}\`]`,
'',
message.trim(),
'',
`---`,
`That message came from another agent on this dashboard, not from the human. Reply to it by doing`,
`the work, and hand back when you are done.`,
].join('\n');
}
/** Send the role prompt as the session's very first turn, once. Roles are prompts, not features. */
export async function introduceAgentPanel(panel: AgentPanel, peers: AgentPanel[], apiOrigin: string): Promise<void> {
if (panel.introducedAt) return;
const prompt = composeIntroduction(panel, peers, apiOrigin);
await deliverToAgentPanel(panel, prompt);
await markAgentPanelIntroduced(panel.id);
}
/**
* What an agent is told about the world it lives in, verbatim, at creation.
*
* The handoff token is inlined into a runnable curl. That is a bearer credential sitting in a
* transcript, and it is a deliberate trade for this MVP on a single-owner machine: its authority is
* "deliver a prompt to a named peer on this one dashboard" and nothing else. Revisit before this
* platform has a second human on it — see docs/agent-coordination.md.
*/
export function composeIntroduction(panel: AgentPanel, peers: AgentPanel[], apiOrigin: string): string {
const others = peers.filter((p) => p.id !== panel.id);
const peerList = others.length
? others.map((p) => ` - \`${p.name}\`${p.cwd ? ` (working in ${p.cwd})` : ''}`).join('\n')
: ' (none yet — the human may add more panels to this dashboard later)';
return [
`You are \`${panel.name}\`, one of several agents working together on the dashboard`,
`\`${panel.dashboardId}\`. Each of us is a separate Claude session in its own panel, on the same`,
`page, with our own working directory and our own transcript.`,
'',
...(panel.rolePrompt?.trim() ? [`Your role, as the human described it:`, '', panel.rolePrompt.trim(), ''] : []),
`The other agents on this dashboard:`,
peerList,
'',
`## Handing work to another agent`,
'',
`When you finish something a peer needs to act on, tell them. Run:`,
'',
'```bash',
`curl -sS -X POST ${apiOrigin}/api/agent-handoff \\`,
` -H 'content-type: application/json' \\`,
` -H 'x-officer-agent-token: ${panel.handoffToken}' \\`,
` -d "$(jq -n --arg to 'PEER_NAME' --arg message "$(cat HANDOFF.md)" '{to:$to, message:$message}')"`,
'```',
'',
`Replace \`PEER_NAME\` with a name from the list above. The message is delivered to that agent as a`,
`turn in their own session, labelled as coming from you. For anything longer than a sentence, write`,
`it to a file first and pass the file as above — that is what the human expects to be able to read`,
`afterwards.`,
'',
`To see who is currently on this dashboard:`,
'',
'```bash',
`curl -sS ${apiOrigin}/api/agent-handoff/peers -H 'x-officer-agent-token: ${panel.handoffToken}'`,
'```',
'',
`An unknown name is an error, not a silent no-op: the response tells you which names exist.`,
'',
`## What to expect back`,
'',
`A message from a peer arrives as a new turn in this conversation, prefixed \`[handoff from ...]\`.`,
`It may arrive minutes or hours later, and the human may not be watching when it does. Treat it as`,
`a real instruction and continue working; do not wait for the human to confirm it.`,
'',
`Acknowledge this message briefly and then wait.`,
].join('\n');
}
+96
View File
@@ -0,0 +1,96 @@
import { Hono } from 'hono';
import { getAgentPanelByHandoffToken, getAgentPanelByName, listAgentPanels, type AgentPanel } from 'officerdb';
import { composeHandoff, deliverToAgentPanel } from './deliver';
import { logger } from '../chat/logger';
/**
* The door an AGENT knocks on — not a human, and not a browser.
*
* This is mounted above the account gate, so it does not carry a platform JWT and does not go through
* `userMiddleware`. That exemption is declared and justified in `capabilities/totality.ts`; the claim
* it makes is that this surface is authenticated by a per-panel bearer token instead, and that the
* token's authority is tiny by construction:
*
* - it identifies exactly one agent panel, so the SENDER cannot be forged and needs no `from` field
* - it can address only peers in that panel's own dashboard, by name
* - it can do one thing: deliver a prompt. No reads of anything else, no writes, no session keys.
*
* Deliberately its own router rather than a route inside `/chat`: the two have different callers,
* different credentials and different blast radii, and folding one into the other would have hidden
* that. See docs/agent-coordination.md.
*
* Note this is a bare `Hono` and not `createRouter()` — createRouter's helpers assume the `user` and
* `body` context values that `userMiddleware`/`bodyParser` set, and neither runs here.
*/
export const agentHandoffRouter = new Hono();
const TOKEN_HEADER = 'x-officer-agent-token';
async function senderFrom(header: string | undefined): Promise<AgentPanel | undefined> {
const token = header?.trim();
if (!token) return undefined;
return getAgentPanelByHandoffToken(token);
}
/** Errors here are read by a language model, so they say what to do instead of just what went wrong. */
const plain = (text: string, status: 400 | 401 | 404 | 500) => new Response(`${text}\n`, { status });
// GET /agent-handoff/peers — who else is on my dashboard. Discovery, so a wrong name is recoverable.
agentHandoffRouter.get('/peers', async (ctx) => {
const sender = await senderFrom(ctx.req.header(TOKEN_HEADER));
if (!sender) return plain(`Unknown or missing ${TOKEN_HEADER}.`, 401);
const peers = await listAgentPanels(sender.userId, sender.dashboardId);
return ctx.json({
you: sender.name,
dashboardId: sender.dashboardId,
peers: peers.filter((p) => p.id !== sender.id).map((p) => ({ name: p.name, cwd: p.cwd, role: p.rolePrompt })),
});
});
// POST /agent-handoff — deliver a message to a named peer as a turn in their own session.
agentHandoffRouter.post('/', async (ctx) => {
const sender = await senderFrom(ctx.req.header(TOKEN_HEADER));
if (!sender) return plain(`Unknown or missing ${TOKEN_HEADER}.`, 401);
let body: { to?: unknown; message?: unknown };
try {
body = await ctx.req.json();
} catch {
return plain('Body must be JSON: {"to": "<peer name>", "message": "<text>"}', 400);
}
const to = typeof body.to === 'string' ? body.to.trim() : '';
const message = typeof body.message === 'string' ? body.message.trim() : '';
if (!to) return plain('Missing "to". It is the name of a peer on your dashboard.', 400);
if (!message) return plain('Missing "message". An empty handoff would tell the other agent nothing.', 400);
if (to === sender.name) return plain(`You are \`${sender.name}\`. An agent cannot hand off to itself.`, 400);
const target = await getAgentPanelByName(sender.userId, sender.dashboardId, to);
if (!target) {
// Loud, and useful: a typo is the likeliest cause and the fix is in the error.
const peers = await listAgentPanels(sender.userId, sender.dashboardId);
const names = peers
.filter((p) => p.id !== sender.id)
.map((p) => `\`${p.name}\``)
.join(', ');
return plain(
`No agent named \`${to}\` on dashboard \`${sender.dashboardId}\`.\n` +
(names ? `Known peers: ${names}` : 'There are no other agents on this dashboard yet.'),
404,
);
}
try {
await deliverToAgentPanel(target, composeHandoff(sender.name, message));
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
logger.error('Handoff delivery failed', { from: sender.name, to: target.name, error: detail });
// Never swallow this. A handoff that silently vanished is the exact failure this whole mechanism
// exists to avoid — the sender would sit waiting for a reply that was never going to come.
return plain(`Could not deliver to \`${target.name}\`: ${detail}`, 500);
}
return ctx.json({ delivered: true, from: sender.name, to: target.name });
});