@monster-orange/den 0.4.11 → 0.4.13

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 (50) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +212 -12
  4. package/package.json +2 -1
  5. package/src/shared/agents/catalog.ts +77 -0
  6. package/src/shared/agents/default-pack/be-dev.md +1 -0
  7. package/src/shared/agents/default-pack/devops.md +1 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +1 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +3 -1
  10. package/src/shared/agents/default-pack/planner.md +1 -0
  11. package/src/shared/agents/default-pack/reviewer.md +1 -0
  12. package/src/shared/agents/default-pack/tester.md +16 -0
  13. package/src/shared/agents/default-pack/ux-designer.md +1 -0
  14. package/src/shared/agents/den-claude.ts +151 -17
  15. package/src/shared/agents/permissions.ts +108 -0
  16. package/src/shared/agents/persona-store.ts +286 -0
  17. package/src/shared/agents/personas.ts +116 -12
  18. package/src/shared/agents/records.ts +57 -0
  19. package/src/shared/agents/roles.ts +288 -0
  20. package/src/shared/agents/runtime.ts +49 -0
  21. package/src/shared/agents/session-name.ts +110 -0
  22. package/src/shared/agents/transcript-follow.ts +180 -0
  23. package/src/shared/den-config.ts +27 -1
  24. package/src/ui/cli/agent-ops.ts +578 -11
  25. package/src/ui/cli/agents-command.ts +12 -2
  26. package/src/ui/cli/env-probe.ts +4 -1
  27. package/src/ui/mcp/tools.ts +1 -1
  28. package/src/ui/server/agent-paths.ts +52 -0
  29. package/src/ui/server/agent-routes.ts +329 -19
  30. package/src/ui/server/agents-layout-pref.ts +43 -0
  31. package/src/ui/server/editor-open-pref.ts +41 -0
  32. package/src/ui/server/index.ts +22 -0
  33. package/src/ui/server/public/agent-start.js +3 -2
  34. package/src/ui/server/public/agents-page.js +1037 -67
  35. package/src/ui/server/public/agents-section.js +1 -1
  36. package/src/ui/server/public/agents.html +236 -20
  37. package/src/ui/server/public/header.js +2 -0
  38. package/src/ui/server/public/i18n/es.js +139 -0
  39. package/src/ui/server/public/i18n/ja.js +139 -0
  40. package/src/ui/server/public/persona-ui.css +539 -0
  41. package/src/ui/server/public/persona-ui.js +796 -0
  42. package/src/ui/server/public/settings-page.js +4 -9
  43. package/src/ui/server/public/settings-registry.js +66 -0
  44. package/src/ui/server/public/settings.html +1 -0
  45. package/src/ui/server/public/shell.js +137 -0
  46. package/src/ui/server/public/term-input.js +129 -0
  47. package/src/ui/server/public/topbar.js +22 -4
  48. package/src/ui/server/route-classes.ts +29 -0
  49. package/src/ui/server/session-routes.ts +34 -1
  50. package/src/ui/server/ui-assets.ts +8 -0
@@ -410,13 +410,23 @@ async function listPersonas(
410
410
  return 0;
411
411
  }
412
412
  for (const l of columns([
413
- ["PERSONA", "STANDING", "RUNTIME", "MODEL", "ROLE"],
413
+ [
414
+ "PERSONA",
415
+ "SOURCE",
416
+ "STANDING",
417
+ "RUNTIME",
418
+ "MODEL",
419
+ "ROLE",
420
+ "DESCRIPTION",
421
+ ],
414
422
  ...all.map((x) => [
415
423
  x.id,
424
+ x.origin,
416
425
  x.standing ? "yes" : "-",
417
426
  x.runtime ?? DEFAULT_RUNTIME_ID,
418
427
  x.model ?? "-",
419
- x.role,
428
+ x.role ?? "-",
429
+ x.description,
420
430
  ]),
421
431
  ]))
422
432
  d.out(l);
@@ -112,7 +112,10 @@ function probeInput(d: ProbeDeps, dir: string): LaunchInput {
112
112
  return {
113
113
  persona: {
114
114
  id: PROBE_ID,
115
- role: "",
115
+ description: "",
116
+ category: "engineering",
117
+ allowedCommands: [],
118
+ origin: "builtin",
116
119
  prompt: "",
117
120
  standing: false,
118
121
  tools: [],
@@ -740,7 +740,7 @@ const agentResumeTool: DenTool = {
740
740
  name: "agent_resume",
741
741
  title: "Resume an agent",
742
742
  description:
743
- "Resume a stopped or interrupted agent in the same conversation, persona, workspace and folder. Never starts a new conversation: an agent whose runtime can't resume is refused. Never the caller itself.",
743
+ "Resume a stopped or interrupted agent in the same conversation, persona, workspace and folder. Never starts a new conversation: an agent whose runtime can't resume is refused. Never the caller itself. Answers with agent: its id, persona, runtime, workspace, cwd, session (the session handle, <dist>-<ws>:<id>), sessionId, displayName (a person's name for it) and sessionTitle (the name its session shows: Den's rename or the runtime's, the later), when set.",
744
744
  action: true,
745
745
  shape: {
746
746
  agent: z.string().describe("The agent's id"),
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Which file paths an agent printed are links (AG-4b): GET
3
+ * /api/workspaces/<ws>/agents/<id>/paths?p=<path>&p=<path>… answers
4
+ * { paths: { <path as given>: <absolute path> | null } }.
5
+ *
6
+ * A path is resolved against the agent's cwd (an absolute one stays), and
7
+ * is a link only when it exists and its real path (symlinks followed) is
8
+ * inside the agent's workspace: the workspace's directory, or the agent's
9
+ * own cwd (a worktree of it). Anything else is null, so a path outside
10
+ * the workspace is never a link and the page never learns whether it
11
+ * exists. Only stat is used: nothing is read, nothing is opened.
12
+ */
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+
16
+ /** The most paths one ask takes (a screen's worth). */
17
+ export const MAX_PATHS = 64;
18
+ /** The longest path taken. */
19
+ const MAX_PATH_LEN = 1024;
20
+
21
+ function realOrNull(p: string): string | null {
22
+ try {
23
+ return fs.realpathSync(p);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ const inside = (p: string, root: string) =>
30
+ p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
31
+
32
+ /** Each path's absolute path when it is a link, else null. */
33
+ export function agentPathLinks(
34
+ given: readonly string[],
35
+ cwd: string,
36
+ roots: readonly string[],
37
+ ): Record<string, string | null> {
38
+ const real = roots
39
+ .map((r) => realOrNull(r))
40
+ .filter((r): r is string => r !== null);
41
+ const base = realOrNull(cwd) ?? path.resolve(cwd);
42
+ const out: Record<string, string | null> = {};
43
+ for (const p of given) {
44
+ if (Object.hasOwn(out, p)) continue;
45
+ out[p] = null;
46
+ if (!p || p.length > MAX_PATH_LEN || p.includes("\0")) continue;
47
+ const abs = path.resolve(base, p);
48
+ const r = realOrNull(abs);
49
+ if (r && real.some((root) => inside(r, root))) out[p] = r;
50
+ }
51
+ return out;
52
+ }
@@ -12,8 +12,26 @@
12
12
  * runs its version command and
13
13
  * refuses a path that isn't
14
14
  * that runtime (AG-0.5)
15
+ * GET /api/personas/<id> one persona with its
16
+ * instructions (Details)
17
+ * POST /api/personas New persona (AG-10.7): a
18
+ * local one; a person only
19
+ * POST /api/personas/<id> Edit: a local one only
20
+ * (409 read_only otherwise)
21
+ * DELETE /api/personas/<id> Delete: a local one only
15
22
  * GET /api/workspaces/<ws>/agents the workspace's agents
16
- * POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
23
+ * POST /api/workspaces/<ws>/agents start { persona, runtime?,
24
+ * model?, version?,
25
+ * permission?, allow?,
26
+ * instruction?, id? }
27
+ * (AG-10.1): each checked
28
+ * against the catalog; the
29
+ * instruction is typed into
30
+ * the session once it is
31
+ * ready, never an argument
32
+ * POST /api/workspaces/<ws>/sessions a plain session: the login
33
+ * shell in the workspace's
34
+ * folder (AG-10.1)
17
35
  * POST /api/workspaces/<ws>/agents/<id>/stop
18
36
  * POST /api/workspaces/<ws>/agents/<id>/resume the same conversation (AG-14)
19
37
  * POST /api/workspaces/<ws>/agents/<id>/fresh a new conversation, same
@@ -23,6 +41,14 @@
23
41
  * first prompt is its
24
42
  * handoff.md; a resume of
25
43
  * one is refused (409 rotated)
44
+ * POST /api/workspaces/<ws>/agents/<id>/name { name: string | null }: the
45
+ * name its tab and the lists
46
+ * show (AG-4b); the id never
47
+ * changes; null or "" clears
48
+ * GET /api/workspaces/<ws>/agents/<id>/paths?p=… which printed paths are
49
+ * links (AG-4b): inside its
50
+ * workspace and there (stat
51
+ * only; agent-paths.ts)
26
52
  * POST /api/workspaces/<ws>/agents/<id>/remove forget a stopped one (Undo:
27
53
  * /api/agents/restore)
28
54
  * POST /api/agents/clear-stopped { workspaces: [...] }: forget
@@ -67,13 +93,22 @@ import { HostError } from "../../shared/sessions/client.js";
67
93
  import {
68
94
  AgentError,
69
95
  agentRecord,
96
+ checkInstruction,
97
+ createPersona,
98
+ deletePersona,
70
99
  listAgents,
71
100
  listPersonas,
101
+ personaDetail,
102
+ registries,
103
+ sendFirstInstruction,
104
+ startPlainSession,
105
+ updatePersona,
72
106
  listRuntimes,
73
107
  markInterrupted,
74
108
  NOT_IN_WORKSPACE,
75
109
  pruneStopped,
76
110
  removeAgent,
111
+ renameAgent,
77
112
  removeStopped,
78
113
  restoreAgents,
79
114
  resumeInterrupted,
@@ -88,7 +123,9 @@ import {
88
123
  type AgentsResolved,
89
124
  type Started,
90
125
  } from "../cli/agent-ops.js";
126
+ import { agentSessionName } from "../../shared/agents/session-name.js";
91
127
  import { recordAction } from "./audit.js";
128
+ import { agentPathLinks, MAX_PATHS } from "./agent-paths.js";
92
129
  import {
93
130
  agentPrincipalOf,
94
131
  auditActorOf,
@@ -101,14 +138,18 @@ import type { Actor } from "../../shared/agent-actor.js";
101
138
  export interface AgentRouteDeps {
102
139
  /** The operations' deps (tests: an in-process host, a temp state dir). */
103
140
  agents?: AgentsDeps;
141
+ /** When a first instruction counts its session ready (tests: shorter). */
142
+ instruction?: { quietMs?: number; maxMs?: number };
104
143
  }
105
144
 
106
145
  const AGENTS_PATH =
107
- /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove))?$/;
146
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths))?$/;
108
147
 
109
148
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
110
- /** A start's body is a few short names. */
111
- const MAX_BODY = 4096;
149
+ const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
150
+ const PLAIN_PATH = /^\/api\/workspaces\/([^/]+)\/sessions$/;
151
+ /** A start's body: a few names, commands and a first instruction. */
152
+ const MAX_BODY = 32 * 1024;
112
153
  /** A list of names (a page's workspaces, an Undo's agents). */
113
154
  const MANY_BODY = 64 * 1024;
114
155
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -163,10 +204,20 @@ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
163
204
  throw e;
164
205
  }
165
206
 
207
+ function sessionTitleOf(
208
+ rec: Started["rec"],
209
+ d: AgentsResolved,
210
+ ): { sessionTitle?: string } {
211
+ const n = agentSessionName(rec, d.stateDir());
212
+ return n ? { sessionTitle: n } : {};
213
+ }
214
+
166
215
  /** A started or resumed agent's answer: its record, or its early exit. */
167
216
  function sendStarted(
168
217
  res: http.ServerResponse,
169
218
  s: Started & { fresh?: boolean },
219
+ d: AgentsResolved,
220
+ extra: Record<string, unknown> = {},
170
221
  ): void {
171
222
  if (s.early) {
172
223
  const code =
@@ -197,17 +248,47 @@ function sendStarted(
197
248
  // The host's id: the Agents tab opens its pane by it (?open=<id>).
198
249
  sessionId: s.info.ref.id,
199
250
  startedAt: s.rec.startedAt,
251
+ ...(s.rec.model ? { model: s.rec.model } : {}),
252
+ ...(s.rec.version ? { version: s.rec.version } : {}),
253
+ ...(s.rec.permission ? { permission: s.rec.permission } : {}),
254
+ ...(s.rec.overrides?.length ? { overrides: s.rec.overrides } : {}),
255
+ ...(s.rec.displayName ? { displayName: s.rec.displayName } : {}),
256
+ // AG-4b: the name its session shows (Den's rename or the runtime's),
257
+ // as the lists show it (MCP agent_resume answers with this). Its
258
+ // handle is `session`.
259
+ ...sessionTitleOf(s.rec, d),
200
260
  },
201
261
  // A resume with nothing to continue yet started fresh (same id).
202
262
  ...(s.fresh ? { fresh: true } : {}),
203
263
  ...(s.warnings.length ? { warnings: s.warnings } : {}),
264
+ ...extra,
204
265
  });
205
266
  }
206
267
 
207
- /** What a POST's body names: a few optional strings. */
208
- async function startBody(
209
- req: http.IncomingMessage,
210
- ): Promise<Record<string, string | undefined>> {
268
+ /** What a start's body names: optional strings, a command list, an instruction. */
269
+ interface StartBody {
270
+ persona?: string;
271
+ runtime?: string;
272
+ model?: string;
273
+ id?: string;
274
+ version?: string;
275
+ permission?: string;
276
+ allow?: unknown;
277
+ instruction?: unknown;
278
+ }
279
+
280
+ const START_KEYS = [
281
+ "persona",
282
+ "runtime",
283
+ "model",
284
+ "id",
285
+ "version",
286
+ "permission",
287
+ "allow",
288
+ "instruction",
289
+ ];
290
+
291
+ async function startBody(req: http.IncomingMessage): Promise<StartBody> {
211
292
  const raw = await readBody(req).catch(() => "");
212
293
  if (raw.length > MAX_BODY)
213
294
  throw new AgentError("request too large", { status: 413, usage: true });
@@ -219,15 +300,38 @@ async function startBody(
219
300
  }
220
301
  if (!v || typeof v !== "object" || Array.isArray(v))
221
302
  throw new AgentError("the body must be a JSON object", { usage: true });
222
- const out: Record<string, string | undefined> = {};
223
- for (const k of ["persona", "runtime", "model", "id"]) {
224
- const x = (v as Record<string, unknown>)[k];
303
+ const o = v as Record<string, unknown>;
304
+ for (const k of Object.keys(o))
305
+ if (!START_KEYS.includes(k))
306
+ throw new AgentError(
307
+ `${k}: unknown field (a start takes ${START_KEYS.join(", ")})`,
308
+ { usage: true },
309
+ );
310
+ const out: Record<string, unknown> = {};
311
+ for (const k of [
312
+ "persona",
313
+ "runtime",
314
+ "model",
315
+ "id",
316
+ "version",
317
+ "permission",
318
+ ]) {
319
+ const x = o[k];
225
320
  if (x === undefined || x === null || x === "") continue;
226
321
  if (typeof x !== "string")
227
322
  throw new AgentError(`${k} must be a string`, { usage: true });
323
+ if (x.length > 128 || /[^\x21-\x7e]/.test(x))
324
+ throw new AgentError(
325
+ `${k} must be a short name (no spaces or control characters)`,
326
+ {
327
+ usage: true,
328
+ },
329
+ );
228
330
  out[k] = x;
229
331
  }
230
- return out;
332
+ if (o.allow !== undefined && o.allow !== null) out.allow = o.allow;
333
+ if (o.instruction !== undefined) out.instruction = o.instruction;
334
+ return out as StartBody;
231
335
  }
232
336
 
233
337
  /**
@@ -243,6 +347,8 @@ export async function handleAgentRoutes(
243
347
  ): Promise<boolean> {
244
348
  const m = AGENTS_PATH.exec(pathname);
245
349
  const rp = RUNTIME_PATH.exec(pathname);
350
+ const pp = PERSONA_PATH.exec(pathname);
351
+ const plain = PLAIN_PATH.exec(pathname);
246
352
  // AG-14's routes over many agents at once.
247
353
  const many =
248
354
  pathname === "/api/agents/clear-stopped" ||
@@ -253,22 +359,34 @@ export async function handleAgentRoutes(
253
359
  pathname !== "/api/agents/runtimes" &&
254
360
  !m &&
255
361
  !rp &&
362
+ !pp &&
363
+ !plain &&
256
364
  !many
257
365
  )
258
366
  return false;
259
367
  const method = req.method ?? "GET";
260
368
  const d = webDeps(deps.agents);
261
369
 
370
+ if (pp) return personaRoute(req, res, safeDecode(pp[1]), d);
371
+ if (plain) return plainSession(req, res, safeDecode(plain[1]), d);
372
+ if (pathname === "/api/personas" && method === "POST")
373
+ return personaRoute(req, res, null, d);
374
+
262
375
  if (rp) return runtimePath(req, res, safeDecode(rp[1]), d);
263
376
  if (many) return manyAgents(req, res, pathname, d);
264
377
  if (!m) {
265
378
  if (method !== "GET" && method !== "HEAD")
266
- return methodNotAllowed(res, "GET");
379
+ return methodNotAllowed(
380
+ res,
381
+ pathname === "/api/personas" ? "GET, POST" : "GET",
382
+ );
267
383
  try {
268
384
  const ws = queryWorkspace(req, d);
269
385
  const runtimes = await listRuntimes(d, ws);
270
386
  sendJson(res, {
271
- ...(pathname === "/api/personas" ? { personas: listPersonas(d) } : {}),
387
+ ...(pathname === "/api/personas"
388
+ ? { personas: listPersonas(d), ...registries(d) }
389
+ : {}),
272
390
  runtimes,
273
391
  ...(ws ? { workspace: ws } : {}),
274
392
  // AG-0.2: the file a person edits to set a runtime's path.
@@ -294,6 +412,8 @@ export async function handleAgentRoutes(
294
412
  | "fresh"
295
413
  | "relaunch"
296
414
  | "remove"
415
+ | "name"
416
+ | "paths"
297
417
  | undefined;
298
418
 
299
419
  if (!action && method === "GET") {
@@ -313,6 +433,38 @@ export async function handleAgentRoutes(
313
433
  }
314
434
  return true;
315
435
  }
436
+ // AG-4b: which paths an agent printed are links (read-only: stat only).
437
+ if (action === "paths") {
438
+ if (method !== "GET" && method !== "HEAD")
439
+ return methodNotAllowed(res, "GET");
440
+ try {
441
+ const rec = agentRecord(d, id!, ws);
442
+ const given = new URL(
443
+ req.url ?? "/",
444
+ "http://localhost",
445
+ ).searchParams.getAll("p");
446
+ if (given.length > MAX_PATHS)
447
+ throw new AgentError(`at most ${MAX_PATHS} paths at once`, {
448
+ usage: true,
449
+ });
450
+ let wsDir: string | null = null;
451
+ try {
452
+ wsDir = d.workspaceDir(ws);
453
+ } catch {
454
+ wsDir = null;
455
+ }
456
+ sendJson(res, {
457
+ paths: agentPathLinks(
458
+ given,
459
+ rec.cwd,
460
+ [wsDir, rec.cwd].filter((x): x is string => !!x),
461
+ ),
462
+ });
463
+ } catch (e) {
464
+ sendError(res, e, ws);
465
+ }
466
+ return true;
467
+ }
316
468
  if (method !== "POST")
317
469
  return methodNotAllowed(res, action ? "POST" : "GET, POST");
318
470
 
@@ -345,16 +497,54 @@ export async function handleAgentRoutes(
345
497
  if (!body.persona)
346
498
  throw new AgentError("persona is required", { usage: true });
347
499
  what = `start ${body.id ?? body.persona} in ${ws}`;
500
+ const instruction = checkInstruction(body.instruction);
348
501
  const s = await startAgent(d, {
349
502
  workspace: ws,
350
503
  persona: body.persona,
351
504
  runtime: body.runtime,
352
505
  model: body.model,
353
506
  id: body.id,
507
+ ...(body.version !== undefined ? { version: body.version } : {}),
508
+ ...(body.permission !== undefined
509
+ ? { permission: body.permission }
510
+ : {}),
511
+ ...(body.allow !== undefined ? { allow: body.allow } : {}),
512
+ strict: true,
354
513
  });
355
- what = `start ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
514
+ const ov = s.rec.overrides?.length
515
+ ? `; overrides ${s.rec.overrides.join(", ")}`
516
+ : "";
517
+ what = `start ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}${ov}) in ${ws}`;
356
518
  audit(what, s.early ? "exited at once" : undefined);
357
- sendStarted(res, s);
519
+ sendStarted(res, s, d, instruction ? { instruction: "pending" } : {});
520
+ // AG-10.1: its first message, once it is ready: typed into its
521
+ // session (the host's write path), never an argument. Its text is
522
+ // never logged; its length is.
523
+ if (instruction && !s.early) {
524
+ const id = s.rec.id;
525
+ void sendFirstInstruction(
526
+ d,
527
+ s.info.ref.id,
528
+ s.rec.runtime,
529
+ instruction,
530
+ deps.instruction ?? {},
531
+ ).then((sent) =>
532
+ recordAction(
533
+ actor,
534
+ {
535
+ verb: "agents",
536
+ detail: `first instruction to ${id} in ${ws} (${instruction.length} characters)`,
537
+ ...(sent
538
+ ? {}
539
+ : {
540
+ outcome: "refused" as const,
541
+ reason: "its session ended or no host",
542
+ }),
543
+ },
544
+ principalOf(req),
545
+ ),
546
+ );
547
+ }
358
548
  } else if (verb === "stop") {
359
549
  // The answer comes once the session has ended (200, stopped: true),
360
550
  // or, still running after the wait, 202 with stillStopping: true.
@@ -375,12 +565,25 @@ export async function handleAgentRoutes(
375
565
  const s = await startFresh(d, id!, ws);
376
566
  what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
377
567
  audit(what, s.early ? "exited at once" : undefined);
378
- sendStarted(res, s);
568
+ sendStarted(res, s, d);
379
569
  } else if (verb === "relaunch") {
380
570
  const s = await relaunchAgent(d, id!, ws);
381
571
  what = `relaunch ${s.rec.id} from its handoff (${s.why}) in ${ws}`;
382
572
  audit(what, s.early ? "exited at once" : undefined);
383
- sendStarted(res, s);
573
+ sendStarted(res, s, d);
574
+ } else if (verb === "name") {
575
+ const body = await jsonBody(req);
576
+ if (!("name" in body))
577
+ throw new AgentError("name is required (null clears it)", {
578
+ usage: true,
579
+ });
580
+ const rec = renameAgent(d, id!, ws, body.name);
581
+ audit(what);
582
+ sendJson(res, {
583
+ ok: true,
584
+ agent: rec.id,
585
+ ...(rec.displayName ? { displayName: rec.displayName } : {}),
586
+ });
384
587
  } else if (verb === "remove") {
385
588
  const removed = await removeAgent(d, id!, ws);
386
589
  audit(what);
@@ -388,7 +591,7 @@ export async function handleAgentRoutes(
388
591
  } else {
389
592
  const s = await resumeAgent(d, id!, ws);
390
593
  audit(what);
391
- sendStarted(res, s);
594
+ sendStarted(res, s, d);
392
595
  }
393
596
  } catch (e) {
394
597
  audit(what, e instanceof Error ? e.message : String(e));
@@ -515,6 +718,113 @@ function queryWorkspace(
515
718
  return ws;
516
719
  }
517
720
 
721
+ /**
722
+ * The persona routes (AG-10.6, 10.7): GET one (anyone who may read);
723
+ * POST (new: `id` null; edit: a local one), DELETE (a local one): a
724
+ * person only. An agent's token can't make or change a persona (its
725
+ * permissions are the person's to set). Every change is audited, refused
726
+ * or done; never the instructions themselves.
727
+ */
728
+ async function personaRoute(
729
+ req: http.IncomingMessage,
730
+ res: http.ServerResponse,
731
+ id: string | null,
732
+ d: AgentsResolved,
733
+ ): Promise<true> {
734
+ const method = req.method ?? "GET";
735
+ if (id !== null && (method === "GET" || method === "HEAD")) {
736
+ try {
737
+ sendJson(res, { persona: personaDetail(d, id) });
738
+ } catch (e) {
739
+ sendError(res, e);
740
+ }
741
+ return true;
742
+ }
743
+ if (method !== "POST" && !(method === "DELETE" && id !== null))
744
+ return methodNotAllowed(
745
+ res,
746
+ id === null ? "GET, POST" : "GET, POST, DELETE",
747
+ );
748
+ const actor = auditActorOf(req);
749
+ const verb = id === null ? "create" : method === "DELETE" ? "delete" : "edit";
750
+ let what = `persona ${verb}${id ? ` ${id}` : ""}`;
751
+ const audit = (reason?: string) =>
752
+ recordAction(
753
+ actor,
754
+ {
755
+ verb: "agents",
756
+ detail: what,
757
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
758
+ },
759
+ principalOf(req),
760
+ );
761
+ try {
762
+ if (actor && !actor.unverified)
763
+ throw new AgentError(
764
+ "an agent can't create, change or delete personas: a person sets what agents may do",
765
+ { status: 403 },
766
+ );
767
+ if (verb === "delete") {
768
+ deletePersona(d, id!);
769
+ audit();
770
+ sendJson(res, { ok: true, deleted: id });
771
+ return true;
772
+ }
773
+ const body = await jsonBody(req);
774
+ const p =
775
+ verb === "create" ? createPersona(d, body) : updatePersona(d, id!, body);
776
+ what = `persona ${verb} ${p.id}`;
777
+ audit();
778
+ sendJson(res, { ok: true, persona: p });
779
+ } catch (e) {
780
+ audit(e instanceof Error ? e.message : String(e));
781
+ sendError(res, e);
782
+ }
783
+ return true;
784
+ }
785
+
786
+ /**
787
+ * POST /api/workspaces/<ws>/sessions (AG-10.1 Plain session): a person's
788
+ * login shell in the workspace's folder. A person only; audited.
789
+ */
790
+ async function plainSession(
791
+ req: http.IncomingMessage,
792
+ res: http.ServerResponse,
793
+ ws: string,
794
+ d: AgentsResolved,
795
+ ): Promise<true> {
796
+ if (req.method !== "POST") return methodNotAllowed(res, "POST");
797
+ const actor = auditActorOf(req);
798
+ let what = `plain session in ${ws}`;
799
+ const audit = (reason?: string) =>
800
+ recordAction(
801
+ actor,
802
+ {
803
+ verb: "sessions",
804
+ detail: what,
805
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
806
+ },
807
+ principalOf(req),
808
+ );
809
+ try {
810
+ if (actor && !actor.unverified)
811
+ throw new AgentError("an agent can't start a session from the page", {
812
+ status: 403,
813
+ });
814
+ const { info } = await startPlainSession(d, ws);
815
+ what = `plain session ${info.name} in ${ws}`;
816
+ audit();
817
+ sendJson(res, {
818
+ ok: true,
819
+ session: { id: info.ref.id, name: info.name, workspace: ws },
820
+ });
821
+ } catch (e) {
822
+ audit(e instanceof Error ? e.message : String(e));
823
+ sendError(res, e, ws);
824
+ }
825
+ return true;
826
+ }
827
+
518
828
  /**
519
829
  * POST /api/agents/runtimes/<id>/path: a person only, never an agent (it
520
830
  * runs the path given). Audited, refused or done; never the path's output.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The Agents tab's layout (AG-4b), a person's view preference: Focus (one
3
+ * pane, the default), Tabs or Tile. Stored in the user config as
4
+ * `agents.layout` and served by /api/settings as `agents_layout`, like the
5
+ * other view prefs (graph-prefs.ts, shell-prefs.ts). It changes no session
6
+ * and no agent: only how the page lays out their terminals.
7
+ */
8
+ import type { UserConfig } from "../../shared/den-config.js";
9
+
10
+ export const AGENTS_LAYOUTS = ["focus", "tabs", "tile"] as const;
11
+ export type AgentsLayout = (typeof AGENTS_LAYOUTS)[number];
12
+ export const AGENTS_LAYOUT_DEFAULT: AgentsLayout = "focus";
13
+
14
+ const isLayout = (v: unknown): v is AgentsLayout =>
15
+ typeof v === "string" && (AGENTS_LAYOUTS as readonly string[]).includes(v);
16
+
17
+ /** The stored layout, or the default when unset or hand-broken. */
18
+ export function agentsLayout(user: UserConfig): AgentsLayout {
19
+ const v = (user.agents as { layout?: unknown } | undefined)?.layout;
20
+ return isLayout(v) ? v : AGENTS_LAYOUT_DEFAULT;
21
+ }
22
+
23
+ /**
24
+ * Apply a POSTed `agents_layout` to `user` (in place): a layout sets
25
+ * agents.layout, null clears it (and an agents block left empty goes).
26
+ * Anything else: the error to answer with.
27
+ */
28
+ export function patchAgentsLayout(
29
+ user: UserConfig,
30
+ v: unknown,
31
+ ): { ok: true } | { ok: false; error: string } {
32
+ if (v !== null && !isLayout(v))
33
+ return {
34
+ ok: false,
35
+ error: `agents_layout must be one of ${AGENTS_LAYOUTS.map((l) => `"${l}"`).join(", ")}, or null`,
36
+ };
37
+ const agents = { ...(user.agents ?? {}) } as Record<string, unknown>;
38
+ if (v === null) delete agents.layout;
39
+ else agents.layout = v;
40
+ if (Object.keys(agents).length) user.agents = agents as UserConfig["agents"];
41
+ else delete user.agents;
42
+ return { ok: true };
43
+ }