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,
} 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,
@@ -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 './capabilities';
export * from './chat-events';