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".
117 lines
5.6 KiB
TypeScript
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 });
|
|
});
|
|
}
|