@monster-orange/den 0.4.6 → 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 (49) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +244 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/skills/install-den/SKILL.md +2 -2
  7. package/src/shared/agents/den-claude.ts +38 -5
  8. package/src/shared/agents/guardrails/hooks.json +27 -0
  9. package/src/shared/agents/guardrails/instructions.md +39 -0
  10. package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
  11. package/src/shared/agents/guardrails/stop-hook.ts +66 -0
  12. package/src/shared/agents/guardrails.ts +114 -0
  13. package/src/shared/agents/handback.ts +241 -0
  14. package/src/shared/agents/personas.ts +66 -7
  15. package/src/shared/agents/records.ts +114 -0
  16. package/src/shared/agents/runtime.ts +12 -0
  17. package/src/shared/den-config.ts +97 -6
  18. package/src/shared/dev-metrics/emit.ts +31 -4
  19. package/src/shared/dev-metrics/polling.ts +135 -0
  20. package/src/shared/engine.ts +14 -2
  21. package/src/shared/plugins/manifest.ts +3 -0
  22. package/src/shared/usage/paths.ts +16 -0
  23. package/src/shared/usage/report.ts +283 -0
  24. package/src/shared/usage/sources.ts +127 -0
  25. package/src/shared/usage/transcript.ts +262 -0
  26. package/src/ui/cli/agent-ops.ts +495 -53
  27. package/src/ui/cli/agents-command.ts +170 -14
  28. package/src/ui/cli/doctor-command.ts +20 -0
  29. package/src/ui/cli/flag-parser.ts +1 -0
  30. package/src/ui/cli/index.ts +119 -2
  31. package/src/ui/cli/usage-command.ts +256 -0
  32. package/src/ui/cli/web-command.ts +3 -1
  33. package/src/ui/mcp/api.ts +10 -1
  34. package/src/ui/mcp/server.ts +34 -0
  35. package/src/ui/mcp/tools.ts +50 -0
  36. package/src/ui/server/agent-resume-grant.ts +53 -0
  37. package/src/ui/server/agent-routes.ts +230 -9
  38. package/src/ui/server/audit.ts +68 -1
  39. package/src/ui/server/index.ts +16 -5
  40. package/src/ui/server/public/agents-page.js +333 -11
  41. package/src/ui/server/public/agents-section.js +17 -3
  42. package/src/ui/server/public/agents.html +44 -0
  43. package/src/ui/server/public/i18n/es.js +28 -0
  44. package/src/ui/server/public/i18n/ja.js +25 -0
  45. package/src/ui/server/public/shell.js +66 -0
  46. package/src/ui/server/request-guard.ts +21 -1
  47. package/src/ui/server/route-classes.ts +23 -0
  48. package/src/ui/server/session-routes.ts +52 -4
  49. package/src/ui/server/sudo-store.ts +36 -7
@@ -15,7 +15,16 @@
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>/remove forget a stopped one (Undo:
22
+ * /api/agents/restore)
23
+ * POST /api/agents/clear-stopped { workspaces: [...] }: forget
24
+ * every stopped one there
25
+ * POST /api/agents/restore { agents: [ids] }: Undo
26
+ * POST /api/agents/resume-interrupted { workspaces: [...] }: Resume
27
+ * all after a restart (AG-14.3)
19
28
  *
20
29
  * Each calls the CLI's own operation (cli/agent-ops.ts): neither the page
21
30
  * nor the CLI keeps state of its own, so an agent started here is the one
@@ -39,7 +48,11 @@
39
48
  * the only route that writes to a session or streams its output.
40
49
  */
41
50
  import * as http from "http";
42
- import { getReposRoot } from "../../shared/den-config.js";
51
+ import {
52
+ configuredRemoveStoppedAfter,
53
+ getReposRoot,
54
+ readMcpConfig,
55
+ } from "../../shared/den-config.js";
43
56
  import {
44
57
  readWorkspacesFile,
45
58
  resolveWorkspaceDir,
@@ -52,18 +65,30 @@ import {
52
65
  listAgents,
53
66
  listPersonas,
54
67
  listRuntimes,
68
+ markInterrupted,
55
69
  NOT_IN_WORKSPACE,
70
+ pruneStopped,
71
+ removeAgent,
72
+ removeStopped,
73
+ restoreAgents,
74
+ resumeInterrupted,
56
75
  setRuntimePath,
57
76
  resolveAgentsDeps,
58
77
  resumeAgent,
59
78
  startAgent,
79
+ startFresh,
60
80
  stopAgent,
61
81
  type AgentsDeps,
62
82
  type AgentsResolved,
63
83
  type Started,
64
84
  } from "../cli/agent-ops.js";
65
85
  import { recordAction } from "./audit.js";
66
- import { auditActorOf, principalOf } from "./request-guard.js";
86
+ import {
87
+ agentPrincipalOf,
88
+ auditActorOf,
89
+ principalOf,
90
+ } from "./request-guard.js";
91
+ import { agentResumeRefusal } from "./agent-resume-grant.js";
67
92
  import { readBody, sendJson } from "./http-helpers.js";
68
93
  import type { Actor } from "../../shared/agent-actor.js";
69
94
 
@@ -73,10 +98,14 @@ export interface AgentRouteDeps {
73
98
  }
74
99
 
75
100
  const AGENTS_PATH =
76
- /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume))?$/;
101
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|remove))?$/;
102
+
77
103
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
78
104
  /** A start's body is a few short names. */
79
105
  const MAX_BODY = 4096;
106
+ /** A list of names (a page's workspaces, an Undo's agents). */
107
+ const MANY_BODY = 64 * 1024;
108
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
80
109
 
81
110
  /** Never a TTY: a web start's terminal is 80×24. */
82
111
  const NO_TTY = { isTTY: false } as NodeJS.WriteStream;
@@ -129,7 +158,10 @@ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
129
158
  }
130
159
 
131
160
  /** A started or resumed agent's answer: its record, or its early exit. */
132
- function sendStarted(res: http.ServerResponse, s: Started): void {
161
+ function sendStarted(
162
+ res: http.ServerResponse,
163
+ s: Started & { fresh?: boolean },
164
+ ): void {
133
165
  if (s.early) {
134
166
  const code =
135
167
  s.early.exitCode === undefined ? "" : ` (exit ${s.early.exitCode})`;
@@ -160,6 +192,8 @@ function sendStarted(res: http.ServerResponse, s: Started): void {
160
192
  sessionId: s.info.ref.id,
161
193
  startedAt: s.rec.startedAt,
162
194
  },
195
+ // A resume with nothing to continue yet started fresh (same id).
196
+ ...(s.fresh ? { fresh: true } : {}),
163
197
  ...(s.warnings.length ? { warnings: s.warnings } : {}),
164
198
  });
165
199
  }
@@ -203,17 +237,24 @@ export async function handleAgentRoutes(
203
237
  ): Promise<boolean> {
204
238
  const m = AGENTS_PATH.exec(pathname);
205
239
  const rp = RUNTIME_PATH.exec(pathname);
240
+ // AG-14's routes over many agents at once.
241
+ const many =
242
+ pathname === "/api/agents/clear-stopped" ||
243
+ pathname === "/api/agents/restore" ||
244
+ pathname === "/api/agents/resume-interrupted";
206
245
  if (
207
246
  pathname !== "/api/personas" &&
208
247
  pathname !== "/api/agents/runtimes" &&
209
248
  !m &&
210
- !rp
249
+ !rp &&
250
+ !many
211
251
  )
212
252
  return false;
213
253
  const method = req.method ?? "GET";
214
254
  const d = webDeps(deps.agents);
215
255
 
216
256
  if (rp) return runtimePath(req, res, safeDecode(rp[1]), d);
257
+ if (many) return manyAgents(req, res, pathname, d);
217
258
  if (!m) {
218
259
  if (method !== "GET" && method !== "HEAD")
219
260
  return methodNotAllowed(res, "GET");
@@ -241,7 +282,7 @@ export async function handleAgentRoutes(
241
282
  return true;
242
283
  }
243
284
  const id = m[2] === undefined ? undefined : safeDecode(m[2]);
244
- const action = m[3] as "stop" | "resume" | undefined;
285
+ const action = m[3] as "stop" | "resume" | "fresh" | "remove" | undefined;
245
286
 
246
287
  if (!action && method === "GET") {
247
288
  try {
@@ -281,7 +322,12 @@ export async function handleAgentRoutes(
281
322
  // Another workspace's agent is no agent here: a 404 that doesn't name
282
323
  // it, before anything else is said about it.
283
324
  if (id !== undefined) agentRecord(d, id, ws);
284
- guard(actor);
325
+ // AG-14.4: MCP agent_resume may resume another agent of its scope.
326
+ const agent = agentPrincipalOf(req);
327
+ if (verb === "resume" && agent) {
328
+ const why = agentResumeRefusal(agent, ws, id!, mcpActions());
329
+ if (why) throw new AgentError(why, { status: 403 });
330
+ } else guard(actor);
285
331
  if (verb === "start") {
286
332
  const body = await startBody(req);
287
333
  if (!body.persona)
@@ -298,9 +344,30 @@ export async function handleAgentRoutes(
298
344
  audit(what, s.early ? "exited at once" : undefined);
299
345
  sendStarted(res, s);
300
346
  } else if (verb === "stop") {
347
+ // The answer comes once the session has ended (200, stopped: true),
348
+ // or, still running after the wait, 202 with stillStopping: true.
301
349
  const s = await stopAgent(d, id!, ws);
350
+ audit(s.stillStopping ? `${what} (still stopping)` : what);
351
+ sendJson(
352
+ res,
353
+ {
354
+ ok: true,
355
+ agent: s.rec.id,
356
+ wasRunning: !!s.session,
357
+ stopped: !s.stillStopping,
358
+ ...(s.stillStopping ? { stillStopping: true } : {}),
359
+ },
360
+ s.stillStopping ? 202 : 200,
361
+ );
362
+ } else if (verb === "fresh") {
363
+ const s = await startFresh(d, id!, ws);
364
+ what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
365
+ audit(what, s.early ? "exited at once" : undefined);
366
+ sendStarted(res, s);
367
+ } else if (verb === "remove") {
368
+ const removed = await removeAgent(d, id!, ws);
302
369
  audit(what);
303
- sendJson(res, { ok: true, agent: s.rec.id, wasRunning: !!s.session });
370
+ sendJson(res, { ok: true, removed });
304
371
  } else {
305
372
  const s = await resumeAgent(d, id!, ws);
306
373
  audit(what);
@@ -313,6 +380,112 @@ export async function handleAgentRoutes(
313
380
  return true;
314
381
  }
315
382
 
383
+ /** The org config's mcp.actions; none when it can't be read. */
384
+ function mcpActions(): readonly string[] {
385
+ try {
386
+ return readMcpConfig().actions;
387
+ } catch {
388
+ return [];
389
+ }
390
+ }
391
+
392
+ /** A POST body's JSON object (a usage error otherwise). */
393
+ async function jsonBody(
394
+ req: http.IncomingMessage,
395
+ ): Promise<Record<string, unknown>> {
396
+ const raw = await readBody(req).catch(() => "");
397
+ if (raw.length > MANY_BODY)
398
+ throw new AgentError("request too large", { status: 413, usage: true });
399
+ let v: unknown;
400
+ try {
401
+ v = JSON.parse(raw || "{}");
402
+ } catch {
403
+ throw new AgentError("invalid JSON", { usage: true });
404
+ }
405
+ if (!v || typeof v !== "object" || Array.isArray(v))
406
+ throw new AgentError("the body must be a JSON object", { usage: true });
407
+ return v as Record<string, unknown>;
408
+ }
409
+
410
+ /** A body's list of names (workspaces or agent ids), each checked. */
411
+ function names(v: unknown, key: string): string[] {
412
+ if (!Array.isArray(v) || !v.length)
413
+ throw new AgentError(`${key} must be a list of names`, { usage: true });
414
+ for (const x of v)
415
+ if (typeof x !== "string" || !NAME_RE.test(x))
416
+ throw new AgentError(`${key}: "${String(x)}" is not a name`, {
417
+ usage: true,
418
+ });
419
+ return [...new Set(v as string[])];
420
+ }
421
+
422
+ /**
423
+ * POST /api/agents/clear-stopped, /restore, /resume-interrupted (AG-14):
424
+ * a person only. Each is audited, refused or done.
425
+ */
426
+ async function manyAgents(
427
+ req: http.IncomingMessage,
428
+ res: http.ServerResponse,
429
+ pathname: string,
430
+ d: AgentsResolved,
431
+ ): Promise<true> {
432
+ if (req.method !== "POST") return methodNotAllowed(res, "POST");
433
+ const actor = auditActorOf(req);
434
+ const verb = pathname.slice("/api/agents/".length);
435
+ let what = verb;
436
+ const audit = (reason?: string) =>
437
+ recordAction(
438
+ actor,
439
+ {
440
+ verb: "agents",
441
+ detail: what,
442
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
443
+ },
444
+ principalOf(req),
445
+ );
446
+ try {
447
+ guard(actor);
448
+ const body = await jsonBody(req);
449
+ if (verb === "restore") {
450
+ const ids = names(body.agents, "agents");
451
+ const restored = restoreAgents(d, ids);
452
+ what = `restore ${restored.join(", ") || "nothing"}`;
453
+ audit();
454
+ sendJson(res, { ok: true, restored });
455
+ return true;
456
+ }
457
+ const wss = names(body.workspaces, "workspaces");
458
+ for (const w of wss)
459
+ if (!d.workspaceExists(w))
460
+ throw new AgentError(`workspace "${w}" does not exist`, {
461
+ status: 404,
462
+ });
463
+ if (verb === "clear-stopped") {
464
+ const removed = await removeStopped(d, wss);
465
+ what = `clear stopped in ${wss.join(", ")}: ${removed.map((r) => r.agent).join(", ") || "none"}`;
466
+ audit();
467
+ sendJson(res, { ok: true, removed });
468
+ return true;
469
+ }
470
+ const r = await resumeInterrupted(d, wss);
471
+ what = `resume interrupted in ${wss.join(", ")}: ${r.resumed.map((x) => x.rec.id).join(", ") || "none"}`;
472
+ audit(r.failed.length ? `${r.failed.length} could not resume` : undefined);
473
+ sendJson(res, {
474
+ ok: r.failed.length === 0,
475
+ resumed: r.resumed.map((x) => ({
476
+ id: x.rec.id,
477
+ workspace: x.rec.workspace,
478
+ sessionId: x.info.ref.id,
479
+ })),
480
+ failed: r.failed,
481
+ });
482
+ } catch (e) {
483
+ audit(e instanceof Error ? e.message : String(e));
484
+ sendError(res, e);
485
+ }
486
+ return true;
487
+ }
488
+
316
489
  /** `?workspace=<ws>`, when it names a workspace here; else none. */
317
490
  function queryWorkspace(
318
491
  req: http.IncomingMessage,
@@ -419,3 +592,51 @@ function methodNotAllowed(res: http.ServerResponse, allow: string): true {
419
592
  sendJson(res, { ok: false, error: "method not allowed" }, 405);
420
593
  return true;
421
594
  }
595
+
596
+ /** How often stopped agents are checked against remove_stopped_after. */
597
+ const PRUNE_EVERY_MS = 60 * 60 * 1000;
598
+
599
+ /**
600
+ * What the web server does for agents on its own (AG-14):
601
+ * - once, shortly after it starts: marks interrupted the agents whose
602
+ * session is gone (Den or the machine went down), and resumes the
603
+ * standing personas and those with `resume_on_restart: true`
604
+ * (markInterrupted). A session host that outlived a web-server-only
605
+ * restart keeps every session: nothing is marked;
606
+ * - at start and every hour: forgets stopped and exited agents older
607
+ * than user.yml's agents.remove_stopped_after (never by default;
608
+ * interrupted ones never).
609
+ * Nothing here asks for the admin password (LK-0).
610
+ */
611
+ export function startAgentUpkeep(
612
+ log: (line: string) => void,
613
+ deps: AgentRouteDeps & { delayMs?: number } = {},
614
+ ): void {
615
+ const d = webDeps(deps.agents);
616
+ const restart = async () => {
617
+ try {
618
+ const f = await markInterrupted(d);
619
+ if (f.interrupted.length)
620
+ log(
621
+ `Agents interrupted when Den restarted: ${f.interrupted.join(", ")}` +
622
+ (f.resumed.length ? ` · resumed: ${f.resumed.join(", ")}` : ""),
623
+ );
624
+ for (const x of f.failed)
625
+ log(` ⚠ agent ${x.agent} did not resume: ${x.why}`);
626
+ } catch (e) {
627
+ log(` ⚠ agents after the restart: ${(e as Error).message}`);
628
+ }
629
+ };
630
+ const prune = async () => {
631
+ try {
632
+ const gone = await pruneStopped(d, configuredRemoveStoppedAfter());
633
+ if (gone.length) log(`Removed stopped agents: ${gone.join(", ")}`);
634
+ } catch (e) {
635
+ log(` ⚠ agents.remove_stopped_after: ${(e as Error).message}`);
636
+ }
637
+ };
638
+ setTimeout(() => {
639
+ void restart().then(prune);
640
+ }, deps.delayMs ?? 2000).unref();
641
+ setInterval(() => void prune(), PRUNE_EVERY_MS).unref();
642
+ }
@@ -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")