diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 953bc9b5..91675066 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -85,6 +85,19 @@ export { pruneChatEventsOlderThan, } 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 { getMusicFavorites, addMusicFavorite, diff --git a/src/databases/officer_db/src/queries/agent-panels.ts b/src/databases/officer_db/src/queries/agent-panels.ts new file mode 100644 index 00000000..db018c88 --- /dev/null +++ b/src/databases/officer_db/src/queries/agent-panels.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await db.update(agentPanels).set({ introducedAt: new Date() }).where(eq(agentPanels.id, id)); +} + +export async function deleteAgentPanel(userId: number, id: number): Promise { + await db.delete(agentPanels).where(and(eq(agentPanels.userId, userId), eq(agentPanels.id, id))); +} diff --git a/src/databases/officer_db/src/schema/agent-panels.ts b/src/databases/officer_db/src/schema/agent-panels.ts new file mode 100644 index 00000000..321e3846 --- /dev/null +++ b/src/databases/officer_db/src/schema/agent-panels.ts @@ -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; diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index fb62b3b7..876a580e 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -1,3 +1,4 @@ +export * from './agent-panels'; export * from './auth'; export * from './capabilities'; export * from './chat-events'; diff --git a/src/servers/api/agent-handoff/deliver.ts b/src/servers/api/agent-handoff/deliver.ts new file mode 100644 index 00000000..db014db8 --- /dev/null +++ b/src/servers/api/agent-handoff/deliver.ts @@ -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 { + 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 { + 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'); +} diff --git a/src/servers/api/agent-handoff/router.ts b/src/servers/api/agent-handoff/router.ts new file mode 100644 index 00000000..8f4bd37e --- /dev/null +++ b/src/servers/api/agent-handoff/router.ts @@ -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 { + 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": "", "message": ""}', 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 }); +}); diff --git a/src/servers/api/chat/agent-panels-routes.ts b/src/servers/api/chat/agent-panels-routes.ts new file mode 100644 index 00000000..c96673b2 --- /dev/null +++ b/src/servers/api/chat/agent-panels-routes.ts @@ -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): 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; + + 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; + + const patch: Record = {}; + 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 }); + }); +} diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 664f5be5..cd8f4dcc 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -23,6 +23,7 @@ import { listChatModels } from './list-models'; import { logger } from './logger'; import { readSttConfig } from '../server-settings/stt'; import { transcribeAudio } from '../stt/transcribe'; +import { registerAgentPanelRoutes } from './agent-panels-routes'; 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); } }); + +// 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); diff --git a/src/servers/capabilities/totality.ts b/src/servers/capabilities/totality.ts index b38fc281..cd82984c 100644 --- a/src/servers/capabilities/totality.ts +++ b/src/servers/capabilities/totality.ts @@ -33,6 +33,13 @@ const EXEMPT_API_PREFIXES: Record = { '/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. '/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', }; /** diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 6bc3f8f0..713591db 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -22,6 +22,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { musicRouter } from './api/music/router'; import { vaultRouter } from './api/vault/router'; +import { agentHandoffRouter } from './api/agent-handoff/router'; import { slskdRouter } from './api/slskd/router'; import { headscaleRouter } from './api/headscale/router'; import { transmissionRouter } from './api/transmission/router'; @@ -103,6 +104,13 @@ honoServer.route('/api/waitlist', waitlistRouter); honoServer.route('/api/vault', vaultRouter); 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: // 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 @@ -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 * 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);