@monster-orange/den 0.4.7 → 0.4.9

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 (44) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +243 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +57 -3
  7. package/src/shared/agents/guardrails/hooks.json +23 -0
  8. package/src/shared/agents/guardrails/instructions.md +23 -0
  9. package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
  10. package/src/shared/agents/guardrails/stop-hook.ts +48 -2
  11. package/src/shared/agents/guardrails.ts +38 -7
  12. package/src/shared/agents/handoff.ts +118 -0
  13. package/src/shared/agents/personas.ts +11 -1
  14. package/src/shared/agents/records.ts +122 -1
  15. package/src/shared/agents/rotation.ts +362 -0
  16. package/src/shared/agents/runtime.ts +27 -0
  17. package/src/shared/den-config.ts +72 -1
  18. package/src/shared/git/gh-pr-state.ts +32 -0
  19. package/src/shared/plugins/manifest.ts +3 -0
  20. package/src/shared/usage/paths.ts +16 -0
  21. package/src/shared/usage/report.ts +377 -0
  22. package/src/shared/usage/sources.ts +127 -0
  23. package/src/shared/usage/transcript.ts +319 -0
  24. package/src/ui/cli/agent-ops.ts +678 -51
  25. package/src/ui/cli/agents-command.ts +192 -22
  26. package/src/ui/cli/index.ts +13 -1
  27. package/src/ui/cli/usage-command.ts +323 -0
  28. package/src/ui/mcp/api.ts +10 -1
  29. package/src/ui/mcp/server.ts +34 -0
  30. package/src/ui/mcp/tools.ts +50 -0
  31. package/src/ui/server/agent-resume-grant.ts +53 -0
  32. package/src/ui/server/agent-routes.ts +247 -9
  33. package/src/ui/server/audit.ts +68 -1
  34. package/src/ui/server/index.ts +16 -5
  35. package/src/ui/server/public/agents-page.js +400 -13
  36. package/src/ui/server/public/agents-section.js +17 -3
  37. package/src/ui/server/public/agents.html +44 -0
  38. package/src/ui/server/public/i18n/es.js +40 -0
  39. package/src/ui/server/public/i18n/ja.js +36 -0
  40. package/src/ui/server/public/shell.js +79 -0
  41. package/src/ui/server/request-guard.ts +21 -1
  42. package/src/ui/server/route-classes.ts +29 -0
  43. package/src/ui/server/session-routes.ts +60 -5
  44. package/src/ui/server/sudo-store.ts +36 -7
@@ -15,7 +15,21 @@
15
15
  * GET /api/workspaces/<ws>/agents the workspace's agents
16
16
  * POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
17
17
  * POST /api/workspaces/<ws>/agents/<id>/stop
18
- * POST /api/workspaces/<ws>/agents/<id>/resume
18
+ * POST /api/workspaces/<ws>/agents/<id>/resume the same conversation (AG-14)
19
+ * POST /api/workspaces/<ws>/agents/<id>/fresh a new conversation, same
20
+ * persona and workspace
21
+ * POST /api/workspaces/<ws>/agents/<id>/relaunch a rotated agent (den#4):
22
+ * a new conversation whose
23
+ * first prompt is its
24
+ * handoff.md; a resume of
25
+ * one is refused (409 rotated)
26
+ * POST /api/workspaces/<ws>/agents/<id>/remove forget a stopped one (Undo:
27
+ * /api/agents/restore)
28
+ * POST /api/agents/clear-stopped { workspaces: [...] }: forget
29
+ * every stopped one there
30
+ * POST /api/agents/restore { agents: [ids] }: Undo
31
+ * POST /api/agents/resume-interrupted { workspaces: [...] }: Resume
32
+ * all after a restart (AG-14.3)
19
33
  *
20
34
  * Each calls the CLI's own operation (cli/agent-ops.ts): neither the page
21
35
  * nor the CLI keeps state of its own, so an agent started here is the one
@@ -39,7 +53,11 @@
39
53
  * the only route that writes to a session or streams its output.
40
54
  */
41
55
  import * as http from "http";
42
- import { getReposRoot } from "../../shared/den-config.js";
56
+ import {
57
+ configuredRemoveStoppedAfter,
58
+ getReposRoot,
59
+ readMcpConfig,
60
+ } from "../../shared/den-config.js";
43
61
  import {
44
62
  readWorkspacesFile,
45
63
  resolveWorkspaceDir,
@@ -52,18 +70,31 @@ import {
52
70
  listAgents,
53
71
  listPersonas,
54
72
  listRuntimes,
73
+ markInterrupted,
55
74
  NOT_IN_WORKSPACE,
75
+ pruneStopped,
76
+ removeAgent,
77
+ removeStopped,
78
+ restoreAgents,
79
+ resumeInterrupted,
56
80
  setRuntimePath,
57
81
  resolveAgentsDeps,
82
+ relaunchAgent,
58
83
  resumeAgent,
59
84
  startAgent,
85
+ startFresh,
60
86
  stopAgent,
61
87
  type AgentsDeps,
62
88
  type AgentsResolved,
63
89
  type Started,
64
90
  } from "../cli/agent-ops.js";
65
91
  import { recordAction } from "./audit.js";
66
- import { auditActorOf, principalOf } from "./request-guard.js";
92
+ import {
93
+ agentPrincipalOf,
94
+ auditActorOf,
95
+ principalOf,
96
+ } from "./request-guard.js";
97
+ import { agentResumeRefusal } from "./agent-resume-grant.js";
67
98
  import { readBody, sendJson } from "./http-helpers.js";
68
99
  import type { Actor } from "../../shared/agent-actor.js";
69
100
 
@@ -73,10 +104,14 @@ export interface AgentRouteDeps {
73
104
  }
74
105
 
75
106
  const AGENTS_PATH =
76
- /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume))?$/;
107
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove))?$/;
108
+
77
109
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
78
110
  /** A start's body is a few short names. */
79
111
  const MAX_BODY = 4096;
112
+ /** A list of names (a page's workspaces, an Undo's agents). */
113
+ const MANY_BODY = 64 * 1024;
114
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
80
115
 
81
116
  /** Never a TTY: a web start's terminal is 80×24. */
82
117
  const NO_TTY = { isTTY: false } as NodeJS.WriteStream;
@@ -129,7 +164,10 @@ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
129
164
  }
130
165
 
131
166
  /** A started or resumed agent's answer: its record, or its early exit. */
132
- function sendStarted(res: http.ServerResponse, s: Started): void {
167
+ function sendStarted(
168
+ res: http.ServerResponse,
169
+ s: Started & { fresh?: boolean },
170
+ ): void {
133
171
  if (s.early) {
134
172
  const code =
135
173
  s.early.exitCode === undefined ? "" : ` (exit ${s.early.exitCode})`;
@@ -160,6 +198,8 @@ function sendStarted(res: http.ServerResponse, s: Started): void {
160
198
  sessionId: s.info.ref.id,
161
199
  startedAt: s.rec.startedAt,
162
200
  },
201
+ // A resume with nothing to continue yet started fresh (same id).
202
+ ...(s.fresh ? { fresh: true } : {}),
163
203
  ...(s.warnings.length ? { warnings: s.warnings } : {}),
164
204
  });
165
205
  }
@@ -203,17 +243,24 @@ export async function handleAgentRoutes(
203
243
  ): Promise<boolean> {
204
244
  const m = AGENTS_PATH.exec(pathname);
205
245
  const rp = RUNTIME_PATH.exec(pathname);
246
+ // AG-14's routes over many agents at once.
247
+ const many =
248
+ pathname === "/api/agents/clear-stopped" ||
249
+ pathname === "/api/agents/restore" ||
250
+ pathname === "/api/agents/resume-interrupted";
206
251
  if (
207
252
  pathname !== "/api/personas" &&
208
253
  pathname !== "/api/agents/runtimes" &&
209
254
  !m &&
210
- !rp
255
+ !rp &&
256
+ !many
211
257
  )
212
258
  return false;
213
259
  const method = req.method ?? "GET";
214
260
  const d = webDeps(deps.agents);
215
261
 
216
262
  if (rp) return runtimePath(req, res, safeDecode(rp[1]), d);
263
+ if (many) return manyAgents(req, res, pathname, d);
217
264
  if (!m) {
218
265
  if (method !== "GET" && method !== "HEAD")
219
266
  return methodNotAllowed(res, "GET");
@@ -241,7 +288,13 @@ export async function handleAgentRoutes(
241
288
  return true;
242
289
  }
243
290
  const id = m[2] === undefined ? undefined : safeDecode(m[2]);
244
- const action = m[3] as "stop" | "resume" | undefined;
291
+ const action = m[3] as
292
+ | "stop"
293
+ | "resume"
294
+ | "fresh"
295
+ | "relaunch"
296
+ | "remove"
297
+ | undefined;
245
298
 
246
299
  if (!action && method === "GET") {
247
300
  try {
@@ -281,7 +334,12 @@ export async function handleAgentRoutes(
281
334
  // Another workspace's agent is no agent here: a 404 that doesn't name
282
335
  // it, before anything else is said about it.
283
336
  if (id !== undefined) agentRecord(d, id, ws);
284
- guard(actor);
337
+ // AG-14.4: MCP agent_resume may resume another agent of its scope.
338
+ const agent = agentPrincipalOf(req);
339
+ if (verb === "resume" && agent) {
340
+ const why = agentResumeRefusal(agent, ws, id!, mcpActions());
341
+ if (why) throw new AgentError(why, { status: 403 });
342
+ } else guard(actor);
285
343
  if (verb === "start") {
286
344
  const body = await startBody(req);
287
345
  if (!body.persona)
@@ -298,9 +356,35 @@ export async function handleAgentRoutes(
298
356
  audit(what, s.early ? "exited at once" : undefined);
299
357
  sendStarted(res, s);
300
358
  } else if (verb === "stop") {
359
+ // The answer comes once the session has ended (200, stopped: true),
360
+ // or, still running after the wait, 202 with stillStopping: true.
301
361
  const s = await stopAgent(d, id!, ws);
362
+ audit(s.stillStopping ? `${what} (still stopping)` : what);
363
+ sendJson(
364
+ res,
365
+ {
366
+ ok: true,
367
+ agent: s.rec.id,
368
+ wasRunning: !!s.session,
369
+ stopped: !s.stillStopping,
370
+ ...(s.stillStopping ? { stillStopping: true } : {}),
371
+ },
372
+ s.stillStopping ? 202 : 200,
373
+ );
374
+ } else if (verb === "fresh") {
375
+ const s = await startFresh(d, id!, ws);
376
+ what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
377
+ audit(what, s.early ? "exited at once" : undefined);
378
+ sendStarted(res, s);
379
+ } else if (verb === "relaunch") {
380
+ const s = await relaunchAgent(d, id!, ws);
381
+ what = `relaunch ${s.rec.id} from its handoff (${s.why}) in ${ws}`;
382
+ audit(what, s.early ? "exited at once" : undefined);
383
+ sendStarted(res, s);
384
+ } else if (verb === "remove") {
385
+ const removed = await removeAgent(d, id!, ws);
302
386
  audit(what);
303
- sendJson(res, { ok: true, agent: s.rec.id, wasRunning: !!s.session });
387
+ sendJson(res, { ok: true, removed });
304
388
  } else {
305
389
  const s = await resumeAgent(d, id!, ws);
306
390
  audit(what);
@@ -313,6 +397,112 @@ export async function handleAgentRoutes(
313
397
  return true;
314
398
  }
315
399
 
400
+ /** The org config's mcp.actions; none when it can't be read. */
401
+ function mcpActions(): readonly string[] {
402
+ try {
403
+ return readMcpConfig().actions;
404
+ } catch {
405
+ return [];
406
+ }
407
+ }
408
+
409
+ /** A POST body's JSON object (a usage error otherwise). */
410
+ async function jsonBody(
411
+ req: http.IncomingMessage,
412
+ ): Promise<Record<string, unknown>> {
413
+ const raw = await readBody(req).catch(() => "");
414
+ if (raw.length > MANY_BODY)
415
+ throw new AgentError("request too large", { status: 413, usage: true });
416
+ let v: unknown;
417
+ try {
418
+ v = JSON.parse(raw || "{}");
419
+ } catch {
420
+ throw new AgentError("invalid JSON", { usage: true });
421
+ }
422
+ if (!v || typeof v !== "object" || Array.isArray(v))
423
+ throw new AgentError("the body must be a JSON object", { usage: true });
424
+ return v as Record<string, unknown>;
425
+ }
426
+
427
+ /** A body's list of names (workspaces or agent ids), each checked. */
428
+ function names(v: unknown, key: string): string[] {
429
+ if (!Array.isArray(v) || !v.length)
430
+ throw new AgentError(`${key} must be a list of names`, { usage: true });
431
+ for (const x of v)
432
+ if (typeof x !== "string" || !NAME_RE.test(x))
433
+ throw new AgentError(`${key}: "${String(x)}" is not a name`, {
434
+ usage: true,
435
+ });
436
+ return [...new Set(v as string[])];
437
+ }
438
+
439
+ /**
440
+ * POST /api/agents/clear-stopped, /restore, /resume-interrupted (AG-14):
441
+ * a person only. Each is audited, refused or done.
442
+ */
443
+ async function manyAgents(
444
+ req: http.IncomingMessage,
445
+ res: http.ServerResponse,
446
+ pathname: string,
447
+ d: AgentsResolved,
448
+ ): Promise<true> {
449
+ if (req.method !== "POST") return methodNotAllowed(res, "POST");
450
+ const actor = auditActorOf(req);
451
+ const verb = pathname.slice("/api/agents/".length);
452
+ let what = verb;
453
+ const audit = (reason?: string) =>
454
+ recordAction(
455
+ actor,
456
+ {
457
+ verb: "agents",
458
+ detail: what,
459
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
460
+ },
461
+ principalOf(req),
462
+ );
463
+ try {
464
+ guard(actor);
465
+ const body = await jsonBody(req);
466
+ if (verb === "restore") {
467
+ const ids = names(body.agents, "agents");
468
+ const restored = restoreAgents(d, ids);
469
+ what = `restore ${restored.join(", ") || "nothing"}`;
470
+ audit();
471
+ sendJson(res, { ok: true, restored });
472
+ return true;
473
+ }
474
+ const wss = names(body.workspaces, "workspaces");
475
+ for (const w of wss)
476
+ if (!d.workspaceExists(w))
477
+ throw new AgentError(`workspace "${w}" does not exist`, {
478
+ status: 404,
479
+ });
480
+ if (verb === "clear-stopped") {
481
+ const removed = await removeStopped(d, wss);
482
+ what = `clear stopped in ${wss.join(", ")}: ${removed.map((r) => r.agent).join(", ") || "none"}`;
483
+ audit();
484
+ sendJson(res, { ok: true, removed });
485
+ return true;
486
+ }
487
+ const r = await resumeInterrupted(d, wss);
488
+ what = `resume interrupted in ${wss.join(", ")}: ${r.resumed.map((x) => x.rec.id).join(", ") || "none"}`;
489
+ audit(r.failed.length ? `${r.failed.length} could not resume` : undefined);
490
+ sendJson(res, {
491
+ ok: r.failed.length === 0,
492
+ resumed: r.resumed.map((x) => ({
493
+ id: x.rec.id,
494
+ workspace: x.rec.workspace,
495
+ sessionId: x.info.ref.id,
496
+ })),
497
+ failed: r.failed,
498
+ });
499
+ } catch (e) {
500
+ audit(e instanceof Error ? e.message : String(e));
501
+ sendError(res, e);
502
+ }
503
+ return true;
504
+ }
505
+
316
506
  /** `?workspace=<ws>`, when it names a workspace here; else none. */
317
507
  function queryWorkspace(
318
508
  req: http.IncomingMessage,
@@ -419,3 +609,51 @@ function methodNotAllowed(res: http.ServerResponse, allow: string): true {
419
609
  sendJson(res, { ok: false, error: "method not allowed" }, 405);
420
610
  return true;
421
611
  }
612
+
613
+ /** How often stopped agents are checked against remove_stopped_after. */
614
+ const PRUNE_EVERY_MS = 60 * 60 * 1000;
615
+
616
+ /**
617
+ * What the web server does for agents on its own (AG-14):
618
+ * - once, shortly after it starts: marks interrupted the agents whose
619
+ * session is gone (Den or the machine went down), and resumes the
620
+ * standing personas and those with `resume_on_restart: true`
621
+ * (markInterrupted). A session host that outlived a web-server-only
622
+ * restart keeps every session: nothing is marked;
623
+ * - at start and every hour: forgets stopped and exited agents older
624
+ * than user.yml's agents.remove_stopped_after (never by default;
625
+ * interrupted ones never).
626
+ * Nothing here asks for the admin password (LK-0).
627
+ */
628
+ export function startAgentUpkeep(
629
+ log: (line: string) => void,
630
+ deps: AgentRouteDeps & { delayMs?: number } = {},
631
+ ): void {
632
+ const d = webDeps(deps.agents);
633
+ const restart = async () => {
634
+ try {
635
+ const f = await markInterrupted(d);
636
+ if (f.interrupted.length)
637
+ log(
638
+ `Agents interrupted when Den restarted: ${f.interrupted.join(", ")}` +
639
+ (f.resumed.length ? ` · resumed: ${f.resumed.join(", ")}` : ""),
640
+ );
641
+ for (const x of f.failed)
642
+ log(` ⚠ agent ${x.agent} did not resume: ${x.why}`);
643
+ } catch (e) {
644
+ log(` ⚠ agents after the restart: ${(e as Error).message}`);
645
+ }
646
+ };
647
+ const prune = async () => {
648
+ try {
649
+ const gone = await pruneStopped(d, configuredRemoveStoppedAfter());
650
+ if (gone.length) log(`Removed stopped agents: ${gone.join(", ")}`);
651
+ } catch (e) {
652
+ log(` ⚠ agents.remove_stopped_after: ${(e as Error).message}`);
653
+ }
654
+ };
655
+ setTimeout(() => {
656
+ void restart().then(prune);
657
+ }, deps.delayMs ?? 2000).unref();
658
+ setInterval(() => void prune(), PRUNE_EVERY_MS).unref();
659
+ }
@@ -3,8 +3,15 @@
3
3
  * asked (a person in the UI, or an agent through `<cli> mcp`). Each action
4
4
  * becomes a lifecycle line in the server log, the stream the /logs page shows
5
5
  * as the web-server log, so agent actions sit beside the UI's own; the
6
- * structured entries are kept in a small in-memory ring.
6
+ * structured entries are kept in a small ring.
7
+ *
8
+ * den#6: the ring persists. With setAuditFile (the web server's state dir,
9
+ * audit.jsonl, 0600), each entry is appended as one JSON line and the last
10
+ * RING_MAX are read back when the server starts, so a restart keeps the
11
+ * trail. The file is compacted to the ring once it holds twice as many.
12
+ * Entries hold names and reasons, never an env value or a secret.
7
13
  */
14
+ import * as fs from "fs";
8
15
  import * as path from "path";
9
16
  import {
10
17
  actorLabel,
@@ -72,6 +79,65 @@ const RING_MAX = 200;
72
79
  const ring: AuditEntry[] = [];
73
80
  let sink: Sink | null = null;
74
81
 
82
+ /** The persisted ring's file name, in the state dir. */
83
+ export const AUDIT_FILE = "audit.jsonl";
84
+ let file: string | null = null;
85
+ let fileLines = 0;
86
+
87
+ /**
88
+ * Persist the ring to `f` (null: memory only). Reads the last RING_MAX
89
+ * entries back into the ring; a line that isn't an entry is skipped.
90
+ */
91
+ export function setAuditFile(f: string | null): void {
92
+ file = f;
93
+ fileLines = 0;
94
+ ring.length = 0;
95
+ if (!f) return;
96
+ let text = "";
97
+ try {
98
+ text = fs.readFileSync(f, "utf-8");
99
+ } catch (e) {
100
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
101
+ }
102
+ const lines = text.split("\n").filter(Boolean);
103
+ fileLines = lines.length;
104
+ for (const l of lines.slice(-RING_MAX)) {
105
+ try {
106
+ const e = JSON.parse(l) as AuditEntry;
107
+ if (e && typeof e.ts === "string" && typeof e.verb === "string")
108
+ ring.push(e);
109
+ } catch {
110
+ /* a torn line */
111
+ }
112
+ }
113
+ }
114
+
115
+ function persist(entry: AuditEntry): void {
116
+ if (!file) return;
117
+ try {
118
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
119
+ if (fileLines + 1 > RING_MAX * 2) {
120
+ // Compact to the ring (which already holds this entry).
121
+ const tmp = `${file}.${process.pid}.tmp`;
122
+ fs.writeFileSync(
123
+ tmp,
124
+ ring.map((e) => JSON.stringify(e)).join("\n") + "\n",
125
+ {
126
+ mode: 0o600,
127
+ },
128
+ );
129
+ fs.renameSync(tmp, file);
130
+ fileLines = ring.length;
131
+ return;
132
+ }
133
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n", { mode: 0o600 });
134
+ fs.chmodSync(file, 0o600);
135
+ fileLines++;
136
+ } catch {
137
+ /* the trail stays in memory; the log line still goes out */
138
+ }
139
+ }
140
+
75
141
  /** Where audit lines go (the server log); null to disconnect. */
76
142
  export function setAuditSink(fn: Sink | null): void {
77
143
  sink = fn;
@@ -135,6 +201,7 @@ export function recordAction(
135
201
  };
136
202
  ring.push(entry);
137
203
  if (ring.length > RING_MAX) ring.shift();
204
+ persist(entry);
138
205
  const mark = entry.outcome === "refused" ? "✗" : "▶";
139
206
  const why = entry.reason ? ` — refused: ${entry.reason}` : "";
140
207
  // A refusal whose detail is its reason names what it touched instead.
@@ -24,7 +24,7 @@ import * as net from "net";
24
24
  import * as fs from "fs";
25
25
  import * as os from "os";
26
26
  import * as path from "path";
27
- import { execSync, spawn, spawnSync } from "child_process";
27
+ import { execFileSync, spawn, spawnSync } from "child_process";
28
28
  import { fileURLToPath } from "url";
29
29
  import { envSelector } from "../../shared/environment/env-selector.js";
30
30
  import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
@@ -179,6 +179,8 @@ import {
179
179
  recordCrossWorkspace,
180
180
  repoAtPath,
181
181
  setAuditSink,
182
+ setAuditFile,
183
+ AUDIT_FILE,
182
184
  } from "./audit.js";
183
185
  import { handleReadonlyRoutes } from "./readonly-routes.js";
184
186
  import {
@@ -672,6 +674,12 @@ function sendErrorPage(
672
674
  setAuditSink((line, actor) =>
673
675
  serverLog(line, "lifecycle", actor ? actorLabel(actor) : undefined),
674
676
  );
677
+ // den#6: the audit ring outlives a restart (<state dir>/audit.jsonl, 0600).
678
+ try {
679
+ setAuditFile(path.join(getStateDir(), AUDIT_FILE));
680
+ } catch (e) {
681
+ console.error(`[server] the audit file could not be read: ${String(e)}`);
682
+ }
675
683
 
676
684
  /** ANSI helpers (no chalk dependency in the server) */
677
685
  const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
@@ -752,6 +760,7 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
752
760
  // ── Sudo password store ───────────────────────────────────────────────────────
753
761
  import {
754
762
  setSudoPassword,
763
+ realSudoPath,
755
764
  clearSudoPassword,
756
765
  clearSudoShims,
757
766
  closeLockForThisRequest,
@@ -814,7 +823,7 @@ import { parseChangelog } from "./changelog-parser.js";
814
823
  import { GitHubService } from "./github-service.js";
815
824
  import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
816
825
  import { handleWorkspaceRoutes } from "./workspace-routes.js";
817
- import { handleAgentRoutes } from "./agent-routes.js";
826
+ import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
818
827
  import {
819
828
  handleSessionList,
820
829
  handleSessionUpgrade,
@@ -4321,7 +4330,7 @@ async function handleRequest(
4321
4330
  // Validate the password before storing it.
4322
4331
  try {
4323
4332
  // -k: ignore cached credentials for this check (don't permanently invalidate)
4324
- execSync("sudo -k -S -v", {
4333
+ execFileSync(realSudoPath(), ["-k", "-S", "-v"], {
4325
4334
  input: password + "\n",
4326
4335
  stdio: ["pipe", "pipe", "pipe"],
4327
4336
  timeout: CLI_SPAWN_TIMEOUT_MS,
@@ -4379,7 +4388,7 @@ async function handleRequest(
4379
4388
  }
4380
4389
  clearSudoPassword();
4381
4390
  try {
4382
- execSync("sudo -k", {
4391
+ execFileSync(realSudoPath(), ["-k"], {
4383
4392
  stdio: ["ignore", "ignore", "ignore"],
4384
4393
  timeout: CLI_SPAWN_TIMEOUT_MS,
4385
4394
  });
@@ -4433,7 +4442,7 @@ async function handleRequest(
4433
4442
  return sendJson(res, { ok: false, reason: "not-found" });
4434
4443
  }
4435
4444
  try {
4436
- execSync("sudo -k -S -v", {
4445
+ execFileSync(realSudoPath(), ["-k", "-S", "-v"], {
4437
4446
  input: password + "\n",
4438
4447
  stdio: ["pipe", "pipe", "pipe"],
4439
4448
  timeout: CLI_SPAWN_TIMEOUT_MS,
@@ -5152,6 +5161,8 @@ try {
5152
5161
  // service's PATH leaves out ~/.local/bin. Until (or unless) it answers, the
5153
5162
  // search is this PATH and the runtimes' install directories.
5154
5163
  void initLoginPath();
5164
+ // AG-14: agents interrupted by a restart, and stopped ones to forget.
5165
+ startAgentUpkeep((line) => serverLog(line, "lifecycle"));
5155
5166
  server.listen(PORT, BIND_HOST, () => {
5156
5167
  const hosts = fs.existsSync("/etc/hosts")
5157
5168
  ? fs.readFileSync("/etc/hosts", "utf-8")