mover-os 4.7.7 → 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 (119) hide show
  1. package/README.md +34 -24
  2. package/install.js +2868 -251
  3. package/package.json +15 -3
  4. package/src/dashboard/build.js +1541 -0
  5. package/src/dashboard/dashboard.js +276 -0
  6. package/src/dashboard/index.js +319 -0
  7. package/src/dashboard/lib/activation-log.js +297 -0
  8. package/src/dashboard/lib/active-context-parser.js +189 -0
  9. package/src/dashboard/lib/agent-command.js +93 -0
  10. package/src/dashboard/lib/agent-detect.js +255 -0
  11. package/src/dashboard/lib/agent-detector.js +92 -0
  12. package/src/dashboard/lib/agent-session.js +483 -0
  13. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  14. package/src/dashboard/lib/approval-registry.js +170 -0
  15. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  16. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  17. package/src/dashboard/lib/config-parser.js +109 -0
  18. package/src/dashboard/lib/connect-recommender.js +131 -0
  19. package/src/dashboard/lib/correlations-parser.js +231 -0
  20. package/src/dashboard/lib/daily-note-resolver.js +228 -0
  21. package/src/dashboard/lib/date-utils.js +43 -0
  22. package/src/dashboard/lib/distribution-parser.js +137 -0
  23. package/src/dashboard/lib/dossier-parser.js +64 -0
  24. package/src/dashboard/lib/drift-history.js +88 -0
  25. package/src/dashboard/lib/drift-score.js +119 -0
  26. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  27. package/src/dashboard/lib/engine-health.js +173 -0
  28. package/src/dashboard/lib/engine-writer.js +1831 -0
  29. package/src/dashboard/lib/execution-plan.js +125 -0
  30. package/src/dashboard/lib/experiments-parser.js +429 -0
  31. package/src/dashboard/lib/feed-parser.js +294 -0
  32. package/src/dashboard/lib/forked-future.js +60 -0
  33. package/src/dashboard/lib/goal-forecast.js +427 -0
  34. package/src/dashboard/lib/goals-parser.js +67 -0
  35. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  36. package/src/dashboard/lib/hook-activity.js +48 -0
  37. package/src/dashboard/lib/hook-indexer.js +169 -0
  38. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  39. package/src/dashboard/lib/identity-parser.js +85 -0
  40. package/src/dashboard/lib/ingestion.js +418 -0
  41. package/src/dashboard/lib/library-indexer-v2.js +226 -0
  42. package/src/dashboard/lib/library-indexer.js +105 -0
  43. package/src/dashboard/lib/library-search.js +290 -0
  44. package/src/dashboard/lib/log-activation.sh +61 -0
  45. package/src/dashboard/lib/memory-curator.js +97 -0
  46. package/src/dashboard/lib/memory-gardener.js +177 -0
  47. package/src/dashboard/lib/memory-gepa.js +102 -0
  48. package/src/dashboard/lib/memory-index.js +519 -0
  49. package/src/dashboard/lib/memory-rerank.js +72 -0
  50. package/src/dashboard/lib/memory-text.js +136 -0
  51. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  52. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  53. package/src/dashboard/lib/onboarding-forge.js +70 -0
  54. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  55. package/src/dashboard/lib/override-summary.js +73 -0
  56. package/src/dashboard/lib/paths.js +192 -0
  57. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  58. package/src/dashboard/lib/phantom-strategy.js +129 -0
  59. package/src/dashboard/lib/pid-markers.js +80 -0
  60. package/src/dashboard/lib/project-scanner.js +121 -0
  61. package/src/dashboard/lib/promise-wall.js +88 -0
  62. package/src/dashboard/lib/record-score.js +173 -0
  63. package/src/dashboard/lib/redaction.js +140 -0
  64. package/src/dashboard/lib/refusal-parser.js +44 -0
  65. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  66. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  67. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  68. package/src/dashboard/lib/run-registry.js +286 -0
  69. package/src/dashboard/lib/safe-write.js +63 -0
  70. package/src/dashboard/lib/session-log-parser.js +145 -0
  71. package/src/dashboard/lib/session-time-parser.js +158 -0
  72. package/src/dashboard/lib/skill-index.js +171 -0
  73. package/src/dashboard/lib/skill-indexer.js +118 -0
  74. package/src/dashboard/lib/skill-recommender.js +689 -0
  75. package/src/dashboard/lib/state-core/backfill.js +298 -0
  76. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  77. package/src/dashboard/lib/state-core/event-log.js +615 -0
  78. package/src/dashboard/lib/state-core/events.js +265 -0
  79. package/src/dashboard/lib/state-core/projections.js +376 -0
  80. package/src/dashboard/lib/state-core/start-close.js +162 -0
  81. package/src/dashboard/lib/state-core/trial.js +96 -0
  82. package/src/dashboard/lib/strategy-parser.js +248 -0
  83. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  84. package/src/dashboard/lib/streak-parser.js +95 -0
  85. package/src/dashboard/lib/suggested-now.js +254 -0
  86. package/src/dashboard/lib/tool-awareness.js +125 -0
  87. package/src/dashboard/lib/transcript-parser.js +371 -0
  88. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  89. package/src/dashboard/lib/view-generator.js +163 -0
  90. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  91. package/src/dashboard/lib/walkthrough-script.js +140 -0
  92. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  93. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  94. package/src/dashboard/server.js +2402 -0
  95. package/src/dashboard/shortcut.js +284 -0
  96. package/src/dashboard/static/setup-poc.html +306 -0
  97. package/src/dashboard/static/walkthrough-poc.html +580 -0
  98. package/src/dashboard/styles.css +1201 -0
  99. package/src/dashboard/templates/index.html +278 -0
  100. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  101. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  102. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  103. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  104. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  105. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  106. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  107. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  108. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  109. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  110. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  111. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  112. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  113. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  114. package/src/dashboard/ui/dist/icon.svg +4 -0
  115. package/src/dashboard/ui/dist/index.html +18 -0
  116. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  117. package/src/dashboard/ui/dist/registerSW.js +1 -0
  118. package/src/dashboard/ui/dist/sw.js +1 -0
  119. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { dailiesDir, autoLearningsPath } = require("./paths");
6
+
7
+ // roast-wall-parser.js — Codex's #1 viral pick. Surfaces dated AI verdicts
8
+ // (the brutal honest one-liners /analyse-day produces). Sources:
9
+ // 1. Daily Notes "## Session Log" entries that contain verdict markers
10
+ // 2. Auto_Learnings.md CORRECTION + BELIEF SHIFT entries
11
+ // 3. Roast quotes embedded in Daily Note ## Session Log sections
12
+ //
13
+ // Returns ranked Roast[] with severity, date, quote, pattern attribution.
14
+
15
+ const SEVERITY_KEYWORDS = {
16
+ high: ["override", "broke", "violated", "again", "third time", "structural", "avoidance", "phantom"],
17
+ med: ["drift", "slipping", "deferred", "missed", "skipped", "stale"],
18
+ };
19
+
20
+ function compute({ vault, autoLearnings = null, daysLookback = 60 } = {}) {
21
+ if (!vault) return { available: false, count: 0, roasts: [], stats: { high: 0, med: 0, low: 0 }, sourcesChecked: 0 };
22
+ const roasts = [];
23
+
24
+ // False-zero discipline (T261): track how many real sources we were able to
25
+ // open. A wall we actually scanned but found empty is a true count:0 and
26
+ // stays available:true. But if NOTHING was scannable — no Auto_Learnings
27
+ // patterns AND no Dailies dir on disk — then count:0 is an artifact of
28
+ // having nothing to read, not a clean record. That flips available:false so
29
+ // the consumer shows "calibrating"/empty rather than "0 roasts = you're good".
30
+ let sourcesChecked = 0;
31
+
32
+ // Collect from Auto_Learnings — pre-categorized.
33
+ // False-zero fix (T261, Codex #1): the build.js fallback for an UNAVAILABLE
34
+ // Auto_Learnings still carries `activePatternsConf3Plus: []`, so a truthiness
35
+ // check on the array alone counted a non-source as "scanned" and forced
36
+ // available:true with count:0. Only count this source when it was genuinely
37
+ // readable (available !== false) AND actually had patterns to read. A real
38
+ // available AL with zero patterns contributes no roasts and does NOT, on its
39
+ // own, make the wall "scanned" — the Dailies walk below governs that case
40
+ // (a present Dailies dir → available:true count:0; nothing on disk →
41
+ // sourcesChecked stays 0 → available:false).
42
+ if (
43
+ autoLearnings &&
44
+ autoLearnings.available !== false &&
45
+ Array.isArray(autoLearnings.activePatternsConf3Plus) &&
46
+ autoLearnings.activePatternsConf3Plus.length > 0
47
+ ) {
48
+ sourcesChecked++;
49
+ autoLearnings.activePatternsConf3Plus.forEach((p) => {
50
+ if (!p.description) return;
51
+ roasts.push({
52
+ source: "Auto_Learnings.md",
53
+ date: p.lastSeen || p.firstSeen || "live",
54
+ severity: p.confidence >= 4 ? "high" : "med",
55
+ quote: p.description.slice(0, 280),
56
+ pattern: p.id || "—",
57
+ confidence: p.confidence || 0,
58
+ });
59
+ });
60
+ }
61
+
62
+ // Walk recent Daily Notes for verdict-marker lines.
63
+ // Audit pass 2 (find-bugs #14): existsSync check before readdirSync so
64
+ // fresh-install vaults (no Dailies dir yet) don't silently swallow the
65
+ // ENOENT inside the catch — distinguishes "no dailies dir" from "no
66
+ // verdict markers found in dailies."
67
+ let dailiesAvailable = true;
68
+ try {
69
+ const dDir = dailiesDir(vault);
70
+ if (!fs.existsSync(dDir)) { dailiesAvailable = false; throw Object.assign(new Error("dailies dir missing"), { code: "ENOENT" }); }
71
+ // False-zero fix (T261, Codex #4): only count the Dailies source as
72
+ // "checked" AFTER we successfully READ the directory. existsSync passing
73
+ // doesn't guarantee readability (permissions / race) — incrementing before
74
+ // readdirSync could leave sourcesChecked >= 1 on a dir we never actually
75
+ // scanned, fabricating available:true count:0 from an unscanned source.
76
+ const months = fs.readdirSync(dDir).filter((d) => /^\d{4}-\d{2}$/.test(d)).sort().reverse();
77
+ sourcesChecked++;
78
+ const cutoff = Date.now() - daysLookback * 24 * 3600 * 1000;
79
+ let scanned = 0;
80
+ for (const mDir of months) {
81
+ if (scanned >= 30) break;
82
+ const monthPath = path.join(dDir, mDir);
83
+ try {
84
+ const files = fs.readdirSync(monthPath).filter((f) => /^Daily - \d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse();
85
+ for (const f of files) {
86
+ if (scanned >= 30) break;
87
+ const m = f.match(/(\d{4})-(\d{2})-(\d{2})/);
88
+ if (!m) continue;
89
+ const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}`);
90
+ if (!Number.isFinite(t) || t < cutoff) continue;
91
+ scanned++;
92
+ const text = safeRead(path.join(monthPath, f));
93
+ extractRoastsFromDaily(text, m[0], roasts);
94
+ }
95
+ } catch (_) { /* skip month */ }
96
+ }
97
+ } catch (_) { /* daily walk failed */ }
98
+
99
+ // Rank: severity → date desc → confidence
100
+ const sev = { high: 0, med: 1, low: 2 };
101
+ roasts.sort((a, b) => {
102
+ const s = (sev[a.severity] ?? 3) - (sev[b.severity] ?? 3);
103
+ if (s !== 0) return s;
104
+ if (a.date && b.date && a.date !== "live" && b.date !== "live") {
105
+ return String(b.date).localeCompare(String(a.date));
106
+ }
107
+ return (b.confidence || 0) - (a.confidence || 0);
108
+ });
109
+
110
+ // Dedupe by first 80 chars of quote
111
+ const seen = new Set();
112
+ const deduped = roasts.filter((r) => {
113
+ const key = (r.quote || "").slice(0, 80).toLowerCase().replace(/\s+/g, " ");
114
+ if (seen.has(key)) return false;
115
+ seen.add(key);
116
+ return true;
117
+ });
118
+
119
+ // If no source was openable, count:0 is meaningless absence, not a clean
120
+ // wall — flag unavailable so the consumer renders an empty/calibrating
121
+ // state instead of "0 roasts" read as "all clear". A scanned-but-empty
122
+ // wall (sourcesChecked >= 1) keeps available:true with a real count:0.
123
+ if (sourcesChecked === 0) {
124
+ return {
125
+ available: false,
126
+ count: 0,
127
+ roasts: [],
128
+ stats: { high: 0, med: 0, low: 0 },
129
+ sourcesChecked: 0,
130
+ };
131
+ }
132
+
133
+ return {
134
+ available: true,
135
+ count: deduped.length,
136
+ roasts: deduped.slice(0, 24),
137
+ stats: {
138
+ high: deduped.filter((r) => r.severity === "high").length,
139
+ med: deduped.filter((r) => r.severity === "med").length,
140
+ low: deduped.filter((r) => r.severity === "low").length,
141
+ },
142
+ sourcesChecked,
143
+ };
144
+ }
145
+
146
+ function extractRoastsFromDaily(text, dateKey, accumulator) {
147
+ if (!text) return;
148
+ // Find roast markers in session logs — look for italicized quotes,
149
+ // [FLAG], [VERDICT], or block-quote lines with sharp prose.
150
+ const lines = text.split(/\n/);
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const line = lines[i];
153
+ // Block quote with sharp prose >40 chars
154
+ const blockMatch = line.match(/^>\s+["“]([^"”]{40,300})["”]/);
155
+ if (blockMatch) {
156
+ accumulator.push({
157
+ source: `Daily - ${dateKey}.md`,
158
+ date: dateKey,
159
+ severity: scoreSeverity(blockMatch[1]),
160
+ quote: blockMatch[1].trim(),
161
+ pattern: "—",
162
+ confidence: 3,
163
+ });
164
+ continue;
165
+ }
166
+ // FLAG: / VERDICT: / OVERRIDE: lines.
167
+ //
168
+ // T242 bug hunt fix (B#15): anchor to start of line. Previous regex
169
+ // had no `^` so prose like "this was not an OVERRIDE: success"
170
+ // matched and got scored with inverted polarity. Reject lines whose
171
+ // preceding context contains a negation cue.
172
+ const negationCue = /\b(?:not|no|wasn'?t|isn'?t|don'?t|never)\s+[^.]{0,40}$/i;
173
+ const headStart = line.match(/^(.{0,40}?)\s*(?:FLAG|VERDICT|OVERRIDE|ROAST):/i);
174
+ if (headStart && negationCue.test(headStart[1])) continue;
175
+ const flag = line.match(/^\s*[-*>]*\s*(?:FLAG|VERDICT|OVERRIDE|ROAST):\s+(.{40,300})/i);
176
+ if (flag) {
177
+ accumulator.push({
178
+ source: `Daily - ${dateKey}.md`,
179
+ date: dateKey,
180
+ severity: scoreSeverity(flag[1]),
181
+ quote: flag[1].trim(),
182
+ pattern: flag[0].split(":")[0].toUpperCase(),
183
+ confidence: 4,
184
+ });
185
+ }
186
+ }
187
+ }
188
+
189
+ function scoreSeverity(text) {
190
+ const t = String(text).toLowerCase();
191
+ if (SEVERITY_KEYWORDS.high.some((k) => t.includes(k))) return "high";
192
+ if (SEVERITY_KEYWORDS.med.some((k) => t.includes(k))) return "med";
193
+ return "low";
194
+ }
195
+
196
+ function safeRead(p) {
197
+ try { return fs.readFileSync(p, "utf8"); } catch (_) { return ""; }
198
+ }
199
+
200
+ module.exports = { compute };
@@ -0,0 +1,286 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * run-registry.js — server-held registry for one-shot agent runs (the Run tab).
5
+ *
6
+ * WHY: the original Run path (server.js runAgentStream) piped the child's
7
+ * stdout STRAIGHT to the HTTP response with no server-side buffer and no
8
+ * handle on the child. So navigating away from the Run tab — or a page
9
+ * refresh — lost the run and its output, and there was no way to re-attach.
10
+ * The Run tab was ephemeral; the Chat tab (AgentSessionManager) was not.
11
+ *
12
+ * This registry makes Run persistent the same way Chat is: the child and its
13
+ * output live on the SERVER, decoupled from the request that started them. A
14
+ * client can disconnect and reconnect; the run keeps going and its output is
15
+ * retained. Unlike the Chat path (Claude-only stream-json), this wraps the
16
+ * existing multi-agent one-shot command (agentCommand), so Run stays
17
+ * claude/codex/gemini — there is no Claude-only regression.
18
+ *
19
+ * Persistence scope == Chat's: in-memory, per running daemon. A daemon restart
20
+ * clears runs (AgentSessionManager behaves identically). Bounds keep memory
21
+ * sane for a long-lived daemon: a per-run output cap (drop the head, keep the
22
+ * meaningful tail incl. the exit line) and a retained-run cap + age eviction.
23
+ *
24
+ * Subscribe semantics mirror AgentSessionManager.subscribe: a new subscriber
25
+ * gets the full buffer replayed first (so a reattaching client sees history),
26
+ * then live chunks. The replay snapshot + subscriber-add happen in one
27
+ * synchronous step, so a stdout 'data' event (only dispatched at an I/O yield)
28
+ * can never interleave and drop or duplicate a chunk.
29
+ */
30
+
31
+ const { spawn } = require("child_process");
32
+ const { writeMarker, removeMarker, groupAlive, killGroup, reapOrphans } = require("./pid-markers");
33
+
34
+ const DEFAULTS = {
35
+ maxRuns: 40, // retain at most N runs (live + finished)
36
+ maxConcurrent: 4, // live child processes at once; excess requests are refused
37
+ maxBuffer: 1024 * 1024, // 1 MB per-run output cap (head dropped, tail kept)
38
+ finishedTtlMs: 6 * 60 * 60 * 1000, // evict finished runs 6h after they end
39
+ idleSweepMs: 5 * 60 * 1000, // sweep cadence
40
+ timeoutMs: 10 * 60 * 1000, // hard stop a run after 10 min (matches legacy)
41
+ };
42
+
43
+ class RunRegistry {
44
+ constructor(opts = {}) {
45
+ this.opt = { ...DEFAULTS, ...opts };
46
+ this.runs = new Map(); // id -> run record
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();
52
+ this._sweep = setInterval(() => this._evict(), this.opt.idleSweepMs);
53
+ if (this._sweep.unref) this._sweep.unref();
54
+ }
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
+
63
+ /**
64
+ * Spawn a tracked run. The CALLER builds the command (agentCommand) and the
65
+ * final prompt (buildAgentPrompt); this owns the process + buffer lifecycle.
66
+ * spec: { agent, label, cmd, args, stdin, finalPrompt, cwd, env, preamble }
67
+ * Returns the run record (its `.id` is the handle for subscribe/get/stop).
68
+ */
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
+ }
77
+ const id = `run_${process.pid}_${++this._seq}`;
78
+ const run = {
79
+ id,
80
+ agent: spec.agent || "claude-code",
81
+ label: (spec.label || spec.agent || "task").slice(0, 200),
82
+ status: "running",
83
+ output: spec.preamble ? String(spec.preamble) : "",
84
+ started: Date.now(),
85
+ ended: null,
86
+ exit: null,
87
+ truncated: false,
88
+ child: null,
89
+ subscribers: new Set(),
90
+ };
91
+ this.runs.set(id, run);
92
+ this._trimCount();
93
+
94
+ let child;
95
+ try {
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 });
100
+ } catch (e) {
101
+ this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${e && e.message}\n`);
102
+ this._finish(run, "failed", null);
103
+ return run;
104
+ }
105
+ run.child = child;
106
+ if (child.pid) writeMarker(child.pid, { pid: child.pid, ownerPid: process.pid, at: Date.now(), kind: "run" });
107
+
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";
112
+ this._append(run, "\n[mover-studio] timeout after 10 minutes; stopping agent.\n", true);
113
+ killGroup(child.pid, "SIGTERM");
114
+ const esc = setTimeout(() => { if (groupAlive(child.pid)) killGroup(child.pid, "SIGKILL"); }, 2500);
115
+ if (esc.unref) esc.unref();
116
+ }, this.opt.timeoutMs);
117
+ if (timeout.unref) timeout.unref();
118
+
119
+ // Guard stdin against an async EPIPE if the child dies early (mirrors the
120
+ // legacy one-shot path + the AgentSession stdin guard).
121
+ if (child.stdin) child.stdin.on("error", () => {});
122
+ try {
123
+ if (spec.stdin) child.stdin.end(spec.finalPrompt);
124
+ else child.stdin.end();
125
+ } catch (_) {}
126
+
127
+ child.stdout.on("data", (c) => this._onChunk(run, c));
128
+ child.stderr.on("data", (c) => this._onChunk(run, c));
129
+ child.on("error", (err) => {
130
+ clearTimeout(timeout);
131
+ this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${err && err.message}\n`, true);
132
+ this._finish(run, "failed", null);
133
+ });
134
+ child.on("close", (code, signal) => {
135
+ clearTimeout(timeout);
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";
142
+ this._finish(run, status, code);
143
+ });
144
+
145
+ return run;
146
+ }
147
+
148
+ /** Subscribe to a run: replay the full buffer, then stream live chunks.
149
+ * cb receives {type:"replay"|"chunk"|"end", text?, status?, exit?}.
150
+ * Returns an unsubscribe fn. */
151
+ subscribe(id, cb) {
152
+ const run = this.runs.get(id);
153
+ if (!run) { cb({ type: "end", status: "missing", exit: null }); return () => {}; }
154
+ // Snapshot + register atomically (no I/O between) so no chunk is lost.
155
+ cb({ type: "replay", text: run.output, status: run.status, exit: run.exit });
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 () => {}; }
161
+ run.subscribers.add(cb);
162
+ return () => run.subscribers.delete(cb);
163
+ }
164
+ cb({ type: "end", status: run.status, exit: run.exit });
165
+ return () => {};
166
+ }
167
+
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
170
+ * detaching its stream ("stop watching"). */
171
+ stop(id) {
172
+ const run = this.runs.get(id);
173
+ if (!run || !run.child || (run.status !== "running")) return false;
174
+ run.status = "stopping";
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);
179
+ if (t.unref) t.unref();
180
+ return true;
181
+ }
182
+
183
+ get(id) { const r = this.runs.get(id); return r ? this._summary(r, true) : null; }
184
+ has(id) { return this.runs.has(id); }
185
+ list() {
186
+ return [...this.runs.values()].sort((a, b) => b.started - a.started).map((r) => this._summary(r, false));
187
+ }
188
+
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) {
195
+ if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
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
+ })));
213
+ }
214
+
215
+ // ── internals ──────────────────────────────────────────────────────────────
216
+
217
+ _onChunk(run, chunk) {
218
+ const s = chunk.toString("utf8");
219
+ this._append(run, s);
220
+ this._emit(run, { type: "chunk", text: s });
221
+ }
222
+
223
+ _append(run, s, isTail) {
224
+ run.output += s;
225
+ if (run.output.length > this.opt.maxBuffer) {
226
+ // Keep the TAIL — it carries the latest work and the exit line that the
227
+ // client derives status from. `isTail` writes (exit/timeout lines) are
228
+ // short and always survive because they were just appended.
229
+ run.output = "[… earlier output truncated …]\n" + run.output.slice(run.output.length - this.opt.maxBuffer);
230
+ run.truncated = true;
231
+ }
232
+ }
233
+
234
+ _emit(run, ev) {
235
+ for (const cb of [...run.subscribers]) { try { cb(ev); } catch (_) { /* a dead subscriber must not break the loop */ } }
236
+ }
237
+
238
+ _finish(run, status, exit) {
239
+ // Terminal guard: a spawn failure fires 'error' THEN 'close', so both
240
+ // handlers call _finish. Without this, the second call overwrites a real
241
+ // "failed"/"stopped" with the close code (null -> "done") and emits a
242
+ // second "end". First finish wins. (Found by a Codex review, 2026-06-30.)
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);
248
+ run.status = status;
249
+ run.exit = exit;
250
+ run.ended = Date.now();
251
+ run.child = null;
252
+ this._emit(run, { type: "end", status, exit });
253
+ run.subscribers.clear();
254
+ }
255
+
256
+ _summary(run, withOutput) {
257
+ const s = {
258
+ id: run.id, agent: run.agent, label: run.label, status: run.status,
259
+ started: run.started, ended: run.ended, exit: run.exit,
260
+ truncated: run.truncated, bytes: run.output.length,
261
+ };
262
+ if (withOutput) s.output = run.output;
263
+ return s;
264
+ }
265
+
266
+ // Cap the number of retained runs: drop the OLDEST finished runs first; never
267
+ // evict a still-running run to make room.
268
+ _trimCount() {
269
+ if (this.runs.size <= this.opt.maxRuns) return;
270
+ const finished = [...this.runs.values()]
271
+ .filter((r) => r.status !== "running" && r.status !== "stopping")
272
+ .sort((a, b) => a.started - b.started);
273
+ while (this.runs.size > this.opt.maxRuns && finished.length) this.runs.delete(finished.shift().id);
274
+ }
275
+
276
+ _evict() {
277
+ const now = Date.now();
278
+ for (const r of [...this.runs.values()]) {
279
+ if (r.status !== "running" && r.status !== "stopping" && r.ended && now - r.ended > this.opt.finishedTtlMs) {
280
+ this.runs.delete(r.id);
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ module.exports = { RunRegistry, DEFAULTS };
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+
3
+ // safe-write.js — drift-guarded file writes (B5). Global Rules S21 (multi-instance safety) made
4
+ // MECHANICAL instead of prompt-level. Multiple agents/dashboards can touch the same vault; before a
5
+ // programmatic writer overwrites an important file (an Engine file, the Daily Note) it re-reads it
6
+ // and byte-compares against the content it based its edit on (`expected`). If the file changed
7
+ // underneath — another instance, the user in Obsidian, or an agent — the current bytes are preserved
8
+ // to a timestamped sidecar (.bak.<ts>) BEFORE the new content lands, so a concurrent edit is never
9
+ // silently clobbered. The write itself is atomic (tmp + rename). Pure Node stdlib, zero-dep.
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ class DriftError extends Error {
15
+ constructor(file, backupPath) {
16
+ super(
17
+ backupPath
18
+ ? `drift: ${file} changed since it was read; preserved a backup at ${backupPath}`
19
+ : `drift: ${file} changed since it was read; aborted without writing (re-read and retry)`
20
+ );
21
+ this.name = "DriftError";
22
+ this.code = "EDRIFT";
23
+ this.file = file;
24
+ this.backupPath = backupPath || null;
25
+ }
26
+ }
27
+
28
+ // Write `next` to `file`, guarding against a concurrent edit.
29
+ // expected — the bytes the caller last read and based `next` on. Pass null to skip drift
30
+ // detection (a first write / no baseline / a known-fresh file).
31
+ // opts.throwOnDrift — on drift, abort WITHOUT touching the file and throw DriftError, so the
32
+ // caller can re-read + re-merge. Default false: back up the current bytes,
33
+ // then proceed with the overwrite.
34
+ // opts.now — timestamp for the .bak/.tmp suffix (deterministic in tests).
35
+ // Returns { written, existed, drifted, backupPath }.
36
+ function writeWithDriftGuard(file, expected, next, opts = {}) {
37
+ const ts = opts.now != null ? opts.now : Date.now();
38
+
39
+ let current = null, existed = false;
40
+ try { current = fs.readFileSync(file, "utf8"); existed = true; } catch { existed = false; }
41
+ const drifted = existed && expected != null && current !== expected;
42
+
43
+ // Abort path: leave the file exactly as the concurrent writer left it — no data loss, no backup.
44
+ if (drifted && opts.throwOnDrift) throw new DriftError(file, null);
45
+
46
+ let backupPath = null;
47
+ if (drifted) {
48
+ // We are about to overwrite a concurrent edit → preserve it first so nothing is lost silently.
49
+ backupPath = `${file}.bak.${ts}`;
50
+ fs.writeFileSync(backupPath, current);
51
+ }
52
+
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ // Same-directory tmp so the rename is a same-filesystem (atomic) operation, never a cross-device
55
+ // copy. pid in the suffix so two writers in the same ms don't collide on the scratch file.
56
+ const tmp = `${file}.tmp.${ts}.${process.pid}`;
57
+ fs.writeFileSync(tmp, next);
58
+ fs.renameSync(tmp, file); // atomic on POSIX: a concurrent reader sees old-or-new, never a partial
59
+
60
+ return { written: true, existed, drifted, backupPath };
61
+ }
62
+
63
+ module.exports = { writeWithDriftGuard, DriftError };