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