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.
- package/README.md +34 -24
- package/install.js +2868 -251
- package/package.json +15 -3
- package/src/dashboard/build.js +1541 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +189 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +483 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +228 -0
- package/src/dashboard/lib/date-utils.js +43 -0
- package/src/dashboard/lib/distribution-parser.js +137 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +88 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +173 -0
- package/src/dashboard/lib/engine-writer.js +1831 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +427 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +226 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +519 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +286 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +248 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +371 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2402 -0
- package/src/dashboard/shortcut.js +284 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
- package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- 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 };
|