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
+13
View File
@@ -85,6 +85,19 @@ export {
pruneChatEventsOlderThan, pruneChatEventsOlderThan,
} from './queries/chat-events'; } from './queries/chat-events';
export {
listAgentPanels,
getAgentPanelByPanelId,
getAgentPanelByName,
getAgentPanelByHandoffToken,
createAgentPanel,
updateAgentPanel,
markAgentPanelIntroduced,
deleteAgentPanel,
toAgentPanelView,
} from './queries/agent-panels';
export type { AgentPanel, AgentPanelView, CreateAgentPanelInput, UpdateAgentPanelInput } from './queries/agent-panels';
export { export {
getMusicFavorites, getMusicFavorites,
addMusicFavorite, addMusicFavorite,
@@ -0,0 +1,117 @@
import { randomUUID } from 'crypto';
import { and, asc, eq } from 'drizzle-orm';
import { db } from '../db';
import { agentPanels } from '../schema';
import type { AgentPanelRow } from '../schema/agent-panels';
export type AgentPanel = AgentPanelRow;
/** What the browser is allowed to see. The handoff token is a bearer credential and never crosses. */
export type AgentPanelView = Omit<AgentPanel, 'handoffToken' | 'userId'>;
export const toAgentPanelView = ({ handoffToken: _t, userId: _u, ...rest }: AgentPanel): AgentPanelView => rest;
/** Every agent on one dashboard, in creation order — this is the address book. */
export function listAgentPanels(userId: number, dashboardId: string): Promise<AgentPanel[]> {
return db
.select()
.from(agentPanels)
.where(and(eq(agentPanels.userId, userId), eq(agentPanels.dashboardId, dashboardId)))
.orderBy(asc(agentPanels.id));
}
export async function getAgentPanelByPanelId(
userId: number,
dashboardId: string,
panelId: string,
): Promise<AgentPanel | undefined> {
const [row] = await db
.select()
.from(agentPanels)
.where(
and(eq(agentPanels.userId, userId), eq(agentPanels.dashboardId, dashboardId), eq(agentPanels.panelId, panelId)),
)
.limit(1);
return row;
}
/** Resolve an address. The whole point of the table. */
export async function getAgentPanelByName(
userId: number,
dashboardId: string,
name: string,
): Promise<AgentPanel | undefined> {
const [row] = await db
.select()
.from(agentPanels)
.where(and(eq(agentPanels.userId, userId), eq(agentPanels.dashboardId, dashboardId), eq(agentPanels.name, name)))
.limit(1);
return row;
}
/** The sender's identity, established by the bearer token alone — see the schema comment. */
export async function getAgentPanelByHandoffToken(token: string): Promise<AgentPanel | undefined> {
const [row] = await db.select().from(agentPanels).where(eq(agentPanels.handoffToken, token)).limit(1);
return row;
}
export type CreateAgentPanelInput = {
userId: number;
dashboardId: string;
panelId: string;
name: string;
cwd?: string | null;
rolePrompt?: string | null;
};
/**
* Mint an agent. The sessionKey is generated here and only here: it is the one identifier that must
* never change for the life of the panel, because the sidecar's on-disk resume map and the durable
* event log are both keyed on it.
*/
export async function createAgentPanel(input: CreateAgentPanelInput): Promise<AgentPanel> {
const [row] = await db
.insert(agentPanels)
.values({
userId: input.userId,
dashboardId: input.dashboardId,
panelId: input.panelId,
name: input.name,
sessionKey: randomUUID(),
handoffToken: randomUUID(),
cwd: input.cwd ?? null,
rolePrompt: input.rolePrompt ?? null,
})
.returning();
return row!;
}
export type UpdateAgentPanelInput = {
name?: string;
cwd?: string | null;
rolePrompt?: string | null;
panelId?: string;
};
/** Rename, re-home or re-scope an agent. `sessionKey` is intentionally not updatable. */
export async function updateAgentPanel(
userId: number,
id: number,
patch: UpdateAgentPanelInput,
): Promise<AgentPanel | undefined> {
const [row] = await db
.update(agentPanels)
.set({ ...patch, updatedAt: new Date() })
.where(and(eq(agentPanels.userId, userId), eq(agentPanels.id, id)))
.returning();
return row;
}
/** Stamp the introduction as sent, so a reload cannot re-introduce an agent to itself. */
export async function markAgentPanelIntroduced(id: number): Promise<void> {
await db.update(agentPanels).set({ introducedAt: new Date() }).where(eq(agentPanels.id, id));
}
export async function deleteAgentPanel(userId: number, id: number): Promise<void> {
await db.delete(agentPanels).where(and(eq(agentPanels.userId, userId), eq(agentPanels.id, id)));
}
@@ -0,0 +1,71 @@
import { pgTable, serial, text, integer, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
/**
* One named agent living in one dashboard panel — the address book that lets two chat panels on the
* same page hand work to each other instead of routing every step through the human.
*
* See `docs/agent-coordination.md` for why this exists at all. The three columns that carry the design:
*
* `name` the ADDRESS. Human-assigned at dashboard setup ("frontend", "backend", "reviewer")
* and unique within a dashboard. Deliberately not the panel id: `movePanel` mints a
* fresh panel id when a panel is dragged, so an id-based address would break the first
* time the owner rearranged the layout. A name survives that; it is also what the
* agents themselves use in a prompt, which nothing else here can claim.
*
* `sessionKey` the CONTINUITY. Minted once, here, and never again — where the chat UI has always
* let the server mint a throwaway uuid per connection (`websocket.ts` does
* `msg.sessionId || randomUUID()`), a panel hands the same key back on every load.
* That is what makes an agent addressable across a reload, an officer restart and a
* night with no browser open: the claude sidecar keeps a write-through
* `sessionKey → claudeSessionId` map on disk, so a reaped session resumes its own
* transcript on the next turn, and `chat_session_events` replays under the same key.
* No heartbeat, no held process. Idle costs nothing.
*
* `handoffToken` the DOOR. A bearer token the agent presents to `POST /agent-handoff` to deliver a
* message to a peer. It authenticates the SENDER — which is why no `from` field is
* needed on the wire and why an agent cannot forge one. Its authority is deliberately
* tiny: deliver a prompt to a named peer *in the same dashboard*, nothing else. It
* cannot read, cannot create panels, and cannot name a raw sessionKey.
*
* `rolePrompt` is the introduction sent as the session's first turn ("you are the front-end developer;
* your peers are …"). Roles are prompts, not features — there is no role registry and there is not
* going to be one.
*/
export const agentPanels = pgTable(
'agent_panels',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Dashboard slug as the frontend knows it. No FK: `dashboards.id` is a global PK today and is
* being reworked to a composite (see docs/workspace-panel-todo.md §3); coupling to it now would
* make that change harder for no benefit here. */
dashboardId: text('dashboard_id').notNull(),
/** Where this agent currently lives. Bookkeeping for the UI, NOT the address — see `name`. */
panelId: text('panel_id').notNull(),
name: text('name').notNull(),
sessionKey: text('session_key').notNull(),
handoffToken: text('handoff_token').notNull(),
/** Home-relative working directory for this agent's session, or null for the owner's home. */
cwd: text('cwd'),
/** Sent as the first turn when the session is created. Null once it has been sent. */
rolePrompt: text('role_prompt'),
/** Null until the introduction turn has actually been delivered, so a reload cannot re-send it. */
introducedAt: timestamp('introduced_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// The address must resolve to exactly one agent, or a handoff is ambiguous.
uniqueIndex('uq_agent_panels_address').on(table.userId, table.dashboardId, table.name),
// One agent per panel, so a panel cannot end up with two sessions behind it.
uniqueIndex('uq_agent_panels_panel').on(table.userId, table.dashboardId, table.panelId),
uniqueIndex('uq_agent_panels_session_key').on(table.sessionKey),
uniqueIndex('uq_agent_panels_handoff_token').on(table.handoffToken),
index('idx_agent_panels_dashboard').on(table.userId, table.dashboardId),
],
);
export type AgentPanelRow = typeof agentPanels.$inferSelect;
@@ -1,3 +1,4 @@
export * from './agent-panels';
export * from './auth'; export * from './auth';
export * from './capabilities'; export * from './capabilities';
export * from './chat-events'; export * from './chat-events';
+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 });
});
+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 });
});
}
+6
View File
@@ -23,6 +23,7 @@ import { listChatModels } from './list-models';
import { logger } from './logger'; import { logger } from './logger';
import { readSttConfig } from '../server-settings/stt'; import { readSttConfig } from '../server-settings/stt';
import { transcribeAudio } from '../stt/transcribe'; import { transcribeAudio } from '../stt/transcribe';
import { registerAgentPanelRoutes } from './agent-panels-routes';
export const chatRouter = createRouter(); export const chatRouter = createRouter();
@@ -165,3 +166,8 @@ chatRouter.post('/stt', async (ctx: Context) => {
return ctx.json({ error: err instanceof Error ? err.message : 'Failed to reach Whisper server' }, 502); return ctx.json({ error: err instanceof Error ? err.message : 'Failed to reach Whisper server' }, 502);
} }
}); });
// The agent address book — naming a chat panel so other agents on the same dashboard can reach it.
// Lives on this router because it is the same authority `chat` already grants: creating and naming
// Claude sessions. See servers/api/chat/agent-panels-routes.ts and docs/agent-coordination.md.
registerAgentPanelRoutes(chatRouter);
+7
View File
@@ -33,6 +33,13 @@ const EXEMPT_API_PREFIXES: Record<string, string> = {
'/vault': 'Bitwarden protocol clients authenticate to Vaultwarden, not to Officer', '/vault': 'Bitwarden protocol clients authenticate to Vaultwarden, not to Officer',
// Registration socket for sidecars. Process-to-process on loopback; there is no user on this path. // Registration socket for sidecars. Process-to-process on loopback; there is no user on this path.
'/sidecar': 'sidecar registration, loopback process-to-process', '/sidecar': 'sidecar registration, loopback process-to-process',
// The caller is a Claude session running curl, not a browser: it has no platform JWT to present, so
// userMiddleware would 401 it and a capability lookup would have no account to resolve. What it does
// present is a per-panel token minted by the platform, which identifies exactly one agent panel and
// authorises exactly one action — deliver a prompt to a NAMED PEER ON THAT PANEL'S OWN DASHBOARD.
// It reads nothing else, writes nothing else, and cannot name a raw session key. The narrowness is
// the whole justification: this is not a general API door with a second credential, it is one verb.
'/agent-handoff': 'agent-to-agent handoff; a per-panel bearer token authorising one verb, not a platform JWT',
}; };
/** /**
+16 -1
View File
@@ -22,6 +22,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs';
import { router as fileBrowserRouter } from './api/file-browser/router'; import { router as fileBrowserRouter } from './api/file-browser/router';
import { musicRouter } from './api/music/router'; import { musicRouter } from './api/music/router';
import { vaultRouter } from './api/vault/router'; import { vaultRouter } from './api/vault/router';
import { agentHandoffRouter } from './api/agent-handoff/router';
import { slskdRouter } from './api/slskd/router'; import { slskdRouter } from './api/slskd/router';
import { headscaleRouter } from './api/headscale/router'; import { headscaleRouter } from './api/headscale/router';
import { transmissionRouter } from './api/transmission/router'; import { transmissionRouter } from './api/transmission/router';
@@ -103,6 +104,13 @@ honoServer.route('/api/waitlist', waitlistRouter);
honoServer.route('/api/vault', vaultRouter); honoServer.route('/api/vault', vaultRouter);
honoServer.get('/api/integrations/google/callback', googleCallbackHandler); honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT,
// so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token
// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
honoServer.route('/api/agent-handoff', agentHandoffRouter);
// CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is: // CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is:
// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a // DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a
// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see // platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see
@@ -209,7 +217,14 @@ export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix])
* Mounted above the account gate, and so exempt from capability checks — see EXEMPT_API_PREFIXES in * Mounted above the account gate, and so exempt from capability checks — see EXEMPT_API_PREFIXES in
* capabilities/totality.ts, which has to justify each one. * capabilities/totality.ts, which has to justify each one.
*/ */
export const UNPROTECTED_API_PREFIXES: string[] = ['/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar']; export const UNPROTECTED_API_PREFIXES: string[] = [
'/auth',
'/landing-page-data',
'/waitlist',
'/vault',
'/sidecar',
'/agent-handoff',
];
honoServer.route('/api', protectedRouter); honoServer.route('/api', protectedRouter);