@monster-orange/den 0.4.15 → 0.4.17

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 (37) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +137 -11
  4. package/package.json +1 -1
  5. package/src/shared/agents/conversations.ts +122 -4
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +19 -0
  8. package/src/shared/agents/runtime.ts +5 -0
  9. package/src/shared/github-app.ts +7 -0
  10. package/src/ui/cli/agent-ops.ts +304 -1
  11. package/src/ui/cli/agents-command.ts +71 -7
  12. package/src/ui/server/agent-routes.ts +92 -2
  13. package/src/ui/server/check-pool.ts +72 -0
  14. package/src/ui/server/github-routes.ts +1 -1
  15. package/src/ui/server/index.ts +127 -15
  16. package/src/ui/server/public/agents-page.js +358 -38
  17. package/src/ui/server/public/agents.html +10 -0
  18. package/src/ui/server/public/environments.html +223 -140
  19. package/src/ui/server/public/error-page.js +70 -15
  20. package/src/ui/server/public/error.html +18 -0
  21. package/src/ui/server/public/graph.css +18 -0
  22. package/src/ui/server/public/graph.js +23 -6
  23. package/src/ui/server/public/i18n/es.js +47 -5
  24. package/src/ui/server/public/i18n/ja.js +47 -5
  25. package/src/ui/server/public/index.html +6 -0
  26. package/src/ui/server/public/local-dev.html +188 -25
  27. package/src/ui/server/public/panel.js +1 -1
  28. package/src/ui/server/public/persona-ui.css +11 -0
  29. package/src/ui/server/public/release.html +19 -1
  30. package/src/ui/server/public/shell.js +187 -12
  31. package/src/ui/server/public/topbar.js +34 -1
  32. package/src/ui/server/public/view-scope.js +61 -0
  33. package/src/ui/server/public/workspace-picker.js +2 -0
  34. package/src/ui/server/route-classes.ts +8 -0
  35. package/src/ui/server/run-history.ts +181 -0
  36. package/src/ui/server/session-routes.ts +97 -17
  37. package/src/ui/server/ui-assets.ts +2 -0
@@ -455,6 +455,11 @@ export interface AgentRow {
455
455
  * runtime's resume token, and it isn't running.
456
456
  */
457
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;
458
463
  /**
459
464
  * Why Resume doesn't apply to a stopped agent (AG-14.2), e.g. "codex
460
465
  * can't resume a conversation": only Start fresh is offered.
@@ -553,6 +558,9 @@ export function agentRows(
553
558
  state: st,
554
559
  startedAt: s?.startedAt ?? rec.startedAt,
555
560
  resumable,
561
+ ...(!rot?.ready && !personaChangeRefusal(rec)
562
+ ? { personaChange: true }
563
+ : {}),
556
564
  ...(why ? { noResume: why } : {}),
557
565
  ...(rec.stoppedAt ? { stoppedAt: rec.stoppedAt } : {}),
558
566
  ...(rec.endedAt ? { endedAt: rec.endedAt } : {}),
@@ -1675,6 +1683,17 @@ export interface ListedConversation extends RuntimeConversation {
1675
1683
  open: boolean;
1676
1684
  /** The Den agent already continuing it, if one is. */
1677
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[];
1678
1697
  }
1679
1698
 
1680
1699
  /** Each runtime, and whether it can bring a conversation in. */
@@ -1729,6 +1748,7 @@ export function listConversations(
1729
1748
  // Its folder and every repo's; a shared root only exactly (AG-17.1).
1730
1749
  const scope = d.workspaceFolders(ws);
1731
1750
  const taken = agentByToken(d);
1751
+ const suggestFor = personaSuggester(d, ws);
1732
1752
  const now = Date.now();
1733
1753
  const conversations: ListedConversation[] = [];
1734
1754
  const seen = new Set<string>();
@@ -1740,11 +1760,15 @@ export function listConversations(
1740
1760
  if (seen.has(`${id} ${c.id}`)) continue;
1741
1761
  seen.add(`${id} ${c.id}`);
1742
1762
  const agent = taken.get(`${id} ${c.id}`);
1763
+ const lost = (c.mcp ?? []).filter((m) => m !== d.cli);
1764
+ const suggest = suggestFor(c.cwd);
1743
1765
  conversations.push({
1744
1766
  ...c,
1745
1767
  runtime: id,
1746
1768
  open: openElsewhere(c.lastAt, now),
1747
1769
  ...(agent ? { agent } : {}),
1770
+ ...(suggest ? { suggest } : {}),
1771
+ ...(lost.length ? { lost } : {}),
1748
1772
  });
1749
1773
  }
1750
1774
  }
@@ -1752,6 +1776,36 @@ export function listConversations(
1752
1776
  return { runtimes: bringInRuntimes(), conversations };
1753
1777
  }
1754
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
+
1755
1809
  export interface ImportRequest {
1756
1810
  workspace: string;
1757
1811
  /** The runtime's conversation id. */
@@ -1759,6 +1813,13 @@ export interface ImportRequest {
1759
1813
  persona: string;
1760
1814
  runtime?: string;
1761
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;
1762
1823
  }
1763
1824
 
1764
1825
  /** The persona's id, else the first `<persona>-<n>` no agent has. */
@@ -1831,9 +1892,23 @@ export async function importAgent(
1831
1892
  const id = req.id ?? freeId(d, req.persona);
1832
1893
  if (readRecord(d.stateDir(), id))
1833
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;
1834
1897
  return startAgent(
1835
1898
  d,
1836
- { 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
+ },
1837
1912
  { bringIn: { token: c.id, cwd: c.cwd } },
1838
1913
  );
1839
1914
  }
@@ -2113,6 +2188,234 @@ export async function resumeAgent(
2113
2188
  });
2114
2189
  }
2115
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
+ /**
2216
+ * What a resume with nothing to continue yet says (AG-14, AG-18): the
2217
+ * runtime started fresh under the same conversation id.
2218
+ */
2219
+ export const FRESH_NOTE =
2220
+ "nothing to resume yet; started fresh (same conversation id, folder and scope)";
2221
+
2222
+ /** How often, and how long, "after this turn" checks its turn (AG-18). */
2223
+ export const TURN_POLL_MS = 1_000;
2224
+ export const TURN_WAIT_MS = 30 * 60_000;
2225
+
2226
+ /**
2227
+ * Whether agent `rec` is mid-turn: true or false from its runtime (den-
2228
+ * claude: its transcript), null when it can't tell. Not running: false.
2229
+ */
2230
+ async function agentBusy(
2231
+ d: AgentsResolved,
2232
+ rec: AgentRecord,
2233
+ ): Promise<boolean | null> {
2234
+ const live = await withClient(d, async (c) => {
2235
+ const s = sessionOf(await c.list(), rec);
2236
+ return Boolean(s && !s.exited);
2237
+ });
2238
+ if (!live) return false;
2239
+ const rt = getRuntime(rec.runtime);
2240
+ if (!rt?.turnBusy || !rec.resumeToken) return null;
2241
+ return rt.turnBusy(rec.resumeToken, d.env);
2242
+ }
2243
+
2244
+ /** Why agent `rec` can't change persona (it can't resume), or null. */
2245
+ export function personaChangeRefusal(rec: AgentRecord): string | null {
2246
+ const rt = getRuntime(rec.runtime);
2247
+ if (!rt?.resume)
2248
+ return `${rt?.label ?? rec.runtime} can't continue a conversation, so it can't change persona`;
2249
+ return resumeRefusal(rec);
2250
+ }
2251
+
2252
+ /**
2253
+ * AG-18, change persona: Den stops the agent's session and resumes the
2254
+ * same conversation with the new persona's launch settings (its role
2255
+ * prompt, permission mode, allowed commands). The history stays; so do the
2256
+ * id, name, workspace, folder and grants, and the model it runs unless
2257
+ * one is given. Mid-turn it never stops a turn unasked: refused with
2258
+ * PERSONA_BUSY, or `when` says to wait for the turn's end or stop now.
2259
+ * Only a runtime that can resume can. The person's only: callers refuse an
2260
+ * agent's token before this.
2261
+ */
2262
+ export async function changePersona(
2263
+ d: AgentsResolved,
2264
+ id: string,
2265
+ ws: string | undefined,
2266
+ req: PersonaRequest,
2267
+ opts: { pollMs?: number; maxMs?: number } = {},
2268
+ ): Promise<PersonaChanged> {
2269
+ const where = "agents persona";
2270
+ const rec = agentRecord(d, id, ws);
2271
+ const cfg = agentsConfig(d);
2272
+ const all = personas(d, cfg);
2273
+ if (!all.get(req.persona))
2274
+ throw usage(
2275
+ `${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
2276
+ );
2277
+ if (req.persona === rec.persona && req.model === undefined)
2278
+ throw usage(`${where}: agent ${rec.id} is already ${rec.persona}`);
2279
+ const why = personaChangeRefusal(rec);
2280
+ if (why) throw refused(why, [], 422);
2281
+ let busy = await agentBusy(d, rec);
2282
+ if (busy !== false && req.when === undefined)
2283
+ throw new AgentError(
2284
+ busy
2285
+ ? `agent ${rec.id} is working. Change after this turn, or stop it now?`
2286
+ : `agent ${rec.id} is running and Den can't tell whether it is mid-turn. Change after this turn, or stop it now?`,
2287
+ {
2288
+ status: 409,
2289
+ code: PERSONA_BUSY,
2290
+ hints: [
2291
+ `After this turn: ${d.cli} agents persona ${rec.id} ${req.persona} --after-turn`,
2292
+ `Stop it now: ${d.cli} agents persona ${rec.id} ${req.persona} --now`,
2293
+ ],
2294
+ },
2295
+ );
2296
+ if (busy === null && req.when === "after-turn")
2297
+ throw refused(
2298
+ `${where}: Den can't tell when agent ${rec.id}'s turn ends; stop it now instead (--now)`,
2299
+ );
2300
+ if (req.when !== "after-turn") return switchPersona(d, rec, req, where);
2301
+ // Waiting: marked on the record, so a restart that drops the wait can
2302
+ // say so (cancelStalePersonaChanges); cleared however this ends.
2303
+ writeRecord(d.stateDir(), {
2304
+ ...rec,
2305
+ pendingPersona: {
2306
+ to: req.persona,
2307
+ since: new Date().toISOString(),
2308
+ ...(req.by ? { by: req.by } : {}),
2309
+ },
2310
+ });
2311
+ try {
2312
+ const deadline = Date.now() + (opts.maxMs ?? TURN_WAIT_MS);
2313
+ while (busy) {
2314
+ if (Date.now() >= deadline)
2315
+ throw refused(
2316
+ `${where}: agent ${rec.id} was still working after ${Math.round((opts.maxMs ?? TURN_WAIT_MS) / 60_000)} min; nothing changed`,
2317
+ );
2318
+ await new Promise((r) => setTimeout(r, opts.pollMs ?? TURN_POLL_MS));
2319
+ busy = await agentBusy(d, agentRecord(d, id, ws));
2320
+ }
2321
+ return await switchPersona(d, rec, req, where);
2322
+ } catch (e) {
2323
+ clearPendingPersona(d, rec.id);
2324
+ throw e;
2325
+ }
2326
+ }
2327
+
2328
+ /** Drop agent `id`'s pending persona change mark, if it has one. */
2329
+ function clearPendingPersona(d: AgentsResolved, id: string): void {
2330
+ const cur = readRecord(d.stateDir(), id);
2331
+ if (!cur?.pendingPersona) return;
2332
+ const { pendingPersona: _pp, ...rest } = cur;
2333
+ writeRecord(d.stateDir(), rest);
2334
+ }
2335
+
2336
+ /**
2337
+ * At start (AG-18): every persona change still marked pending was dropped
2338
+ * when Den stopped, since the wait lived in memory. Clears each mark and
2339
+ * returns them, for the audit trail.
2340
+ */
2341
+ export function cancelStalePersonaChanges(d: AgentsResolved): {
2342
+ id: string;
2343
+ workspace: string;
2344
+ from: string;
2345
+ to: string;
2346
+ by?: string;
2347
+ }[] {
2348
+ const out: ReturnType<typeof cancelStalePersonaChanges> = [];
2349
+ for (const rec of listRecords(d.stateDir())) {
2350
+ const p = rec.pendingPersona;
2351
+ if (!p) continue;
2352
+ clearPendingPersona(d, rec.id);
2353
+ out.push({
2354
+ id: rec.id,
2355
+ workspace: rec.workspace,
2356
+ from: rec.persona,
2357
+ to: p.to,
2358
+ ...(p.by ? { by: p.by } : {}),
2359
+ });
2360
+ }
2361
+ return out;
2362
+ }
2363
+
2364
+ /** The switch itself: stop, the new persona's settings, resume. */
2365
+ async function switchPersona(
2366
+ d: AgentsResolved,
2367
+ rec: AgentRecord,
2368
+ req: PersonaRequest,
2369
+ where: string,
2370
+ ): Promise<PersonaChanged> {
2371
+ const stopped = await stopAgent(d, rec.id, rec.workspace);
2372
+ if (stopped.stillStopping)
2373
+ throw refused(
2374
+ `${where}: agent ${rec.id} is still stopping; try again in a moment`,
2375
+ );
2376
+ // The new persona's permissions apply: the old one's choices go. The
2377
+ // model stays (the conversation's) unless one is given.
2378
+ const {
2379
+ permission: _p,
2380
+ allow: _a,
2381
+ overrides: _o,
2382
+ pendingPersona: _pp,
2383
+ ...base
2384
+ } = stopped.rec;
2385
+ const keptOverrides = (rec.overrides ?? []).filter(
2386
+ (o) => o === "runtime" || o === "model" || o === "version",
2387
+ );
2388
+ const next: AgentRecord = {
2389
+ ...base,
2390
+ persona: req.persona,
2391
+ ...(req.model !== undefined
2392
+ ? { model: req.model, version: req.version ?? "latest" }
2393
+ : {}),
2394
+ ...(keptOverrides.length || req.model !== undefined
2395
+ ? {
2396
+ overrides: [
2397
+ ...new Set([
2398
+ ...keptOverrides,
2399
+ ...(req.model !== undefined ? (["model"] as const) : []),
2400
+ ]),
2401
+ ],
2402
+ }
2403
+ : {}),
2404
+ personaChanges: [
2405
+ ...(rec.personaChanges ?? []),
2406
+ {
2407
+ from: rec.persona,
2408
+ to: req.persona,
2409
+ at: new Date().toISOString(),
2410
+ ...(req.by ? { by: req.by } : {}),
2411
+ },
2412
+ ],
2413
+ };
2414
+ writeRecord(d.stateDir(), next);
2415
+ const r = await resumeAgent(d, rec.id, rec.workspace);
2416
+ return { ...r, from: rec.persona };
2417
+ }
2418
+
2116
2419
  // ── AG-14: start fresh, remove, restart ─────────────────────────────────────
2117
2420
 
2118
2421
  /**
@@ -64,6 +64,7 @@
64
64
  */
65
65
  import * as fs from "fs";
66
66
  import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
67
+ import { ACTOR_TOKEN_ENV } from "../../shared/actor-token.js";
67
68
  import { initLoginPath } from "../../shared/agents/runtime-search.js";
68
69
  import { HostError } from "../../shared/sessions/client.js";
69
70
  import { checkHandback } from "../../shared/agents/handback.js";
@@ -77,6 +78,7 @@ import {
77
78
  import {
78
79
  AgentError,
79
80
  importAgent,
81
+ changePersona,
80
82
  listAgents,
81
83
  listConversations,
82
84
  listPersonas as personaList,
@@ -93,6 +95,7 @@ import {
93
95
  type AgentsDeps,
94
96
  type AgentsResolved,
95
97
  type EarlyExit,
98
+ FRESH_NOTE,
96
99
  type RuntimeChoice,
97
100
  type Started,
98
101
  } from "./agent-ops.js";
@@ -116,7 +119,7 @@ export {
116
119
 
117
120
  export const AGENTS_HELP = {
118
121
  usage:
119
- "agents <list|start|stop|resume|relaunch|import|rm|personas|doctor|handback-check|handoff-check> ...",
122
+ "agents <list|start|stop|resume|relaunch|import|persona|rm|personas|doctor|handback-check|handoff-check> ...",
120
123
  lines: [
121
124
  "Agents: a persona and a workspace, run by a runtime in a session that",
122
125
  "outlives the web server (see `{cli} sessions`).",
@@ -142,11 +145,17 @@ export const AGENTS_HELP = {
142
145
  " passes the check, a fresh conversation (same persona, workspace, folder,",
143
146
  " grants) whose first prompt is the handoff",
144
147
  "{cli} agents import -w <ws> [--runtime <r>] [--json]",
145
- "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>]",
148
+ "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>] [--model <m>]",
146
149
  " bring in a conversation started outside Den: the first lists the runtime's",
147
150
  " own (default den-claude) in the workspace's folder; the second continues",
148
151
  " one as a Den agent (its resume, in the folder it ran in). Refused while it",
149
- " is still open in another terminal: close it there first",
152
+ " is still open in another terminal: close it there first. It keeps the",
153
+ " conversation's own model unless --model gives one",
154
+ "{cli} agents persona <id> <persona> [-w <ws>] [--model <m>] [--now | --after-turn]",
155
+ " change an agent's persona: the same conversation resumes with the new",
156
+ " persona's role, permissions and commands; id, name, folder and model stay.",
157
+ " Mid-turn it asks: --after-turn waits for the turn to end, --now stops it.",
158
+ " A person's only (refused in an agent's session)",
150
159
  "{cli} agents rm <id> [-w <ws>]",
151
160
  "{cli} agents rm --stopped [-w <ws>]",
152
161
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -306,6 +315,8 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
306
315
  "--session": true,
307
316
  "--persona": true,
308
317
  "--id": true,
318
+ "--model": true,
319
+ "--version": true,
309
320
  "--json": false,
310
321
  });
311
322
  noExtra(where, p);
@@ -355,6 +366,11 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
355
366
  persona,
356
367
  runtime,
357
368
  id: str(p, "--id"),
369
+ // AG-17.1c: the conversation's own model unless one is given.
370
+ ...(str(p, "--model") !== undefined ? { model: str(p, "--model") } : {}),
371
+ ...(str(p, "--version") !== undefined
372
+ ? { version: str(p, "--version") }
373
+ : {}),
358
374
  });
359
375
  return report(
360
376
  d,
@@ -363,6 +379,54 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
363
379
  );
364
380
  }
365
381
 
382
+ /**
383
+ * `agents persona <id> <persona> [-w <ws>] [--model <m>] [--version <v>]
384
+ * [--now | --after-turn]` (AG-18). A person's only: refused inside an
385
+ * agent's session (its DEN_ACTOR_TOKEN), so an agent can't widen its own
386
+ * permissions.
387
+ */
388
+ async function persona(args: string[], d: AgentsResolved): Promise<number> {
389
+ const where = "agents persona";
390
+ if (d.env[ACTOR_TOKEN_ENV] !== undefined)
391
+ throw new AgentError(
392
+ `${where}: only a person can change an agent's persona (this is an agent's session)`,
393
+ );
394
+ const p = parseArgs(where, args, {
395
+ "--workspace": true,
396
+ "--model": true,
397
+ "--version": true,
398
+ "--now": false,
399
+ "--after-turn": false,
400
+ });
401
+ noExtra(where, p, 2);
402
+ const [id, to] = p.positional;
403
+ if (!id || !to) throw new UsageError(`${where} needs <id> <persona>`);
404
+ if (p.flags["--now"] && p.flags["--after-turn"])
405
+ throw new UsageError(`${where}: --now or --after-turn, not both`);
406
+ const when = p.flags["--now"]
407
+ ? "now"
408
+ : p.flags["--after-turn"]
409
+ ? "after-turn"
410
+ : undefined;
411
+ if (when === "after-turn") d.out(`waiting for agent ${id}'s turn to end…`);
412
+ const s = await changePersona(d, id, workspaceFlag(where, p), {
413
+ persona: to,
414
+ ...(str(p, "--model") !== undefined ? { model: str(p, "--model") } : {}),
415
+ ...(str(p, "--version") !== undefined
416
+ ? { version: str(p, "--version") }
417
+ : {}),
418
+ ...(when ? { when } : {}),
419
+ by: "cli",
420
+ });
421
+ return report(
422
+ d,
423
+ s,
424
+ s.fresh
425
+ ? `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); ${FRESH_NOTE}, as ${s.rec.sessionName} in ${s.rec.cwd}`
426
+ : `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); same conversation, as ${s.rec.sessionName} in ${s.rec.cwd}`,
427
+ );
428
+ }
429
+
366
430
  /** `<id> [-w <ws>]`. */
367
431
  function idAndWorkspace(
368
432
  where: string,
@@ -407,10 +471,7 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
407
471
  if (args.includes("--all-interrupted")) return resumeAll(args, d);
408
472
  const { id, ws } = idAndWorkspace("agents resume", args);
409
473
  const s = await resumeAgent(d, id, ws);
410
- if (s.fresh)
411
- d.out(
412
- `agent ${s.rec.id}: nothing to resume yet; started fresh (same conversation id, folder and scope)`,
413
- );
474
+ if (s.fresh) d.out(`agent ${s.rec.id}: ${FRESH_NOTE}`);
414
475
  return report(
415
476
  d,
416
477
  s,
@@ -703,6 +764,9 @@ export async function cmdAgents(
703
764
  case "import":
704
765
  await loadAgentWorkspaces(deps);
705
766
  return await bringIn(rest, d);
767
+ case "persona":
768
+ await loadAgentWorkspaces(deps);
769
+ return await persona(rest, d);
706
770
  case "rm":
707
771
  await loadAgentWorkspaces(deps);
708
772
  return await rm(rest, d);
@@ -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,
@@ -138,6 +140,7 @@ import {
138
140
  type AgentsDeps,
139
141
  type AgentsResolved,
140
142
  type Started,
143
+ FRESH_NOTE,
141
144
  } from "../cli/agent-ops.js";
142
145
  import { agentSessionName } from "../../shared/agents/session-name.js";
143
146
  import { recordAction } from "./audit.js";
@@ -159,10 +162,12 @@ export interface AgentRouteDeps {
159
162
  agents?: AgentsDeps;
160
163
  /** When a first instruction counts its session ready (tests: shorter). */
161
164
  instruction?: { quietMs?: number; maxMs?: number };
165
+ /** AG-18: how "after this turn" polls (tests: shorter). */
166
+ personaWait?: { pollMs?: number; maxMs?: number };
162
167
  }
163
168
 
164
169
  const AGENTS_PATH =
165
- /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths))?$/;
170
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona))?$/;
166
171
 
167
172
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
168
173
  const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
@@ -476,6 +481,7 @@ export async function handleAgentRoutes(
476
481
  | "remove"
477
482
  | "name"
478
483
  | "paths"
484
+ | "persona"
479
485
  | undefined;
480
486
 
481
487
  if (!action && method === "GET") {
@@ -550,6 +556,13 @@ export async function handleAgentRoutes(
550
556
  if (id !== undefined) agentRecord(d, id, ws);
551
557
  // AG-14.4: MCP agent_resume may resume another agent of its scope.
552
558
  const agent = agentPrincipalOf(req);
559
+ // AG-18: a persona is the person's choice: an agent could widen its
560
+ // own permissions with it. Any agent credential or claim is refused.
561
+ if (verb === "persona" && (agent || actor))
562
+ throw new AgentError(
563
+ "only a person can change an agent's persona (an agent can't change its own or another's)",
564
+ { status: 403 },
565
+ );
553
566
  if (verb === "resume" && agent) {
554
567
  const why = agentResumeRefusal(agent, ws, id!, mcpActions());
555
568
  if (why) throw new AgentError(why, { status: 403 });
@@ -647,6 +660,64 @@ export async function handleAgentRoutes(
647
660
  agent: rec.id,
648
661
  ...(rec.displayName ? { displayName: rec.displayName } : {}),
649
662
  });
663
+ } else if (verb === "persona") {
664
+ const body = await jsonBody(req);
665
+ for (const k of Object.keys(body))
666
+ if (!PERSONA_KEYS.includes(k))
667
+ throw new AgentError(
668
+ `${k}: unknown field (a persona change takes ${PERSONA_KEYS.join(", ")})`,
669
+ { usage: true },
670
+ );
671
+ const str = (k: string): string | undefined => {
672
+ const v = body[k];
673
+ if (v === undefined || v === null || v === "") return undefined;
674
+ if (typeof v !== "string" || v.length > 128 || /[^\x21-\x7e]/.test(v))
675
+ throw new AgentError(`${k} must be a short name`, { usage: true });
676
+ return v;
677
+ };
678
+ const persona = str("persona");
679
+ if (!persona)
680
+ throw new AgentError("persona is required", { usage: true });
681
+ const when = str("when");
682
+ if (when !== undefined && when !== "now" && when !== "after-turn")
683
+ throw new AgentError("when must be now or after-turn", {
684
+ usage: true,
685
+ });
686
+ const from = agentRecord(d, id!, ws).persona;
687
+ const by = principalOf(req) ?? "this machine";
688
+ what = `persona ${id} in ${ws}: ${from} → ${persona}, by ${by}`;
689
+ const change = () =>
690
+ changePersona(
691
+ d,
692
+ id!,
693
+ ws,
694
+ {
695
+ persona,
696
+ ...(str("model") !== undefined ? { model: str("model") } : {}),
697
+ ...(str("version") !== undefined
698
+ ? { version: str("version") }
699
+ : {}),
700
+ ...(when ? { when: when as "now" | "after-turn" } : {}),
701
+ by,
702
+ },
703
+ deps.personaWait ?? {},
704
+ );
705
+ if (when === "after-turn") {
706
+ // It may wait for a long turn: answer now, audit when it's done.
707
+ agentRecord(d, id!, ws);
708
+ void change().then(
709
+ (s) => audit(what, s.early ? "exited at once" : undefined),
710
+ (e: unknown) =>
711
+ audit(what, e instanceof Error ? e.message : String(e)),
712
+ );
713
+ sendJson(res, { ok: true, agent: id, pending: true, persona }, 202);
714
+ return true;
715
+ }
716
+ const s = await change();
717
+ audit(what, s.early ? "exited at once" : undefined);
718
+ // Never prompted: nothing was resumed, so it says so (not "same
719
+ // conversation").
720
+ sendStarted(res, s, d, s.fresh ? { note: FRESH_NOTE } : {});
650
721
  } else if (verb === "remove") {
651
722
  const removed = await removeAgent(d, id!, ws);
652
723
  audit(what);
@@ -663,7 +734,9 @@ export async function handleAgentRoutes(
663
734
  return true;
664
735
  }
665
736
 
666
- const IMPORT_KEYS = ["persona", "runtime", "id"];
737
+ const IMPORT_KEYS = ["persona", "runtime", "id", "model", "version"];
738
+ /** AG-18: what a persona change takes. */
739
+ const PERSONA_KEYS = ["persona", "model", "version", "when"];
667
740
 
668
741
  /**
669
742
  * Bring in (AG-17.1): GET lists the workspace's conversations, POST
@@ -737,6 +810,8 @@ async function conversations(
737
810
  persona,
738
811
  runtime: str("runtime"),
739
812
  id: str("id"),
813
+ ...(str("model") !== undefined ? { model: str("model") } : {}),
814
+ ...(str("version") !== undefined ? { version: str("version") } : {}),
740
815
  });
741
816
  what = `bring in ${session} as ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
742
817
  audit(s.early ? "exited at once" : undefined);
@@ -1089,6 +1164,21 @@ export function startAgentUpkeep(
1089
1164
  ): void {
1090
1165
  const d = webDeps(deps.agents);
1091
1166
  const restart = async () => {
1167
+ // AG-18: a persona change waiting for a turn's end lived in memory.
1168
+ try {
1169
+ for (const c of cancelStalePersonaChanges(d)) {
1170
+ const what = `persona ${c.id} in ${c.workspace}: ${c.from} → ${c.to}, by ${c.by ?? "this machine"}`;
1171
+ recordAction(null, {
1172
+ verb: "agents",
1173
+ detail: what,
1174
+ outcome: "refused",
1175
+ reason: "persona change cancelled: Den restarted",
1176
+ });
1177
+ log(`Persona change cancelled (Den restarted): ${what}`);
1178
+ }
1179
+ } catch (e) {
1180
+ log(` ⚠ persona changes after the restart: ${(e as Error).message}`);
1181
+ }
1092
1182
  try {
1093
1183
  const f = await markInterrupted(d);
1094
1184
  if (f.interrupted.length)