@monster-orange/den 0.4.7 → 0.4.8

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 (36) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +120 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +19 -0
  7. package/src/shared/agents/personas.ts +11 -1
  8. package/src/shared/agents/records.ts +114 -0
  9. package/src/shared/agents/runtime.ts +12 -0
  10. package/src/shared/den-config.ts +30 -0
  11. package/src/shared/plugins/manifest.ts +3 -0
  12. package/src/shared/usage/paths.ts +16 -0
  13. package/src/shared/usage/report.ts +283 -0
  14. package/src/shared/usage/sources.ts +127 -0
  15. package/src/shared/usage/transcript.ts +262 -0
  16. package/src/ui/cli/agent-ops.ts +488 -50
  17. package/src/ui/cli/agents-command.ts +120 -14
  18. package/src/ui/cli/index.ts +13 -1
  19. package/src/ui/cli/usage-command.ts +256 -0
  20. package/src/ui/mcp/api.ts +10 -1
  21. package/src/ui/mcp/server.ts +34 -0
  22. package/src/ui/mcp/tools.ts +50 -0
  23. package/src/ui/server/agent-resume-grant.ts +53 -0
  24. package/src/ui/server/agent-routes.ts +230 -9
  25. package/src/ui/server/audit.ts +68 -1
  26. package/src/ui/server/index.ts +16 -5
  27. package/src/ui/server/public/agents-page.js +333 -11
  28. package/src/ui/server/public/agents-section.js +17 -3
  29. package/src/ui/server/public/agents.html +44 -0
  30. package/src/ui/server/public/i18n/es.js +28 -0
  31. package/src/ui/server/public/i18n/ja.js +25 -0
  32. package/src/ui/server/public/shell.js +66 -0
  33. package/src/ui/server/request-guard.ts +21 -1
  34. package/src/ui/server/route-classes.ts +23 -0
  35. package/src/ui/server/session-routes.ts +52 -4
  36. package/src/ui/server/sudo-store.ts +36 -7
@@ -28,11 +28,24 @@
28
28
  * listed as stopped or ended. A plain session is not an agent.
29
29
  * stop ends its session (TERM), marks the record stopped and revokes
30
30
  * its actor token.
31
- * resume only when its runtime declares resume and its session is not
32
- * running: a new session, in the same cwd, continuing the last run;
31
+ * resume only when its runtime declares resume, the record has its
32
+ * resume token, and its session is not running: a new session,
33
+ * in the same cwd and workspace with the grants it had (never
34
+ * more than the config still gives), continuing the last run with
35
+ * the runtime's own resume (den-claude: `--resume <token>`);
33
36
  * when that session was never prompted, a fresh start under the
34
37
  * same conversation id (there is nothing to continue). A fresh
35
38
  * actor token; the old one stops working.
39
+ * fresh (AG-14) a new conversation with the same persona, runtime,
40
+ * model and workspace, under the same id.
41
+ * remove (AG-14.1b) forgets a stopped, interrupted or exited agent: its
42
+ * record moves aside for Undo (restore). Never a running one.
43
+ * The runtime's own history stays.
44
+ * interrupted (AG-14.3) an agent whose session is gone though nobody
45
+ * stopped it and it never exited: Den or the machine went down.
46
+ * markInterrupted stamps them when the web server starts, and
47
+ * resumes the standing personas and those with
48
+ * `resume_on_restart: true`.
36
49
  *
37
50
  * Nothing here takes a grant: grants come from the org config only. The
38
51
  * host enforces agents.max_concurrent on every start, counting agents only.
@@ -71,7 +84,10 @@ import {
71
84
  AGENT_ID_RE,
72
85
  agentFilesDir,
73
86
  listRecords,
87
+ purgeRemoved,
74
88
  readRecord,
89
+ removeRecord,
90
+ restoreRecord,
75
91
  writeRecord,
76
92
  writeRuntimeFiles,
77
93
  type AgentRecord,
@@ -130,6 +146,11 @@ export interface AgentsDeps extends SessionsDeps {
130
146
  * exit (default 3000 ms; 0: not at all).
131
147
  */
132
148
  exitWatchMs?: number;
149
+ /**
150
+ * How long stop waits for the session to end after TERM (default
151
+ * 5000 ms; 0: it doesn't wait, as `--no-wait`).
152
+ */
153
+ stopWaitMs?: number;
133
154
  /** `agents.runtimes.<id>.bin` (default: user.yml's; undefined: unset). */
134
155
  runtimeBin?: (id: string) => unknown;
135
156
  /**
@@ -156,6 +177,7 @@ export interface AgentsResolved extends Resolved {
156
177
  workspaceDir: (ws: string) => string;
157
178
  mcpServer: () => { command: string[] | string; env: Record<string, string> };
158
179
  exitWatchMs: number;
180
+ stopWaitMs: number;
159
181
  runtimeBin: (id: string) => unknown;
160
182
  workspaceRuntimeBin: (ws: string, id: string) => unknown;
161
183
  userCatalog: (taken: readonly string[]) => CatalogEntry[];
@@ -166,6 +188,8 @@ export interface AgentsResolved extends Resolved {
166
188
  /** The early-exit watch: at most this long, polling the host. */
167
189
  export const EXIT_WATCH_MS = 3000;
168
190
  const EXIT_POLL_MS = 100;
191
+ /** Stop waits at most this long for the session to end (0.4.8). */
192
+ export const STOP_WAIT_MS = 5000;
169
193
  /** The most lines of an early exit's scrollback returned. */
170
194
  const EXIT_TAIL_LINES = 10;
171
195
 
@@ -249,6 +273,7 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
249
273
  }),
250
274
  mcpServer: d.mcpServer ?? (() => defaultMcpServer(base.cli)),
251
275
  exitWatchMs: d.exitWatchMs ?? EXIT_WATCH_MS,
276
+ stopWaitMs: d.stopWaitMs ?? STOP_WAIT_MS,
252
277
  runtimeBin: d.runtimeBin ?? ((id) => configuredRuntimeBin(id)),
253
278
  workspaceRuntimeBin:
254
279
  d.workspaceRuntimeBin ?? ((ws, id) => workspaceRuntimeBin(ws, id)),
@@ -322,32 +347,73 @@ export interface AgentRow {
322
347
  workspace?: string;
323
348
  cwd?: string;
324
349
  /** running, stopping (stopped, its session not yet gone), exited
325
- * (<code>), stopped or ended. */
350
+ * (<code>), stopped or interrupted. */
326
351
  state: string;
327
352
  /** When its current (or last) session started (ISO 8601). */
328
353
  startedAt?: string;
329
- /** Resume applies: its runtime declares resume and it isn't running. */
354
+ /**
355
+ * Resume applies: its runtime declares resume, its record has the
356
+ * runtime's resume token, and it isn't running.
357
+ */
330
358
  resumable: boolean;
359
+ /**
360
+ * Why Resume doesn't apply to a stopped agent (AG-14.2), e.g. "codex
361
+ * can't resume a conversation": only Start fresh is offered.
362
+ */
363
+ noResume?: string;
364
+ /** When `agents stop` stopped it. */
365
+ stoppedAt?: string;
366
+ /** When its session was seen to end on its own. */
367
+ endedAt?: string;
368
+ /** When Den found it interrupted by a restart. */
369
+ interruptedAt?: string;
370
+ /** Its persona resumes on its own after a restart (AG-14.3). */
371
+ autoResume?: boolean;
372
+ outcome?: AgentRecord["outcome"];
373
+ usage?: AgentRecord["usage"];
331
374
  session?: SessionInfo;
332
375
  }
333
376
 
334
- function rowState(rec: AgentRecord | undefined, s?: SessionInfo): string {
377
+ /** Its state as a row shows it (AG-14.1: stopped, exited or interrupted). */
378
+ export function rowState(
379
+ rec: AgentRecord | undefined,
380
+ s?: SessionInfo,
381
+ ): string {
335
382
  // Stopped (TERM sent) while its session still runs: on its way out.
336
383
  if (s && !s.exited)
337
384
  return rec?.stoppedAt && rec.stoppedAt >= s.startedAt
338
385
  ? "stopping"
339
386
  : "running";
340
387
  if (rec?.stoppedAt) return "stopped";
341
- return s ? state(s) : "ended";
388
+ if (rec?.endedAt)
389
+ return rec.exitCode === undefined ? "exited" : `exited (${rec.exitCode})`;
390
+ // A host sees every exit of its own sessions, with its code. A session
391
+ // listed as ended with no code ended with an earlier host (the host
392
+ // loads them so: AG-3a); one not listed at all, the same. Nobody stopped
393
+ // it and it never exited: the host (Den, or the machine) went down.
394
+ if (s && s.exitCode !== undefined) return state(s);
395
+ return "interrupted";
396
+ }
397
+
398
+ /** A state an agent can be removed or resumed in: not running. */
399
+ export function isStoppedState(st: string): boolean {
400
+ return st !== "running" && st !== "stopping";
401
+ }
402
+
403
+ /**
404
+ * Why `rec` can't be resumed (AG-14.2), or null when it can be: its
405
+ * runtime can't resume a conversation, or has no token to resume with.
406
+ */
407
+ export function resumeRefusal(rec: AgentRecord): string | null {
408
+ const rt = getRuntime(rec.runtime);
409
+ if (!rt?.resume) return `${rec.runtime} can't resume a conversation`;
410
+ if (!rec.resumeToken) return `${rec.id} has no conversation to resume`;
411
+ return null;
342
412
  }
343
413
 
344
414
  /** Whether `rec` may be resumed now, in `rowState`. */
345
415
  function canResume(rec: AgentRecord, st: string): boolean {
346
- return (
347
- st !== "running" &&
348
- st !== "stopping" &&
349
- Boolean(getRuntime(rec.runtime)?.resume)
350
- );
416
+ return isStoppedState(st) && resumeRefusal(rec) === null;
351
417
  }
352
418
 
353
419
  export function agentRows(
@@ -359,12 +425,20 @@ export function agentRows(
359
425
  const shown = new Set<string>();
360
426
  const row = (rec: AgentRecord, s?: SessionInfo): AgentRow => {
361
427
  const st = rowState(rec, s);
428
+ const resumable = canResume(rec, st);
429
+ const why = isStoppedState(st) && !resumable ? resumeRefusal(rec) : null;
362
430
  return {
363
431
  agent: rec.id,
364
432
  ...recordFields(rec),
365
433
  state: st,
366
434
  startedAt: s?.startedAt ?? rec.startedAt,
367
- resumable: canResume(rec, st),
435
+ resumable,
436
+ ...(why ? { noResume: why } : {}),
437
+ ...(rec.stoppedAt ? { stoppedAt: rec.stoppedAt } : {}),
438
+ ...(rec.endedAt ? { endedAt: rec.endedAt } : {}),
439
+ ...(rec.interruptedAt ? { interruptedAt: rec.interruptedAt } : {}),
440
+ ...(rec.outcome ? { outcome: rec.outcome } : {}),
441
+ ...(rec.usage ? { usage: rec.usage } : {}),
368
442
  ...(s ? { session: contractInfo(s) } : {}),
369
443
  };
370
444
  };
@@ -388,6 +462,28 @@ function recordFields(rec: AgentRecord): Partial<AgentRow> {
388
462
  };
389
463
  }
390
464
 
465
+ /**
466
+ * Note in each record whose session the host lists as exited that it
467
+ * ended, with its code (AG-14.1: "Exited (code n)"), so it still says so
468
+ * once the host forgets the session (a restart). Only what the host says.
469
+ */
470
+ export function noteEnded(
471
+ stateDir: string,
472
+ sessions: SessionInfo[],
473
+ records: AgentRecord[],
474
+ ): void {
475
+ const byId = new Map(sessions.map((s) => [s.ref.id, s]));
476
+ for (const rec of records) {
477
+ const s = byId.get(rec.session.id);
478
+ // An exit with no code is a host that went down: interrupted, not ended.
479
+ if (!s?.exited || s.exitCode === undefined) continue;
480
+ if (rec.endedAt || rec.stoppedAt) continue;
481
+ rec.endedAt = new Date().toISOString();
482
+ rec.exitCode = s.exitCode;
483
+ writeRecord(stateDir, rec);
484
+ }
485
+ }
486
+
391
487
  /** A workspace name as given, checked (a usage error otherwise). */
392
488
  export function checkWorkspaceName(where: string, ws: unknown): string {
393
489
  if (typeof ws !== "string" || !WORKSPACE_NAME_RE.test(ws))
@@ -403,7 +499,7 @@ export function checkWorkspaceName(where: string, ws: unknown): string {
403
499
  export async function listAgents(
404
500
  d: AgentsResolved,
405
501
  ws: string,
406
- opts: { startHost?: boolean } = {},
502
+ opts: { startHost?: boolean; stopped?: boolean } = {},
407
503
  ): Promise<AgentRow[]> {
408
504
  checkWorkspaceName("agents list", ws);
409
505
  let all: SessionInfo[];
@@ -417,7 +513,9 @@ export async function listAgents(
417
513
  } else all = await withClient(d, (c) => c.list());
418
514
  const sessions = all.filter((s) => inWorkspace(s, d.dist, ws));
419
515
  const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
420
- return agentRows(sessions, records);
516
+ noteEnded(d.stateDir(), sessions, records);
517
+ const rows = agentRows(sessions, records);
518
+ return opts.stopped ? rows.filter((r) => isStoppedState(r.state)) : rows;
421
519
  }
422
520
 
423
521
  // ── personas, runtimes ──────────────────────────────────────────────────────
@@ -804,17 +902,26 @@ async function startSession(
804
902
  ...launch.rec,
805
903
  session: { backend: info.ref.backend, id: info.ref.id },
806
904
  startedAt: info.startedAt,
905
+ grants: [...launch.grants],
807
906
  ...extra,
808
907
  };
908
+ // A new session: whatever ended the last one no longer applies.
809
909
  delete rec.stoppedAt;
910
+ delete rec.endedAt;
911
+ delete rec.exitCode;
912
+ delete rec.interruptedAt;
810
913
  writeRecord(d.stateDir(), rec);
811
914
  return { rec, info };
812
915
  }
813
916
 
814
- /** CSI, OSC (BEL or ST ended), and other two-byte escapes. */
917
+ /**
918
+ * CSI, OSC (BEL or ST ended), charset designations (`ESC ( B`), and other
919
+ * two-byte escapes, save and restore (`ESC 7`, `ESC 8`) and keypad modes
920
+ * included: Claude Code ends with `ESC ( B ESC 7 ESC 8`.
921
+ */
815
922
  /* eslint-disable no-control-regex */
816
923
  const TERMINAL_ESCAPE =
817
- /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
924
+ /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()*+\-./][ -~]|\x1b[0-9:;<=>?@-Z\\-_]/g;
818
925
  /* eslint-enable no-control-regex */
819
926
 
820
927
  /** Printable lines of terminal output: escapes and control bytes removed. */
@@ -836,6 +943,8 @@ export interface EarlyExit {
836
943
  exitCode?: number;
837
944
  /** Its last printed lines, secret-shaped values redacted. */
838
945
  tail: string[];
946
+ /** All it printed (the last 64 KB), as plain text, to match against. */
947
+ said: string;
839
948
  sessionName: string;
840
949
  }
841
950
 
@@ -857,14 +966,14 @@ async function exitedAtOnce(
857
966
  if (!s) return null;
858
967
  if (!s.exited) continue;
859
968
  if (s.exitCode === 0) return null;
860
- const tail = plainLines(
969
+ const lines = plainLines(
861
970
  (await c.scrollback(s.ref.id, 64 * 1024)).toString("utf-8"),
862
- )
863
- .slice(-EXIT_TAIL_LINES)
864
- .map((l) => redactSecrets(l));
971
+ );
972
+ const tail = lines.slice(-EXIT_TAIL_LINES).map((l) => redactSecrets(l));
865
973
  return {
866
974
  ...(s.exitCode === undefined ? {} : { exitCode: s.exitCode }),
867
975
  tail,
976
+ said: lines.join("\n"),
868
977
  sessionName: s.name,
869
978
  };
870
979
  }
@@ -879,8 +988,10 @@ function launchInput(
879
988
  id: string,
880
989
  model: string | undefined,
881
990
  bin?: string,
991
+ /** The grants to serve (a resume's, narrowed); default the config's. */
992
+ only?: readonly string[],
882
993
  ): LaunchInput {
883
- const grants = cfg.agents[id]?.grants ?? [];
994
+ const grants = only ? [...only] : (cfg.agents[id]?.grants ?? []);
884
995
  for (const g of grants)
885
996
  if (!d.workspaceExists(g))
886
997
  throw refused(
@@ -993,7 +1104,10 @@ export async function startAgent(
993
1104
  workspace: ws,
994
1105
  cwd: input.worktree,
995
1106
  sessionName: sessionName(d.dist, ws, id),
996
- ...(out.resumeToken ? { resumeToken: out.resumeToken } : {}),
1107
+ // 0.4.8: launched on this token, so a resume continues it.
1108
+ ...(out.resumeToken
1109
+ ? { resumeToken: out.resumeToken, launched: true }
1110
+ : {}),
997
1111
  },
998
1112
  argv: out.argv,
999
1113
  env: out.env,
@@ -1035,22 +1149,49 @@ export interface Stopped {
1035
1149
  rec: AgentRecord;
1036
1150
  /** The session it sent TERM to; undefined when it wasn't running. */
1037
1151
  session?: SessionInfo;
1152
+ /** Its session was still running when the wait ran out. */
1153
+ stillStopping?: boolean;
1038
1154
  }
1039
1155
 
1156
+ /**
1157
+ * Stop: TERM to the agent's session, then wait (up to d.stopWaitMs) until
1158
+ * the session has ended, so a resume right after it never finds the agent
1159
+ * still running. Still running at the bound: `stillStopping`. With
1160
+ * `wait: false` (or d.stopWaitMs 0) it returns once TERM is sent.
1161
+ */
1040
1162
  export async function stopAgent(
1041
1163
  d: AgentsResolved,
1042
1164
  id: string,
1043
1165
  ws?: string,
1166
+ opts: { wait?: boolean } = {},
1044
1167
  ): Promise<Stopped> {
1045
1168
  const rec = agentRecord(d, id, ws);
1046
1169
  return withClient(d, async (c) => {
1047
1170
  const s = sessionOf(await c.list(), rec);
1048
1171
  // Its token stops working before the session is asked to end.
1049
1172
  revokeAgentTokens(d.stateDir(), rec.id);
1050
- if (s && !s.exited) await c.kill(s.ref.id, "TERM");
1173
+ const live = Boolean(s && !s.exited);
1174
+ if (live) await c.kill(s!.ref.id, "TERM");
1051
1175
  const next = { ...rec, stoppedAt: new Date().toISOString() };
1052
1176
  writeRecord(d.stateDir(), next);
1053
- return { rec: next, ...(s && !s.exited ? { session: s } : {}) };
1177
+ let stillStopping = false;
1178
+ if (live && opts.wait !== false && d.stopWaitMs > 0) {
1179
+ const deadline = Date.now() + d.stopWaitMs;
1180
+ for (;;) {
1181
+ const now = (await c.list()).find((x) => x.ref.id === s!.ref.id);
1182
+ if (!now || now.exited) break;
1183
+ if (Date.now() >= deadline) {
1184
+ stillStopping = true;
1185
+ break;
1186
+ }
1187
+ await new Promise((r) => setTimeout(r, EXIT_POLL_MS));
1188
+ }
1189
+ }
1190
+ return {
1191
+ rec: next,
1192
+ ...(live ? { session: s! } : {}),
1193
+ ...(stillStopping ? { stillStopping } : {}),
1194
+ };
1054
1195
  });
1055
1196
  }
1056
1197
 
@@ -1059,6 +1200,20 @@ export interface Resumed extends Started {
1059
1200
  fresh: boolean;
1060
1201
  }
1061
1202
 
1203
+ /**
1204
+ * The grants a resume serves (AG-14): the ones the agent had, never more
1205
+ * than agents.<id>.grants gives now (a grant taken out of the config is
1206
+ * gone). A record from before 0.4.8 has none noted: the config's.
1207
+ */
1208
+ export function resumeGrants(
1209
+ rec: AgentRecord,
1210
+ cfg: ResolvedAgentsConfig,
1211
+ ): string[] {
1212
+ const now = cfg.agents[rec.id]?.grants ?? [];
1213
+ if (!rec.grants) return [...now];
1214
+ return rec.grants.filter((g) => now.includes(g));
1215
+ }
1216
+
1062
1217
  export async function resumeAgent(
1063
1218
  d: AgentsResolved,
1064
1219
  id: string,
@@ -1067,11 +1222,14 @@ export async function resumeAgent(
1067
1222
  const rec = agentRecord(d, id, ws);
1068
1223
  syncCatalog(d);
1069
1224
  const rt = runtimeFor(rec.runtime);
1070
- if (!rt.resume)
1071
- throw refused(`runtime ${rt.id} does not declare resume`, [
1072
- `Start it again: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
1225
+ // AG-14.2: never a resume that would start a new conversation instead.
1226
+ const why = resumeRefusal(rec);
1227
+ if (why)
1228
+ throw refused(why, [
1229
+ `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`,
1073
1230
  ]);
1074
1231
  const cfg = agentsConfig(d);
1232
+ const grants = resumeGrants(rec, cfg);
1075
1233
  const persona = personas(d, cfg).get(rec.persona);
1076
1234
  if (!persona)
1077
1235
  throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
@@ -1091,6 +1249,7 @@ export async function resumeAgent(
1091
1249
  rec.id,
1092
1250
  rec.model,
1093
1251
  det.path,
1252
+ grants,
1094
1253
  );
1095
1254
  // Resume in the cwd the agent had, wherever the workspace points now.
1096
1255
  const at = {
@@ -1098,29 +1257,308 @@ export async function resumeAgent(
1098
1257
  worktree: rec.cwd,
1099
1258
  ...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
1100
1259
  };
1101
- // A session nobody prompted left no conversation: continuing it fails,
1102
- // so it starts afresh under the same id. A session the host no longer
1103
- // lists is assumed prompted.
1104
- const fresh = Boolean(old && !old.prompted && rec.resumeToken);
1105
- const out = launchOrThrow(() => (fresh ? rt.launch(at) : rt.resume!(at)));
1106
- if (!out) throw refused(`agent ${rec.id} has nothing to resume`);
1107
- writeRuntimeFiles(input.filesDir, out.files);
1260
+ // 0.4.8: the runtime's own resume first, whenever this token was
1261
+ // launched (the record says so), its conversation is on disk, the last
1262
+ // session was prompted, or the host no longer lists it. Only a record
1263
+ // from before 0.4.8 whose session nobody prompted starts afresh under
1264
+ // the same id.
1265
+ const token = rec.resumeToken;
1266
+ // A runtime that can tell (den-claude: its transcript) decides before
1267
+ // launching: a conversation on disk, or a last session someone typed
1268
+ // into, resumes; neither starts fresh at once. Without that, the record
1269
+ // decides: launched on this token, or a session the host lost.
1270
+ const known = rt.conversationExists
1271
+ ? Boolean(token && rt.conversationExists(token, d.env)) ||
1272
+ Boolean(old?.prompted)
1273
+ : Boolean(rec.launched) || !old || Boolean(old.prompted);
1274
+ const launchAs = (asFresh: boolean) => {
1275
+ const out = launchOrThrow(() =>
1276
+ asFresh ? rt.launch(at) : rt.resume!(at),
1277
+ );
1278
+ if (!out) throw refused(`agent ${rec.id} has nothing to resume`);
1279
+ writeRuntimeFiles(input.filesDir, out.files);
1280
+ return out;
1281
+ };
1108
1282
  const { session: _s, startedAt: _t, resumedAt: _r, ...base } = rec;
1109
- const { rec: next, info } = await startSession(
1110
- d,
1111
- c,
1112
- {
1113
- rec: { ...base, cwd: rec.cwd },
1114
- argv: out.argv,
1115
- env: out.env,
1116
- passthrough: rt.envPassthrough,
1117
- grants: cfg.agents[rec.id]?.grants ?? [],
1118
- ...binDirOf(det),
1119
- },
1120
- old,
1121
- fresh ? {} : { resumedAt: new Date().toISOString() },
1122
- );
1123
- const early = await exitedAtOnce(d, c, info);
1124
- return { rec: next, info, warnings: out.warnings ?? [], early, fresh };
1283
+ const run = async (asFresh: boolean, prior: SessionInfo | undefined) => {
1284
+ const out = launchAs(asFresh);
1285
+ const { rec: next, info } = await startSession(
1286
+ d,
1287
+ c,
1288
+ {
1289
+ rec: { ...base, cwd: rec.cwd, ...(token ? { launched: true } : {}) },
1290
+ argv: out.argv,
1291
+ env: out.env,
1292
+ passthrough: rt.envPassthrough,
1293
+ grants,
1294
+ ...binDirOf(det),
1295
+ },
1296
+ prior,
1297
+ asFresh ? {} : { resumedAt: new Date().toISOString() },
1298
+ );
1299
+ const early = await exitedAtOnce(d, c, info);
1300
+ return { out, next, info, early };
1301
+ };
1302
+ let fresh = !known && Boolean(token);
1303
+ let r = await run(fresh, old);
1304
+ // It exited at once saying the other way was the right one: try that,
1305
+ // once. A resume with nothing to continue starts fresh under the same
1306
+ // id; a fresh start whose id is taken resumes it.
1307
+ const said = r.early?.said ?? "";
1308
+ const other = fresh
1309
+ ? rt.launchFailures?.idInUse?.test(said)
1310
+ : rt.launchFailures?.noConversation?.test(said);
1311
+ if (r.early && other && token) {
1312
+ const first = r;
1313
+ fresh = !fresh;
1314
+ // The first try's token goes with its session.
1315
+ revokeAgentTokens(d.stateDir(), rec.id);
1316
+ r = await run(
1317
+ fresh,
1318
+ (await c.list()).find((x) => x.ref.id === first.info.ref.id),
1319
+ );
1320
+ if (r.early)
1321
+ throw new AgentError(
1322
+ `agent ${rec.id}: conversation ${token} could not be continued or started (${rt.id} exited at once both ways: ${[...first.early!.tail, ...r.early.tail].slice(-4).join(" / ") || "no output"})`,
1323
+ {
1324
+ status: 502,
1325
+ code: "resume_failed",
1326
+ hints: [
1327
+ `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id} (a new conversation)`,
1328
+ ],
1329
+ },
1330
+ );
1331
+ }
1332
+ return {
1333
+ rec: r.next,
1334
+ info: r.info,
1335
+ warnings: r.out.warnings ?? [],
1336
+ early: r.early,
1337
+ fresh,
1338
+ };
1339
+ });
1340
+ }
1341
+
1342
+ // ── AG-14: start fresh, remove, restart ─────────────────────────────────────
1343
+
1344
+ /**
1345
+ * A new conversation for agent `id` (AG-14.1 Start fresh): the same
1346
+ * persona, runtime, model and workspace, under the same id. Its last
1347
+ * conversation stays in the runtime's own history.
1348
+ */
1349
+ export async function startFresh(
1350
+ d: AgentsResolved,
1351
+ id: string,
1352
+ ws?: string,
1353
+ ): Promise<Started> {
1354
+ const rec = agentRecord(d, id, ws);
1355
+ return startAgent(d, {
1356
+ workspace: rec.workspace,
1357
+ persona: rec.persona,
1358
+ runtime: rec.runtime,
1359
+ ...(rec.model ? { model: rec.model } : {}),
1360
+ id: rec.id,
1125
1361
  });
1126
1362
  }
1363
+
1364
+ /** The host's sessions, never starting a host (none running: none). */
1365
+ async function hostSessions(d: AgentsResolved): Promise<SessionInfo[]> {
1366
+ const c = await d.connectExisting();
1367
+ try {
1368
+ return c ? await c.list() : [];
1369
+ } finally {
1370
+ c?.close();
1371
+ }
1372
+ }
1373
+
1374
+ /** An agent removed (AG-14.1b): what Undo brings back. */
1375
+ export interface Removed {
1376
+ agent: string;
1377
+ workspace: string;
1378
+ }
1379
+
1380
+ /**
1381
+ * Forget agents `ids` (AG-14.1b): each record moves aside for Undo, its
1382
+ * actor tokens are revoked and the host forgets its ended session. A
1383
+ * running agent is refused: stop it first. Never asks a host to start.
1384
+ */
1385
+ async function removeRecords(
1386
+ d: AgentsResolved,
1387
+ recs: AgentRecord[],
1388
+ ): Promise<Removed[]> {
1389
+ const c = await d.connectExisting();
1390
+ try {
1391
+ const sessions = c ? await c.list() : [];
1392
+ for (const rec of recs) {
1393
+ const s = sessionOf(sessions, rec);
1394
+ if (!isStoppedState(rowState(rec, s)))
1395
+ throw refused(`agent ${rec.id} is running: stop it first`, [
1396
+ `${d.cli} agents stop ${rec.id}`,
1397
+ ]);
1398
+ }
1399
+ const out: Removed[] = [];
1400
+ for (const rec of recs) {
1401
+ const s = sessionOf(sessions, rec);
1402
+ revokeAgentTokens(d.stateDir(), rec.id);
1403
+ if (c && s?.exited) await c.forget(s.ref.id).catch(() => undefined);
1404
+ if (removeRecord(d.stateDir(), rec.id))
1405
+ out.push({ agent: rec.id, workspace: rec.workspace });
1406
+ }
1407
+ purgeRemoved(d.stateDir());
1408
+ return out;
1409
+ } finally {
1410
+ c?.close();
1411
+ }
1412
+ }
1413
+
1414
+ export async function removeAgent(
1415
+ d: AgentsResolved,
1416
+ id: string,
1417
+ ws?: string,
1418
+ ): Promise<Removed[]> {
1419
+ return removeRecords(d, [agentRecord(d, id, ws)]);
1420
+ }
1421
+
1422
+ /**
1423
+ * Remove every stopped, interrupted or exited agent of `workspaces`
1424
+ * (AG-14.1b Clear stopped); running ones stay.
1425
+ */
1426
+ export async function removeStopped(
1427
+ d: AgentsResolved,
1428
+ workspaces: readonly string[],
1429
+ ): Promise<Removed[]> {
1430
+ const sessions = await hostSessions(d);
1431
+ const recs = listRecords(d.stateDir()).filter(
1432
+ (r) =>
1433
+ workspaces.includes(r.workspace) &&
1434
+ isStoppedState(rowState(r, sessionOf(sessions, r))),
1435
+ );
1436
+ return removeRecords(d, recs);
1437
+ }
1438
+
1439
+ /** Undo: bring removed agents back; the ones that came back. */
1440
+ export function restoreAgents(
1441
+ d: AgentsResolved,
1442
+ ids: readonly string[],
1443
+ ): string[] {
1444
+ return ids.filter((id) => restoreRecord(d.stateDir(), id));
1445
+ }
1446
+
1447
+ /**
1448
+ * Forget stopped and exited agents older than `days` (AG-14.1b,
1449
+ * agents.remove_stopped_after in user.yml; 0: never). Interrupted ones
1450
+ * are never forgotten. Returns the ids forgotten.
1451
+ */
1452
+ export async function pruneStopped(
1453
+ d: AgentsResolved,
1454
+ days: number,
1455
+ now = Date.now(),
1456
+ ): Promise<string[]> {
1457
+ if (!(days > 0)) return [];
1458
+ const sessions = await hostSessions(d);
1459
+ const cutoff = now - days * 86_400_000;
1460
+ const old = listRecords(d.stateDir()).filter((r) => {
1461
+ const st = rowState(r, sessionOf(sessions, r));
1462
+ if (st === "interrupted" || !isStoppedState(st)) return false;
1463
+ const at = Date.parse(r.stoppedAt ?? r.endedAt ?? r.startedAt);
1464
+ return Number.isFinite(at) && at < cutoff;
1465
+ });
1466
+ for (const r of old) {
1467
+ revokeAgentTokens(d.stateDir(), r.id);
1468
+ removeRecord(d.stateDir(), r.id);
1469
+ }
1470
+ purgeRemoved(d.stateDir(), 0, now);
1471
+ return old.map((r) => r.id);
1472
+ }
1473
+
1474
+ /** What a restart found (AG-14.3). */
1475
+ export interface RestartFound {
1476
+ /** Agents newly marked interrupted. */
1477
+ interrupted: string[];
1478
+ /** Of those, the ones resumed on their own (standing, resume_on_restart). */
1479
+ resumed: string[];
1480
+ /** Ones that should have resumed but couldn't, with why. */
1481
+ failed: { agent: string; why: string }[];
1482
+ }
1483
+
1484
+ /**
1485
+ * When the web server starts (AG-14.3): every agent whose session is gone
1486
+ * though nobody stopped it and it never exited is marked interrupted. A
1487
+ * session host that survived a web-server-only restart still lists its
1488
+ * sessions, so nothing is marked. Then the standing personas, and those
1489
+ * with `resume_on_restart: true`, resume (when their runtime can); the
1490
+ * rest wait for someone to click Resume.
1491
+ */
1492
+ export async function markInterrupted(
1493
+ d: AgentsResolved,
1494
+ opts: { autoResume?: boolean } = {},
1495
+ ): Promise<RestartFound> {
1496
+ const sessions = await hostSessions(d);
1497
+ const found: RestartFound = { interrupted: [], resumed: [], failed: [] };
1498
+ const now = new Date().toISOString();
1499
+ const marked: AgentRecord[] = [];
1500
+ for (const rec of listRecords(d.stateDir())) {
1501
+ if (rec.interruptedAt) continue;
1502
+ if (rowState(rec, sessionOf(sessions, rec)) !== "interrupted") continue;
1503
+ const next = { ...rec, interruptedAt: now };
1504
+ writeRecord(d.stateDir(), next);
1505
+ marked.push(next);
1506
+ found.interrupted.push(rec.id);
1507
+ }
1508
+ if (opts.autoResume === false || !marked.length) return found;
1509
+ let all: Map<string, Persona>;
1510
+ try {
1511
+ all = personas(d, agentsConfig(d));
1512
+ } catch {
1513
+ return found;
1514
+ }
1515
+ for (const rec of marked) {
1516
+ const p = all.get(rec.persona);
1517
+ if (!p || !(p.standing || p.resumeOnRestart)) continue;
1518
+ if (resumeRefusal(rec)) continue;
1519
+ try {
1520
+ const r = await resumeAgent(d, rec.id);
1521
+ if (r.early) found.failed.push({ agent: rec.id, why: "exited at once" });
1522
+ else found.resumed.push(rec.id);
1523
+ } catch (e) {
1524
+ found.failed.push({ agent: rec.id, why: (e as Error).message });
1525
+ }
1526
+ }
1527
+ return found;
1528
+ }
1529
+
1530
+ /**
1531
+ * Resume every interrupted agent of `workspaces` that can resume
1532
+ * (AG-14.3 "Resume all", `agents resume --all-interrupted`).
1533
+ */
1534
+ export async function resumeInterrupted(
1535
+ d: AgentsResolved,
1536
+ workspaces?: readonly string[],
1537
+ ): Promise<{
1538
+ resumed: Resumed[];
1539
+ failed: { agent: string; why: string }[];
1540
+ }> {
1541
+ const sessions = await hostSessions(d);
1542
+ const recs = listRecords(d.stateDir()).filter(
1543
+ (r) =>
1544
+ (!workspaces || workspaces.includes(r.workspace)) &&
1545
+ rowState(r, sessionOf(sessions, r)) === "interrupted",
1546
+ );
1547
+ const resumed: Resumed[] = [];
1548
+ const failed: { agent: string; why: string }[] = [];
1549
+ for (const rec of recs) {
1550
+ const why = resumeRefusal(rec);
1551
+ if (why) {
1552
+ failed.push({ agent: rec.id, why });
1553
+ continue;
1554
+ }
1555
+ try {
1556
+ const r = await resumeAgent(d, rec.id);
1557
+ if (r.early) failed.push({ agent: rec.id, why: "exited at once" });
1558
+ else resumed.push(r);
1559
+ } catch (e) {
1560
+ failed.push({ agent: rec.id, why: (e as Error).message });
1561
+ }
1562
+ }
1563
+ return { resumed, failed };
1564
+ }