@monster-orange/den 0.4.14 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +226 -19
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +155 -8
  7. package/src/shared/agents/den-claude.ts +25 -3
  8. package/src/shared/agents/records.ts +37 -0
  9. package/src/shared/agents/runtime.ts +11 -1
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +12 -0
  13. package/src/shared/environment-manager/workspaces.ts +20 -0
  14. package/src/shared/github-app.ts +7 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +372 -23
  17. package/src/ui/cli/agents-command.ts +71 -4
  18. package/src/ui/cli/doctor-command.ts +29 -3
  19. package/src/ui/cli/doctor-onboard.ts +26 -0
  20. package/src/ui/cli/flag-parser.ts +9 -1
  21. package/src/ui/cli/index.ts +29 -3
  22. package/src/ui/cli/onboard-command.ts +180 -0
  23. package/src/ui/cli/onboard-reads.ts +613 -0
  24. package/src/ui/cli/repo-command.ts +156 -10
  25. package/src/ui/cli/web-command.ts +92 -1
  26. package/src/ui/mcp/api.ts +14 -2
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +368 -1
  29. package/src/ui/server/agent-routes.ts +91 -7
  30. package/src/ui/server/approval-routes.ts +390 -0
  31. package/src/ui/server/approvals.ts +416 -0
  32. package/src/ui/server/github-routes.ts +13 -5
  33. package/src/ui/server/held-actions.ts +203 -0
  34. package/src/ui/server/http-helpers.ts +35 -0
  35. package/src/ui/server/index.ts +308 -21
  36. package/src/ui/server/lifecycle-exec.ts +70 -0
  37. package/src/ui/server/onboard-routes.ts +132 -0
  38. package/src/ui/server/pair-routes.ts +7 -4
  39. package/src/ui/server/public/agents-page.js +378 -30
  40. package/src/ui/server/public/agents.html +1 -0
  41. package/src/ui/server/public/error-page.js +97 -18
  42. package/src/ui/server/public/error.html +18 -0
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +65 -5
  45. package/src/ui/server/public/i18n/ja.js +67 -5
  46. package/src/ui/server/public/index.html +1 -1
  47. package/src/ui/server/public/local-dev.html +5 -4
  48. package/src/ui/server/public/persona-ui.css +32 -0
  49. package/src/ui/server/public/release.html +0 -1
  50. package/src/ui/server/public/settings-registry.js +3 -1
  51. package/src/ui/server/public/settings.html +9 -1
  52. package/src/ui/server/public/shell.js +348 -5
  53. package/src/ui/server/request-guard.ts +2 -0
  54. package/src/ui/server/route-classes.ts +34 -1
  55. package/src/ui/server/session-routes.ts +14 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -64,6 +64,15 @@ export interface ToolContext {
64
64
  * behaves as it always has.
65
65
  */
66
66
  scope?: McpScope;
67
+ /**
68
+ * How a write waits for the person's answer (ON-3, approvals): poll
69
+ * every `intervalMs` for up to `waitMs`. Tests pass a short one.
70
+ */
71
+ approvalWait?: {
72
+ intervalMs: number;
73
+ waitMs: number;
74
+ sleep?: (ms: number) => Promise<void>;
75
+ };
67
76
  }
68
77
 
69
78
  type Args = Record<string, unknown>;
@@ -178,6 +187,20 @@ const read = (
178
187
  ): DenTool => ({ name, title, description, action: false, shape, run });
179
188
 
180
189
  const READ_TOOLS: DenTool[] = [
190
+ read(
191
+ "approval_check",
192
+ "Check an approval",
193
+ "Where one of your write requests stands (workspace_create, repo_add, service_try): `state` pending, done (with the server's `result`), denied, or expired. `id` is the request id the write tool gave you. Reads only.",
194
+ { id: z.string().describe("The request id a write tool returned") },
195
+ async (a, { api }) => {
196
+ const id = str(a.id)?.trim() ?? "";
197
+ if (!/^[A-Za-z0-9_-]{16}$/.test(id))
198
+ throw new Refusal(
199
+ "approval_check: give the request id a write tool returned.",
200
+ );
201
+ return getApproval(api, id);
202
+ },
203
+ ),
181
204
  read(
182
205
  "status",
183
206
  "Service health",
@@ -528,6 +551,79 @@ const READ_TOOLS: DenTool[] = [
528
551
  );
529
552
  },
530
553
  ),
554
+ read(
555
+ "onboard_status",
556
+ "Onboarding status",
557
+ "What's set up and the one next step: `checks` (distribution, node, git, docker, web, workspaces, repos, service, mcp), each `ok`, `warn` or `todo` with its `detail` and, when there is one, a `fix` command or an `ask` for the person; then `next`: the step in plain words and its `command`. Reads only. Start here when setting Den up.",
558
+ {},
559
+ (_a, { api }) => api.get("/api/onboard/status"),
560
+ ),
561
+ read(
562
+ "repos_detect",
563
+ "Find repos in a folder",
564
+ "Scan one folder for git repos (the folder itself, or one or two levels down) and say how each runs: `manifest` (its own, a side-car, or none), `runs` (the commands Den detected) or `ask` and `choices` when there are several ways. `workspaces` suggests a grouping: the repos in one parent folder go in a workspace named after it. `path` is required: an absolute folder (or ~/…) inside the person's home, not Den's state folder. Reads only: it never writes, runs a repo's tools or follows a symlink. Skips node_modules, vendor and dot-folders; at most `max` repos (50).",
565
+ {
566
+ path: z
567
+ .string()
568
+ .describe("The folder to scan: absolute, or starting with ~/"),
569
+ max: z
570
+ .number()
571
+ .int()
572
+ .min(1)
573
+ .max(50)
574
+ .optional()
575
+ .describe("At most this many repos (default and ceiling 50)"),
576
+ },
577
+ async (a, { api, scope }) => {
578
+ // Scanning folders is Den-wide, not one workspace's: the server's own
579
+ // (unscoped) agent does onboarding.
580
+ if (scope)
581
+ throw new ScopeRefusal(
582
+ `repos_detect: this MCP server is scoped to workspace "${scope.workspace}" (${scope.actor}), and scanning folders reaches outside any workspace; nothing was read. Den's own unscoped MCP server does onboarding.`,
583
+ );
584
+ const p = needPath("repos_detect", a);
585
+ return refusedOn400("repos_detect", () =>
586
+ api.get("/api/onboard/repos", {
587
+ path: p,
588
+ ...(typeof a.max === "number" ? { max: String(a.max) } : {}),
589
+ }),
590
+ );
591
+ },
592
+ ),
593
+ read(
594
+ "manifest_draft",
595
+ "Draft a side-car manifest",
596
+ "The side-car manifest `repo add` would write for one repo, checked by the same validator, with nothing written (`repo add --dry-run`): `draft` (the YAML), `validation`, `manifest.file` (where it would go, never in the repo) and `services`. When the repo can start in several ways it answers `ask` and `choices` instead: call again with `run` set to the one the person picks. A repo with its own manifest needs no draft (`manifest.source: repo`). `path`: the repo's absolute folder (or ~/…) inside the person's home.",
597
+ {
598
+ path: z.string().describe("The repo's folder: absolute, or ~/…"),
599
+ name: z
600
+ .string()
601
+ .optional()
602
+ .describe("The repo name (default: its folder)"),
603
+ run: z
604
+ .string()
605
+ .optional()
606
+ .describe("The start command, when detection asked which one"),
607
+ down: z.string().optional().describe("The stop command"),
608
+ health: z
609
+ .string()
610
+ .optional()
611
+ .describe("An http(s) URL for the health check"),
612
+ },
613
+ async (a, { api, scope }) => {
614
+ const p = needPath("manifest_draft", a);
615
+ if (scope) await assertPathInScope("manifest_draft", api, scope, p);
616
+ return refusedOn400("manifest_draft", () =>
617
+ api.get("/api/onboard/manifest", {
618
+ path: p,
619
+ name: str(a.name),
620
+ run: str(a.run),
621
+ down: str(a.down),
622
+ health: str(a.health),
623
+ }),
624
+ );
625
+ },
626
+ ),
531
627
  read(
532
628
  "edge_status",
533
629
  "Edge status",
@@ -551,6 +647,28 @@ const READ_TOOLS: DenTool[] = [
551
647
  ),
552
648
  ];
553
649
 
650
+ /** The `path` argument, or a refusal before anything is asked. */
651
+ function needPath(tool: string, a: Args): string {
652
+ const p = str(a.path)?.trim();
653
+ if (!p)
654
+ throw new Refusal(`${tool}: give a path (an absolute folder, or ~/…).`);
655
+ return p;
656
+ }
657
+
658
+ /** The web server's 400 (the path rule said no) as a refusal naming why. */
659
+ async function refusedOn400(
660
+ tool: string,
661
+ call: () => Promise<unknown>,
662
+ ): Promise<unknown> {
663
+ try {
664
+ return await call();
665
+ } catch (e) {
666
+ if (e instanceof DenHttpError && e.status === 400)
667
+ throw new Refusal(`${tool}: ${e.body}`);
668
+ throw e;
669
+ }
670
+ }
671
+
554
672
  /**
555
673
  * The /api/workspaces body a scoped answer is counted against, read at most
556
674
  * once every few seconds per scope (one tool call often makes several).
@@ -928,10 +1046,253 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
928
1046
  export const READ_TOOL_NAMES: readonly string[] = READ_TOOLS.map((t) => t.name);
929
1047
  export const ACTION_TOOL_NAMES: readonly McpAction[] = MCP_ACTIONS;
930
1048
 
931
- /** The tools to register: every read tool, plus the actions the org named. */
1049
+ // ── Writes held for the person (ON-3, 0.4.15) ──────────────────────────────
1050
+
1051
+ /** How long a write waits for the person by default, and how often it looks. */
1052
+ const APPROVAL_WAIT: NonNullable<ToolContext["approvalWait"]> = {
1053
+ intervalMs: 2_000,
1054
+ waitMs: 120_000,
1055
+ };
1056
+
1057
+ /**
1058
+ * The tickets this MCP server was handed for its own asks (id → ticket):
1059
+ * with no agent token, a ticket is what lets it read its own request.
1060
+ * Never shown to the agent. Bounded; the oldest go first.
1061
+ */
1062
+ const TICKETS = new Map<string, string>();
1063
+ const MAX_TICKETS = 200;
1064
+ function keepTicket(id: string, ticket: unknown): void {
1065
+ if (typeof ticket !== "string" || !ticket) return;
1066
+ TICKETS.set(id, ticket);
1067
+ while (TICKETS.size > MAX_TICKETS)
1068
+ TICKETS.delete(TICKETS.keys().next().value as string);
1069
+ }
1070
+ /** GET one approval, with its ticket when this server asked for it. */
1071
+ function getApproval(api: DenApi, id: string): Promise<unknown> {
1072
+ const t = TICKETS.get(id);
1073
+ return api.get(
1074
+ `/api/approvals/${id}`,
1075
+ {},
1076
+ "GET",
1077
+ undefined,
1078
+ t ? { "X-Den-Approval-Ticket": t } : undefined,
1079
+ );
1080
+ }
1081
+
1082
+ /**
1083
+ * Ask the web server to hold `action` for the person (POST /api/approvals),
1084
+ * then wait for the answer. Allowed: what the server's run said (an error
1085
+ * when it failed). Denied, expired, the agent stopped: a refusal. Still
1086
+ * pending: a plain answer naming approval_check.
1087
+ */
1088
+ async function askPerson(
1089
+ tool: string,
1090
+ action: string,
1091
+ args: Args,
1092
+ note: unknown,
1093
+ ctx: ToolContext,
1094
+ ): Promise<unknown> {
1095
+ const clean = Object.fromEntries(
1096
+ Object.entries(args).filter(([, v]) => v !== undefined && v !== ""),
1097
+ );
1098
+ let asked: { id?: unknown; ticket?: unknown };
1099
+ try {
1100
+ asked = (await ctx.api.get("/api/approvals", {}, "POST", {
1101
+ action,
1102
+ args: clean,
1103
+ ...(typeof note === "string" && note.trim() ? { note: note.trim() } : {}),
1104
+ })) as { id?: unknown; ticket?: unknown };
1105
+ } catch (e) {
1106
+ if (e instanceof DenHttpError && [400, 403, 409, 429].includes(e.status))
1107
+ throw new Refusal(`${tool}: ${e.body}`);
1108
+ throw e;
1109
+ }
1110
+ const id = String(asked?.id ?? "");
1111
+ keepTicket(id, asked?.ticket);
1112
+ const w = ctx.approvalWait ?? APPROVAL_WAIT;
1113
+ const sleep =
1114
+ w.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
1115
+ const until = Date.now() + w.waitMs;
1116
+ for (;;) {
1117
+ const a = (await getApproval(ctx.api, id)) as {
1118
+ state?: string;
1119
+ result?: unknown;
1120
+ endedBecause?: string;
1121
+ };
1122
+ if (a.state === "done") {
1123
+ const result = a.result as { ok?: unknown } | undefined;
1124
+ const out = { approved: true, id, result };
1125
+ if (result && result.ok === false) throw new ActionFailed(out);
1126
+ return out;
1127
+ }
1128
+ if (a.state === "denied")
1129
+ throw new Refusal(
1130
+ a.endedBecause === "actor_gone"
1131
+ ? `${tool}: the agent that asked was stopped before the person allowed it (request ${id}); nothing ran.`
1132
+ : `${tool}: the person denied it (request ${id}); nothing ran.`,
1133
+ );
1134
+ if (a.state === "expired")
1135
+ throw new Refusal(
1136
+ `${tool}: nobody answered in time (request ${id}); nothing ran. Ask again when the person is at Den.`,
1137
+ );
1138
+ if (Date.now() >= until) break;
1139
+ await sleep(w.intervalMs);
1140
+ }
1141
+ return `Waiting for the person to approve in Den (request ${id}); call approval_check with this id later.`;
1142
+ }
1143
+
1144
+ /** A required string argument, or a refusal before anything is asked. */
1145
+ function needArg(tool: string, a: Args, k: string, what: string): string {
1146
+ const v = str(a[k])?.trim();
1147
+ if (!v) throw new Refusal(`${tool}: give ${k} (${what}).`);
1148
+ return v;
1149
+ }
1150
+
1151
+ const noteArg = z
1152
+ .string()
1153
+ .max(500)
1154
+ .optional()
1155
+ .describe(
1156
+ 'One line for the person on why (shown quoted as "Agent says"; Den writes the request itself)',
1157
+ );
1158
+
1159
+ /** Onboarding writes are the unscoped server agent's. */
1160
+ function refuseScoped(tool: string, scope: McpScope | undefined): void {
1161
+ if (scope)
1162
+ throw new ScopeRefusal(
1163
+ `${tool}: this MCP server is scoped to workspace "${scope.workspace}" (${scope.actor}), and ${tool} changes Den beyond it; nothing was asked. Den's own unscoped MCP server does onboarding.`,
1164
+ );
1165
+ }
1166
+
1167
+ const HELD =
1168
+ " Nothing changes until the person allows it in Den's browser dialog: the request waits up to 10 minutes; this call waits up to 2 for the answer, then returns the request id for approval_check.";
1169
+
1170
+ const APPROVAL_TOOLS: DenTool[] = [
1171
+ {
1172
+ name: "workspace_create",
1173
+ title: "Create a workspace",
1174
+ description: "Create an empty Den workspace named `name`." + HELD,
1175
+ action: true,
1176
+ shape: {
1177
+ name: z
1178
+ .string()
1179
+ .describe(
1180
+ "The workspace name (no quotes, slashes or control characters)",
1181
+ ),
1182
+ note: noteArg,
1183
+ },
1184
+ async run(a, ctx) {
1185
+ refuseScoped("workspace_create", ctx.scope);
1186
+ const name = needArg("workspace_create", a, "name", "the workspace name");
1187
+ return askPerson(
1188
+ "workspace_create",
1189
+ "workspace_create",
1190
+ { name },
1191
+ a.note,
1192
+ ctx,
1193
+ );
1194
+ },
1195
+ },
1196
+ {
1197
+ name: "repo_add",
1198
+ title: "Add a repo",
1199
+ description:
1200
+ "Add the local repo at `path` to a workspace, as `repo add` does (manifest_draft first shows what it would write)." +
1201
+ HELD,
1202
+ action: true,
1203
+ shape: {
1204
+ path: z
1205
+ .string()
1206
+ .describe(
1207
+ "The repo's folder: absolute, or ~/… inside the person's home",
1208
+ ),
1209
+ workspace: workspaceArg,
1210
+ name: z
1211
+ .string()
1212
+ .optional()
1213
+ .describe("The repo name (default: its folder)"),
1214
+ run: z
1215
+ .string()
1216
+ .optional()
1217
+ .describe("The start command, when detection asked which"),
1218
+ down: z.string().optional().describe("The stop command"),
1219
+ health: z
1220
+ .string()
1221
+ .optional()
1222
+ .describe("An http(s) URL for the health check"),
1223
+ note: noteArg,
1224
+ },
1225
+ async run(a, ctx) {
1226
+ refuseScoped("repo_add", ctx.scope);
1227
+ const p = needArg("repo_add", a, "path", "the repo's folder");
1228
+ return askPerson(
1229
+ "repo_add",
1230
+ "repo_add",
1231
+ {
1232
+ path: p,
1233
+ workspace: str(a.workspace),
1234
+ name: str(a.name),
1235
+ run: str(a.run),
1236
+ down: str(a.down),
1237
+ health: str(a.health),
1238
+ },
1239
+ a.note,
1240
+ ctx,
1241
+ );
1242
+ },
1243
+ },
1244
+ {
1245
+ name: "service_try",
1246
+ title: "Start one service",
1247
+ description:
1248
+ "Start one service locally (`up`, as the Services page does) and report how it went: the exit code and the last lines of output." +
1249
+ HELD,
1250
+ action: true,
1251
+ shape: {
1252
+ service: z.string().describe("The service: <repo> or <repo>/<service>"),
1253
+ workspace: workspaceArg,
1254
+ note: noteArg,
1255
+ },
1256
+ async run(a, ctx) {
1257
+ const service = needArg(
1258
+ "service_try",
1259
+ a,
1260
+ "service",
1261
+ "<repo> or <repo>/<service>",
1262
+ );
1263
+ return askPerson(
1264
+ "service_try",
1265
+ "service_try",
1266
+ { service, workspace: str(a.workspace) },
1267
+ a.note,
1268
+ ctx,
1269
+ );
1270
+ },
1271
+ },
1272
+ ];
1273
+
1274
+ /**
1275
+ * The held writes: registered unless `mcp.approvals: false` (the person's
1276
+ * Allow is the gate, so they are on by default).
1277
+ */
1278
+ export const APPROVAL_TOOL_NAMES: readonly string[] = APPROVAL_TOOLS.map(
1279
+ (t) => t.name,
1280
+ );
1281
+
1282
+ /** One line saying whether the held writes are on (MCP instructions, --list, doctor). */
1283
+ export function approvalsState(
1284
+ cfg: Pick<ResolvedMcpConfig, "approvals">,
1285
+ ): string {
1286
+ return cfg.approvals
1287
+ ? `held writes on: ${APPROVAL_TOOL_NAMES.join(", ")} run only after the person's Allow in the browser (mcp.approvals: false turns them off)`
1288
+ : `held writes off (mcp.approvals: false): ${APPROVAL_TOOL_NAMES.join(", ")} are not registered`;
1289
+ }
1290
+
1291
+ /** The tools to register: every read tool, the held writes (unless turned off), plus the actions the org named. */
932
1292
  export function toolsFor(cfg: ResolvedMcpConfig, scope?: McpScope): DenTool[] {
933
1293
  const tools = [
934
1294
  ...READ_TOOLS,
1295
+ ...(cfg.approvals ? APPROVAL_TOOLS : []),
935
1296
  ...MCP_ACTIONS.filter((a) => cfg.actions.includes(a)).map(
936
1297
  (a) => ACTION_TOOLS[a],
937
1298
  ),
@@ -954,6 +1315,8 @@ const WORKSPACE_TOOLS = new Set([
954
1315
  "logs_tail",
955
1316
  "tests_available",
956
1317
  "manifest_validate",
1318
+ "manifest_draft",
1319
+ "service_try",
957
1320
  "stacks",
958
1321
  "service_up",
959
1322
  "service_down",
@@ -1006,6 +1369,10 @@ export function pinnedTool(t: DenTool, scope: McpScope): DenTool {
1006
1369
  * than the web server it talks to.
1007
1370
  */
1008
1371
  export const ROUTE_SINCE: Readonly<Record<string, string>> = {
1372
+ "/api/approvals": "0.4.15",
1373
+ "/api/onboard/status": "0.4.15",
1374
+ "/api/onboard/repos": "0.4.15",
1375
+ "/api/onboard/manifest": "0.4.15",
1009
1376
  "/api/stacks": "0.2.0",
1010
1377
  "/api/edge": "0.2.0",
1011
1378
  "/api/security/plan": "0.2.0",
@@ -98,7 +98,7 @@ import {
98
98
  } from "../../shared/den-config.js";
99
99
  import {
100
100
  readWorkspacesFile,
101
- repoFolders,
101
+ conversationFolders,
102
102
  resolveWorkspaceDir,
103
103
  } from "../../shared/environment-manager/workspaces.js";
104
104
  import { getStateDir } from "../../shared/paths.js";
@@ -119,6 +119,7 @@ import {
119
119
  updatePersona,
120
120
  listRuntimes,
121
121
  markInterrupted,
122
+ cancelStalePersonaChanges,
122
123
  NOT_IN_WORKSPACE,
123
124
  pruneStopped,
124
125
  removeAgent,
@@ -129,6 +130,7 @@ import {
129
130
  setRuntimePath,
130
131
  resolveAgentsDeps,
131
132
  importAgent,
133
+ changePersona,
132
134
  listConversations,
133
135
  relaunchAgent,
134
136
  resumeAgent,
@@ -159,10 +161,12 @@ export interface AgentRouteDeps {
159
161
  agents?: AgentsDeps;
160
162
  /** When a first instruction counts its session ready (tests: shorter). */
161
163
  instruction?: { quietMs?: number; maxMs?: number };
164
+ /** AG-18: how "after this turn" polls (tests: shorter). */
165
+ personaWait?: { pollMs?: number; maxMs?: number };
162
166
  }
163
167
 
164
168
  const AGENTS_PATH =
165
- /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths))?$/;
169
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona))?$/;
166
170
 
167
171
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
168
172
  const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
@@ -195,10 +199,7 @@ function webDeps(over: AgentsDeps = {}): AgentsResolved {
195
199
  workspaceFolders: (ws: string) => {
196
200
  const cfg = readWorkspacesFile().workspaces[ws];
197
201
  if (!cfg) throw new AgentError(`workspace "${ws}" does not exist`);
198
- return [
199
- resolveWorkspaceDir(ws, cfg, getReposRoot()),
200
- ...repoFolders(cfg),
201
- ];
202
+ return conversationFolders(ws, cfg, getReposRoot());
202
203
  },
203
204
  }),
204
205
  stdout: NO_TTY,
@@ -479,6 +480,7 @@ export async function handleAgentRoutes(
479
480
  | "remove"
480
481
  | "name"
481
482
  | "paths"
483
+ | "persona"
482
484
  | undefined;
483
485
 
484
486
  if (!action && method === "GET") {
@@ -553,6 +555,13 @@ export async function handleAgentRoutes(
553
555
  if (id !== undefined) agentRecord(d, id, ws);
554
556
  // AG-14.4: MCP agent_resume may resume another agent of its scope.
555
557
  const agent = agentPrincipalOf(req);
558
+ // AG-18: a persona is the person's choice: an agent could widen its
559
+ // own permissions with it. Any agent credential or claim is refused.
560
+ if (verb === "persona" && (agent || actor))
561
+ throw new AgentError(
562
+ "only a person can change an agent's persona (an agent can't change its own or another's)",
563
+ { status: 403 },
564
+ );
556
565
  if (verb === "resume" && agent) {
557
566
  const why = agentResumeRefusal(agent, ws, id!, mcpActions());
558
567
  if (why) throw new AgentError(why, { status: 403 });
@@ -650,6 +659,62 @@ export async function handleAgentRoutes(
650
659
  agent: rec.id,
651
660
  ...(rec.displayName ? { displayName: rec.displayName } : {}),
652
661
  });
662
+ } else if (verb === "persona") {
663
+ const body = await jsonBody(req);
664
+ for (const k of Object.keys(body))
665
+ if (!PERSONA_KEYS.includes(k))
666
+ throw new AgentError(
667
+ `${k}: unknown field (a persona change takes ${PERSONA_KEYS.join(", ")})`,
668
+ { usage: true },
669
+ );
670
+ const str = (k: string): string | undefined => {
671
+ const v = body[k];
672
+ if (v === undefined || v === null || v === "") return undefined;
673
+ if (typeof v !== "string" || v.length > 128 || /[^\x21-\x7e]/.test(v))
674
+ throw new AgentError(`${k} must be a short name`, { usage: true });
675
+ return v;
676
+ };
677
+ const persona = str("persona");
678
+ if (!persona)
679
+ throw new AgentError("persona is required", { usage: true });
680
+ const when = str("when");
681
+ if (when !== undefined && when !== "now" && when !== "after-turn")
682
+ throw new AgentError("when must be now or after-turn", {
683
+ usage: true,
684
+ });
685
+ const from = agentRecord(d, id!, ws).persona;
686
+ const by = principalOf(req) ?? "this machine";
687
+ what = `persona ${id} in ${ws}: ${from} → ${persona}, by ${by}`;
688
+ const change = () =>
689
+ changePersona(
690
+ d,
691
+ id!,
692
+ ws,
693
+ {
694
+ persona,
695
+ ...(str("model") !== undefined ? { model: str("model") } : {}),
696
+ ...(str("version") !== undefined
697
+ ? { version: str("version") }
698
+ : {}),
699
+ ...(when ? { when: when as "now" | "after-turn" } : {}),
700
+ by,
701
+ },
702
+ deps.personaWait ?? {},
703
+ );
704
+ if (when === "after-turn") {
705
+ // It may wait for a long turn: answer now, audit when it's done.
706
+ agentRecord(d, id!, ws);
707
+ void change().then(
708
+ (s) => audit(what, s.early ? "exited at once" : undefined),
709
+ (e: unknown) =>
710
+ audit(what, e instanceof Error ? e.message : String(e)),
711
+ );
712
+ sendJson(res, { ok: true, agent: id, pending: true, persona }, 202);
713
+ return true;
714
+ }
715
+ const s = await change();
716
+ audit(what, s.early ? "exited at once" : undefined);
717
+ sendStarted(res, s, d);
653
718
  } else if (verb === "remove") {
654
719
  const removed = await removeAgent(d, id!, ws);
655
720
  audit(what);
@@ -666,7 +731,9 @@ export async function handleAgentRoutes(
666
731
  return true;
667
732
  }
668
733
 
669
- const IMPORT_KEYS = ["persona", "runtime", "id"];
734
+ const IMPORT_KEYS = ["persona", "runtime", "id", "model", "version"];
735
+ /** AG-18: what a persona change takes. */
736
+ const PERSONA_KEYS = ["persona", "model", "version", "when"];
670
737
 
671
738
  /**
672
739
  * Bring in (AG-17.1): GET lists the workspace's conversations, POST
@@ -740,6 +807,8 @@ async function conversations(
740
807
  persona,
741
808
  runtime: str("runtime"),
742
809
  id: str("id"),
810
+ ...(str("model") !== undefined ? { model: str("model") } : {}),
811
+ ...(str("version") !== undefined ? { version: str("version") } : {}),
743
812
  });
744
813
  what = `bring in ${session} as ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
745
814
  audit(s.early ? "exited at once" : undefined);
@@ -1092,6 +1161,21 @@ export function startAgentUpkeep(
1092
1161
  ): void {
1093
1162
  const d = webDeps(deps.agents);
1094
1163
  const restart = async () => {
1164
+ // AG-18: a persona change waiting for a turn's end lived in memory.
1165
+ try {
1166
+ for (const c of cancelStalePersonaChanges(d)) {
1167
+ const what = `persona ${c.id} in ${c.workspace}: ${c.from} → ${c.to}, by ${c.by ?? "this machine"}`;
1168
+ recordAction(null, {
1169
+ verb: "agents",
1170
+ detail: what,
1171
+ outcome: "refused",
1172
+ reason: "persona change cancelled: Den restarted",
1173
+ });
1174
+ log(`Persona change cancelled (Den restarted): ${what}`);
1175
+ }
1176
+ } catch (e) {
1177
+ log(` ⚠ persona changes after the restart: ${(e as Error).message}`);
1178
+ }
1095
1179
  try {
1096
1180
  const f = await markInterrupted(d);
1097
1181
  if (f.interrupted.length)