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