Files
platform/src/servers/api/chat/agent-panels-routes.ts
T
pastilhas 2e8ec845c8 step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:

  1. the permission registry              → RENAMED here
  2. $OFFICER_ROOT/capabilities/ items    → kept; this is what capabilities are
  3. sidecar routing keys                 → step 3, becoming `handles`
  4. Lightning wallet features            → kept; a domain term, and on the wire
                                            to the mobile apps

The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.

Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.

Three breaks the typechecker could not see, all found by exercising it live.

The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.

The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.

And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.

The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.

The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.

Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.

tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
2026-08-15 16:03:22 +00:00

117 lines
5.6 KiB
TypeScript

import type { Hono } from 'hono';
import {
createAgentPanel,
deleteAgentPanel,
getAgentPanelByPanelId,
listAgentPanels,
toAgentPanelView,
updateAgentPanel,
} from 'officerdb';
import { introduceAgentPanel } from '../agent-handoff/deliver';
import { logger } from './logger';
import { OFFICER_API_URL } from '../../officer-url.mjs';
/**
* 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 permission —
* these routes create and name Claude sessions, which is what `chat` already grants. A new top-level
* mount would have meant a new permission 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 = OFFICER_API_URL;
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 });
});
}