@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
@@ -21,7 +21,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
21
21
  import * as fs from "fs";
22
22
  import * as path from "path";
23
23
  import * as yaml from "yaml";
24
- import { parseMachineName } from "./cli-command.js";
24
+ import { parseMachineName, parseSshUser } from "./cli-command.js";
25
25
  import { getStateDir } from "./paths.js";
26
26
 
27
27
  export const LOGIN_CODE_TTL_MS = 60_000;
@@ -195,6 +195,12 @@ export interface WebAuthConfig {
195
195
  * page gives this machine ("runs on <name>"). Unset: the short hostname.
196
196
  */
197
197
  machineName?: string;
198
+ /**
199
+ * `web.ssh_user` (SEC-2a-UX; user.yml, else the distribution's): the
200
+ * account the signed-out page's ssh link logs in as. Unset: the account
201
+ * Den runs as. "": no account on the page.
202
+ */
203
+ sshUser?: string;
198
204
  }
199
205
 
200
206
  /** `web.edge_unattested`'s values. */
@@ -290,8 +296,13 @@ export function readWebAuthConfig(
290
296
  const clientId =
291
297
  clientIdOf(user, "config/local/user.yml") ?? clientIdOf(team, configFile);
292
298
  const machineName = parseMachineName(user.machine_name);
299
+ const sshUser =
300
+ user.ssh_user !== undefined
301
+ ? parseSshUser(user.ssh_user)
302
+ : parseSshUser(team.ssh_user);
293
303
  return {
294
304
  ...(machineName ? { machineName } : {}),
305
+ ...(sshUser !== undefined ? { sshUser } : {}),
295
306
  allowPlainHttpRemote: plain === true,
296
307
  ignored,
297
308
  edgeUnattested: unattested ?? "signin",
@@ -319,6 +330,43 @@ function clientIdOf(
319
330
  return v.trim();
320
331
  }
321
332
 
333
+ // ── The approval key (0.4.15) ─────────────────────────────────────────────
334
+
335
+ /**
336
+ * Why a cookie alone can't approve an agent's change: browsers send a
337
+ * localhost cookie to every localhost port (cookies ignore ports), so a
338
+ * dev server an agent runs on another port receives Den's session cookie,
339
+ * and GET /api/session gives any cookie holder the session's CSRF token.
340
+ * The approval key is a second secret only the signed-in page holds: it
341
+ * comes in the sign-in's own answer (a 303's URL fragment, which never
342
+ * reaches a server, or the sign-in poll's JSON), the page keeps it in its
343
+ * localStorage (per origin, port included), and sends it as
344
+ * X-Den-Approve-Key.
345
+ */
346
+ export const APPROVE_KEY_HEADER = "x-den-approve-key";
347
+ /** The fragment parameter a sign-in's 303 carries it in. */
348
+ export const APPROVE_KEY_FRAGMENT = "ak";
349
+
350
+ export function approveKeyHash(key: string): string {
351
+ return createHash("sha256").update(`den-approve-v1:${key}`).digest("hex");
352
+ }
353
+
354
+ /** Whether `given` is the key whose hash the session keeps (constant time). */
355
+ export function approveKeyMatches(
356
+ storedHash: string | undefined,
357
+ given: string | undefined | null,
358
+ ): boolean {
359
+ if (!storedHash || !given || !/^[A-Za-z0-9_-]{43}$/.test(given)) return false;
360
+ const a = Buffer.from(approveKeyHash(given), "hex");
361
+ const b = Buffer.from(storedHash, "hex");
362
+ return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
363
+ }
364
+
365
+ /** `next` with the approval key in its fragment (a sign-in's 303). */
366
+ export function withApproveKey(next: string, key: string): string {
367
+ return `${next.split("#")[0]}#${APPROVE_KEY_FRAGMENT}=${key}`;
368
+ }
369
+
322
370
  // ── The session store ──────────────────────────────────────────────────────
323
371
 
324
372
  export interface SessionRecord {
@@ -329,14 +377,21 @@ export interface SessionRecord {
329
377
  csrf: string;
330
378
  /** The Host it was issued on. */
331
379
  host: string;
332
- /** SEC-2a: how it signed in: a GitHub sign-in, or a one-time link (pairing). Unset: a link (before 0.4.5). */
333
- via?: "github" | "link" | "code";
380
+ /** SEC-2a: how it signed in: a GitHub sign-in, a one-time link, a code (pairing), or the install's setup link (claim, 0.4.15). Unset: a link (before 0.4.5). */
381
+ via?: "github" | "link" | "code" | "claim";
334
382
  /** SEC-2a: the client's address when Den could attest it. */
335
383
  client?: string;
336
384
  /** SEC-2a: a GitHub sign-in's login (the chip's "@login"). */
337
385
  login?: string;
338
386
  /** SEC-2a: not yet greeted ("This device is signed in…", once). */
339
387
  welcome?: true;
388
+ /**
389
+ * 0.4.15: sha256 of this session's approval key (approvalKeyMatches).
390
+ * The key itself is handed over once, at the end of the sign-in, and
391
+ * never by GET /api/session. Unset: a session from before 0.4.15, which
392
+ * must sign in again to approve.
393
+ */
394
+ approveKeyHash?: string;
340
395
  }
341
396
 
342
397
  interface StoreFile {
@@ -464,14 +519,17 @@ export class WebSessionStore {
464
519
  create(meta: {
465
520
  userAgent?: string;
466
521
  host?: string;
467
- via?: "github" | "link" | "code";
522
+ via?: "github" | "link" | "code" | "claim";
468
523
  client?: string;
469
524
  login?: string;
470
525
  }): {
471
526
  id: string;
472
527
  session: SessionView;
528
+ /** The approval key, for the sign-in's answer only (never stored). */
529
+ approveKey: string;
473
530
  } {
474
531
  const id = randomBytes(32).toString("base64url");
532
+ const approveKey = randomBytes(32).toString("base64url");
475
533
  const key = this.keyOf(id);
476
534
  if (!key) throw new Error("web.secret is missing: no session can be made");
477
535
  const now = this.now();
@@ -487,12 +545,13 @@ export class WebSessionStore {
487
545
  ...(meta.client ? { client: String(meta.client).slice(0, 64) } : {}),
488
546
  ...(meta.login ? { login: String(meta.login).slice(0, 64) } : {}),
489
547
  welcome: true,
548
+ approveKeyHash: approveKeyHash(approveKey),
490
549
  };
491
550
  const data = structuredClone(this.load());
492
551
  this.prune(data, now);
493
552
  data.sessions[key] = rec;
494
553
  this.save(data);
495
- return { id, session: { key, ...rec } };
554
+ return { id, session: { key, ...rec }, approveKey };
496
555
  }
497
556
 
498
557
  /**
@@ -116,6 +116,7 @@ import {
116
116
  restoreRecord,
117
117
  writeRecord,
118
118
  checkDisplayName,
119
+ nameSlug,
119
120
  writeRuntimeFiles,
120
121
  type AgentRecord,
121
122
  type Override,
@@ -138,6 +139,7 @@ import type {
138
139
  RuntimePermissions,
139
140
  } from "../../shared/agents/runtime.js";
140
141
  import {
142
+ inScope,
141
143
  OPEN_ELSEWHERE_MESSAGE,
142
144
  openElsewhere,
143
145
  type RuntimeConversation,
@@ -195,7 +197,7 @@ export interface AgentsDeps extends SessionsDeps {
195
197
  * AG-17.1: the folders a workspace's conversations may have run in: its
196
198
  * own folder, and each repo's path, a repo added by path outside it too.
197
199
  */
198
- workspaceFolders?: (ws: string) => string[];
200
+ workspaceFolders?: (ws: string) => { under: string[]; exact?: string[] };
199
201
  /** The MCP server binary and its env; Den adds the scoped args. */
200
202
  mcpServer?: () => { command: string[] | string; env: Record<string, string> };
201
203
  /**
@@ -232,7 +234,7 @@ export interface AgentsResolved extends Resolved {
232
234
  agentsConfig: () => ResolvedAgentsConfig;
233
235
  distRoot: () => string;
234
236
  workspaceDir: (ws: string) => string;
235
- workspaceFolders: (ws: string) => string[];
237
+ workspaceFolders: (ws: string) => { under: string[]; exact?: string[] };
236
238
  mcpServer: () => { command: string[] | string; env: Record<string, string> };
237
239
  exitWatchMs: number;
238
240
  stopWaitMs: number;
@@ -292,7 +294,8 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
292
294
  new AgentError(message, { status, hints });
293
295
 
294
296
  let workspaceDirFn: ((ws: string) => string) | null = null;
295
- let repoPathsFn: ((ws: string) => string[]) | null = null;
297
+ let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
298
+ null;
296
299
  /** The workspaces config, for start and resume (loaded on first use). */
297
300
  export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
298
301
  await loadWorkspaces(d);
@@ -305,8 +308,11 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
305
308
  return ws.resolveWorkspaceDir(name, cfg, getReposRoot());
306
309
  };
307
310
  // A by-path repo's own folder (repo add <path>), wherever it is.
308
- repoPathsFn = (name) =>
309
- ws.repoFolders(ws.readWorkspacesFile().workspaces[name]);
311
+ foldersFn = (name) => {
312
+ const cfg = ws.readWorkspacesFile().workspaces[name];
313
+ if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
314
+ return ws.conversationFolders(name, cfg, getReposRoot());
315
+ };
310
316
  }
311
317
 
312
318
  /** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
@@ -340,13 +346,10 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
340
346
  workspaceFolders:
341
347
  d.workspaceFolders ??
342
348
  ((ws) => {
343
- const dir = d.workspaceDir
344
- ? d.workspaceDir(ws)
345
- : (() => {
346
- if (!workspaceDirFn) throw new Error("workspaces not loaded");
347
- return workspaceDirFn(ws);
348
- })();
349
- return [dir, ...(repoPathsFn?.(ws) ?? [])];
349
+ // A caller that gives its own folder (a test) gives just it.
350
+ if (d.workspaceDir) return { under: [d.workspaceDir(ws)] };
351
+ if (!foldersFn) throw new Error("workspaces not loaded");
352
+ return foldersFn(ws);
350
353
  }),
351
354
  mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
352
355
  exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
@@ -452,6 +455,11 @@ export interface AgentRow {
452
455
  * runtime's resume token, and it isn't running.
453
456
  */
454
457
  resumable: boolean;
458
+ /**
459
+ * AG-18: Change persona… applies: its runtime can resume and its record
460
+ * has a conversation to continue (running or not), not rotated.
461
+ */
462
+ personaChange?: boolean;
455
463
  /**
456
464
  * Why Resume doesn't apply to a stopped agent (AG-14.2), e.g. "codex
457
465
  * can't resume a conversation": only Start fresh is offered.
@@ -550,6 +558,9 @@ export function agentRows(
550
558
  state: st,
551
559
  startedAt: s?.startedAt ?? rec.startedAt,
552
560
  resumable,
561
+ ...(!rot?.ready && !personaChangeRefusal(rec)
562
+ ? { personaChange: true }
563
+ : {}),
553
564
  ...(why ? { noResume: why } : {}),
554
565
  ...(rec.stoppedAt ? { stoppedAt: rec.stoppedAt } : {}),
555
566
  ...(rec.endedAt ? { endedAt: rec.endedAt } : {}),
@@ -1048,6 +1059,48 @@ function sessionOf(
1048
1059
  * else the first of `<base>-2`, `<base>-3`… no agent has. A stopped agent's
1049
1060
  * id is never reused by a new start: only its Resume or Start fresh.
1050
1061
  */
1062
+ /**
1063
+ * AG-10.10b: the id a Name gives: its slug while no agent has it; a
1064
+ * running agent in the workspace with that id or that title is refused
1065
+ * (open it instead); a stopped one, or another workspace's, makes it the
1066
+ * next free `<slug>-2`… (Resume continues the stopped one). Null when the
1067
+ * Name has no id characters: the persona's default then.
1068
+ */
1069
+ async function namedId(
1070
+ d: AgentsResolved,
1071
+ name: string,
1072
+ ws: string,
1073
+ ): Promise<string | null> {
1074
+ const slug = nameSlug(name);
1075
+ const sessions = await withClient(d, (c) => c.list());
1076
+ const live = (rec: AgentRecord) => {
1077
+ const s = sessionOf(sessions, rec);
1078
+ return s && !s.exited ? s : undefined;
1079
+ };
1080
+ // No two live agents in one workspace share a title, or this id.
1081
+ for (const rec of listRecords(d.stateDir())) {
1082
+ if (rec.workspace !== ws) continue;
1083
+ const same =
1084
+ rec.id === slug ||
1085
+ (rec.displayName ?? "").toLowerCase() === name.toLowerCase();
1086
+ const s = same ? live(rec) : undefined;
1087
+ if (s)
1088
+ throw new AgentError(`agent ${rec.id} is running (${s.name})`, {
1089
+ status: 409,
1090
+ hints: [`Attach: ${d.cli} sessions attach ${s.name}`],
1091
+ running: { id: rec.id, sessionId: s.ref.id },
1092
+ });
1093
+ }
1094
+ if (!slug) return null;
1095
+ if (!readRecord(d.stateDir(), slug)) return slug;
1096
+ const stem = slug.slice(0, 58);
1097
+ for (let n = 2; n < 1000; n++) {
1098
+ const id = `${stem}-${n}`;
1099
+ if (!readRecord(d.stateDir(), id)) return id;
1100
+ }
1101
+ throw refused(`no free agent id for ${slug}: give one with --id`);
1102
+ }
1103
+
1051
1104
  export function defaultId(
1052
1105
  stateDir: string,
1053
1106
  persona: string,
@@ -1487,8 +1540,6 @@ export async function startAgent(
1487
1540
  throw usage(
1488
1541
  `${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
1489
1542
  );
1490
- const id = req.id ?? defaultId(d.stateDir(), persona.id, ws);
1491
- checkId(where, id);
1492
1543
  let given: string | null = null;
1493
1544
  if (req.name !== undefined)
1494
1545
  try {
@@ -1496,6 +1547,11 @@ export async function startAgent(
1496
1547
  } catch (e) {
1497
1548
  throw usage(`${where}: ${(e as Error).message}`);
1498
1549
  }
1550
+ const id =
1551
+ req.id ??
1552
+ (given ? await namedId(d, given, ws) : null) ??
1553
+ defaultId(d.stateDir(), persona.id, ws);
1554
+ checkId(where, id);
1499
1555
  syncCatalog(d);
1500
1556
  const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
1501
1557
  const det = await availableOrRefuse(d, rt, ws);
@@ -1627,6 +1683,17 @@ export interface ListedConversation extends RuntimeConversation {
1627
1683
  open: boolean;
1628
1684
  /** The Den agent already continuing it, if one is. */
1629
1685
  agent?: string;
1686
+ /**
1687
+ * AG-17.1c: the persona Bring in preselects: the one of the latest Den
1688
+ * agent that worked in its folder (or one inside it), else the
1689
+ * workspace's latest agent's. The person confirms; never submitted.
1690
+ */
1691
+ suggest?: string;
1692
+ /**
1693
+ * AG-17.1c: the MCP servers its tools came from that Den won't load (an
1694
+ * agent runs with Den's tools only).
1695
+ */
1696
+ lost?: string[];
1630
1697
  }
1631
1698
 
1632
1699
  /** Each runtime, and whether it can bring a conversation in. */
@@ -1678,14 +1745,10 @@ export function listConversations(
1678
1745
  throw usage(`${where}: workspace "${ws}" does not exist`, 404);
1679
1746
  syncCatalog(d);
1680
1747
  if (runtime !== undefined) runtimeFor(runtime);
1681
- // Its folder and every repo's (one under another is scanned once).
1682
- const folders = [
1683
- ...new Set(d.workspaceFolders(ws).map((f) => path.resolve(f))),
1684
- ];
1685
- const scan = folders.filter(
1686
- (f) => !folders.some((o) => o !== f && f.startsWith(o + path.sep)),
1687
- );
1748
+ // Its folder and every repo's; a shared root only exactly (AG-17.1).
1749
+ const scope = d.workspaceFolders(ws);
1688
1750
  const taken = agentByToken(d);
1751
+ const suggestFor = personaSuggester(d, ws);
1689
1752
  const now = Date.now();
1690
1753
  const conversations: ListedConversation[] = [];
1691
1754
  const seen = new Set<string>();
@@ -1693,15 +1756,19 @@ export function listConversations(
1693
1756
  if (runtime !== undefined && id !== runtime) continue;
1694
1757
  const rt = getRuntime(id);
1695
1758
  if (!rt?.listSessions) continue;
1696
- for (const c of scan.flatMap((f) => rt.listSessions!(f, d.env))) {
1759
+ for (const c of rt.listSessions(scope, d.env)) {
1697
1760
  if (seen.has(`${id} ${c.id}`)) continue;
1698
1761
  seen.add(`${id} ${c.id}`);
1699
1762
  const agent = taken.get(`${id} ${c.id}`);
1763
+ const lost = (c.mcp ?? []).filter((m) => m !== d.cli);
1764
+ const suggest = suggestFor(c.cwd);
1700
1765
  conversations.push({
1701
1766
  ...c,
1702
1767
  runtime: id,
1703
1768
  open: openElsewhere(c.lastAt, now),
1704
1769
  ...(agent ? { agent } : {}),
1770
+ ...(suggest ? { suggest } : {}),
1771
+ ...(lost.length ? { lost } : {}),
1705
1772
  });
1706
1773
  }
1707
1774
  }
@@ -1709,6 +1776,36 @@ export function listConversations(
1709
1776
  return { runtimes: bringInRuntimes(), conversations };
1710
1777
  }
1711
1778
 
1779
+ /**
1780
+ * AG-17.1c: the persona to preselect for a conversation that ran in a
1781
+ * folder: the latest agent's of this workspace that worked there (or in a
1782
+ * folder inside it), else the workspace's latest agent's; only a persona
1783
+ * that still exists. Undefined: none to suggest (the page's first).
1784
+ */
1785
+ function personaSuggester(
1786
+ d: AgentsResolved,
1787
+ ws: string,
1788
+ ): (cwd: string) => string | undefined {
1789
+ let known: Set<string>;
1790
+ try {
1791
+ known = new Set(personas(d, agentsConfig(d)).keys());
1792
+ } catch {
1793
+ return () => undefined;
1794
+ }
1795
+ const recs = listRecords(d.stateDir())
1796
+ .filter((r) => r.workspace === ws && known.has(r.persona))
1797
+ .sort((a, b) =>
1798
+ (b.resumedAt ?? b.startedAt).localeCompare(a.resumedAt ?? a.startedAt),
1799
+ );
1800
+ const inside = (p: string, dir: string) => {
1801
+ const rel = path.relative(dir, p);
1802
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
1803
+ };
1804
+ return (cwd) =>
1805
+ (recs.find((r) => inside(r.cwd, cwd) || inside(cwd, r.cwd)) ?? recs[0])
1806
+ ?.persona;
1807
+ }
1808
+
1712
1809
  export interface ImportRequest {
1713
1810
  workspace: string;
1714
1811
  /** The runtime's conversation id. */
@@ -1716,6 +1813,13 @@ export interface ImportRequest {
1716
1813
  persona: string;
1717
1814
  runtime?: string;
1718
1815
  id?: string;
1816
+ /**
1817
+ * AG-17.1c: a model for it (Advanced). Absent: the conversation's own
1818
+ * (its last answer's), else the persona's.
1819
+ */
1820
+ model?: string;
1821
+ /** With model: "latest" or a pinned version (AG-12.3). */
1822
+ version?: string;
1719
1823
  }
1720
1824
 
1721
1825
  /** The persona's id, else the first `<persona>-<n>` no agent has. */
@@ -1755,6 +1859,16 @@ export async function importAgent(
1755
1859
  422,
1756
1860
  );
1757
1861
  const c = conversations.find((x) => x.id === req.session);
1862
+ // Another workspace's (or no repo's): say whose, never bring it in.
1863
+ const elsewhere = c
1864
+ ? null
1865
+ : (getRuntime(runtime)?.conversationCwd?.(req.session, d.env) ?? null);
1866
+ if (!c && elsewhere && !inScope(elsewhere, d.workspaceFolders(req.workspace)))
1867
+ throw refused(
1868
+ `This conversation belongs to ${elsewhere}, not to workspace ${req.workspace}`,
1869
+ [`List this workspace's: ${d.cli} agents import -w ${req.workspace}`],
1870
+ 409,
1871
+ );
1758
1872
  if (!c)
1759
1873
  throw refused(
1760
1874
  `${where}: no ${rt.label} conversation "${req.session}" in workspace ${req.workspace}'s folder`,
@@ -1778,9 +1892,23 @@ export async function importAgent(
1778
1892
  const id = req.id ?? freeId(d, req.persona);
1779
1893
  if (readRecord(d.stateDir(), id))
1780
1894
  throw refused(`${where}: agent ${id} exists; give this one another --id`);
1895
+ // AG-17.1c: the conversation keeps its own model unless one is picked.
1896
+ const model = req.model ?? c.model;
1781
1897
  return startAgent(
1782
1898
  d,
1783
- { workspace: req.workspace, persona: req.persona, runtime, id },
1899
+ {
1900
+ workspace: req.workspace,
1901
+ persona: req.persona,
1902
+ runtime,
1903
+ id,
1904
+ ...(model ? { model } : {}),
1905
+ ...(req.model !== undefined && req.version !== undefined
1906
+ ? { version: req.version }
1907
+ : {}),
1908
+ // A model picked on the page is checked against the catalog; the
1909
+ // conversation's own is taken as it ran.
1910
+ ...(req.model !== undefined ? { strict: true } : {}),
1911
+ },
1784
1912
  { bringIn: { token: c.id, cwd: c.cwd } },
1785
1913
  );
1786
1914
  }
@@ -2060,6 +2188,227 @@ export async function resumeAgent(
2060
2188
  });
2061
2189
  }
2062
2190
 
2191
+ // ── AG-18: change a running agent's persona ─────────────────────────────────
2192
+
2193
+ /** The refusal code while its turn is going and no `when` was given. */
2194
+ export const PERSONA_BUSY = "busy";
2195
+
2196
+ export interface PersonaRequest {
2197
+ persona: string;
2198
+ /** Advanced: another model. Absent: the one it runs (the conversation's). */
2199
+ model?: string;
2200
+ /** With model: "latest" or a pinned version. */
2201
+ version?: string;
2202
+ /**
2203
+ * Mid-turn: "after-turn" waits until its turn ends, "now" stops it. None:
2204
+ * refused (PERSONA_BUSY) while it works, so a turn is never cut silently.
2205
+ */
2206
+ when?: "now" | "after-turn";
2207
+ /** Who asked (a person's sign-in, "cli"), for its record. */
2208
+ by?: string;
2209
+ }
2210
+
2211
+ export interface PersonaChanged extends Resumed {
2212
+ from: string;
2213
+ }
2214
+
2215
+ /** How often, and how long, "after this turn" checks its turn (AG-18). */
2216
+ export const TURN_POLL_MS = 1_000;
2217
+ export const TURN_WAIT_MS = 30 * 60_000;
2218
+
2219
+ /**
2220
+ * Whether agent `rec` is mid-turn: true or false from its runtime (den-
2221
+ * claude: its transcript), null when it can't tell. Not running: false.
2222
+ */
2223
+ async function agentBusy(
2224
+ d: AgentsResolved,
2225
+ rec: AgentRecord,
2226
+ ): Promise<boolean | null> {
2227
+ const live = await withClient(d, async (c) => {
2228
+ const s = sessionOf(await c.list(), rec);
2229
+ return Boolean(s && !s.exited);
2230
+ });
2231
+ if (!live) return false;
2232
+ const rt = getRuntime(rec.runtime);
2233
+ if (!rt?.turnBusy || !rec.resumeToken) return null;
2234
+ return rt.turnBusy(rec.resumeToken, d.env);
2235
+ }
2236
+
2237
+ /** Why agent `rec` can't change persona (it can't resume), or null. */
2238
+ export function personaChangeRefusal(rec: AgentRecord): string | null {
2239
+ const rt = getRuntime(rec.runtime);
2240
+ if (!rt?.resume)
2241
+ return `${rt?.label ?? rec.runtime} can't continue a conversation, so it can't change persona`;
2242
+ return resumeRefusal(rec);
2243
+ }
2244
+
2245
+ /**
2246
+ * AG-18, change persona: Den stops the agent's session and resumes the
2247
+ * same conversation with the new persona's launch settings (its role
2248
+ * prompt, permission mode, allowed commands). The history stays; so do the
2249
+ * id, name, workspace, folder and grants, and the model it runs unless
2250
+ * one is given. Mid-turn it never stops a turn unasked: refused with
2251
+ * PERSONA_BUSY, or `when` says to wait for the turn's end or stop now.
2252
+ * Only a runtime that can resume can. The person's only: callers refuse an
2253
+ * agent's token before this.
2254
+ */
2255
+ export async function changePersona(
2256
+ d: AgentsResolved,
2257
+ id: string,
2258
+ ws: string | undefined,
2259
+ req: PersonaRequest,
2260
+ opts: { pollMs?: number; maxMs?: number } = {},
2261
+ ): Promise<PersonaChanged> {
2262
+ const where = "agents persona";
2263
+ const rec = agentRecord(d, id, ws);
2264
+ const cfg = agentsConfig(d);
2265
+ const all = personas(d, cfg);
2266
+ if (!all.get(req.persona))
2267
+ throw usage(
2268
+ `${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
2269
+ );
2270
+ if (req.persona === rec.persona && req.model === undefined)
2271
+ throw usage(`${where}: agent ${rec.id} is already ${rec.persona}`);
2272
+ const why = personaChangeRefusal(rec);
2273
+ if (why) throw refused(why, [], 422);
2274
+ let busy = await agentBusy(d, rec);
2275
+ if (busy !== false && req.when === undefined)
2276
+ throw new AgentError(
2277
+ busy
2278
+ ? `agent ${rec.id} is working. Change after this turn, or stop it now?`
2279
+ : `agent ${rec.id} is running and Den can't tell whether it is mid-turn. Change after this turn, or stop it now?`,
2280
+ {
2281
+ status: 409,
2282
+ code: PERSONA_BUSY,
2283
+ hints: [
2284
+ `After this turn: ${d.cli} agents persona ${rec.id} ${req.persona} --after-turn`,
2285
+ `Stop it now: ${d.cli} agents persona ${rec.id} ${req.persona} --now`,
2286
+ ],
2287
+ },
2288
+ );
2289
+ if (busy === null && req.when === "after-turn")
2290
+ throw refused(
2291
+ `${where}: Den can't tell when agent ${rec.id}'s turn ends; stop it now instead (--now)`,
2292
+ );
2293
+ if (req.when !== "after-turn") return switchPersona(d, rec, req, where);
2294
+ // Waiting: marked on the record, so a restart that drops the wait can
2295
+ // say so (cancelStalePersonaChanges); cleared however this ends.
2296
+ writeRecord(d.stateDir(), {
2297
+ ...rec,
2298
+ pendingPersona: {
2299
+ to: req.persona,
2300
+ since: new Date().toISOString(),
2301
+ ...(req.by ? { by: req.by } : {}),
2302
+ },
2303
+ });
2304
+ try {
2305
+ const deadline = Date.now() + (opts.maxMs ?? TURN_WAIT_MS);
2306
+ while (busy) {
2307
+ if (Date.now() >= deadline)
2308
+ throw refused(
2309
+ `${where}: agent ${rec.id} was still working after ${Math.round((opts.maxMs ?? TURN_WAIT_MS) / 60_000)} min; nothing changed`,
2310
+ );
2311
+ await new Promise((r) => setTimeout(r, opts.pollMs ?? TURN_POLL_MS));
2312
+ busy = await agentBusy(d, agentRecord(d, id, ws));
2313
+ }
2314
+ return await switchPersona(d, rec, req, where);
2315
+ } catch (e) {
2316
+ clearPendingPersona(d, rec.id);
2317
+ throw e;
2318
+ }
2319
+ }
2320
+
2321
+ /** Drop agent `id`'s pending persona change mark, if it has one. */
2322
+ function clearPendingPersona(d: AgentsResolved, id: string): void {
2323
+ const cur = readRecord(d.stateDir(), id);
2324
+ if (!cur?.pendingPersona) return;
2325
+ const { pendingPersona: _pp, ...rest } = cur;
2326
+ writeRecord(d.stateDir(), rest);
2327
+ }
2328
+
2329
+ /**
2330
+ * At start (AG-18): every persona change still marked pending was dropped
2331
+ * when Den stopped, since the wait lived in memory. Clears each mark and
2332
+ * returns them, for the audit trail.
2333
+ */
2334
+ export function cancelStalePersonaChanges(d: AgentsResolved): {
2335
+ id: string;
2336
+ workspace: string;
2337
+ from: string;
2338
+ to: string;
2339
+ by?: string;
2340
+ }[] {
2341
+ const out: ReturnType<typeof cancelStalePersonaChanges> = [];
2342
+ for (const rec of listRecords(d.stateDir())) {
2343
+ const p = rec.pendingPersona;
2344
+ if (!p) continue;
2345
+ clearPendingPersona(d, rec.id);
2346
+ out.push({
2347
+ id: rec.id,
2348
+ workspace: rec.workspace,
2349
+ from: rec.persona,
2350
+ to: p.to,
2351
+ ...(p.by ? { by: p.by } : {}),
2352
+ });
2353
+ }
2354
+ return out;
2355
+ }
2356
+
2357
+ /** The switch itself: stop, the new persona's settings, resume. */
2358
+ async function switchPersona(
2359
+ d: AgentsResolved,
2360
+ rec: AgentRecord,
2361
+ req: PersonaRequest,
2362
+ where: string,
2363
+ ): Promise<PersonaChanged> {
2364
+ const stopped = await stopAgent(d, rec.id, rec.workspace);
2365
+ if (stopped.stillStopping)
2366
+ throw refused(
2367
+ `${where}: agent ${rec.id} is still stopping; try again in a moment`,
2368
+ );
2369
+ // The new persona's permissions apply: the old one's choices go. The
2370
+ // model stays (the conversation's) unless one is given.
2371
+ const {
2372
+ permission: _p,
2373
+ allow: _a,
2374
+ overrides: _o,
2375
+ pendingPersona: _pp,
2376
+ ...base
2377
+ } = stopped.rec;
2378
+ const keptOverrides = (rec.overrides ?? []).filter(
2379
+ (o) => o === "runtime" || o === "model" || o === "version",
2380
+ );
2381
+ const next: AgentRecord = {
2382
+ ...base,
2383
+ persona: req.persona,
2384
+ ...(req.model !== undefined
2385
+ ? { model: req.model, version: req.version ?? "latest" }
2386
+ : {}),
2387
+ ...(keptOverrides.length || req.model !== undefined
2388
+ ? {
2389
+ overrides: [
2390
+ ...new Set([
2391
+ ...keptOverrides,
2392
+ ...(req.model !== undefined ? (["model"] as const) : []),
2393
+ ]),
2394
+ ],
2395
+ }
2396
+ : {}),
2397
+ personaChanges: [
2398
+ ...(rec.personaChanges ?? []),
2399
+ {
2400
+ from: rec.persona,
2401
+ to: req.persona,
2402
+ at: new Date().toISOString(),
2403
+ ...(req.by ? { by: req.by } : {}),
2404
+ },
2405
+ ],
2406
+ };
2407
+ writeRecord(d.stateDir(), next);
2408
+ const r = await resumeAgent(d, rec.id, rec.workspace);
2409
+ return { ...r, from: rec.persona };
2410
+ }
2411
+
2063
2412
  // ── AG-14: start fresh, remove, restart ─────────────────────────────────────
2064
2413
 
2065
2414
  /**