@monster-orange/den 0.4.15 → 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.
@@ -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,227 @@ 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
+ /** 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
+
2116
2412
  // ── AG-14: start fresh, remove, restart ─────────────────────────────────────
2117
2413
 
2118
2414
  /**
@@ -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,
@@ -116,7 +118,7 @@ export {
116
118
 
117
119
  export const AGENTS_HELP = {
118
120
  usage:
119
- "agents <list|start|stop|resume|relaunch|import|rm|personas|doctor|handback-check|handoff-check> ...",
121
+ "agents <list|start|stop|resume|relaunch|import|persona|rm|personas|doctor|handback-check|handoff-check> ...",
120
122
  lines: [
121
123
  "Agents: a persona and a workspace, run by a runtime in a session that",
122
124
  "outlives the web server (see `{cli} sessions`).",
@@ -142,11 +144,17 @@ export const AGENTS_HELP = {
142
144
  " passes the check, a fresh conversation (same persona, workspace, folder,",
143
145
  " grants) whose first prompt is the handoff",
144
146
  "{cli} agents import -w <ws> [--runtime <r>] [--json]",
145
- "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>]",
147
+ "{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>] [--model <m>]",
146
148
  " bring in a conversation started outside Den: the first lists the runtime's",
147
149
  " own (default den-claude) in the workspace's folder; the second continues",
148
150
  " 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",
151
+ " is still open in another terminal: close it there first. It keeps the",
152
+ " conversation's own model unless --model gives one",
153
+ "{cli} agents persona <id> <persona> [-w <ws>] [--model <m>] [--now | --after-turn]",
154
+ " change an agent's persona: the same conversation resumes with the new",
155
+ " persona's role, permissions and commands; id, name, folder and model stay.",
156
+ " Mid-turn it asks: --after-turn waits for the turn to end, --now stops it.",
157
+ " A person's only (refused in an agent's session)",
150
158
  "{cli} agents rm <id> [-w <ws>]",
151
159
  "{cli} agents rm --stopped [-w <ws>]",
152
160
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -306,6 +314,8 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
306
314
  "--session": true,
307
315
  "--persona": true,
308
316
  "--id": true,
317
+ "--model": true,
318
+ "--version": true,
309
319
  "--json": false,
310
320
  });
311
321
  noExtra(where, p);
@@ -355,6 +365,11 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
355
365
  persona,
356
366
  runtime,
357
367
  id: str(p, "--id"),
368
+ // AG-17.1c: the conversation's own model unless one is given.
369
+ ...(str(p, "--model") !== undefined ? { model: str(p, "--model") } : {}),
370
+ ...(str(p, "--version") !== undefined
371
+ ? { version: str(p, "--version") }
372
+ : {}),
358
373
  });
359
374
  return report(
360
375
  d,
@@ -363,6 +378,52 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
363
378
  );
364
379
  }
365
380
 
381
+ /**
382
+ * `agents persona <id> <persona> [-w <ws>] [--model <m>] [--version <v>]
383
+ * [--now | --after-turn]` (AG-18). A person's only: refused inside an
384
+ * agent's session (its DEN_ACTOR_TOKEN), so an agent can't widen its own
385
+ * permissions.
386
+ */
387
+ async function persona(args: string[], d: AgentsResolved): Promise<number> {
388
+ const where = "agents persona";
389
+ if (d.env[ACTOR_TOKEN_ENV] !== undefined)
390
+ throw new AgentError(
391
+ `${where}: only a person can change an agent's persona (this is an agent's session)`,
392
+ );
393
+ const p = parseArgs(where, args, {
394
+ "--workspace": true,
395
+ "--model": true,
396
+ "--version": true,
397
+ "--now": false,
398
+ "--after-turn": false,
399
+ });
400
+ noExtra(where, p, 2);
401
+ const [id, to] = p.positional;
402
+ if (!id || !to) throw new UsageError(`${where} needs <id> <persona>`);
403
+ if (p.flags["--now"] && p.flags["--after-turn"])
404
+ throw new UsageError(`${where}: --now or --after-turn, not both`);
405
+ const when = p.flags["--now"]
406
+ ? "now"
407
+ : p.flags["--after-turn"]
408
+ ? "after-turn"
409
+ : undefined;
410
+ if (when === "after-turn") d.out(`waiting for agent ${id}'s turn to end…`);
411
+ const s = await changePersona(d, id, workspaceFlag(where, p), {
412
+ persona: to,
413
+ ...(str(p, "--model") !== undefined ? { model: str(p, "--model") } : {}),
414
+ ...(str(p, "--version") !== undefined
415
+ ? { version: str(p, "--version") }
416
+ : {}),
417
+ ...(when ? { when } : {}),
418
+ by: "cli",
419
+ });
420
+ return report(
421
+ d,
422
+ s,
423
+ `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); same conversation, as ${s.rec.sessionName} in ${s.rec.cwd}`,
424
+ );
425
+ }
426
+
366
427
  /** `<id> [-w <ws>]`. */
367
428
  function idAndWorkspace(
368
429
  where: string,
@@ -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,
@@ -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\/([^/]+)$/;
@@ -476,6 +480,7 @@ export async function handleAgentRoutes(
476
480
  | "remove"
477
481
  | "name"
478
482
  | "paths"
483
+ | "persona"
479
484
  | undefined;
480
485
 
481
486
  if (!action && method === "GET") {
@@ -550,6 +555,13 @@ export async function handleAgentRoutes(
550
555
  if (id !== undefined) agentRecord(d, id, ws);
551
556
  // AG-14.4: MCP agent_resume may resume another agent of its scope.
552
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
+ );
553
565
  if (verb === "resume" && agent) {
554
566
  const why = agentResumeRefusal(agent, ws, id!, mcpActions());
555
567
  if (why) throw new AgentError(why, { status: 403 });
@@ -647,6 +659,62 @@ export async function handleAgentRoutes(
647
659
  agent: rec.id,
648
660
  ...(rec.displayName ? { displayName: rec.displayName } : {}),
649
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);
650
718
  } else if (verb === "remove") {
651
719
  const removed = await removeAgent(d, id!, ws);
652
720
  audit(what);
@@ -663,7 +731,9 @@ export async function handleAgentRoutes(
663
731
  return true;
664
732
  }
665
733
 
666
- 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"];
667
737
 
668
738
  /**
669
739
  * Bring in (AG-17.1): GET lists the workspace's conversations, POST
@@ -737,6 +807,8 @@ async function conversations(
737
807
  persona,
738
808
  runtime: str("runtime"),
739
809
  id: str("id"),
810
+ ...(str("model") !== undefined ? { model: str("model") } : {}),
811
+ ...(str("version") !== undefined ? { version: str("version") } : {}),
740
812
  });
741
813
  what = `bring in ${session} as ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
742
814
  audit(s.early ? "exited at once" : undefined);
@@ -1089,6 +1161,21 @@ export function startAgentUpkeep(
1089
1161
  ): void {
1090
1162
  const d = webDeps(deps.agents);
1091
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
+ }
1092
1179
  try {
1093
1180
  const f = await markInterrupted(d);
1094
1181
  if (f.interrupted.length)
@@ -98,7 +98,7 @@ function finishedMessage(v: FlowView, cli: string): string | undefined {
98
98
  switch (v.state) {
99
99
  case "refused":
100
100
  return v.purpose === "signin"
101
- ? `@${v.login} isn't allowed on this Den. Ask the owner to add you.`
101
+ ? `@${v.login} isn't the owner of this Den. Only the owner's account can use Sign in with GitHub. Use Approve this computer instead.`
102
102
  : `An owner is linked already; \`${cli} web owner unlink\` first.`;
103
103
  case "expired":
104
104
  return "The code expired before it was approved. Start again.";