mover-os 4.7.8 → 4.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +34 -24
  2. package/install.js +2197 -200
  3. package/package.json +1 -1
  4. package/src/dashboard/build.js +41 -3
  5. package/src/dashboard/lib/active-context-parser.js +16 -4
  6. package/src/dashboard/lib/agent-session.js +70 -49
  7. package/src/dashboard/lib/approval-registry.js +170 -0
  8. package/src/dashboard/lib/daily-note-resolver.js +20 -3
  9. package/src/dashboard/lib/date-utils.js +9 -1
  10. package/src/dashboard/lib/distribution-parser.js +69 -10
  11. package/src/dashboard/lib/drift-history.js +6 -1
  12. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  13. package/src/dashboard/lib/engine-health.js +4 -1
  14. package/src/dashboard/lib/engine-writer.js +157 -11
  15. package/src/dashboard/lib/goal-forecast.js +154 -31
  16. package/src/dashboard/lib/library-indexer-v2.js +14 -0
  17. package/src/dashboard/lib/library-search.js +290 -0
  18. package/src/dashboard/lib/log-activation.sh +0 -0
  19. package/src/dashboard/lib/memory-index.js +61 -12
  20. package/src/dashboard/lib/pid-markers.js +80 -0
  21. package/src/dashboard/lib/regenerate-manifest.js +0 -0
  22. package/src/dashboard/lib/run-registry.js +75 -15
  23. package/src/dashboard/lib/state-core/backfill.js +298 -0
  24. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  25. package/src/dashboard/lib/state-core/event-log.js +615 -0
  26. package/src/dashboard/lib/state-core/events.js +265 -0
  27. package/src/dashboard/lib/state-core/projections.js +376 -0
  28. package/src/dashboard/lib/state-core/start-close.js +162 -0
  29. package/src/dashboard/lib/state-core/trial.js +96 -0
  30. package/src/dashboard/lib/strategy-parser.js +3 -0
  31. package/src/dashboard/lib/suggested-now.js +2 -2
  32. package/src/dashboard/lib/transcript-parser.js +48 -8
  33. package/src/dashboard/server.js +422 -44
  34. package/src/dashboard/shortcut.js +0 -0
  35. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  36. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  37. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  41. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  42. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
@@ -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
- const isThisMode = (headerLine) => {
139
- const h = headerLine.toLowerCase();
140
- // Codex W3 round 2: ONLY a header that explicitly starts with the mode name
141
- // is replaced. A block that merely mentions "alibi"/"prediction" (or any
142
- // other user heading) is left untouchedan evening write never deletes a
143
- // block it didn't author. The endpoint always writes the alibi as
144
- // "### Evening · alibi", so the anchored prefix already covers our own data;
145
- // the old loose alibi/prediction fallback was pure data-loss risk.
146
- if (mode === "evening") return /^###\s+evening\b/.test(h);
147
- return /^###\s+morning\b/.test(h);
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
- const PORT_FALLBACKS = [3737, 3738, 3739, 3740];
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
- try { existing = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch (_) {}
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
- const unsub = runRegistry.subscribe(id, (ev) => {
557
- if (ev.type === "replay" || ev.type === "chunk") { try { res.write(ev.text); } catch (_) {} }
558
- else if (ev.type === "end") { try { res.end(); } catch (_) {} }
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
- const unsub = agentSessions.subscribe(sid, (ev) => {
708
- try { res.write(`data: ${JSON.stringify(ev)}\n\n`); } catch (_) {}
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
- if (!workflow.startsWith("/")) {
811
- return jsonResponse(res, 400, { ok: false, error: "workflow must start with /" });
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
- const filePath = url.searchParams.get("path") || "";
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(vault, filePath, { mustExist: true });
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
- return jsonResponse(res, code, { ok: false, error: e.message });
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
- return jsonResponse(res, code, { ok: false, error: e.message });
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
- if (!workflow.startsWith("/")) {
1288
- return jsonResponse(res, 400, { ok: false, error: "workflow must start with /" });
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
- try { manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); } catch (_) {}
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
- const run = runRegistry.start({
1879
- agent,
1880
- label: (workflow || prompt || "task").slice(0, 200),
1881
- cmd: command.cmd,
1882
- args: command.args(finalPrompt),
1883
- stdin: command.stdin,
1884
- finalPrompt,
1885
- cwd: vault || repoRoot,
1886
- env: { ...process.env, MOVER_STUDIO_RUN: "1" },
1887
- preamble,
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
- const unsub = runRegistry.subscribe(run.id, (ev) => {
1901
- if (ev.type === "replay" || ev.type === "chunk") { try { res.write(ev.text); } catch (_) {} }
1902
- else if (ev.type === "end") { try { res.end(); } catch (_) {} }
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 };