mover-os 4.7.8 → 4.7.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 (42) hide show
  1. package/README.md +34 -24
  2. package/install.js +2197 -200
  3. package/package.json +1 -1
  4. package/src/dashboard/build.js +41 -3
  5. package/src/dashboard/lib/active-context-parser.js +16 -4
  6. package/src/dashboard/lib/agent-session.js +70 -49
  7. package/src/dashboard/lib/approval-registry.js +170 -0
  8. package/src/dashboard/lib/daily-note-resolver.js +20 -3
  9. package/src/dashboard/lib/date-utils.js +9 -1
  10. package/src/dashboard/lib/distribution-parser.js +69 -10
  11. package/src/dashboard/lib/drift-history.js +6 -1
  12. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  13. package/src/dashboard/lib/engine-health.js +4 -1
  14. package/src/dashboard/lib/engine-writer.js +157 -11
  15. package/src/dashboard/lib/goal-forecast.js +154 -31
  16. package/src/dashboard/lib/library-indexer-v2.js +14 -0
  17. package/src/dashboard/lib/library-search.js +290 -0
  18. package/src/dashboard/lib/log-activation.sh +0 -0
  19. package/src/dashboard/lib/memory-index.js +61 -12
  20. package/src/dashboard/lib/pid-markers.js +80 -0
  21. package/src/dashboard/lib/regenerate-manifest.js +0 -0
  22. package/src/dashboard/lib/run-registry.js +75 -15
  23. package/src/dashboard/lib/state-core/backfill.js +298 -0
  24. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  25. package/src/dashboard/lib/state-core/event-log.js +615 -0
  26. package/src/dashboard/lib/state-core/events.js +265 -0
  27. package/src/dashboard/lib/state-core/projections.js +376 -0
  28. package/src/dashboard/lib/state-core/start-close.js +162 -0
  29. package/src/dashboard/lib/state-core/trial.js +96 -0
  30. package/src/dashboard/lib/strategy-parser.js +3 -0
  31. package/src/dashboard/lib/suggested-now.js +2 -2
  32. package/src/dashboard/lib/transcript-parser.js +48 -8
  33. package/src/dashboard/server.js +422 -44
  34. package/src/dashboard/shortcut.js +0 -0
  35. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  36. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  37. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  41. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  42. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ // library-search.js — ONE truthful search index for the Library route.
7
+ //
8
+ // Sol full product review (2026-07-11, Library 5.0/10): the "EVERYTHING"
9
+ // search box indexed only the libraryV2 subset while the other shelves were
10
+ // counts with no rows behind them. This module makes the coverage equal the
11
+ // advertised shelves: every shelf the bento names is either content-searchable
12
+ // here or explicitly reported as notIndexable with the reason (CLI commands
13
+ // are usage counts, not files).
14
+ //
15
+ // Contract:
16
+ // - Read-only. Never writes. Never leaves the declared shelf roots.
17
+ // - Bounded: per-file size cap, per-shelf file cap, result cap, passage cap.
18
+ // - Results carry root + vault-relative (or root-relative) path + a 1-based
19
+ // line anchor + the matched passage, so the UI can open the real file AT
20
+ // the passage in the existing viewer.
21
+ // - Honest coverage: shelves whose directory does not exist are reported in
22
+ // coverage.missing; shelves with nothing to read are in
23
+ // coverage.notIndexable. Nothing is silently skipped.
24
+ // - Non-text files (PDFs, images) match by NAME only and say so
25
+ // (nameOnly: true, line/passage null) — never implied content coverage.
26
+
27
+ const MAX_FILE_BYTES = 256 * 1024; // matches /api/file/read cap
28
+ const MAX_FILES_PER_SHELF = 2000;
29
+ const MAX_DEPTH = 6;
30
+ const MAX_LIMIT = 100;
31
+ const DEFAULT_LIMIT = 40;
32
+ const PASSAGE_CAP = 240;
33
+ const EXCLUDED_DIRS = new Set([
34
+ "node_modules", "dist", ".git", ".obsidian", ".trash", "coverage",
35
+ ]);
36
+
37
+ // Extensions whose CONTENT we read as text. Everything else is name-only.
38
+ const TEXT_EXTS = new Set([".md", ".txt", ".sh", ".js", ".mjs", ".ps1", ".canvas", ".json"]);
39
+
40
+ // ── shelf definitions — ids are stable API; labels mirror the bento ─────────
41
+ // dirs(ctx) returns the roots to walk for that shelf, each tagged with the
42
+ // serving root id the file-read endpoint understands (vault|skills|workflows|hooks).
43
+ const SHELVES = [
44
+ {
45
+ id: "engine", label: "Engine files",
46
+ dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "02_Areas", "Engine"), depth: 1 }],
47
+ },
48
+ {
49
+ id: "projects", label: "Projects",
50
+ dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "01_Projects"), depth: MAX_DEPTH }],
51
+ },
52
+ {
53
+ id: "library", label: "Library notes",
54
+ dirs: (ctx) => [
55
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "MOCs"), depth: 3 },
56
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "Principles"), depth: 3 },
57
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "SOPs"), depth: 3 },
58
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "Cheatsheets"), depth: 3 },
59
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "Scripts"), depth: 3 },
60
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Decisions"), depth: 3 },
61
+ ],
62
+ },
63
+ {
64
+ id: "skills", label: "Skills",
65
+ dirs: (ctx) => [{ root: "skills", dir: path.join(ctx.claudeDir, "skills"), depth: 3 }],
66
+ },
67
+ {
68
+ id: "hooks", label: "Hooks",
69
+ dirs: (ctx) => [{ root: "hooks", dir: path.join(ctx.bundleRoot, "src", "hooks"), depth: 2 }],
70
+ },
71
+ {
72
+ id: "workflows", label: "Workflows",
73
+ dirs: (ctx) => [{ root: "workflows", dir: path.join(ctx.bundleRoot, "src", "workflows"), depth: 1 }],
74
+ },
75
+ {
76
+ id: "cli", label: "CLI commands",
77
+ notIndexable: "CLI commands are usage counts stored in your config, not files. There is no text to search.",
78
+ },
79
+ {
80
+ id: "people", label: "People",
81
+ dirs: (ctx) => [
82
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "People"), depth: 2 },
83
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Organizations"), depth: 2 },
84
+ { root: "vault", dir: path.join(ctx.vault, "03_Library", "Entities", "Places"), depth: 2 },
85
+ ],
86
+ },
87
+ {
88
+ id: "captures", label: "Captures",
89
+ dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "03_Library", "Inputs"), depth: 3 }],
90
+ },
91
+ {
92
+ id: "archives", label: "Archives",
93
+ dirs: (ctx) => [{ root: "vault", dir: path.join(ctx.vault, "04_Archives"), depth: MAX_DEPTH }],
94
+ },
95
+ ];
96
+
97
+ /** Map a serving-root id to its absolute directory. Used by the file-read
98
+ * endpoint to resolve non-vault results with the same realpath containment
99
+ * it already applies to the vault. Returns null for unknown roots. */
100
+ function rootDirFor(ctx, root) {
101
+ if (root === "vault") return ctx.vault;
102
+ if (root === "skills") return path.join(ctx.claudeDir, "skills");
103
+ if (root === "workflows") return path.join(ctx.bundleRoot, "src", "workflows");
104
+ if (root === "hooks") return path.join(ctx.bundleRoot, "src", "hooks");
105
+ return null;
106
+ }
107
+
108
+ function walkFiles(dir, maxDepth, out, state) {
109
+ if (state.count >= MAX_FILES_PER_SHELF) return;
110
+ let entries;
111
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { return; }
112
+ for (const ent of entries) {
113
+ if (state.count >= MAX_FILES_PER_SHELF) return;
114
+ if (ent.name.startsWith(".")) continue;
115
+ const full = path.join(dir, ent.name);
116
+ if (ent.isDirectory()) {
117
+ if (EXCLUDED_DIRS.has(ent.name)) continue;
118
+ if (maxDepth > 1) walkFiles(full, maxDepth - 1, out, state);
119
+ continue;
120
+ }
121
+ if (!ent.isFile()) continue; // symlinks are not followed — containment stays real
122
+ out.push(full);
123
+ state.count += 1;
124
+ }
125
+ }
126
+
127
+ /** Collect the files behind one shelf. Returns { files: [{abs, root, rootDir}], missing } */
128
+ function shelfFiles(shelf, ctx) {
129
+ const roots = shelf.dirs ? shelf.dirs(ctx) : [];
130
+ const files = [];
131
+ let anyDirExists = false;
132
+ const state = { count: 0 };
133
+ for (const r of roots) {
134
+ let stat = null;
135
+ try { stat = fs.statSync(r.dir); } catch (_e) { /* absent */ }
136
+ if (!stat || !stat.isDirectory()) continue;
137
+ anyDirExists = true;
138
+ const abs = [];
139
+ walkFiles(r.dir, r.depth || 1, abs, state);
140
+ for (const a of abs) files.push({ abs: a, root: r.root, rootDir: rootDirFor(ctx, r.root) });
141
+ }
142
+ return { files, missing: roots.length > 0 && !anyDirExists };
143
+ }
144
+
145
+ /** Build the passage around the first match inside a line, capped. */
146
+ function passageFrom(line, matchIdx, qLen) {
147
+ const trimmed = line.trim();
148
+ if (trimmed.length <= PASSAGE_CAP) return trimmed;
149
+ // center the window on the match
150
+ const lead = Math.max(0, matchIdx - Math.floor((PASSAGE_CAP - qLen) / 2));
151
+ const slice = line.slice(lead, lead + PASSAGE_CAP).trim();
152
+ return slice;
153
+ }
154
+
155
+ /**
156
+ * searchLibrary(ctx, opts) — the one index.
157
+ * ctx = { vault, bundleRoot, claudeDir }
158
+ * opts = { q, shelf, limit }
159
+ * q — case-insensitive substring; empty q + shelf = browse listing
160
+ * shelf — shelf id to scope to ("" = every indexed shelf)
161
+ * limit — max results (1..100)
162
+ * Returns { q, shelf, results, truncated, scanned, coverage }.
163
+ */
164
+ function searchLibrary(ctx, opts = {}) {
165
+ const q = String(opts.q || "").slice(0, 200);
166
+ const scope = String(opts.shelf || "");
167
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Number(opts.limit) || DEFAULT_LIMIT));
168
+ const lc = q.toLowerCase();
169
+
170
+ const coverage = { indexed: [], notIndexable: [], missing: [] };
171
+ const results = [];
172
+ let scannedFiles = 0;
173
+ let skippedLarge = 0;
174
+ let truncated = false;
175
+
176
+ for (const shelf of SHELVES) {
177
+ if (shelf.notIndexable) {
178
+ coverage.notIndexable.push({ id: shelf.id, label: shelf.label, reason: shelf.notIndexable });
179
+ continue;
180
+ }
181
+ const { files, missing } = shelfFiles(shelf, ctx);
182
+ if (missing) {
183
+ coverage.missing.push({ id: shelf.id, label: shelf.label });
184
+ continue;
185
+ }
186
+ coverage.indexed.push({ id: shelf.id, label: shelf.label, files: files.length });
187
+
188
+ if (scope && scope !== shelf.id) continue; // counted for coverage, never searched
189
+
190
+ for (const f of files) {
191
+ // Every skill's file is literally named SKILL.md — the skill's actual
192
+ // name is its folder. Rendering the basename made the whole Skills
193
+ // shelf read "SKILL.md, SKILL.md, ..." (owner report + terra CONFIRMED
194
+ // #1, 2026-07-12). Same rule for any convention-named container file.
195
+ const base = path.basename(f.abs);
196
+ let name = base;
197
+ if (/^(SKILL|README|INDEX)\.md$/i.test(base)) {
198
+ const folder = path.basename(path.dirname(f.abs));
199
+ if (folder && folder !== "." && folder !== path.basename(f.rootDir)) name = folder;
200
+ }
201
+ const relPath = path.relative(f.rootDir, f.abs).split(path.sep).join("/");
202
+ // ext/textReadable ALWAYS derive from the real file, never the display
203
+ // name — a folder-derived name has no extension and would silently
204
+ // demote the file to name-only search.
205
+ const ext = path.extname(base).toLowerCase();
206
+ const textReadable = TEXT_EXTS.has(ext);
207
+
208
+ let mtime = null;
209
+ let size = 0;
210
+ try { const st = fs.statSync(f.abs); mtime = st.mtime.toISOString(); size = st.size; } catch (_e) { continue; }
211
+
212
+ // Browse mode: empty query + a shelf scope lists the shelf's real files.
213
+ if (!lc) {
214
+ if (!scope) continue; // browsing "everything" is the catalog's job
215
+ results.push({
216
+ shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
217
+ line: null, passage: null, matches: 0, nameOnly: !textReadable, mtime,
218
+ });
219
+ continue;
220
+ }
221
+
222
+ const nameHit = name.toLowerCase().includes(lc);
223
+
224
+ if (!textReadable) {
225
+ // Content is not text — a name match is the only honest match.
226
+ if (nameHit) {
227
+ results.push({
228
+ shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
229
+ line: null, passage: null, matches: 0, nameOnly: true, mtime,
230
+ });
231
+ }
232
+ continue;
233
+ }
234
+
235
+ if (size > MAX_FILE_BYTES) { skippedLarge += 1; continue; }
236
+
237
+ let content = "";
238
+ try { content = fs.readFileSync(f.abs, "utf8"); } catch (_e) { continue; }
239
+ scannedFiles += 1;
240
+
241
+ let firstLine = null;
242
+ let firstPassage = null;
243
+ let matches = 0;
244
+ const lines = content.split("\n");
245
+ for (let i = 0; i < lines.length; i++) {
246
+ const idx = lines[i].toLowerCase().indexOf(lc);
247
+ if (idx === -1) continue;
248
+ matches += 1;
249
+ if (firstLine === null) {
250
+ firstLine = i + 1;
251
+ firstPassage = passageFrom(lines[i], idx, lc.length);
252
+ }
253
+ }
254
+
255
+ if (!nameHit && matches === 0) continue;
256
+ results.push({
257
+ shelf: shelf.id, shelfLabel: shelf.label, name, relPath, root: f.root,
258
+ line: firstLine, passage: firstPassage, matches,
259
+ nameOnly: matches === 0, mtime,
260
+ });
261
+ }
262
+ }
263
+
264
+ // Rank: name matches first, then by match density, then recency.
265
+ if (lc) {
266
+ results.sort((a, b) => {
267
+ const an = a.name.toLowerCase().includes(lc) ? 1 : 0;
268
+ const bn = b.name.toLowerCase().includes(lc) ? 1 : 0;
269
+ if (an !== bn) return bn - an;
270
+ if (a.matches !== b.matches) return b.matches - a.matches;
271
+ return String(b.mtime || "").localeCompare(String(a.mtime || ""));
272
+ });
273
+ } else {
274
+ results.sort((a, b) => String(b.mtime || "").localeCompare(String(a.mtime || "")));
275
+ }
276
+
277
+ if (results.length > limit) {
278
+ results.length = limit;
279
+ truncated = true;
280
+ }
281
+
282
+ return {
283
+ q, shelf: scope || "all",
284
+ results, truncated,
285
+ scanned: { files: scannedFiles, skippedLarge },
286
+ coverage,
287
+ };
288
+ }
289
+
290
+ module.exports = { searchLibrary, rootDirFor, SHELVES };
File without changes
@@ -15,6 +15,7 @@
15
15
 
16
16
  const fs = require("fs");
17
17
  const path = require("path");
18
+ const crypto = require("crypto");
18
19
  const { tokenize, termFreq, buildVocab } = require("./memory-text");
19
20
  const { enumerateSessions, parseTranscript, deriveProject } = require("./transcript-parser");
20
21
  const { parseSessionLogs } = require("./session-log-parser");
@@ -46,9 +47,34 @@ function ensureDirs() {
46
47
  }
47
48
 
48
49
  function atomicWrite(file, obj) {
49
- const tmp = file + ".tmp";
50
+ // terra T-MH-2: a fixed `file + ".tmp"` name means two concurrent SessionEnd
51
+ // hooks writing the same segment/manifest share one temp path and can interleave
52
+ // into a torn file. A per-write unique temp name makes each write's rename
53
+ // independent (the last full write wins cleanly; no half-written temp survives).
54
+ const tmp = file + "." + process.pid + "-" + crypto.randomBytes(4).toString("hex") + ".tmp";
50
55
  fs.writeFileSync(tmp, JSON.stringify(obj));
51
- fs.renameSync(tmp, file);
56
+ try { fs.renameSync(tmp, file); }
57
+ catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
58
+ }
59
+
60
+ // terra T-MH-1: safeReadJson() collapses "file absent" and "file present but
61
+ // MALFORMED" both to null, so `safeReadJson(segFile) || newSeg()` would overwrite
62
+ // a corrupt segment with an empty one — discarding every indexed doc for that
63
+ // month. Distinguish the two: a MISSING segment is a fresh newSeg(); a present-
64
+ // but-unparseable segment THROWS, so the fail-open SessionEnd hook skips indexing
65
+ // this one session and PRESERVES the existing file rather than nuking it.
66
+ function readSegOrNew(segFile) {
67
+ let raw;
68
+ try { raw = fs.readFileSync(segFile, "utf8"); }
69
+ catch (e) { if (e.code === "ENOENT") return newSeg(); throw e; }
70
+ let parsed;
71
+ try { parsed = JSON.parse(raw); }
72
+ catch (_) { throw Object.assign(new Error("memory segment is malformed; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" }); }
73
+ // A parsed-but-wrong-shape file is also not safe to fold into — treat as corrupt.
74
+ if (!parsed || typeof parsed !== "object" || !parsed.docs || !parsed.postings) {
75
+ throw Object.assign(new Error("memory segment has an unexpected shape; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" });
76
+ }
77
+ return parsed;
52
78
  }
53
79
 
54
80
  function monthOf(ts, fallbackMs) {
@@ -377,19 +403,43 @@ async function indexSession(file, opts = {}) {
377
403
  if (!man) return { error: "no index — run --backfill first" };
378
404
  man.docFreq = man.docFreq || {}; man.linkFreq = man.linkFreq || {}; man.indexedSessions = man.indexedSessions || {};
379
405
  const vault = opts.vault || man.vault;
380
- const sessionId = opts.sessionId || path.basename(file).replace(/\.jsonl$/, "");
381
- // Derive the project from the transcript's parent dir (…/projects/<encoded-dir>/<uuid>.jsonl)
382
- // when not passed — the SessionEnd hook calls indexSession(file, {}), and without this every
383
- // live-indexed session would be project=null, making the cross-project scope guard a no-op.
384
- const project = opts.project || deriveProject(path.basename(path.dirname(file))) || null;
406
+ const pathSessionId = path.basename(file).replace(/\.jsonl$/, "");
407
+ const pathProject = deriveProject(path.basename(path.dirname(file))) || null;
385
408
  let mtime = null, size = 0;
386
409
  try { const st = fs.statSync(file); mtime = st.mtimeMs; size = st.size; } catch {}
387
- const prev = man.indexedSessions[sessionId];
388
- if (prev && mtime && prev.mtime === mtime && !opts.force) return { skipped: true, sessionId };
389
410
 
390
- const r = await parseTranscript(file, { vocab: buildVocab(vault), sessionId, project });
411
+ // Parse before selecting identity. Claude stores sessions under an encoded project directory, but
412
+ // Codex stores rollouts under date folders and carries the real session id + cwd in session_meta.
413
+ const r = await parseTranscript(file, {
414
+ vocab: buildVocab(vault),
415
+ sessionId: opts.sessionId,
416
+ project: opts.project,
417
+ agent: opts.agent,
418
+ });
419
+ const sessionId = opts.sessionId || r.sessionId || pathSessionId;
420
+ const project = opts.project || r.project || pathProject;
391
421
  if (r.error) return { error: "parse failed", sessionId };
392
422
 
423
+ let reindexed = false;
424
+ // Remove a pre-fix Codex entry keyed by the rollout filename. Without this migration the same
425
+ // transcript survives twice: once under rollout-* and once under its canonical session id.
426
+ if (pathSessionId !== sessionId) {
427
+ const legacy = man.indexedSessions[pathSessionId];
428
+ if (legacy && legacy.month) {
429
+ const legacyFile = path.join(SEG_DIR, "raw-" + legacy.month + ".json");
430
+ const legacySeg = safeReadJson(legacyFile);
431
+ if (legacySeg && legacySeg.docs["session:" + pathSessionId]) {
432
+ removeDocContribution(legacySeg, "session:" + pathSessionId, man);
433
+ atomicWrite(legacyFile, legacySeg);
434
+ }
435
+ delete man.indexedSessions[pathSessionId];
436
+ reindexed = true;
437
+ }
438
+ }
439
+
440
+ const prev = man.indexedSessions[sessionId];
441
+ if (prev && mtime && prev.mtime === mtime && !opts.force && !reindexed) return { skipped: true, sessionId };
442
+
393
443
  const month = monthOf(r.firstTs, mtime);
394
444
  const id = "session:" + sessionId;
395
445
 
@@ -398,7 +448,6 @@ async function indexSession(file, opts = {}) {
398
448
  // lives in the PREVIOUSLY recorded month's segment — remove it from THERE too, else it orphans
399
449
  // (ghost doc) and permanently inflates docFreq/totalDocs/linkFreq, degrading IDF until a full
400
450
  // --backfill. Only fires on a genuine month change; the common same-month re-index skips it.
401
- let reindexed = false;
402
451
  const prevMonth = prev && prev.month;
403
452
  if (prevMonth && prevMonth !== month) {
404
453
  const prevSegFile = path.join(SEG_DIR, "raw-" + prevMonth + ".json");
@@ -411,7 +460,7 @@ async function indexSession(file, opts = {}) {
411
460
  }
412
461
 
413
462
  const segFile = path.join(SEG_DIR, "raw-" + month + ".json");
414
- const seg = safeReadJson(segFile) || newSeg();
463
+ const seg = readSegOrNew(segFile); // terra T-MH-1: never clobber a malformed segment
415
464
 
416
465
  // remove old contribution (same-month re-index case) — scan only this segment's postings.
417
466
  if (seg.docs[id]) { removeDocContribution(seg, id, man); reindexed = true; }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * pid-markers.js — crash-orphan bookkeeping shared by AgentSessionManager
5
+ * (Chat/onboarding) and RunRegistry (Run tab).
6
+ *
7
+ * Every dashboard-spawned agent child is detached into its own process group
8
+ * and recorded here as one marker file per pid, tagged with the owning
9
+ * dashboard's pid. On startup each manager reaps any group whose OWNER is
10
+ * gone — children escaped by a crashed dashboard (kill -9, OOM, update crash)
11
+ * that no graceful shutdown path could ever reach.
12
+ *
13
+ * Extracted verbatim from agent-session.js (MF2, Codex r2 hardening) so the
14
+ * Run tab gets the same guarantee. One file per pid: no shared-file
15
+ * read-modify-write race across concurrent dashboards; a corrupt write only
16
+ * loses its own marker. Writes are atomic (tmp + rename). Both managers share
17
+ * ONE directory on purpose: whichever starts first reaps the other's orphans.
18
+ */
19
+
20
+ const os = require("os");
21
+ const path = require("path");
22
+ const fs = require("fs");
23
+
24
+ const MARKERS_DIR = path.join(os.homedir(), ".mover", "dashboard", "agent-pids");
25
+
26
+ function markerPath(pid) { return path.join(MARKERS_DIR, String(pid) + ".json"); }
27
+
28
+ function writeMarker(pid, data) {
29
+ try {
30
+ fs.mkdirSync(MARKERS_DIR, { recursive: true });
31
+ const f = markerPath(pid), tmp = f + ".tmp";
32
+ fs.writeFileSync(tmp, JSON.stringify(data));
33
+ fs.renameSync(tmp, f); // atomic: a reader never sees a half-written marker
34
+ } catch (_) {}
35
+ }
36
+
37
+ function removeMarker(pid) { try { fs.unlinkSync(markerPath(pid)); } catch (_) {} }
38
+
39
+ function readMarkers() {
40
+ let files; try { files = fs.readdirSync(MARKERS_DIR); } catch (_) { return []; }
41
+ const out = [];
42
+ for (const fn of files) {
43
+ if (!fn.endsWith(".json")) continue;
44
+ try { out.push(JSON.parse(fs.readFileSync(path.join(MARKERS_DIR, fn), "utf8"))); } catch (_) {}
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function isAlive(pid) {
50
+ if (!pid) return false;
51
+ try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
52
+ }
53
+
54
+ // True if ANY member of the process group still exists (not just the leader) —
55
+ // process.kill(-pid, 0) throws ESRCH only when the whole group is gone.
56
+ function groupAlive(pid) {
57
+ if (!pid) return false;
58
+ try { process.kill(-pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
59
+ }
60
+
61
+ function killGroup(pid, sig) {
62
+ if (!pid) return false;
63
+ try { process.kill(-pid, sig); return true; }
64
+ catch (_) { try { process.kill(pid, sig); return true; } catch (__) { return false; } }
65
+ }
66
+
67
+ // Reap any recorded group whose owning dashboard is gone. Idempotent; safe to
68
+ // call from every manager's constructor (they share the directory).
69
+ function reapOrphans() {
70
+ for (const m of readMarkers()) {
71
+ if (!m || !m.pid) continue;
72
+ // Keep markers whose owning dashboard is still alive (this process OR a
73
+ // concurrent one). Only a marker whose owner is GONE is a real orphan.
74
+ if (m.ownerPid && isAlive(m.ownerPid)) continue;
75
+ if (groupAlive(m.pid)) killGroup(m.pid, "SIGKILL"); // owner gone → orphaned group
76
+ removeMarker(m.pid);
77
+ }
78
+ }
79
+
80
+ module.exports = { MARKERS_DIR, writeMarker, removeMarker, readMarkers, isAlive, groupAlive, killGroup, reapOrphans };
File without changes
@@ -29,9 +29,11 @@
29
29
  */
30
30
 
31
31
  const { spawn } = require("child_process");
32
+ const { writeMarker, removeMarker, groupAlive, killGroup, reapOrphans } = require("./pid-markers");
32
33
 
33
34
  const DEFAULTS = {
34
35
  maxRuns: 40, // retain at most N runs (live + finished)
36
+ maxConcurrent: 4, // live child processes at once; excess requests are refused
35
37
  maxBuffer: 1024 * 1024, // 1 MB per-run output cap (head dropped, tail kept)
36
38
  finishedTtlMs: 6 * 60 * 60 * 1000, // evict finished runs 6h after they end
37
39
  idleSweepMs: 5 * 60 * 1000, // sweep cadence
@@ -43,10 +45,21 @@ class RunRegistry {
43
45
  this.opt = { ...DEFAULTS, ...opts };
44
46
  this.runs = new Map(); // id -> run record
45
47
  this._seq = 0;
48
+ // R5-A: reap children escaped by a crashed dashboard (same MF2 guarantee
49
+ // AgentSessionManager has; the marker dir is shared so either manager's
50
+ // startup cleans up both kinds of orphan).
51
+ reapOrphans();
46
52
  this._sweep = setInterval(() => this._evict(), this.opt.idleSweepMs);
47
53
  if (this._sweep.unref) this._sweep.unref();
48
54
  }
49
55
 
56
+ /** Live (running/stopping) child count — the concurrency the cap guards. */
57
+ liveCount() {
58
+ let n = 0;
59
+ for (const r of this.runs.values()) if (r.status === "running" || r.status === "stopping") n++;
60
+ return n;
61
+ }
62
+
50
63
  /**
51
64
  * Spawn a tracked run. The CALLER builds the command (agentCommand) and the
52
65
  * final prompt (buildAgentPrompt); this owns the process + buffer lifecycle.
@@ -54,6 +67,13 @@ class RunRegistry {
54
67
  * Returns the run record (its `.id` is the handle for subscribe/get/stop).
55
68
  */
56
69
  start(spec) {
70
+ // Concurrency cap (R5-A): every run is a real long-lived CLI child with
71
+ // full tool access; a buggy retry loop must not fork-bomb the machine.
72
+ if (this.liveCount() >= this.opt.maxConcurrent) {
73
+ const err = new Error(`${this.opt.maxConcurrent} agents are already running. Stop one or let it finish first.`);
74
+ err.code = "RUN_LIMIT";
75
+ throw err;
76
+ }
57
77
  const id = `run_${process.pid}_${++this._seq}`;
58
78
  const run = {
59
79
  id,
@@ -73,17 +93,26 @@ class RunRegistry {
73
93
 
74
94
  let child;
75
95
  try {
76
- child = spawn(spec.cmd, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"] });
96
+ // detached: own process group, so Stop/shutdown can signal the whole
97
+ // tree (a run's tool calls spawn grandchildren the bare child.kill()
98
+ // never reached), and the pid marker lets a post-crash startup reap it.
99
+ child = spawn(spec.cmd, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"], detached: true });
77
100
  } catch (e) {
78
101
  this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${e && e.message}\n`);
79
102
  this._finish(run, "failed", null);
80
103
  return run;
81
104
  }
82
105
  run.child = child;
106
+ if (child.pid) writeMarker(child.pid, { pid: child.pid, ownerPid: process.pid, at: Date.now(), kind: "run" });
83
107
 
84
108
  const timeout = setTimeout(() => {
109
+ // Mark stopping BEFORE the kill so the close handler reports "stopped",
110
+ // not a false "done" (terra run-path F2).
111
+ run.status = "stopping";
85
112
  this._append(run, "\n[mover-studio] timeout after 10 minutes; stopping agent.\n", true);
86
- try { child.kill("SIGTERM"); } catch (_) {}
113
+ killGroup(child.pid, "SIGTERM");
114
+ const esc = setTimeout(() => { if (groupAlive(child.pid)) killGroup(child.pid, "SIGKILL"); }, 2500);
115
+ if (esc.unref) esc.unref();
87
116
  }, this.opt.timeoutMs);
88
117
  if (timeout.unref) timeout.unref();
89
118
 
@@ -102,12 +131,14 @@ class RunRegistry {
102
131
  this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${err && err.message}\n`, true);
103
132
  this._finish(run, "failed", null);
104
133
  });
105
- child.on("close", (code) => {
134
+ child.on("close", (code, signal) => {
106
135
  clearTimeout(timeout);
107
- this._append(run, `\n[mover-studio] agent exited with code ${code}\n`, true);
108
- // A run we SIGTERM'd via stop() keeps its "stopped" status; otherwise
109
- // derive from the exit code (0/null = done, non-zero = exited).
110
- const status = run.status === "stopping" ? "stopped" : (code === 0 || code == null) ? "done" : "exited";
136
+ this._append(run, `\n[mover-studio] agent exited with code ${code}${signal ? ` (signal ${signal})` : ""}\n`, true);
137
+ // A run we SIGTERM'd (stop OR timeout) keeps "stopping" -> "stopped". A
138
+ // clean exit (code 0) is "done"; ANY other termination — a non-zero code OR
139
+ // code==null from a signal we did not send is "exited", never a false
140
+ // "done" (terra run-path F2: a signal-killed run was reported as success).
141
+ const status = run.status === "stopping" ? "stopped" : code === 0 ? "done" : "exited";
111
142
  this._finish(run, status, code);
112
143
  });
113
144
 
@@ -123,6 +154,10 @@ class RunRegistry {
123
154
  // Snapshot + register atomically (no I/O between) so no chunk is lost.
124
155
  cb({ type: "replay", text: run.output, status: run.status, exit: run.exit });
125
156
  if (run.status === "running" || run.status === "stopping") {
157
+ // Cap live subscribers per run so a flood of stream connections can't pile
158
+ // up callbacks/sockets (terra run-path F3). Over the cap, the client still
159
+ // got the full replay above; end it rather than live-stream.
160
+ if (run.subscribers.size >= 64) { cb({ type: "end", status: run.status, exit: run.exit }); return () => {}; }
126
161
  run.subscribers.add(cb);
127
162
  return () => run.subscribers.delete(cb);
128
163
  }
@@ -130,15 +165,17 @@ class RunRegistry {
130
165
  return () => {};
131
166
  }
132
167
 
133
- /** Really stop a run (SIGTERM the child). Distinct from a client merely
168
+ /** Really stop a run (SIGTERM the child's whole process group — a run's
169
+ * tool calls spawn grandchildren). Distinct from a client merely
134
170
  * detaching its stream ("stop watching"). */
135
171
  stop(id) {
136
172
  const run = this.runs.get(id);
137
173
  if (!run || !run.child || (run.status !== "running")) return false;
138
174
  run.status = "stopping";
139
- try { run.child.kill("SIGTERM"); } catch (_) {}
140
- // Escalate if the child ignores SIGTERM.
141
- const t = setTimeout(() => { if (run.status === "stopping" && run.child) { try { run.child.kill("SIGKILL"); } catch (_) {} } }, 2500);
175
+ const pid = run.child.pid;
176
+ killGroup(pid, "SIGTERM");
177
+ // Escalate if the group ignores SIGTERM.
178
+ const t = setTimeout(() => { if (run.status === "stopping" && groupAlive(pid)) killGroup(pid, "SIGKILL"); }, 2500);
142
179
  if (t.unref) t.unref();
143
180
  return true;
144
181
  }
@@ -149,11 +186,30 @@ class RunRegistry {
149
186
  return [...this.runs.values()].sort((a, b) => b.started - a.started).map((r) => this._summary(r, false));
150
187
  }
151
188
 
152
- shutdownAll() {
189
+ /** Kill every live run's process group and WAIT for it to die, escalating
190
+ * to SIGKILL after graceMs — mirrors AgentSessionManager.shutdownAll (MF1)
191
+ * so the server's hard-exit timer can't strand a stubborn child. If a
192
+ * group somehow survives SIGKILL, its marker stays on disk so the next
193
+ * startup reaps it — never a silent orphan. */
194
+ shutdownAll(graceMs = 1500) {
153
195
  if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
154
- for (const r of [...this.runs.values()]) {
155
- if (r.child && (r.status === "running" || r.status === "stopping")) { try { r.child.kill("SIGTERM"); } catch (_) {} }
156
- }
196
+ const live = [...this.runs.values()].filter((r) => r.child && (r.status === "running" || r.status === "stopping"));
197
+ return Promise.all(live.map((r) => new Promise((resolve) => {
198
+ const pid = r.child.pid;
199
+ killGroup(pid, "SIGTERM");
200
+ const start = Date.now();
201
+ let killed = false;
202
+ const tick = () => {
203
+ if (!groupAlive(pid)) { removeMarker(pid); return resolve(); }
204
+ if (!killed && Date.now() - start >= graceMs) { killGroup(pid, "SIGKILL"); killed = true; }
205
+ if (killed && Date.now() - start >= graceMs + 700) {
206
+ if (!groupAlive(pid)) removeMarker(pid);
207
+ return resolve();
208
+ }
209
+ const t = setTimeout(tick, 150); if (t.unref) t.unref();
210
+ };
211
+ tick();
212
+ })));
157
213
  }
158
214
 
159
215
  // ── internals ──────────────────────────────────────────────────────────────
@@ -185,6 +241,10 @@ class RunRegistry {
185
241
  // "failed"/"stopped" with the close code (null -> "done") and emits a
186
242
  // second "end". First finish wins. (Found by a Codex review, 2026-06-30.)
187
243
  if (run.ended) return;
244
+ // Drop the crash marker only once the whole GROUP is gone; if a tool
245
+ // grandchild outlived the leader, the marker survives and the next
246
+ // dashboard startup reaps it.
247
+ if (run.child && run.child.pid && !groupAlive(run.child.pid)) removeMarker(run.child.pid);
188
248
  run.status = status;
189
249
  run.exit = exit;
190
250
  run.ended = Date.now();