mover-os 4.7.8 → 4.7.10
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 +2229 -204
- package/package.json +3 -2
- package/src/dashboard/build.js +41 -3
- package/src/dashboard/lib/active-context-parser.js +16 -4
- package/src/dashboard/lib/agent-session.js +70 -49
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/daily-note-resolver.js +20 -3
- package/src/dashboard/lib/date-utils.js +9 -1
- package/src/dashboard/lib/distribution-parser.js +69 -10
- package/src/dashboard/lib/drift-history.js +6 -1
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +4 -1
- package/src/dashboard/lib/engine-writer.js +157 -11
- package/src/dashboard/lib/goal-forecast.js +154 -31
- package/src/dashboard/lib/library-indexer-v2.js +14 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +0 -0
- package/src/dashboard/lib/memory-index.js +61 -12
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/regenerate-manifest.js +0 -0
- package/src/dashboard/lib/run-registry.js +75 -15
- 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 +3 -0
- package/src/dashboard/lib/suggested-now.js +2 -2
- package/src/dashboard/lib/transcript-parser.js +48 -8
- package/src/dashboard/server.js +422 -44
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/ui/dist/assets/index-BoxTW_kj.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
- package/src/dashboard/ui/dist/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- package/src/system/counts.json +7 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
package/src/dashboard/server.js
CHANGED
|
@@ -15,6 +15,10 @@ const feedParser = require("./lib/feed-parser");
|
|
|
15
15
|
// a persistent structured multi-turn session (onboarding-engine-SPEC.md §2 / D1).
|
|
16
16
|
const { AgentSessionManager } = require("./lib/agent-session");
|
|
17
17
|
const { RunRegistry } = require("./lib/run-registry");
|
|
18
|
+
// Packet #2 rider (OWNER-DECISIONS.md) — server-held pending Engine-write
|
|
19
|
+
// approvals for dashboard-launched runs. See lib/approval-registry.js header
|
|
20
|
+
// for the full contract and the current wiring status.
|
|
21
|
+
const { ApprovalRegistry } = require("./lib/approval-registry");
|
|
18
22
|
// Onboarding-engine D3/D4/D7 — note ingestion (drag-drop digest), skill
|
|
19
23
|
// recommendation (work → skills), and which agent CLIs are installed/authed.
|
|
20
24
|
const ingestion = require("./lib/ingestion");
|
|
@@ -24,6 +28,40 @@ const { forgeBeats } = require("./lib/onboarding-forge");
|
|
|
24
28
|
const agentDetect = require("./lib/agent-detect");
|
|
25
29
|
// B5 (Rule S21 made mechanical) — atomic + drift-guarded writes to the user's Daily Notes.
|
|
26
30
|
const { writeWithDriftGuard } = require("./lib/safe-write");
|
|
31
|
+
// Library truthful search (sol product review 2026-07-11, Library 5.0): ONE
|
|
32
|
+
// index whose coverage equals the shelves the Library route advertises.
|
|
33
|
+
const { searchLibrary, rootDirFor } = require("./lib/library-search");
|
|
34
|
+
|
|
35
|
+
// Server-side re-scrub for the /api/report technical attachment (sol Report gap,
|
|
36
|
+
// 2026-07-11): the client already scrubs, but a spoofed/bypassed client could POST
|
|
37
|
+
// raw error strings with filesystem paths. Defense-in-depth: re-apply the SAME
|
|
38
|
+
// logic server-side before forwarding. Ported verbatim from
|
|
39
|
+
// src/dashboard/ui/src/v6app/scrub.js (which ships as UI source, not guaranteed
|
|
40
|
+
// at daemon runtime) — kept in lockstep by test/error-buffer-scrub.test.mjs +
|
|
41
|
+
// test/report-scrub.test.mjs.
|
|
42
|
+
function scrubToken(t) {
|
|
43
|
+
const low = t.toLowerCase();
|
|
44
|
+
if (low.includes("data:")) return "[data-url]";
|
|
45
|
+
if (/(?:https?|file|blob|wss?|ftp):\/\//i.test(t)) return "[url]";
|
|
46
|
+
if (/%2f|%5c|file%3a/i.test(t)) return "[path]";
|
|
47
|
+
if (/(?:[a-z]:\\|\\\\)/i.test(t)) return "[path]";
|
|
48
|
+
if (/\?[^\s]*=/.test(t)) return "[query]";
|
|
49
|
+
if (t.includes("/")) return "[path]";
|
|
50
|
+
return t;
|
|
51
|
+
}
|
|
52
|
+
function scrubReportLine(s) {
|
|
53
|
+
const raw = String(s);
|
|
54
|
+
if (/[\u0000-\u001f\u007f\u200b-\u200f\u2028\u2029\ufeff]/.test(raw)) {
|
|
55
|
+
return "[redacted: the error text contained control characters]";
|
|
56
|
+
}
|
|
57
|
+
return raw
|
|
58
|
+
.split(/\s+/)
|
|
59
|
+
.map(scrubToken)
|
|
60
|
+
.join(" ")
|
|
61
|
+
.replace(/(?:\[(?:path|url|data-url|query)\] ?){2,}/g, (m) => m.trim().split(" ")[0] + " ")
|
|
62
|
+
.trim()
|
|
63
|
+
.slice(0, 300);
|
|
64
|
+
}
|
|
27
65
|
|
|
28
66
|
// Per-server CSRF token — issued on /api/health, required on all POST endpoints.
|
|
29
67
|
// Random per-process so dashboard ↔ server pairing is exclusive to this run.
|
|
@@ -104,6 +142,10 @@ function isTokenValid(req) {
|
|
|
104
142
|
// like a new `###` sub-anchor), so the written answer silently vanishes on the
|
|
105
143
|
// next read. `\#` is the canonical markdown escape (renders as a literal '#').
|
|
106
144
|
function sanitizeJournalLine(v, max) {
|
|
145
|
+
// terra T-DW-10: an object/array answer would String()-coerce to "[object
|
|
146
|
+
// Object]" (or a comma-joined array) and be persisted as if it were the user's
|
|
147
|
+
// text. Drop non-primitive values so a malformed field is omitted, not stored.
|
|
148
|
+
if (v != null && typeof v === "object") return "";
|
|
107
149
|
return String(v == null ? "" : v)
|
|
108
150
|
.replace(/\r/g, "")
|
|
109
151
|
.replace(/\n+/g, " ")
|
|
@@ -135,17 +177,19 @@ function mergeJournalSection(existing, ymd, mode, newBlock) {
|
|
|
135
177
|
// a bare `### Alibi`/`### Prediction` (legacy/alt naming for the evening
|
|
136
178
|
// alibi) is evening ONLY when it isn't a Morning header — so an evening write
|
|
137
179
|
// can never delete a Morning block, and vice-versa.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
180
|
+
// terra T-DW-2: replace ONLY the sub-anchors THIS write actually carries, keyed
|
|
181
|
+
// by exact header, rather than every block of the mode. A partial same-mode save
|
|
182
|
+
// (e.g. just the score) now upserts that one field and PRESERVES the sibling
|
|
183
|
+
// evening fields (win, alibi, ...) instead of silently wiping the whole mode.
|
|
184
|
+
// Cross-mode preservation still falls out for free — a morning write's headers
|
|
185
|
+
// never match an evening block — so the Codex W3 mode-isolation guarantee holds.
|
|
186
|
+
// Preserve-on-omit is the safe default: journaling one field must not delete the
|
|
187
|
+
// others. (mode is retained in the signature for call-site compatibility.)
|
|
188
|
+
const newHeaders = new Set(
|
|
189
|
+
String(newBlock).split("\n").filter((l) => /^###\s+/.test(l)).map((l) => l.trim())
|
|
190
|
+
);
|
|
191
|
+
const isThisMode = (headerLine) => newHeaders.has(String(headerLine).trim());
|
|
192
|
+
void mode;
|
|
149
193
|
const trimTail = (arr) => { const b = arr.slice(); while (b.length && b[b.length - 1].trim() === "") b.pop(); return b; };
|
|
150
194
|
if (!existing) {
|
|
151
195
|
return `# Daily - ${ymd}\n\n## Journal\n\n${newBlock}\n`;
|
|
@@ -253,6 +297,37 @@ function atomicWriteJson(filePath, obj) {
|
|
|
253
297
|
// (UI_LEGACY_DIST_* points at ui/dist — the name is historical.)
|
|
254
298
|
const UI_LEGACY_DIST_DIR = path.join(__dirname, "ui", "dist");
|
|
255
299
|
const UI_LEGACY_DIST_INDEX = path.join(UI_LEGACY_DIST_DIR, "index.html");
|
|
300
|
+
|
|
301
|
+
// terra T-EX-3/T-EX-4: the set of slash-commands the product actually ships,
|
|
302
|
+
// derived once from src/workflows/*.md (server.js lives in src/dashboard/). Used
|
|
303
|
+
// to reject an unknown /workflow before running an agent, opening a terminal, or
|
|
304
|
+
// persisting a snooze — so a bogus token can't start a wasted agent run or leave
|
|
305
|
+
// an inert record in snoozes.json. Lazy + cached; if the dir can't be read we fall
|
|
306
|
+
// back to shape-only so an unusual layout never hard-fails a legitimate run.
|
|
307
|
+
let _knownWorkflows = null;
|
|
308
|
+
function knownWorkflowNames() {
|
|
309
|
+
if (_knownWorkflows) return _knownWorkflows;
|
|
310
|
+
const s = new Set();
|
|
311
|
+
try {
|
|
312
|
+
const dir = path.join(__dirname, "..", "workflows");
|
|
313
|
+
for (const f of fs.readdirSync(dir)) {
|
|
314
|
+
if (f.endsWith(".md")) s.add("/" + f.slice(0, -3).toLowerCase());
|
|
315
|
+
}
|
|
316
|
+
} catch (_) { /* bundle layout unusual — shape-only fallback below */ }
|
|
317
|
+
_knownWorkflows = s;
|
|
318
|
+
return s;
|
|
319
|
+
}
|
|
320
|
+
function isKnownWorkflow(w) {
|
|
321
|
+
// A workflow may carry trailing flags the product ships (e.g. "/ignite --feature",
|
|
322
|
+
// "/research --deep"). Validate the BASE slash-command against the registry and
|
|
323
|
+
// allow only flag-shaped suffixes after it — so a flagged command is accepted but
|
|
324
|
+
// a token with spaces/metachars/injected text is not.
|
|
325
|
+
const m = String(w).trim().match(/^(\/[a-z0-9][a-z0-9-]{0,40})((?:\s+--?[a-z0-9][a-z0-9-]*)*)\s*$/i);
|
|
326
|
+
if (!m) return false;
|
|
327
|
+
const s = knownWorkflowNames();
|
|
328
|
+
if (s.size === 0) return true; // registry unavailable → base shape already validated
|
|
329
|
+
return s.has(m[1].toLowerCase());
|
|
330
|
+
}
|
|
256
331
|
function uiDistAvailable() {
|
|
257
332
|
try { return fs.existsSync(UI_LEGACY_DIST_INDEX); } catch (_) { return false; }
|
|
258
333
|
}
|
|
@@ -267,7 +342,12 @@ const UI_DIST_DIR = activeUiDist().dir;
|
|
|
267
342
|
const UI_DIST_INDEX = activeUiDist().index;
|
|
268
343
|
|
|
269
344
|
const PRIMARY_PORT = 3737;
|
|
270
|
-
|
|
345
|
+
// MOVER_PORT pins the daemon to one port (hermetic tests, or a user running a
|
|
346
|
+
// second vault without colliding with the default 3737-3740 range). Additive:
|
|
347
|
+
// unset behaves exactly as before. A valid 1-65535 value becomes the sole
|
|
348
|
+
// binding target (no fallback scan, so the caller controls isolation).
|
|
349
|
+
const _envPort = parseInt(process.env.MOVER_PORT || "", 10);
|
|
350
|
+
const PORT_FALLBACKS = (_envPort >= 1 && _envPort <= 65535) ? [_envPort] : [3737, 3738, 3739, 3740];
|
|
271
351
|
|
|
272
352
|
const MIME = {
|
|
273
353
|
".html": "text/html; charset=utf-8",
|
|
@@ -316,8 +396,27 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
316
396
|
// from the request that started it; killed on shutdown. See lib/run-registry.js.
|
|
317
397
|
const runRegistry = new RunRegistry();
|
|
318
398
|
|
|
399
|
+
// Packet #2 rider — pending Engine-write approvals for dashboard-launched
|
|
400
|
+
// runs. Killed on shutdown alongside agentSessions/runRegistry.
|
|
401
|
+
const approvalRegistry = new ApprovalRegistry();
|
|
402
|
+
|
|
319
403
|
const server = http.createServer(async (req, res) => {
|
|
320
404
|
try {
|
|
405
|
+
// DNS-rebinding gate (R5-A P0). The browser's origin model is computed
|
|
406
|
+
// from the URL string, so a hostile page served on our port whose DNS
|
|
407
|
+
// record flips to 127.0.0.1 becomes "same-origin" and can READ every
|
|
408
|
+
// un-tokened GET response — including /api/health (the token itself)
|
|
409
|
+
// and /api/file/read (the vault). Host reflects the literal request
|
|
410
|
+
// target and survives the trick: only loopback names pass, on EVERY
|
|
411
|
+
// method. Standard mitigation (webpack-dev-server, Vite, Jupyter).
|
|
412
|
+
const hostHeader = String(req.headers.host || "");
|
|
413
|
+
// Trailing dot = FQDN spelling of the same name ("localhost." is
|
|
414
|
+
// still loopback); strip it so pedantic-but-legit clients pass.
|
|
415
|
+
const hostName = hostHeader.replace(/:\d+$/, "").replace(/\.$/, "").toLowerCase();
|
|
416
|
+
if (hostName !== "127.0.0.1" && hostName !== "localhost" && hostName !== "[::1]") {
|
|
417
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
418
|
+
return res.end("Bad Host header");
|
|
419
|
+
}
|
|
321
420
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
322
421
|
const pathname = url.pathname;
|
|
323
422
|
|
|
@@ -345,7 +444,7 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
345
444
|
// /api/refresh was previously misclassified as "read-only POST" but
|
|
346
445
|
// it triggers a full vault rebuild — DoS amplification vector if
|
|
347
446
|
// hit cross-origin without token (code-bug-audit C2, 2026-05-24).
|
|
348
|
-
const tokenedRoutes = ["/api/agent/run", "/api/runs/stop", "/api/workflow/run", "/api/workflow/open-in-terminal", "/api/cli/run", "/api/cli/open-in-terminal", "/api/checkbox/toggle", "/api/snooze", "/api/override", "/api/config", "/api/file/open", "/api/refresh", "/api/event", "/api/view/generate", "/api/view/delete", "/api/capture", "/api/journal", "/api/session/start", "/api/session/send", "/api/session/approve", "/api/session/end", "/api/session/finalize", "/api/session/ingest", "/api/setup/recommend", "/api/setup/shortcut"];
|
|
447
|
+
const tokenedRoutes = ["/api/agent/run", "/api/runs/stop", "/api/workflow/run", "/api/workflow/open-in-terminal", "/api/cli/run", "/api/cli/open-in-terminal", "/api/checkbox/toggle", "/api/snooze", "/api/override", "/api/config", "/api/file/open", "/api/refresh", "/api/event", "/api/view/generate", "/api/view/delete", "/api/capture", "/api/journal", "/api/session/start", "/api/session/send", "/api/session/approve", "/api/session/end", "/api/session/finalize", "/api/session/ingest", "/api/setup/recommend", "/api/setup/shortcut", "/api/report", "/api/approval/request", "/api/approval/respond"];
|
|
349
448
|
if (tokenedRoutes.includes(pathname) && !isTokenValid(req)) {
|
|
350
449
|
return jsonResponse(res, 401, { ok: false, error: "Missing or invalid X-Mover-Token header" });
|
|
351
450
|
}
|
|
@@ -407,6 +506,33 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
407
506
|
return jsonResponse(res, 200, { available: false });
|
|
408
507
|
}
|
|
409
508
|
|
|
509
|
+
// Library truthful search (sol review 2026-07-11): one index whose
|
|
510
|
+
// coverage equals the advertised shelves. Content search across the
|
|
511
|
+
// vault shelves + skills + bundle workflows/hooks, with an honest
|
|
512
|
+
// coverage report (CLI commands are usage counts, not files). Bounded
|
|
513
|
+
// response (result/passage caps in lib/library-search.js); local-only
|
|
514
|
+
// read like /api/file/read — Host-gated, nothing leaves this machine.
|
|
515
|
+
if (pathname === "/api/library/search" && req.method === "GET") {
|
|
516
|
+
try {
|
|
517
|
+
const os = require("os");
|
|
518
|
+
const out = searchLibrary(
|
|
519
|
+
{
|
|
520
|
+
vault,
|
|
521
|
+
bundleRoot: path.resolve(__dirname, "..", ".."),
|
|
522
|
+
claudeDir: path.join(os.homedir(), ".claude"),
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
q: url.searchParams.get("q") || "",
|
|
526
|
+
shelf: url.searchParams.get("shelf") || "",
|
|
527
|
+
limit: url.searchParams.get("limit") || undefined,
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
return jsonResponse(res, 200, { ok: true, ...out });
|
|
531
|
+
} catch (_e) {
|
|
532
|
+
return jsonResponse(res, 500, { ok: false, error: "search failed" });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
410
536
|
// F37 — OG Image generator. Renders a server-side SVG receipt
|
|
411
537
|
// suitable for og:image meta tags on shared moveros.dev pages.
|
|
412
538
|
// Twitter/X, Discord, and Slack render SVG og:images directly.
|
|
@@ -469,10 +595,37 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
469
595
|
if (req.method === "POST") {
|
|
470
596
|
try {
|
|
471
597
|
const patch = await readJsonBody(req, 16 * 1024);
|
|
598
|
+
// terra T-DW-5: a non-object patch (null / array / scalar) must not
|
|
599
|
+
// reach the shallow merge. {...existing, ...null} is a harmless no-op,
|
|
600
|
+
// but {studio:null} would null out a whole nested prefs block. Require
|
|
601
|
+
// a plain-object patch, and reject a non-object value for the known
|
|
602
|
+
// nested objects so a settings write can never DELETE a prefs block.
|
|
603
|
+
const isPlainObject = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
604
|
+
if (!isPlainObject(patch)) {
|
|
605
|
+
return jsonResponse(res, 400, { ok: false, error: "config patch must be a JSON object" });
|
|
606
|
+
}
|
|
607
|
+
for (const k of ["studio", "providerPrefs"]) {
|
|
608
|
+
if (k in patch && !isPlainObject(patch[k])) {
|
|
609
|
+
return jsonResponse(res, 400, { ok: false, error: `${k} must be an object` });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
472
612
|
// Serialize concurrent config writes (audit pass 2 #4).
|
|
473
613
|
const merged = await withFileLock(configPath, () => {
|
|
474
614
|
let existing = {};
|
|
475
|
-
|
|
615
|
+
// terra T-DW-6: a parse failure of an EXISTING config.json must NOT
|
|
616
|
+
// be swallowed to {} — the next merge would then overwrite the
|
|
617
|
+
// malformed file and lose vaultPath + integration tokens. Only a
|
|
618
|
+
// truly-absent file is empty; a present-but-unparseable (or wrong-
|
|
619
|
+
// shape) file aborts the write so the user can repair it by hand.
|
|
620
|
+
try {
|
|
621
|
+
existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
622
|
+
} catch (e) {
|
|
623
|
+
if (e.code === "ENOENT") existing = {};
|
|
624
|
+
else throw Object.assign(new Error("config.json is unreadable or malformed; refusing to overwrite it"), { code: "ECONFIGCORRUPT" });
|
|
625
|
+
}
|
|
626
|
+
if (!isPlainObject(existing)) {
|
|
627
|
+
throw Object.assign(new Error("config.json is not a JSON object; refusing to overwrite it"), { code: "ECONFIGCORRUPT" });
|
|
628
|
+
}
|
|
476
629
|
// Shallow merge — keys present in patch override, others kept.
|
|
477
630
|
// Allow nested `studio.<key>` merges via top-level studio object.
|
|
478
631
|
const out = { ...existing, ...patch };
|
|
@@ -500,6 +653,9 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
500
653
|
return jsonResponse(res, 500, { ok: false, error: e.message });
|
|
501
654
|
}
|
|
502
655
|
}
|
|
656
|
+
// Any other verb: refuse here — falling through served the SPA's
|
|
657
|
+
// index.html with a 200 for a PUT /api/config (R5-A P2).
|
|
658
|
+
return jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
|
|
503
659
|
}
|
|
504
660
|
|
|
505
661
|
if (pathname === "/api/refresh") {
|
|
@@ -526,6 +682,63 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
526
682
|
return runAgentStream({ req, res, body, vault, runRegistry });
|
|
527
683
|
}
|
|
528
684
|
|
|
685
|
+
// ── /api/report — the dashboard's report button ─────────────────────
|
|
686
|
+
// Same delivery channel as the /mover-report workflow: the developer's
|
|
687
|
+
// feedbackWebhook from ~/.mover/config.json. Server-side forward (the
|
|
688
|
+
// browser can't POST cross-origin), payload is the user's words plus
|
|
689
|
+
// machine facts (version/platform/agents) — never vault content.
|
|
690
|
+
if (pathname === "/api/report" && req.method === "POST") {
|
|
691
|
+
const body = await readJsonBody(req, 32 * 1024);
|
|
692
|
+
const desc = String((body && body.description) || "").trim().slice(0, 8000);
|
|
693
|
+
if (!desc) return jsonResponse(res, 400, { ok: false, error: "Say what happened first." });
|
|
694
|
+
const os = require("os");
|
|
695
|
+
let cfg = {};
|
|
696
|
+
try { cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".mover", "config.json"), "utf8")); } catch {}
|
|
697
|
+
const webhook = cfg.feedbackWebhook || "https://moveros.dev/api/feedback";
|
|
698
|
+
// Optional scrubbed client-error attachment (bug reports only, and
|
|
699
|
+
// only when the user left the attach box ticked). The client already
|
|
700
|
+
// redacts filesystem paths; cap hard here anyway. Strings only.
|
|
701
|
+
const errs = Array.isArray(body && body.errors)
|
|
702
|
+
? body.errors.slice(0, 5).map((e) => scrubReportLine(String(e).slice(0, 400))).filter(Boolean)
|
|
703
|
+
: [];
|
|
704
|
+
// Guided precision (owner ask 2026-07-12): the card sends WHICH
|
|
705
|
+
// SCREEN the report points at, allowlist-validated here (never free
|
|
706
|
+
// text into the issue title) and folded into summary + description
|
|
707
|
+
// so the deployed feedback endpoint needs no schema change.
|
|
708
|
+
const REPORT_WHERE = { home: "Home", chat: "Chat", strategy: "Strategy", evidence: "Evidence", library: "Library", run: "Run", configure: "Settings", app: "" };
|
|
709
|
+
// A string, not a coercion (terra: ["home"] stringifies to "home"
|
|
710
|
+
// and would pass) — non-strings fall through to no label.
|
|
711
|
+
const whereLabel = body && typeof body.where === "string" && Object.prototype.hasOwnProperty.call(REPORT_WHERE, body.where) ? REPORT_WHERE[body.where] : "";
|
|
712
|
+
const REPORT_SEVERITIES = ["Blocking", "Annoying", "Cosmetic"];
|
|
713
|
+
const payload = {
|
|
714
|
+
type: ["bug", "feature", "comment"].includes(body && body.type) ? body.type : "comment",
|
|
715
|
+
severity: REPORT_SEVERITIES.includes(body && body.severity) ? body.severity : "Minor",
|
|
716
|
+
summary: ((whereLabel ? whereLabel + ": " : "") + desc.split("\n")[0]).slice(0, 140),
|
|
717
|
+
description: (whereLabel ? "Where: the " + whereLabel + " screen\n\n" : "")
|
|
718
|
+
+ desc + (errs.length ? "\n\n-- attached technical details (scrubbed, no file contents) --\n" + errs.join("\n") : ""),
|
|
719
|
+
version: version || "unknown",
|
|
720
|
+
platform: process.platform,
|
|
721
|
+
agent: Array.isArray(cfg.agents) ? cfg.agents.join(", ") : "unknown",
|
|
722
|
+
date: new Date().toISOString().slice(0, 10),
|
|
723
|
+
source: "dashboard",
|
|
724
|
+
};
|
|
725
|
+
try {
|
|
726
|
+
const ctl = new AbortController();
|
|
727
|
+
const timer = setTimeout(() => ctl.abort(), 8000);
|
|
728
|
+
const r = await fetch(webhook, {
|
|
729
|
+
method: "POST",
|
|
730
|
+
headers: { "Content-Type": "application/json" },
|
|
731
|
+
body: JSON.stringify(payload),
|
|
732
|
+
signal: ctl.signal,
|
|
733
|
+
});
|
|
734
|
+
clearTimeout(timer);
|
|
735
|
+
if (r.ok) return jsonResponse(res, 200, { ok: true, delivered: true });
|
|
736
|
+
return jsonResponse(res, 200, { ok: true, delivered: false, error: "The developer's server answered " + r.status + "." });
|
|
737
|
+
} catch (e) {
|
|
738
|
+
return jsonResponse(res, 200, { ok: true, delivered: false, error: "Couldn't reach the developer's server." });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
529
742
|
// ── Run-tab persistence: the runs the registry is holding ────────────
|
|
530
743
|
// GET /api/runs → list run summaries (rehydrate after refresh)
|
|
531
744
|
// GET /api/runs/get?id= → one run incl. full output (expand a card)
|
|
@@ -553,9 +766,21 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
553
766
|
"Cache-Control": "no-cache",
|
|
554
767
|
"X-Accel-Buffering": "no",
|
|
555
768
|
});
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
769
|
+
let unsub = () => {};
|
|
770
|
+
unsub = runRegistry.subscribe(id, (ev) => {
|
|
771
|
+
if (ev.type === "replay" || ev.type === "chunk") {
|
|
772
|
+
try {
|
|
773
|
+
const ok = res.write(ev.text);
|
|
774
|
+
// Backpressure guard (terra run-path F3): a slow/stalled reader
|
|
775
|
+
// must not let the socket write buffer grow without bound. Once it
|
|
776
|
+
// exceeds the cap, drop THIS client — the run keeps going for
|
|
777
|
+
// everyone else.
|
|
778
|
+
if (!ok && res.writableLength > 8 * 1024 * 1024) {
|
|
779
|
+
try { unsub(); } catch (_) {}
|
|
780
|
+
try { res.destroy(); } catch (_) {}
|
|
781
|
+
}
|
|
782
|
+
} catch (_) {}
|
|
783
|
+
} else if (ev.type === "end") { try { res.end(); } catch (_) {} }
|
|
559
784
|
});
|
|
560
785
|
req.on("close", () => { try { unsub(); } catch (_) {} });
|
|
561
786
|
return; // hold the stream open
|
|
@@ -566,6 +791,48 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
566
791
|
return jsonResponse(res, 200, { ok });
|
|
567
792
|
}
|
|
568
793
|
|
|
794
|
+
// ── Packet #2 rider — dashboard-owned Engine-write approval ──────────
|
|
795
|
+
// (OWNER-DECISIONS.md Packet #2 rider; dev/plan.md T-PACKET2-RIDER-1.)
|
|
796
|
+
// request [tokened, hook adapter] -> create a pending approval
|
|
797
|
+
// wait [tokened, hook adapter poll] -> current status by id
|
|
798
|
+
// pending [GET, dashboard UI] -> list of live pending ones
|
|
799
|
+
// respond [tokened, dashboard UI] -> approve/deny by id
|
|
800
|
+
if (pathname === "/api/approval/request" && req.method === "POST") {
|
|
801
|
+
const body = await readJsonBody(req, 8 * 1024);
|
|
802
|
+
try {
|
|
803
|
+
const rec = approvalRegistry.request({
|
|
804
|
+
agent: body.agent,
|
|
805
|
+
file: body.file,
|
|
806
|
+
tool: body.tool,
|
|
807
|
+
excerpt: body.excerpt,
|
|
808
|
+
});
|
|
809
|
+
return jsonResponse(res, 200, { ok: true, id: rec.id, expiresAt: rec.expiresAt });
|
|
810
|
+
} catch (e) {
|
|
811
|
+
return jsonResponse(res, 400, { ok: false, error: e.message });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// GET is not covered by the POST-only tokenedRoutes gate above, so this
|
|
815
|
+
// route checks the token inline (same isTokenValid used everywhere else).
|
|
816
|
+
if (pathname === "/api/approval/wait" && req.method === "GET") {
|
|
817
|
+
if (!isTokenValid(req)) {
|
|
818
|
+
return jsonResponse(res, 401, { ok: false, error: "Missing or invalid X-Mover-Token header" });
|
|
819
|
+
}
|
|
820
|
+
const rec = approvalRegistry.get(url.searchParams.get("id") || "");
|
|
821
|
+
if (!rec) return jsonResponse(res, 404, { ok: false, error: "no such approval" });
|
|
822
|
+
return jsonResponse(res, 200, { ok: true, status: rec.status, expiresAt: rec.expiresAt });
|
|
823
|
+
}
|
|
824
|
+
// Un-tokened read, same class as /api/runs: loopback + Host-header gate
|
|
825
|
+
// is the trust boundary, matching every other GET listing in this file.
|
|
826
|
+
if (pathname === "/api/approval/pending" && req.method === "GET") {
|
|
827
|
+
return jsonResponse(res, 200, { ok: true, pending: approvalRegistry.listPending() });
|
|
828
|
+
}
|
|
829
|
+
if (pathname === "/api/approval/respond" && req.method === "POST") {
|
|
830
|
+
const body = await readJsonBody(req, 4 * 1024);
|
|
831
|
+
const result = approvalRegistry.respond(body.id, body.approve === true);
|
|
832
|
+
if (!result.ok) return jsonResponse(res, result.code || 400, { ok: false, error: result.error });
|
|
833
|
+
return jsonResponse(res, 200, { ok: true, status: result.status });
|
|
834
|
+
}
|
|
835
|
+
|
|
569
836
|
// System version + free/major update signal for the dashboard prompt.
|
|
570
837
|
if (pathname === "/api/version" && req.method === "GET") {
|
|
571
838
|
return jsonResponse(res, 200, { ok: true, ...versionInfo(version) });
|
|
@@ -704,8 +971,14 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
704
971
|
"X-Accel-Buffering": "no",
|
|
705
972
|
});
|
|
706
973
|
res.write(": connected\n\n");
|
|
707
|
-
|
|
708
|
-
|
|
974
|
+
// sol batch #1: honor socket backpressure here too (the runs stream
|
|
975
|
+
// already does) — a stalled SSE reader must not queue unboundedly.
|
|
976
|
+
let unsub = () => {};
|
|
977
|
+
unsub = agentSessions.subscribe(sid, (ev) => {
|
|
978
|
+
try {
|
|
979
|
+
const ok = res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
980
|
+
if (!ok && res.writableLength > 8 * 1024 * 1024) { try { unsub(); } catch (_) {} try { res.destroy(); } catch (_) {} }
|
|
981
|
+
} catch (_) {}
|
|
709
982
|
});
|
|
710
983
|
const ping = setInterval(() => { try { res.write(": ping\n\n"); } catch (_) {} }, 25000);
|
|
711
984
|
if (ping.unref) ping.unref();
|
|
@@ -807,8 +1080,10 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
807
1080
|
const body = await readJsonBody(req, 16 * 1024);
|
|
808
1081
|
const workflow = String(body.workflow || "").trim();
|
|
809
1082
|
const agent = String(body.agent || "claude-code").trim();
|
|
810
|
-
|
|
811
|
-
|
|
1083
|
+
// terra T-EX-3: require an actual shipped workflow, not any slash token —
|
|
1084
|
+
// an unknown /foo would start a real agent run with a junk prompt.
|
|
1085
|
+
if (!isKnownWorkflow(workflow)) {
|
|
1086
|
+
return jsonResponse(res, 400, { ok: false, error: "unknown workflow" });
|
|
812
1087
|
}
|
|
813
1088
|
// V5.0 activation event — fires for the streaming workflow runner
|
|
814
1089
|
// path too. Pairs with the open-in-terminal logger below so
|
|
@@ -834,6 +1109,10 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
834
1109
|
if (!/^\/[a-z0-9][a-z0-9-]{0,40}$/i.test(workflow)) {
|
|
835
1110
|
return jsonResponse(res, 400, { ok: false, error: "workflow must match /[a-z0-9-]+" });
|
|
836
1111
|
}
|
|
1112
|
+
// terra T-EX-3: shape is not enough — require an actual shipped workflow.
|
|
1113
|
+
if (!isKnownWorkflow(workflow)) {
|
|
1114
|
+
return jsonResponse(res, 400, { ok: false, error: "unknown workflow" });
|
|
1115
|
+
}
|
|
837
1116
|
// Map agent id → interactive CLI command. Only registered agents
|
|
838
1117
|
// are allowed — no raw shell. Validates against the same registry
|
|
839
1118
|
// that runAgentStream uses (agentCommand).
|
|
@@ -875,6 +1154,27 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
875
1154
|
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cmd)) {
|
|
876
1155
|
return jsonResponse(res, 400, { ok: false, error: "invalid cmd (alphanumeric + dash/underscore only)" });
|
|
877
1156
|
}
|
|
1157
|
+
// terra T-EX-1: a shape-valid but UNKNOWN cmd is not rejected — parseArgs
|
|
1158
|
+
// leaves opts.command unset and main() falls through to the bare-invocation
|
|
1159
|
+
// default (open the dashboard on a configured machine, or ENTER THE INSTALL
|
|
1160
|
+
// path on a fresh one), running something the caller never asked for.
|
|
1161
|
+
// Require exact registry membership, exactly as /api/cli/open-in-terminal.
|
|
1162
|
+
if (!getCliIndex().cli.some((c) => c.cmd === cmd)) {
|
|
1163
|
+
return jsonResponse(res, 400, { ok: false, error: `Unknown cmd: ${cmd}` });
|
|
1164
|
+
}
|
|
1165
|
+
// terra T-EX-2: args must not be able to RE-SELECT the command. parseArgs
|
|
1166
|
+
// maps --update/-u to the (destructive, comprehensive) update command
|
|
1167
|
+
// regardless of the cmd already chosen, and --_self-updated is internal-
|
|
1168
|
+
// only. Reject those, and constrain the rest to safe chars (mirrors the
|
|
1169
|
+
// open-in-terminal ARG_RE — the child is argv-spawned so this is defense
|
|
1170
|
+
// in depth, not the only guard).
|
|
1171
|
+
const RUN_ARG_RE = /^[a-zA-Z0-9._=/-]{0,80}$/;
|
|
1172
|
+
const RUN_BANNED_ARGS = new Set(["--update", "-u", "--_self-updated"]);
|
|
1173
|
+
for (const a of args) {
|
|
1174
|
+
if (RUN_BANNED_ARGS.has(a) || !RUN_ARG_RE.test(a)) {
|
|
1175
|
+
return jsonResponse(res, 400, { ok: false, error: `Invalid arg: ${a}` });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
878
1178
|
return runMoverosStream({ req, res, cmd, args, vault });
|
|
879
1179
|
}
|
|
880
1180
|
|
|
@@ -925,6 +1225,10 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
925
1225
|
const full = path.join(dir, ent.name);
|
|
926
1226
|
if (ent.isDirectory()) { if (depth < 2) walk(full, depth + 1); }
|
|
927
1227
|
else if (ent.isFile() && ent.name.endsWith(".md")) {
|
|
1228
|
+
// Templates are scaffolding, not the user's record — listing
|
|
1229
|
+
// "Strategy_template" under "your Engine files" leaks internals
|
|
1230
|
+
// into the bell on day one. Same convention as the hook guard.
|
|
1231
|
+
if (/template\.md$/i.test(ent.name)) continue;
|
|
928
1232
|
try { const st = fs.statSync(full); found.push({ p: path.relative(vault, full), n: ent.name, m: st.mtimeMs, s: st.size }); } catch (_) {}
|
|
929
1233
|
}
|
|
930
1234
|
}
|
|
@@ -940,13 +1244,35 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
940
1244
|
|
|
941
1245
|
// Read a vault-scoped file's contents (for the in-app MD viewer drawer).
|
|
942
1246
|
// Query: ?path=<absolute path under vault>
|
|
1247
|
+
// &root=vault|skills|workflows|hooks (default vault) — the
|
|
1248
|
+
// Library's truthful search returns results from the skills dir
|
|
1249
|
+
// and the bundle's workflows/hooks; each root gets the SAME
|
|
1250
|
+
// realpath containment the vault does (resolveVaultScoped is
|
|
1251
|
+
// generic over its root argument). Relative paths resolve
|
|
1252
|
+
// against the chosen root, never the process cwd.
|
|
943
1253
|
// Returns { ok, content, mtime, size } — capped at 256KB.
|
|
944
1254
|
if (pathname === "/api/file/read" && req.method === "GET") {
|
|
945
|
-
|
|
1255
|
+
let filePath = url.searchParams.get("path") || "";
|
|
1256
|
+
const rootParam = url.searchParams.get("root") || "vault";
|
|
1257
|
+
const os = require("os");
|
|
1258
|
+
const rootDir = rootDirFor(
|
|
1259
|
+
{
|
|
1260
|
+
vault,
|
|
1261
|
+
bundleRoot: path.resolve(__dirname, "..", ".."),
|
|
1262
|
+
claudeDir: path.join(os.homedir(), ".claude"),
|
|
1263
|
+
},
|
|
1264
|
+
rootParam
|
|
1265
|
+
);
|
|
1266
|
+
if (!rootDir) {
|
|
1267
|
+
return jsonResponse(res, 400, { ok: false, error: "unknown root" });
|
|
1268
|
+
}
|
|
1269
|
+
if (filePath && !path.isAbsolute(filePath)) {
|
|
1270
|
+
filePath = path.join(rootDir, filePath);
|
|
1271
|
+
}
|
|
946
1272
|
let scoped;
|
|
947
1273
|
try {
|
|
948
1274
|
// Realpath-resolved, refuses symlink escapes (audit pass 2 #3).
|
|
949
|
-
scoped = resolveVaultScoped(
|
|
1275
|
+
scoped = resolveVaultScoped(rootDir, filePath, { mustExist: true });
|
|
950
1276
|
} catch (e) {
|
|
951
1277
|
const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
|
|
952
1278
|
// T242 bug hunt fix (B#2): success path returned only relPath
|
|
@@ -989,8 +1315,15 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
989
1315
|
// Realpath-resolved, refuses symlink escapes (audit pass 2 #3).
|
|
990
1316
|
scoped = resolveVaultScoped(vault, filePath, { mustExist: true });
|
|
991
1317
|
} catch (e) {
|
|
1318
|
+
// Generic messages: raw e.message from resolveVaultScoped's
|
|
1319
|
+
// realpath ENOENT carries the resolved ABSOLUTE path, leaking the
|
|
1320
|
+
// filesystem layout. /api/file/read was already hardened for this
|
|
1321
|
+
// exact class; /api/file/open and the checkbox toggle had been
|
|
1322
|
+
// missed.
|
|
992
1323
|
const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
|
|
993
|
-
|
|
1324
|
+
const msg = e.code === "EESCAPE" ? "path escapes vault" :
|
|
1325
|
+
e.code === "ENOENT" ? "file not found" : "invalid path";
|
|
1326
|
+
return jsonResponse(res, code, { ok: false, error: msg });
|
|
994
1327
|
}
|
|
995
1328
|
const targetAbs = scoped.absPath;
|
|
996
1329
|
try {
|
|
@@ -1034,8 +1367,12 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
1034
1367
|
try {
|
|
1035
1368
|
scoped = resolveVaultScoped(vault, filePath, { mustExist: true });
|
|
1036
1369
|
} catch (e) {
|
|
1370
|
+
// Generic messages (same class as /api/file/open above): raw
|
|
1371
|
+
// e.message leaks the resolved absolute path.
|
|
1037
1372
|
const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
|
|
1038
|
-
|
|
1373
|
+
const msg = e.code === "EESCAPE" ? "path escapes vault" :
|
|
1374
|
+
e.code === "ENOENT" ? "file not found" : "invalid path";
|
|
1375
|
+
return jsonResponse(res, code, { ok: false, error: msg });
|
|
1039
1376
|
}
|
|
1040
1377
|
const targetAbs = scoped.absPath;
|
|
1041
1378
|
try {
|
|
@@ -1080,6 +1417,11 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
1080
1417
|
// but inline — no workflow shell-out, no terminal.
|
|
1081
1418
|
if (pathname === "/api/capture" && req.method === "POST") {
|
|
1082
1419
|
const body = await readJsonBody(req, 8 * 1024);
|
|
1420
|
+
// terra T-DW-10: reject a non-string text so an object body can't be
|
|
1421
|
+
// coerced to "[object Object]" and appended to the Daily Note.
|
|
1422
|
+
if (body.text != null && typeof body.text !== "string") {
|
|
1423
|
+
return jsonResponse(res, 400, { ok: false, error: "text must be a string" });
|
|
1424
|
+
}
|
|
1083
1425
|
const text = String(body.text || "").trim();
|
|
1084
1426
|
// tag is optional — accept "work" or "#work", strip the hash.
|
|
1085
1427
|
// Multi-tag input arrives as comma- or space-separated; normalise
|
|
@@ -1284,8 +1626,11 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
1284
1626
|
const workflow = String(body.workflow || "").trim();
|
|
1285
1627
|
const hours = Number.isFinite(body.hours) ? Math.max(1, Math.min(168, body.hours)) : 24;
|
|
1286
1628
|
const reason = String(body.reason || "").slice(0, 200);
|
|
1287
|
-
|
|
1288
|
-
|
|
1629
|
+
// terra T-EX-4: only snooze a real shipped workflow — an unknown token
|
|
1630
|
+
// would persist an inert record in snoozes.json that can never match a
|
|
1631
|
+
// suggestion.
|
|
1632
|
+
if (!isKnownWorkflow(workflow)) {
|
|
1633
|
+
return jsonResponse(res, 400, { ok: false, error: "unknown workflow" });
|
|
1289
1634
|
}
|
|
1290
1635
|
try {
|
|
1291
1636
|
const os = require("os");
|
|
@@ -1391,7 +1736,20 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
1391
1736
|
// (e.g. user clicking twice while server slow) lose one entry.
|
|
1392
1737
|
const entry = await withFileLock(manifestPath, () => {
|
|
1393
1738
|
let manifest = {};
|
|
1394
|
-
|
|
1739
|
+
// terra T-DW-6: pattern-manifest.json holds the whole friction cache
|
|
1740
|
+
// (single_test, sacrifice, recent_wins, override_history). Swallowing
|
|
1741
|
+
// a parse failure to {} would let ONE override click overwrite the
|
|
1742
|
+
// malformed file and wipe all of it. Only a missing file is empty; a
|
|
1743
|
+
// present-but-unparseable/wrong-shape file aborts (preserved to repair).
|
|
1744
|
+
try {
|
|
1745
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
1746
|
+
} catch (e) {
|
|
1747
|
+
if (e.code === "ENOENT") manifest = {};
|
|
1748
|
+
else throw Object.assign(new Error("pattern-manifest.json is unreadable or malformed; refusing to overwrite it"), { code: "EMANIFESTCORRUPT" });
|
|
1749
|
+
}
|
|
1750
|
+
if (manifest == null || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
1751
|
+
throw Object.assign(new Error("pattern-manifest.json is not a JSON object; refusing to overwrite it"), { code: "EMANIFESTCORRUPT" });
|
|
1752
|
+
}
|
|
1395
1753
|
if (!Array.isArray(manifest.override_history)) manifest.override_history = [];
|
|
1396
1754
|
const ent = {
|
|
1397
1755
|
id: `ov-${Date.now()}`,
|
|
@@ -1487,6 +1845,12 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
1487
1845
|
});
|
|
1488
1846
|
fs.createReadStream(filePath).pipe(res);
|
|
1489
1847
|
} catch (e) {
|
|
1848
|
+
// A client sending unparseable JSON is a request problem, not a
|
|
1849
|
+
// server fault — answer 400, keep the generic 500 for real faults.
|
|
1850
|
+
if (e && e.message === "Invalid JSON body") {
|
|
1851
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
1852
|
+
return res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
1853
|
+
}
|
|
1490
1854
|
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
1491
1855
|
res.end(`Server error: ${e.message}`);
|
|
1492
1856
|
}
|
|
@@ -1683,7 +2047,8 @@ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
|
|
|
1683
2047
|
// below (Codex MF1: exit fired at 2s but SIGKILL escalation at 3s, so
|
|
1684
2048
|
// a stubborn child could survive). shutdownAll resolves on child close.
|
|
1685
2049
|
try { await agentSessions.shutdownAll(); } catch (_) { /* best-effort */ }
|
|
1686
|
-
try { runRegistry.shutdownAll(); } catch (_) { /* best-effort */ }
|
|
2050
|
+
try { await runRegistry.shutdownAll(); } catch (_) { /* best-effort */ }
|
|
2051
|
+
try { approvalRegistry.stop(); } catch (_) { /* best-effort */ }
|
|
1687
2052
|
clearRunningMarker();
|
|
1688
2053
|
// Clear the token file so stale scripts don't try to POST against
|
|
1689
2054
|
// a dead server.
|
|
@@ -1875,17 +2240,23 @@ function runAgentStream({ req, res, body, vault, runRegistry }) {
|
|
|
1875
2240
|
const ap = command.applied || {};
|
|
1876
2241
|
const tune = [ap.model ? `model=${ap.model}` : "", ap.effort ? `effort=${ap.effort}` : "", ap.approvalMode && ap.approvalMode !== "yolo" ? `approval=${ap.approvalMode}` : ""].filter(Boolean).join(" ");
|
|
1877
2242
|
const preamble = `[mover-studio] agent=${agent}${tune ? " " + tune : ""}\n[mover-studio] cwd=<bundle-root>\n\n`;
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2243
|
+
let run;
|
|
2244
|
+
try {
|
|
2245
|
+
run = runRegistry.start({
|
|
2246
|
+
agent,
|
|
2247
|
+
label: (workflow || prompt || "task").slice(0, 200),
|
|
2248
|
+
cmd: command.cmd,
|
|
2249
|
+
args: command.args(finalPrompt),
|
|
2250
|
+
stdin: command.stdin,
|
|
2251
|
+
finalPrompt,
|
|
2252
|
+
cwd: vault || repoRoot,
|
|
2253
|
+
env: { ...process.env, MOVER_STUDIO_RUN: "1" },
|
|
2254
|
+
preamble,
|
|
2255
|
+
});
|
|
2256
|
+
} catch (e) {
|
|
2257
|
+
if (e && e.code === "RUN_LIMIT") return jsonResponse(res, 429, { ok: false, error: e.message });
|
|
2258
|
+
throw e;
|
|
2259
|
+
}
|
|
1889
2260
|
|
|
1890
2261
|
res.writeHead(200, {
|
|
1891
2262
|
"Content-Type": "text/plain; charset=utf-8",
|
|
@@ -1897,9 +2268,16 @@ function runAgentStream({ req, res, body, vault, runRegistry }) {
|
|
|
1897
2268
|
// Stream this run to the response by subscribing (replay buffer + live). On
|
|
1898
2269
|
// client disconnect we ONLY unsubscribe — the run is not killed (an explicit
|
|
1899
2270
|
// stop is POST /api/runs/stop). Same handler shape as /api/runs/stream.
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2271
|
+
// sol batch #1: same backpressure guard as /api/runs/stream — drop a stalled
|
|
2272
|
+
// reader whose socket buffer exceeds the cap rather than queue without bound.
|
|
2273
|
+
let unsub = () => {};
|
|
2274
|
+
unsub = runRegistry.subscribe(run.id, (ev) => {
|
|
2275
|
+
if (ev.type === "replay" || ev.type === "chunk") {
|
|
2276
|
+
try {
|
|
2277
|
+
const ok = res.write(ev.text);
|
|
2278
|
+
if (!ok && res.writableLength > 8 * 1024 * 1024) { try { unsub(); } catch (_) {} try { res.destroy(); } catch (_) {} }
|
|
2279
|
+
} catch (_) {}
|
|
2280
|
+
} else if (ev.type === "end") { try { res.end(); } catch (_) {} }
|
|
1903
2281
|
});
|
|
1904
2282
|
req.on("close", () => { try { unsub(); } catch (_) {} });
|
|
1905
2283
|
}
|
|
@@ -2021,4 +2399,4 @@ function renderOgSvg(state, opts = {}) {
|
|
|
2021
2399
|
</svg>`;
|
|
2022
2400
|
}
|
|
2023
2401
|
|
|
2024
|
-
module.exports = { startServer, mergeJournalSection, sanitizeJournalLine, writeNoteAtomic };
|
|
2402
|
+
module.exports = { startServer, mergeJournalSection, sanitizeJournalLine, writeNoteAtomic, isKnownWorkflow };
|