mover-os 4.7.7 → 4.7.8

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 (107) hide show
  1. package/install.js +688 -68
  2. package/package.json +15 -3
  3. package/src/dashboard/build.js +1503 -0
  4. package/src/dashboard/dashboard.js +276 -0
  5. package/src/dashboard/index.js +319 -0
  6. package/src/dashboard/lib/activation-log.js +297 -0
  7. package/src/dashboard/lib/active-context-parser.js +177 -0
  8. package/src/dashboard/lib/agent-command.js +93 -0
  9. package/src/dashboard/lib/agent-detect.js +255 -0
  10. package/src/dashboard/lib/agent-detector.js +92 -0
  11. package/src/dashboard/lib/agent-session.js +462 -0
  12. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  13. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  14. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  15. package/src/dashboard/lib/config-parser.js +109 -0
  16. package/src/dashboard/lib/connect-recommender.js +131 -0
  17. package/src/dashboard/lib/correlations-parser.js +231 -0
  18. package/src/dashboard/lib/daily-note-resolver.js +211 -0
  19. package/src/dashboard/lib/date-utils.js +35 -0
  20. package/src/dashboard/lib/distribution-parser.js +78 -0
  21. package/src/dashboard/lib/dossier-parser.js +64 -0
  22. package/src/dashboard/lib/drift-history.js +83 -0
  23. package/src/dashboard/lib/drift-score.js +119 -0
  24. package/src/dashboard/lib/engine-health.js +170 -0
  25. package/src/dashboard/lib/engine-writer.js +1685 -0
  26. package/src/dashboard/lib/execution-plan.js +125 -0
  27. package/src/dashboard/lib/experiments-parser.js +429 -0
  28. package/src/dashboard/lib/feed-parser.js +294 -0
  29. package/src/dashboard/lib/forked-future.js +60 -0
  30. package/src/dashboard/lib/goal-forecast.js +304 -0
  31. package/src/dashboard/lib/goals-parser.js +67 -0
  32. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  33. package/src/dashboard/lib/hook-activity.js +48 -0
  34. package/src/dashboard/lib/hook-indexer.js +169 -0
  35. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  36. package/src/dashboard/lib/identity-parser.js +85 -0
  37. package/src/dashboard/lib/ingestion.js +418 -0
  38. package/src/dashboard/lib/library-indexer-v2.js +212 -0
  39. package/src/dashboard/lib/library-indexer.js +105 -0
  40. package/src/dashboard/lib/log-activation.sh +61 -0
  41. package/src/dashboard/lib/memory-curator.js +97 -0
  42. package/src/dashboard/lib/memory-gardener.js +177 -0
  43. package/src/dashboard/lib/memory-gepa.js +102 -0
  44. package/src/dashboard/lib/memory-index.js +470 -0
  45. package/src/dashboard/lib/memory-rerank.js +72 -0
  46. package/src/dashboard/lib/memory-text.js +136 -0
  47. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  48. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  49. package/src/dashboard/lib/onboarding-forge.js +70 -0
  50. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  51. package/src/dashboard/lib/override-summary.js +73 -0
  52. package/src/dashboard/lib/paths.js +192 -0
  53. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  54. package/src/dashboard/lib/phantom-strategy.js +129 -0
  55. package/src/dashboard/lib/project-scanner.js +121 -0
  56. package/src/dashboard/lib/promise-wall.js +88 -0
  57. package/src/dashboard/lib/record-score.js +173 -0
  58. package/src/dashboard/lib/redaction.js +140 -0
  59. package/src/dashboard/lib/refusal-parser.js +44 -0
  60. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  61. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  62. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  63. package/src/dashboard/lib/run-registry.js +226 -0
  64. package/src/dashboard/lib/safe-write.js +63 -0
  65. package/src/dashboard/lib/session-log-parser.js +145 -0
  66. package/src/dashboard/lib/session-time-parser.js +158 -0
  67. package/src/dashboard/lib/skill-index.js +171 -0
  68. package/src/dashboard/lib/skill-indexer.js +118 -0
  69. package/src/dashboard/lib/skill-recommender.js +689 -0
  70. package/src/dashboard/lib/strategy-parser.js +245 -0
  71. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  72. package/src/dashboard/lib/streak-parser.js +95 -0
  73. package/src/dashboard/lib/suggested-now.js +254 -0
  74. package/src/dashboard/lib/tool-awareness.js +125 -0
  75. package/src/dashboard/lib/transcript-parser.js +331 -0
  76. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  77. package/src/dashboard/lib/view-generator.js +163 -0
  78. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  79. package/src/dashboard/lib/walkthrough-script.js +140 -0
  80. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  81. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  82. package/src/dashboard/server.js +2024 -0
  83. package/src/dashboard/shortcut.js +0 -0
  84. package/src/dashboard/static/setup-poc.html +306 -0
  85. package/src/dashboard/static/walkthrough-poc.html +580 -0
  86. package/src/dashboard/styles.css +1201 -0
  87. package/src/dashboard/templates/index.html +278 -0
  88. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  89. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  90. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  91. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  92. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
  93. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
  94. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
  95. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  96. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  97. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  98. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  99. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  100. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  101. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  102. package/src/dashboard/ui/dist/icon.svg +4 -0
  103. package/src/dashboard/ui/dist/index.html +18 -0
  104. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  105. package/src/dashboard/ui/dist/registerSW.js +1 -0
  106. package/src/dashboard/ui/dist/sw.js +1 -0
  107. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,2024 @@
1
+ "use strict";
2
+
3
+ const http = require("http");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const crypto = require("crypto");
7
+ const { execFile, spawn } = require("child_process");
8
+ // v6 serve-flip + Codex W4+W5 fix 1: the legacy ~/.mover/dashboard vanilla
9
+ // HTML is no longer served as a fallback, so dashboardOutDir/dashboardIndexPath
10
+ // are intentionally not imported here — ui/dist is the only served surface.
11
+ const { logEvent: logActivationEvent } = require("./lib/activation-log");
12
+ // v6 W3 — the live work feed (GET /api/feed/tail), read fresh per request.
13
+ const feedParser = require("./lib/feed-parser");
14
+ // Onboarding-engine: AgentSession abstraction — drives the user's own agent as
15
+ // a persistent structured multi-turn session (onboarding-engine-SPEC.md §2 / D1).
16
+ const { AgentSessionManager } = require("./lib/agent-session");
17
+ const { RunRegistry } = require("./lib/run-registry");
18
+ // Onboarding-engine D3/D4/D7 — note ingestion (drag-drop digest), skill
19
+ // recommendation (work → skills), and which agent CLIs are installed/authed.
20
+ const ingestion = require("./lib/ingestion");
21
+ const skillRecommender = require("./lib/skill-recommender");
22
+ const connectRecommender = require("./lib/connect-recommender");
23
+ const { forgeBeats } = require("./lib/onboarding-forge");
24
+ const agentDetect = require("./lib/agent-detect");
25
+ // B5 (Rule S21 made mechanical) — atomic + drift-guarded writes to the user's Daily Notes.
26
+ const { writeWithDriftGuard } = require("./lib/safe-write");
27
+
28
+ // Per-server CSRF token — issued on /api/health, required on all POST endpoints.
29
+ // Random per-process so dashboard ↔ server pairing is exclusive to this run.
30
+ const STUDIO_TOKEN = crypto.randomBytes(24).toString("hex");
31
+
32
+ // Engine-presence: has /setup actually run for this vault? Identity_Prime.md and
33
+ // Strategy.md are the only Engine files created exclusively by setup — the
34
+ // structure installer ships everything else as non-empty defaults and ships these
35
+ // two only as *_template.md. Either one present + non-empty => a real Engine
36
+ // exists, so the dashboard's first-open onboarding must NOT re-fire (a terminal
37
+ // /setup user opening the dashboard for the first time). Read-only; mirrors the
38
+ // engine-writer fresh-guard sentinels.
39
+ function engineIsSetUp(vaultDir) {
40
+ if (!vaultDir) return false;
41
+ const base = path.join(vaultDir, "02_Areas", "Engine");
42
+ return ["Identity_Prime.md", "Strategy.md"].some((f) => {
43
+ try { return fs.statSync(path.join(base, f)).size > 0; } catch (_) { return false; }
44
+ });
45
+ }
46
+
47
+ // Origin allowlist for POST requests. Vite dev (5173) + production (3737-3740).
48
+ const ORIGIN_ALLOWLIST = new Set([
49
+ "http://127.0.0.1:5173", "http://localhost:5173",
50
+ "http://127.0.0.1:3737", "http://localhost:3737",
51
+ "http://127.0.0.1:3738", "http://localhost:3738",
52
+ "http://127.0.0.1:3739", "http://localhost:3739",
53
+ "http://127.0.0.1:3740", "http://localhost:3740"
54
+ ]);
55
+
56
+ function isOriginAllowed(req) {
57
+ const origin = req.headers.origin;
58
+ // Audit H1: previous version allowed any request without Origin header
59
+ // — intended for same-origin browser fetches, but curl/scripts/Electron
60
+ // also send no Origin and were getting a pass. Defense-in-depth: only
61
+ // skip the origin check when the request came over the loopback socket
62
+ // (127.0.0.1 / ::1 / ::ffff:127.0.0.1). The token check (isTokenValid)
63
+ // still applies to state-changing endpoints regardless.
64
+ if (!origin) {
65
+ const remote = req.socket && req.socket.remoteAddress;
66
+ if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") return true;
67
+ return false;
68
+ }
69
+ return ORIGIN_ALLOWLIST.has(origin);
70
+ }
71
+
72
+ // Constant-time token comparison (audit pass 2 #2, 2026-05-24).
73
+ // `===` on hex strings allows timing side-channel inference one char at a time.
74
+ // crypto.timingSafeEqual requires equal-length buffers — short-circuit the
75
+ // length check first so we never pass mismatched-length buffers to it.
76
+ const STUDIO_TOKEN_BUF = Buffer.from(STUDIO_TOKEN, "utf8");
77
+ function isTokenValid(req) {
78
+ const t = req.headers["x-mover-token"];
79
+ if (typeof t !== "string") return false;
80
+ const buf = Buffer.from(t, "utf8");
81
+ if (buf.length !== STUDIO_TOKEN_BUF.length) return false;
82
+ try { return crypto.timingSafeEqual(buf, STUDIO_TOKEN_BUF); } catch { return false; }
83
+ }
84
+
85
+ // Vault-scoped path resolution with symlink protection (audit pass 2 #3).
86
+ // Previously path.resolve() + relative() only — a symlink inside the vault
87
+ // pointing outside would pass the prefix check but I/O would touch the
88
+ // real target. fs.realpathSync resolves all symlinks; comparing realpaths
89
+ // closes the escape. Returns { absPath, relPath } or throws.
90
+ // Note: realpathSync requires the path to exist. For write paths (file
91
+ // not yet created) we realpath the parent dir instead and join.
92
+ // v6 W3 — merge one journal mode's sub-anchors into the `## Journal` section of
93
+ // a Daily Note, PRESERVING the other mode's sub-anchors. The invariant: writing
94
+ // Evening never clobbers Morning and vice-versa. Sub-anchors are identified by
95
+ // their `### Evening`/`### Morning` (+ `alibi`) header; only THIS mode's blocks
96
+ // are replaced. Output format matches parseJournal() (daily-note-resolver.js)
97
+ // for a clean read round-trip. Append-only in spirit: nothing outside the
98
+ // rewritten mode's sub-anchors is touched.
99
+ // v6 W3 round 4: sanitize a single journal answer line for writing under its
100
+ // `### …` sub-anchor. Collapses newlines (each answer is one line), trims, caps
101
+ // length, and — critically — escapes a LEADING '#'. Without the escape, an
102
+ // answer like "## Review later" would be written as its own line and then read
103
+ // back as a markdown heading: it would end the `## Journal` section (or look
104
+ // like a new `###` sub-anchor), so the written answer silently vanishes on the
105
+ // next read. `\#` is the canonical markdown escape (renders as a literal '#').
106
+ function sanitizeJournalLine(v, max) {
107
+ return String(v == null ? "" : v)
108
+ .replace(/\r/g, "")
109
+ .replace(/\n+/g, " ")
110
+ .trim()
111
+ .slice(0, max)
112
+ .replace(/^#/, "\\#");
113
+ }
114
+
115
+ // B5: every dashboard write to the user's irreplaceable Daily Note funnels through here.
116
+ // `transform(existing)` returns the new file content, or null to skip the write (e.g. a
117
+ // checkbox item that wasn't found). Two guarantees the raw fs.writeFileSync calls lacked:
118
+ // 1. ATOMIC — tmp + rename, so a crash/power-loss mid-write can never leave a truncated note.
119
+ // 2. DRIFT-SAFE — if the file changed under us since we read `existing` (the user typing in
120
+ // Obsidian, the CLI /log), the concurrent edit is preserved to a `.bak.<ts>` sidecar
121
+ // before our write lands, instead of being silently clobbered. withFileLock only
122
+ // serializes in-process; this covers the cross-process window it cannot.
123
+ function writeNoteAtomic(absPath, transform, opts = {}) {
124
+ let existing = "";
125
+ try { existing = fs.readFileSync(absPath, "utf8"); } catch (_) { existing = ""; }
126
+ const next = transform(existing);
127
+ if (next == null) return { written: false };
128
+ const r = writeWithDriftGuard(absPath, existing, next, opts);
129
+ return { written: true, drifted: r.drifted, backupPath: r.backupPath };
130
+ }
131
+
132
+ function mergeJournalSection(existing, ymd, mode, newBlock) {
133
+ // Codex W3 fix 2: classify a sub-anchor by its ANCHORED mode prefix, not a
134
+ // loose keyword scan. `### Evening …` is evening; `### Morning …` is morning;
135
+ // a bare `### Alibi`/`### Prediction` (legacy/alt naming for the evening
136
+ // alibi) is evening ONLY when it isn't a Morning header — so an evening write
137
+ // 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 untouched — an 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
+ };
149
+ const trimTail = (arr) => { const b = arr.slice(); while (b.length && b[b.length - 1].trim() === "") b.pop(); return b; };
150
+ if (!existing) {
151
+ return `# Daily - ${ymd}\n\n## Journal\n\n${newBlock}\n`;
152
+ }
153
+ const lines = existing.replace(/\r\n/g, "\n").split("\n");
154
+ // Locate the `## Journal` section.
155
+ let jStart = -1;
156
+ for (let i = 0; i < lines.length; i++) {
157
+ if (/^##\s+Journal\s*$/i.test(lines[i])) { jStart = i; break; }
158
+ }
159
+ if (jStart === -1) {
160
+ const sep = existing.endsWith("\n") ? "" : "\n";
161
+ return `${existing}${sep}\n## Journal\n\n${newBlock}\n`;
162
+ }
163
+ // Section runs until the next H1 or H2 header, or EOF (h3 stays inside).
164
+ // Codex W3 round 3: parseJournal ends the Journal section at the next H1 OR
165
+ // H2 (/^#{1,2}\s/), so the merge MUST use the same boundary — otherwise a
166
+ // `# Later` (H1) after `## Journal` lets the merge write the block outside
167
+ // what the parser reads, and the round-trip silently breaks. `#{1,2}\s` does
168
+ // not match `### ` (h3), so sub-anchors stay inside the section.
169
+ let jEnd = lines.length;
170
+ for (let i = jStart + 1; i < lines.length; i++) {
171
+ if (/^#{1,2}\s/.test(lines[i])) { jEnd = i; break; }
172
+ }
173
+ // Split the section body into `###` sub-blocks; keep those NOT of this mode.
174
+ // Codex W3 fix 1: freeform prose BEFORE the first `###` is real user content,
175
+ // not stray blanks — preserve it as a leading block, never drop it.
176
+ const sectionLines = lines.slice(jStart + 1, jEnd);
177
+ const kept = [];
178
+ const preamble = [];
179
+ let curHeader = null;
180
+ let curBuf = [];
181
+ const flush = () => {
182
+ if (curHeader == null) return;
183
+ if (!isThisMode(curHeader)) kept.push(`${curHeader}\n${trimTail(curBuf).join("\n")}`);
184
+ curHeader = null;
185
+ curBuf = [];
186
+ };
187
+ for (const ln of sectionLines) {
188
+ if (/^###\s+/.test(ln)) { flush(); curHeader = ln; curBuf = []; }
189
+ else if (curHeader != null) { curBuf.push(ln); }
190
+ else { preamble.push(ln); } // before the first sub-anchor → user prose
191
+ }
192
+ flush();
193
+ const preText = trimTail(preamble).join("\n").replace(/^\n+/, "").trim();
194
+ const journalBody = [preText, ...kept, newBlock].filter((b) => b && b.trim()).join("\n\n");
195
+ const before = lines.slice(0, jStart + 1).join("\n"); // includes `## Journal`
196
+ const afterArr = lines.slice(jEnd);
197
+ // trim leading blanks off the trailing content so spacing stays normalized
198
+ while (afterArr.length && afterArr[0].trim() === "") afterArr.shift();
199
+ const tail = afterArr.length ? `\n\n${afterArr.join("\n")}` : "\n";
200
+ return `${before}\n\n${journalBody}${tail}`;
201
+ }
202
+
203
+ function resolveVaultScoped(vault, filePath, { mustExist = false } = {}) {
204
+ if (!vault) throw Object.assign(new Error("vault not set"), { code: "EVAULT" });
205
+ if (!filePath) throw Object.assign(new Error("filePath required"), { code: "EARG" });
206
+ const vaultAbs = fs.realpathSync(path.resolve(vault));
207
+ let targetAbs = path.resolve(filePath);
208
+ // Try realpath; if file missing, realpath the parent and reassemble.
209
+ try {
210
+ targetAbs = fs.realpathSync(targetAbs);
211
+ } catch (err) {
212
+ if (mustExist || err.code !== "ENOENT") throw err;
213
+ const parent = fs.realpathSync(path.dirname(targetAbs));
214
+ targetAbs = path.join(parent, path.basename(targetAbs));
215
+ }
216
+ const rel = path.relative(vaultAbs, targetAbs);
217
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
218
+ throw Object.assign(new Error("path escapes vault"), { code: "EESCAPE" });
219
+ }
220
+ return { absPath: targetAbs, relPath: rel, vaultAbs };
221
+ }
222
+
223
+ // Per-file async write queue (audit pass 2 #4, #5, #6).
224
+ // config.json / snoozes.json / pattern-manifest.json all use a
225
+ // read-modify-write pattern. Concurrent writes from two POSTs would
226
+ // load the same baseline, mutate independently, and the second write
227
+ // silently clobbered the first. Each unique file path gets its own
228
+ // promise chain so writes serialize per-file (parallel across files).
229
+ const _fileLocks = new Map();
230
+ function withFileLock(key, fn) {
231
+ const prev = _fileLocks.get(key) || Promise.resolve();
232
+ const next = prev.then(fn, fn).finally(() => {
233
+ if (_fileLocks.get(key) === next) _fileLocks.delete(key);
234
+ });
235
+ _fileLocks.set(key, next);
236
+ return next;
237
+ }
238
+
239
+ // Atomic JSON write — tmp file + rename. Same filesystem rename is
240
+ // atomic on POSIX and effectively atomic on modern Windows NTFS, so
241
+ // a crash mid-write leaves either the old file or the new file, never
242
+ // a half-written one. (audit pass 2 #4 + find-bugs #8.)
243
+ function atomicWriteJson(filePath, obj) {
244
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
245
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
246
+ fs.renameSync(tmp, filePath);
247
+ }
248
+
249
+ // v6 W5 SERVE-FLIP — the SOLE dashboard is the React app in ui/dist (the v6
250
+ // build). The old ui-v2/dist (v5-Rams) + the vanilla ~/.mover/dashboard
251
+ // fallback are retired; ui-v2/ is archived to _archive-v5/. One dist, no
252
+ // fallbacks, no version mixups — this is what fixed the "wrong dashboard".
253
+ // (UI_LEGACY_DIST_* points at ui/dist — the name is historical.)
254
+ const UI_LEGACY_DIST_DIR = path.join(__dirname, "ui", "dist");
255
+ const UI_LEGACY_DIST_INDEX = path.join(UI_LEGACY_DIST_DIR, "index.html");
256
+ function uiDistAvailable() {
257
+ try { return fs.existsSync(UI_LEGACY_DIST_INDEX); } catch (_) { return false; }
258
+ }
259
+
260
+ function activeUiDist() {
261
+ return { dir: UI_LEGACY_DIST_DIR, index: UI_LEGACY_DIST_INDEX };
262
+ }
263
+
264
+ // Legacy constants kept for any downstream code that imports them — they
265
+ // now point at whichever dist is active.
266
+ const UI_DIST_DIR = activeUiDist().dir;
267
+ const UI_DIST_INDEX = activeUiDist().index;
268
+
269
+ const PRIMARY_PORT = 3737;
270
+ const PORT_FALLBACKS = [3737, 3738, 3739, 3740];
271
+
272
+ const MIME = {
273
+ ".html": "text/html; charset=utf-8",
274
+ ".css": "text/css; charset=utf-8",
275
+ ".js": "application/javascript; charset=utf-8",
276
+ ".json": "application/json; charset=utf-8",
277
+ ".svg": "image/svg+xml",
278
+ ".png": "image/png",
279
+ ".jpg": "image/jpeg",
280
+ ".woff2": "font/woff2",
281
+ ".woff": "font/woff",
282
+ ".ttf": "font/ttf",
283
+ ".ico": "image/x-icon",
284
+ ".webmanifest": "application/manifest+json",
285
+ ".map": "application/json; charset=utf-8"
286
+ };
287
+
288
+ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
289
+ return new Promise((resolve, reject) => {
290
+ let lastBuild = null;
291
+ let lastError = null;
292
+ // Activation log gate — fire dashboard_open ONCE per server process.
293
+ let activationLogged = false;
294
+ // Serialize onboarding Engine commits so two concurrent finalizes can't both
295
+ // pass the fresh-guard and interleave a half-written Engine (Codex r1 MF).
296
+ let finalizeLock = false;
297
+ // Build mutex (code-bug-audit H2, 2026-05-24): /api/refresh + /api/
298
+ // checkbox/toggle + /api/snooze + /api/override all call buildFn()
299
+ // and write `lastBuild`. Without a lock, concurrent POSTs torn-write
300
+ // ~/.mover/drift-history.json + ~/.mover/pattern-manifest.json.
301
+ // Pattern: one in-flight build per process; concurrent callers wait
302
+ // on the same promise.
303
+ let buildLock = null;
304
+ const safeBuild = () => {
305
+ if (buildLock) return buildLock;
306
+ buildLock = Promise.resolve(buildFn()).finally(() => { buildLock = null; });
307
+ return buildLock;
308
+ };
309
+
310
+ // Onboarding-engine session manager. cwd = the user's vault so the agent
311
+ // reasons in their real context. Killed on shutdown (zombie-process guard).
312
+ const agentSessions = new AgentSessionManager({ cwd: vault });
313
+
314
+ // Run-tab registry — server-held one-shot runs so the Run tab is persistent
315
+ // (survives navigation + refresh) the same way Chat is. Decouples the child
316
+ // from the request that started it; killed on shutdown. See lib/run-registry.js.
317
+ const runRegistry = new RunRegistry();
318
+
319
+ const server = http.createServer(async (req, res) => {
320
+ try {
321
+ const url = new URL(req.url, `http://${req.headers.host}`);
322
+ const pathname = url.pathname;
323
+
324
+ // API routes
325
+ if (pathname === "/api/health") {
326
+ // Health hands out the per-session CSRF token. Same-origin requests
327
+ // (the React UI on same host) can read it from here.
328
+ return jsonResponse(res, 200, {
329
+ ok: true,
330
+ version,
331
+ vault,
332
+ enginePresent: engineIsSetUp(vault),
333
+ builtAt: lastBuild ? lastBuild.builtAt : null,
334
+ uptime: process.uptime(),
335
+ token: STUDIO_TOKEN
336
+ });
337
+ }
338
+
339
+ // Block POSTs from unknown origins (basic CSRF defense)
340
+ if (req.method === "POST") {
341
+ if (!isOriginAllowed(req)) {
342
+ return jsonResponse(res, 403, { ok: false, error: "Origin not allowed" });
343
+ }
344
+ // Token enforcement on all state-changing endpoints.
345
+ // /api/refresh was previously misclassified as "read-only POST" but
346
+ // it triggers a full vault rebuild — DoS amplification vector if
347
+ // 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"];
349
+ if (tokenedRoutes.includes(pathname) && !isTokenValid(req)) {
350
+ return jsonResponse(res, 401, { ok: false, error: "Missing or invalid X-Mover-Token header" });
351
+ }
352
+ }
353
+
354
+ if (pathname === "/api/state.json") {
355
+ // V5.0 activation instrumentation (audit pass 2 #7 + find-bugs #1).
356
+ // Originally fired on /api/health, but the React UI does not call
357
+ // /api/health on mount — it calls /api/state.json. That meant
358
+ // dashboard_open never logged on a normal page load, only on the
359
+ // (rare) explicit refresh that hit /api/health. Moving it here
360
+ // fires it exactly when the dashboard is actually opened.
361
+ //
362
+ // Pass 3 #9 fix: this is a write side-effect on an unauthenticated
363
+ // GET. Gate behind same-origin signals (Origin header matches an
364
+ // allowed origin OR sec-fetch-site is 'same-origin') so a cross-
365
+ // site prefetch / image / iframe can't pollute the activation log.
366
+ if (!activationLogged) {
367
+ const origin = req.headers.origin;
368
+ const fetchSite = req.headers["sec-fetch-site"];
369
+ const sameOrigin =
370
+ (origin && ORIGIN_ALLOWLIST.has(origin)) ||
371
+ fetchSite === "same-origin" ||
372
+ fetchSite === "none"; // direct navigation (user typed URL)
373
+ if (sameOrigin) {
374
+ activationLogged = true;
375
+ try { logActivationEvent("dashboard_open", { version }); } catch (_) {}
376
+ }
377
+ }
378
+ if (lastBuild && lastBuild.state) return jsonResponse(res, 200, lastBuild.state);
379
+ return jsonResponse(res, 200, { available: false });
380
+ }
381
+
382
+ // v2 dashboard surface endpoints. Each returns a subset of state.json
383
+ // for the Library route's Skills / Hooks / CLI sub-tabs — lighter
384
+ // payload than re-fetching the entire state on every tab switch.
385
+ if (pathname === "/api/skills/index") {
386
+ if (lastBuild && lastBuild.state && lastBuild.state.skills) {
387
+ return jsonResponse(res, 200, lastBuild.state.skills);
388
+ }
389
+ return jsonResponse(res, 200, { available: false, count: 0, byCategory: { system: [], specialist: [], domain: [] }, all: [] });
390
+ }
391
+ if (pathname === "/api/hooks/index") {
392
+ if (lastBuild && lastBuild.state && lastBuild.state.hooks) {
393
+ return jsonResponse(res, 200, lastBuild.state.hooks);
394
+ }
395
+ return jsonResponse(res, 200, { available: false, count: 0, byEvent: {}, registered: [], unregistered: [], all: [] });
396
+ }
397
+ if (pathname === "/api/cli/usage") {
398
+ if (lastBuild && lastBuild.state && lastBuild.state.cliUsage) {
399
+ return jsonResponse(res, 200, lastBuild.state.cliUsage);
400
+ }
401
+ return jsonResponse(res, 200, { available: false, commands: [] });
402
+ }
403
+ if (pathname === "/api/library/v2") {
404
+ if (lastBuild && lastBuild.state && lastBuild.state.libraryV2) {
405
+ return jsonResponse(res, 200, lastBuild.state.libraryV2);
406
+ }
407
+ return jsonResponse(res, 200, { available: false });
408
+ }
409
+
410
+ // F37 — OG Image generator. Renders a server-side SVG receipt
411
+ // suitable for og:image meta tags on shared moveros.dev pages.
412
+ // Twitter/X, Discord, and Slack render SVG og:images directly.
413
+ // Formats:
414
+ // /api/og — default 1200×630 (Twitter/LinkedIn)
415
+ // /api/og?format=square — 1080×1080 (Instagram)
416
+ // /api/og?format=story — 1080×1920 (Stories)
417
+ // Query params:
418
+ // ?metric=drift|streak|patterns (which number to feature)
419
+ // ?title=... (override headline)
420
+ if (pathname === "/api/og") {
421
+ try {
422
+ const state = (lastBuild && lastBuild.state) || {};
423
+ const format = url.searchParams.get("format") || "default";
424
+ const metric = url.searchParams.get("metric") || "drift";
425
+ const titleOverride = url.searchParams.get("title") || undefined;
426
+ const svg = renderOgSvg(state, { format, metric, title: titleOverride });
427
+ res.writeHead(200, {
428
+ "Content-Type": "image/svg+xml; charset=utf-8",
429
+ "Cache-Control": "no-cache, no-store, must-revalidate",
430
+ "X-Mover-Og": "1",
431
+ });
432
+ res.end(svg);
433
+ return;
434
+ } catch (e) {
435
+ return jsonResponse(res, 500, { ok: false, error: e.message });
436
+ }
437
+ }
438
+
439
+ // GET/POST ~/.mover/config.json — surfaces user-tunable settings
440
+ // (density, mono mode, preferredAgent, evening_zone_start, etc).
441
+ // Studio writes here ONLY, never to Engine files.
442
+ if (pathname === "/api/config") {
443
+ const os = require("os");
444
+ const configPath = path.join(os.homedir(), ".mover", "config.json");
445
+ if (req.method === "GET") {
446
+ // T242 bug hunt fix — config.json contains vaultPath and any
447
+ // integration tokens the user has stored. GET previously had
448
+ // no auth, so any local process or cross-origin browser tab
449
+ // that bypassed origin checks could dump the user's vault
450
+ // location. Require same-origin signals OR a valid token,
451
+ // matching the /api/state.json gate at L222-232.
452
+ const origin = req.headers.origin;
453
+ const fetchSite = req.headers["sec-fetch-site"];
454
+ const sameOrigin =
455
+ (origin && ORIGIN_ALLOWLIST.has(origin)) ||
456
+ fetchSite === "same-origin" ||
457
+ fetchSite === "none";
458
+ if (!sameOrigin && !isTokenValid(req)) {
459
+ return jsonResponse(res, 401, { ok: false, error: "Missing or invalid X-Mover-Token header" });
460
+ }
461
+ try {
462
+ const raw = fs.readFileSync(configPath, "utf8");
463
+ return jsonResponse(res, 200, { ok: true, config: JSON.parse(raw) });
464
+ } catch (e) {
465
+ if (e.code === "ENOENT") return jsonResponse(res, 200, { ok: true, config: {} });
466
+ return jsonResponse(res, 500, { ok: false, error: "config read failed" });
467
+ }
468
+ }
469
+ if (req.method === "POST") {
470
+ try {
471
+ const patch = await readJsonBody(req, 16 * 1024);
472
+ // Serialize concurrent config writes (audit pass 2 #4).
473
+ const merged = await withFileLock(configPath, () => {
474
+ let existing = {};
475
+ try { existing = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch (_) {}
476
+ // Shallow merge — keys present in patch override, others kept.
477
+ // Allow nested `studio.<key>` merges via top-level studio object.
478
+ const out = { ...existing, ...patch };
479
+ if (existing.studio && patch.studio) {
480
+ out.studio = { ...existing.studio, ...patch.studio };
481
+ }
482
+ // #112: providerPrefs merges two levels deep (per provider),
483
+ // so posting { providerPrefs: { codex: { effort } } } cannot
484
+ // clobber claude-code's model or gemini's approval mode.
485
+ if (existing.providerPrefs && patch.providerPrefs) {
486
+ const pp = { ...existing.providerPrefs };
487
+ for (const k of Object.keys(patch.providerPrefs)) {
488
+ const prev = pp[k], next = patch.providerPrefs[k];
489
+ pp[k] = prev && typeof prev === "object" && next && typeof next === "object"
490
+ ? { ...prev, ...next } : next;
491
+ }
492
+ out.providerPrefs = pp;
493
+ }
494
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
495
+ atomicWriteJson(configPath, out);
496
+ return out;
497
+ });
498
+ return jsonResponse(res, 200, { ok: true, config: merged });
499
+ } catch (e) {
500
+ return jsonResponse(res, 500, { ok: false, error: e.message });
501
+ }
502
+ }
503
+ }
504
+
505
+ if (pathname === "/api/refresh") {
506
+ // POST-only: the origin + token gate above is wrapped in
507
+ // `if (req.method === "POST")`, so a GET/HEAD would skip both and
508
+ // still trigger a full vault rebuild (cross-origin DoS amplification
509
+ // via <img src>). Refuse any other verb before the handler runs.
510
+ if (req.method !== "POST") {
511
+ return jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
512
+ }
513
+ try {
514
+ const newBuild = await safeBuild();
515
+ lastBuild = newBuild;
516
+ lastError = null;
517
+ return jsonResponse(res, 200, { ok: true, builtAt: newBuild.builtAt, sectionCount: newBuild.sectionCount });
518
+ } catch (e) {
519
+ lastError = e.message;
520
+ return jsonResponse(res, 500, { ok: false, error: e.message });
521
+ }
522
+ }
523
+
524
+ if (pathname === "/api/agent/run" && req.method === "POST") {
525
+ const body = await readJsonBody(req, 64 * 1024);
526
+ return runAgentStream({ req, res, body, vault, runRegistry });
527
+ }
528
+
529
+ // ── Run-tab persistence: the runs the registry is holding ────────────
530
+ // GET /api/runs → list run summaries (rehydrate after refresh)
531
+ // GET /api/runs/get?id= → one run incl. full output (expand a card)
532
+ // GET /api/runs/stream?id= → reattach (replay buffer + live tail), same
533
+ // text/plain shape as /api/agent/run
534
+ // POST /api/runs/stop → actually stop a run (SIGTERM) [tokened]
535
+ // Read endpoints are un-tokened, matching /api/session/events (same class
536
+ // of loopback agent-output stream behind the Origin guard).
537
+ if (pathname === "/api/runs" && req.method === "GET") {
538
+ return jsonResponse(res, 200, { ok: true, runs: runRegistry.list() });
539
+ }
540
+ if (pathname === "/api/runs/get" && req.method === "GET") {
541
+ const run = runRegistry.get(url.searchParams.get("id") || "");
542
+ if (!run) return jsonResponse(res, 404, { ok: false, error: "no such run" });
543
+ return jsonResponse(res, 200, { ok: true, run });
544
+ }
545
+ if (pathname === "/api/runs/stream" && req.method === "GET") {
546
+ const id = url.searchParams.get("id") || "";
547
+ if (!runRegistry.has(id)) {
548
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
549
+ return res.end("no such run\n");
550
+ }
551
+ res.writeHead(200, {
552
+ "Content-Type": "text/plain; charset=utf-8",
553
+ "Cache-Control": "no-cache",
554
+ "X-Accel-Buffering": "no",
555
+ });
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 (_) {} }
559
+ });
560
+ req.on("close", () => { try { unsub(); } catch (_) {} });
561
+ return; // hold the stream open
562
+ }
563
+ if (pathname === "/api/runs/stop" && req.method === "POST") {
564
+ const body = await readJsonBody(req, 4 * 1024);
565
+ const ok = runRegistry.stop(String(body.id || ""));
566
+ return jsonResponse(res, 200, { ok });
567
+ }
568
+
569
+ // System version + free/major update signal for the dashboard prompt.
570
+ if (pathname === "/api/version" && req.method === "GET") {
571
+ return jsonResponse(res, 200, { ok: true, ...versionInfo(version) });
572
+ }
573
+
574
+ // ── Onboarding engine: persistent multi-turn agent sessions ──────────
575
+ // Drives the user's own agent as a structured stream-json process so the
576
+ // dashboard runs setup/walkthrough as a real conversation, never the raw
577
+ // terminal (onboarding-engine-SPEC.md §2). SSE out, POST in.
578
+ if (pathname === "/api/session/start" && req.method === "POST") {
579
+ const body = await readJsonBody(req, 8 * 1024);
580
+ try {
581
+ // Prepend the user's worldview-lens preamble (config.lens) so the
582
+ // interview / walkthrough speaks their own language (Phase D).
583
+ const { DEFAULT_SETUP_SYSTEM, FULL_SETUP_SYSTEM, CHAT_SYSTEM, lensPreamble } = require("./lib/agent-session");
584
+ let lens = "everyday";
585
+ try { lens = require("./lib/config-parser").read().lens || "everyday"; } catch (_) {}
586
+ const isChat = String(body.mode) === "chat";
587
+ const setupDepth = String(body.setupDepth || "full");
588
+ const setupSystem = setupDepth === "core" ? DEFAULT_SETUP_SYSTEM : FULL_SETUP_SYSTEM;
589
+ const base = body.systemPrompt ? String(body.systemPrompt) : (isChat ? CHAT_SYSTEM : setupSystem);
590
+ const pre = lensPreamble(lens);
591
+ // R3-C3: per-session model/effort with the same loud-refusal
592
+ // contract as one-shot runs (cleanToken drops spaces/metachars).
593
+ const { cleanToken, EFFORTS: SESSION_EFFORTS } = require("./lib/agent-command");
594
+ const reqModel = body.model != null ? String(body.model).trim() : "";
595
+ const reqEffort = body.effort != null ? String(body.effort).trim() : "";
596
+ const model = reqModel ? cleanToken(reqModel, 64) : null;
597
+ const effort = reqEffort && (SESSION_EFFORTS["claude-code"] || []).includes(reqEffort) ? reqEffort : null;
598
+ if (reqModel && !model) {
599
+ return jsonResponse(res, 400, { ok: false, error: `Model "${reqModel}" was rejected: letters, digits, . _ : - only, no spaces.` });
600
+ }
601
+ if (reqEffort && !effort) {
602
+ return jsonResponse(res, 400, { ok: false, error: `Effort "${reqEffort}" is not one of: ${(SESSION_EFFORTS["claude-code"] || []).join(", ")}.` });
603
+ }
604
+ const out = agentSessions.start({
605
+ agent: String(body.agent || "claude-code"),
606
+ systemPrompt: pre ? (pre + " " + base) : base,
607
+ model, effort,
608
+ ...(isChat ? { disallowTools: [], chat: true } : {}), // chat = full tool access + recall/skill hooks; setup stays locked
609
+ });
610
+ return jsonResponse(res, 200, { ok: true, ...out });
611
+ } catch (e) {
612
+ return jsonResponse(res, 400, { ok: false, error: e.message });
613
+ }
614
+ }
615
+ if (pathname === "/api/session/send" && req.method === "POST") {
616
+ const body = await readJsonBody(req, 64 * 1024);
617
+ try {
618
+ agentSessions.send(String(body.sessionId || ""), String(body.text || ""));
619
+ return jsonResponse(res, 200, { ok: true });
620
+ } catch (e) {
621
+ return jsonResponse(res, 400, { ok: false, error: e.message });
622
+ }
623
+ }
624
+ if (pathname === "/api/session/approve" && req.method === "POST") {
625
+ const body = await readJsonBody(req, 8 * 1024);
626
+ try {
627
+ agentSessions.approve(String(body.sessionId || ""), body.requestId, String(body.decision || "deny"));
628
+ return jsonResponse(res, 200, { ok: true });
629
+ } catch (e) {
630
+ return jsonResponse(res, 400, { ok: false, error: e.message });
631
+ }
632
+ }
633
+ if (pathname === "/api/session/end" && req.method === "POST") {
634
+ const body = await readJsonBody(req, 4 * 1024);
635
+ agentSessions.end(String(body.sessionId || ""));
636
+ return jsonResponse(res, 200, { ok: true });
637
+ }
638
+ // A — seed the Engine from a completed setup interview. Body:
639
+ // { sessionId, targetDir?, dryRun?, force? }. dryRun defaults TRUE
640
+ // (preview, no write). engine-writer blocks 02_Areas/Engine/ ALWAYS;
641
+ // omitting targetDir falls back to the dev sandbox (safe for preview).
642
+ if (pathname === "/api/session/finalize" && req.method === "POST") {
643
+ const body = await readJsonBody(req, 64 * 1024);
644
+ const sid = String(body.sessionId || "");
645
+ const { writeEngine } = require("./lib/engine-writer");
646
+
647
+ // Transcript source: a LIVE session ALWAYS wins. Only with no session
648
+ // do we accept a client-supplied transcript (the no-agent form) — and
649
+ // then it is role/type-validated, per-item length-capped, count-capped.
650
+ let transcript;
651
+ if (sid) {
652
+ transcript = agentSessions.getTranscript(sid) || [];
653
+ } else if (Array.isArray(body.transcript) && body.transcript.length) {
654
+ transcript = body.transcript
655
+ .slice(0, 200)
656
+ .filter((t) => t && (t.role === "user" || t.role === "assistant") && typeof t.text === "string")
657
+ .map((t) => ({ role: t.role, text: t.text.slice(0, 8000) }));
658
+ } else {
659
+ return jsonResponse(res, 400, { ok: false, error: "sessionId or a non-empty transcript is required" });
660
+ }
661
+ if (!transcript.length) return jsonResponse(res, 400, { ok: false, error: "empty transcript" });
662
+
663
+ // The SERVER resolves the target. The client may NOT pass targetDir or
664
+ // force. Two modes only: commit to THIS vault's Engine (fresh-install
665
+ // only, never force-overwrite), or a dev-sandbox preview.
666
+ const toVault = body.toVault === true;
667
+ const dryRun = body.dryRun !== false;
668
+ const targetDir = toVault
669
+ ? path.join(vault, "02_Areas", "Engine")
670
+ : path.join(__dirname, "..", "..", "dev", "research", "dashboard-direction-2026-06-10", "onboarding-build", "sandbox-engine");
671
+
672
+ const runFinalize = () => {
673
+ try {
674
+ const result = writeEngine({ transcript, targetDir, dryRun, force: false, allowEngineWrite: toVault });
675
+ // the synthesis "forge" beats — derived 1:1 from the real files being written,
676
+ // so the onboarding animation reflects the truth (tested in onboarding-forge).
677
+ const forge = forgeBeats(result.files.map((f) => f.path));
678
+ return jsonResponse(res, 200, { ok: true, written: result.written, target: targetDir, fileCount: result.files.length, forge,
679
+ files: dryRun ? result.files.map((f) => ({ path: f.path, content: f.content })) : result.files.map((f) => ({ path: f.path })) });
680
+ } catch (e) {
681
+ const msg = e.message || "";
682
+ const code = (msg.includes("SAFETY BLOCK") || msg.includes("already exists")) ? 403 : 500;
683
+ return jsonResponse(res, code, { ok: false, error: String(e && e.message || e) });
684
+ }
685
+ };
686
+
687
+ // Previews never lock. Real vault commits serialize so two finalizes
688
+ // can't both pass the fresh-guard and interleave (Codex r1).
689
+ if (dryRun || !toVault) return runFinalize();
690
+ if (finalizeLock) return jsonResponse(res, 409, { ok: false, error: "an Engine write is already in progress" });
691
+ finalizeLock = true;
692
+ try { return runFinalize(); } finally { finalizeLock = false; }
693
+ }
694
+ if (pathname === "/api/session/events" && req.method === "GET") {
695
+ const sid = url.searchParams.get("id") || "";
696
+ if (!agentSessions.has(sid)) {
697
+ res.writeHead(404, { "Content-Type": "text/plain" });
698
+ return res.end("no such session");
699
+ }
700
+ res.writeHead(200, {
701
+ "Content-Type": "text/event-stream; charset=utf-8",
702
+ "Cache-Control": "no-cache, no-transform",
703
+ "Connection": "keep-alive",
704
+ "X-Accel-Buffering": "no",
705
+ });
706
+ res.write(": connected\n\n");
707
+ const unsub = agentSessions.subscribe(sid, (ev) => {
708
+ try { res.write(`data: ${JSON.stringify(ev)}\n\n`); } catch (_) {}
709
+ });
710
+ const ping = setInterval(() => { try { res.write(": ping\n\n"); } catch (_) {} }, 25000);
711
+ if (ping.unref) ping.unref();
712
+ req.on("close", () => { clearInterval(ping); try { unsub(); } catch (_) {} });
713
+ return; // hold the SSE connection open
714
+ }
715
+ // Standalone foundation proof page for the setup turn-loop.
716
+ if (pathname === "/setup" && req.method === "GET") {
717
+ try {
718
+ const html = fs.readFileSync(path.join(__dirname, "static", "setup-poc.html"), "utf8");
719
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
720
+ return res.end(html);
721
+ } catch (_) {
722
+ res.writeHead(404, { "Content-Type": "text/plain" });
723
+ return res.end("setup-poc.html not found");
724
+ }
725
+ }
726
+ // D6 — the WALKTHROUGH proof page (teaches operating the agent). Mirrors
727
+ // /setup; steps are inlined in the page, served fresh each GET.
728
+ if (pathname === "/walkthrough" && req.method === "GET") {
729
+ try {
730
+ const html = fs.readFileSync(path.join(__dirname, "static", "walkthrough-poc.html"), "utf8");
731
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
732
+ return res.end(html);
733
+ } catch (_) {
734
+ res.writeHead(404, { "Content-Type": "text/plain" });
735
+ return res.end("walkthrough-poc.html not found");
736
+ }
737
+ }
738
+ // D7 — which coding agents are installed + authed + runnable (no
739
+ // interactive login is ever triggered). Read-only, no token needed.
740
+ if (pathname === "/api/agents/detect" && req.method === "GET") {
741
+ try { return jsonResponse(res, 200, { ok: true, agents: await agentDetect.detectAgents() }); }
742
+ catch (e) { return jsonResponse(res, 500, { ok: false, error: String(e && e.message || e) }); }
743
+ }
744
+ // D3 — ingest the user's exported notes into a live setup session as a
745
+ // framed context turn. Body: { sessionId, files:[{name, content}] }.
746
+ if (pathname === "/api/session/ingest" && req.method === "POST") {
747
+ const body = await readJsonBody(req, 2 * 1024 * 1024);
748
+ const sid = String(body.sessionId || "");
749
+ if (!agentSessions.has(sid)) return jsonResponse(res, 404, { ok: false, error: "no such session" });
750
+ const files = Array.isArray(body.files) ? body.files : [];
751
+ let digest = "";
752
+ try { digest = ingestion.buildDigest(files); }
753
+ catch (e) { return jsonResponse(res, 400, { ok: false, error: "ingest failed: " + String(e && e.message || e) }); }
754
+ if (!digest) return jsonResponse(res, 200, { ok: true, injected: false, note: "no usable content" });
755
+ try { agentSessions.send(sid, ingestion.frameDigest(digest)); }
756
+ catch (e) { return jsonResponse(res, 500, { ok: false, error: String(e && e.message || e) }); }
757
+ return jsonResponse(res, 200, { ok: true, injected: true, chars: digest.length });
758
+ }
759
+ // D4 — map a work profile to recommended skills/connectors.
760
+ // Body: { profile:{ work?, domain?, tools?, goals? } }.
761
+ if (pathname === "/api/setup/recommend" && req.method === "POST") {
762
+ const body = await readJsonBody(req, 64 * 1024);
763
+ const profile = (body && typeof body.profile === "object" && body.profile) || body || {};
764
+ try {
765
+ return jsonResponse(res, 200, {
766
+ ok: true,
767
+ ...skillRecommender.recommend(profile),
768
+ // personalized "what to feed/connect me" plan (cold-start fix) — honest:
769
+ // upload sources are ready (real ingest), live OAuth connectors are 'planned'.
770
+ connectPlan: connectRecommender.connectPlan(profile),
771
+ // owner ruling 2026-07-07: bundled skills install ALL; the
772
+ // recommend layer's real job is EXTERNAL awareness — MCP
773
+ // connectors + CLI tools, profile-matched from the shipped
774
+ // catalogues, with what's already installed/attached flagged
775
+ // from the machine itself.
776
+ skillsPolicy: "install-all",
777
+ external: require("./lib/tool-awareness").awareness(profile, { vault }),
778
+ });
779
+ }
780
+ catch (e) { return jsonResponse(res, 500, { ok: false, error: String(e && e.message || e) }); }
781
+ }
782
+ // D7 — create a double-clickable desktop launcher so the user can reopen
783
+ // the dashboard from their OS. Body: { name }. Cross-platform (mac .app /
784
+ // win .lnk / linux .desktop); shortcut.js never throws on a single-platform
785
+ // failure (returns a skipped reason), so onboarding is never blocked.
786
+ if (pathname === "/api/setup/shortcut" && req.method === "POST") {
787
+ const body = await readJsonBody(req, 4 * 1024);
788
+ const name = String(body.name || "").slice(0, 80);
789
+ try {
790
+ const { createShortcut } = require("./shortcut");
791
+ const result = createShortcut({
792
+ name,
793
+ nodeBin: process.execPath,
794
+ entry: path.join(__dirname, "..", "..", "install.js"),
795
+ });
796
+ return jsonResponse(res, 200, { ok: true, ...result });
797
+ } catch (e) {
798
+ return jsonResponse(res, 500, { ok: false, error: String(e && e.message || e) });
799
+ }
800
+ }
801
+
802
+ // Run a Mover workflow (e.g. /analyse-day) via the user's preferred
803
+ // agent CLI. Body: { workflow: "/analyse-day", agent?: "claude-code",
804
+ // model?, effort?, approvalMode? } — tuning fields are per-run (#112);
805
+ // omitted ones resolve from config providerPrefs inside runAgentStream.
806
+ if (pathname === "/api/workflow/run" && req.method === "POST") {
807
+ const body = await readJsonBody(req, 16 * 1024);
808
+ const workflow = String(body.workflow || "").trim();
809
+ const agent = String(body.agent || "claude-code").trim();
810
+ if (!workflow.startsWith("/")) {
811
+ return jsonResponse(res, 400, { ok: false, error: "workflow must start with /" });
812
+ }
813
+ // V5.0 activation event — fires for the streaming workflow runner
814
+ // path too. Pairs with the open-in-terminal logger below so
815
+ // "workflow_run" stats reflect both Pattern A and stream usage.
816
+ try { logActivationEvent("workflow_run", { workflow, agent }); } catch (_) {}
817
+ return runAgentStream({
818
+ req, res,
819
+ body: { agent, prompt: workflow, model: body.model, effort: body.effort, approvalMode: body.approvalMode },
820
+ vault, runRegistry
821
+ });
822
+ }
823
+
824
+ // Pattern A: open the user's REAL terminal at the vault root with the
825
+ // agent's interactive CLI running. The slash command is returned in
826
+ // the response so the client can copy it to clipboard and the user
827
+ // pastes it into their own session. No phantom background processes,
828
+ // no hidden execution, full Ctrl+C control.
829
+ // Body: { workflow: "/log", agent: "claude-code" }
830
+ if (pathname === "/api/workflow/open-in-terminal" && req.method === "POST") {
831
+ const body = await readJsonBody(req, 16 * 1024);
832
+ const workflow = String(body.workflow || "").trim();
833
+ const agent = String(body.agent || "claude-code").trim();
834
+ if (!/^\/[a-z0-9][a-z0-9-]{0,40}$/i.test(workflow)) {
835
+ return jsonResponse(res, 400, { ok: false, error: "workflow must match /[a-z0-9-]+" });
836
+ }
837
+ // Map agent id → interactive CLI command. Only registered agents
838
+ // are allowed — no raw shell. Validates against the same registry
839
+ // that runAgentStream uses (agentCommand).
840
+ const AGENT_INTERACTIVE_CMD = {
841
+ "claude-code": "claude",
842
+ "codex": "codex",
843
+ "gemini": "gemini",
844
+ };
845
+ const ALIASES = { "gemini-cli": "gemini", "claude": "claude-code", "codex-cli": "codex" };
846
+ const resolvedAgent = ALIASES[agent] || agent;
847
+ const cli = AGENT_INTERACTIVE_CMD[resolvedAgent];
848
+ if (!cli) {
849
+ return jsonResponse(res, 400, { ok: false, error: `Unsupported agent: ${agent}` });
850
+ }
851
+ try {
852
+ openInTerminal(cli, vault);
853
+ // V5.0 activation event (Phase 41.0.3 wiring, Codex #4 finding):
854
+ // logs the workflow_run event so activation stats can show real
855
+ // user behavior. Best-effort — never blocks the response.
856
+ try { logActivationEvent("workflow_run", { workflow, agent: resolvedAgent }); } catch (_) {}
857
+ return jsonResponse(res, 200, {
858
+ ok: true,
859
+ agent: resolvedAgent,
860
+ cli,
861
+ workflow,
862
+ hint: `Terminal opened at vault root running ${cli}. Paste ${workflow} to run.`,
863
+ });
864
+ } catch (e) {
865
+ return jsonResponse(res, 500, { ok: false, error: e.message });
866
+ }
867
+ }
868
+
869
+ // Run a Mover CLI command (e.g. moveros doctor, moveros capture).
870
+ // Body: { cmd: "doctor", args?: ["--verbose"] }
871
+ if (pathname === "/api/cli/run" && req.method === "POST") {
872
+ const body = await readJsonBody(req, 16 * 1024);
873
+ const cmd = String(body.cmd || "").trim();
874
+ const args = Array.isArray(body.args) ? body.args.map(String).slice(0, 32) : [];
875
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cmd)) {
876
+ return jsonResponse(res, 400, { ok: false, error: "invalid cmd (alphanumeric + dash/underscore only)" });
877
+ }
878
+ return runMoverosStream({ req, res, cmd, args, vault });
879
+ }
880
+
881
+ // Open a registered command in the user's REAL terminal app.
882
+ // Body: { commandId: "doctor", args?: ["--verbose"] }
883
+ // Codex security audit: never accept raw cmd strings — validate against registry.
884
+ if (pathname === "/api/cli/open-in-terminal" && req.method === "POST") {
885
+ const body = await readJsonBody(req, 16 * 1024);
886
+ const commandId = String(body.commandId || "").trim();
887
+ const args = Array.isArray(body.args) ? body.args.map(String).slice(0, 32) : [];
888
+ const reg = getCliIndex().cli.find((c) => c.cmd === commandId);
889
+ if (!reg) return jsonResponse(res, 400, { ok: false, error: `Unknown commandId: ${commandId}` });
890
+ // Validate args — alphanumeric + dash + equals + dot only, no shell metachars
891
+ const ARG_RE = /^[a-zA-Z0-9._=/-]{0,80}$/;
892
+ for (const a of args) {
893
+ if (!ARG_RE.test(a)) {
894
+ return jsonResponse(res, 400, { ok: false, error: `Invalid arg: ${a}` });
895
+ }
896
+ }
897
+ const cmdLine = `moveros ${commandId} ${args.join(" ")}`.trim();
898
+ try {
899
+ openInTerminal(cmdLine, vault);
900
+ return jsonResponse(res, 200, { ok: true, opened: cmdLine });
901
+ } catch (e) {
902
+ return jsonResponse(res, 500, { ok: false, error: e.message });
903
+ }
904
+ }
905
+
906
+ // List of all runnable commands the dashboard can offer.
907
+ if (pathname === "/api/cli/index") {
908
+ return jsonResponse(res, 200, getCliIndex());
909
+ }
910
+
911
+ // Recent Engine writes — "what just changed" for the topbar bell.
912
+ // Scans 02_Areas/Engine/ (depth 2, to include Dailies/YYYY-MM/) for .md
913
+ // files, newest mtime first. Returns vault-RELATIVE paths (openFileView
914
+ // resolves them). Read-only, scoped to the Engine dir.
915
+ if (pathname === "/api/recent" && req.method === "GET") {
916
+ try {
917
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "12", 10) || 12, 40);
918
+ const engineDir = path.join(vault, "02_Areas", "Engine");
919
+ const found = [];
920
+ const walk = (dir, depth) => {
921
+ let entries;
922
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
923
+ for (const ent of entries) {
924
+ if (ent.name.startsWith(".")) continue;
925
+ const full = path.join(dir, ent.name);
926
+ if (ent.isDirectory()) { if (depth < 2) walk(full, depth + 1); }
927
+ else if (ent.isFile() && ent.name.endsWith(".md")) {
928
+ try { const st = fs.statSync(full); found.push({ p: path.relative(vault, full), n: ent.name, m: st.mtimeMs, s: st.size }); } catch (_) {}
929
+ }
930
+ }
931
+ };
932
+ walk(engineDir, 0);
933
+ found.sort((a, b) => b.m - a.m);
934
+ const files = found.slice(0, limit).map((f) => ({ path: f.p, name: f.n, mtime: new Date(f.m).toISOString(), size: f.s }));
935
+ return jsonResponse(res, 200, { ok: true, files });
936
+ } catch (e) {
937
+ return jsonResponse(res, 500, { ok: false, error: "recent scan failed" });
938
+ }
939
+ }
940
+
941
+ // Read a vault-scoped file's contents (for the in-app MD viewer drawer).
942
+ // Query: ?path=<absolute path under vault>
943
+ // Returns { ok, content, mtime, size } — capped at 256KB.
944
+ if (pathname === "/api/file/read" && req.method === "GET") {
945
+ const filePath = url.searchParams.get("path") || "";
946
+ let scoped;
947
+ try {
948
+ // Realpath-resolved, refuses symlink escapes (audit pass 2 #3).
949
+ scoped = resolveVaultScoped(vault, filePath, { mustExist: true });
950
+ } catch (e) {
951
+ const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
952
+ // T242 bug hunt fix (B#2): success path returned only relPath
953
+ // but error path echoed e.message which can contain the
954
+ // resolved absolute path or the input path (e.g. ENOENT errors
955
+ // include 'lstat ~/.ssh/id_rsa'). Generic messages instead —
956
+ // the caller knows which path it sent.
957
+ const msg = e.code === "EESCAPE" ? "path escapes vault" :
958
+ e.code === "ENOENT" ? "file not found" : "invalid path";
959
+ return jsonResponse(res, code, { ok: false, error: msg });
960
+ }
961
+ try {
962
+ const stat = fs.statSync(scoped.absPath);
963
+ if (stat.size > 256 * 1024) {
964
+ return jsonResponse(res, 413, { ok: false, error: "file too large (>256KB) — open in Obsidian" });
965
+ }
966
+ const content = fs.readFileSync(scoped.absPath, "utf8");
967
+ return jsonResponse(res, 200, {
968
+ ok: true,
969
+ // Return vault-relative path only (code-bug-audit M1).
970
+ // Returning targetAbs leaked the user's home/filesystem layout
971
+ // to any browser context that hit this endpoint.
972
+ path: scoped.relPath,
973
+ content,
974
+ mtime: stat.mtime.toISOString(),
975
+ size: stat.size,
976
+ });
977
+ } catch (e) {
978
+ return jsonResponse(res, 500, { ok: false, error: "file read failed" });
979
+ }
980
+ }
981
+
982
+ // Open a file in the user's default app (Obsidian for .md, etc).
983
+ // Body: { filePath: "<absolute path under vault>" } — vault-scoped only.
984
+ if (pathname === "/api/file/open" && req.method === "POST") {
985
+ const body = await readJsonBody(req, 4 * 1024);
986
+ const filePath = String(body.filePath || "");
987
+ let scoped;
988
+ try {
989
+ // Realpath-resolved, refuses symlink escapes (audit pass 2 #3).
990
+ scoped = resolveVaultScoped(vault, filePath, { mustExist: true });
991
+ } catch (e) {
992
+ const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
993
+ return jsonResponse(res, code, { ok: false, error: e.message });
994
+ }
995
+ const targetAbs = scoped.absPath;
996
+ try {
997
+ const { execFile } = require("child_process");
998
+ const platform = process.platform;
999
+ // Simpler approach: always use OS-default opener with the file path.
1000
+ // macOS `open` / Linux `xdg-open` / Windows `start` will dispatch
1001
+ // to whatever app is registered for that file type. If the user has
1002
+ // Obsidian as their default .md handler, it opens in Obsidian; if
1003
+ // they prefer VS Code or another editor, it opens there.
1004
+ //
1005
+ // Previous version used obsidian:// URI scheme which silently failed
1006
+ // when the vault name didn't match (basename != Obsidian vault name).
1007
+ // OS-default dispatch sidesteps that entirely (E2E audit feedback).
1008
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
1009
+ const args = platform === "win32" ? ["/c", "start", "", targetAbs] : [targetAbs];
1010
+ execFile(cmd, args, () => {});
1011
+ return jsonResponse(res, 200, { ok: true, opened: targetAbs });
1012
+ } catch (e) {
1013
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1014
+ }
1015
+ }
1016
+
1017
+ // Toggle a checkbox in a Health protocol .md file.
1018
+ // Body: { filePath: "<absolute path under vault>", itemText: "<exact item text>", checked: true }
1019
+ // Re-reads the protocol file, rewrites the matching `- [ ]`/`- [x]` line, refreshes state.
1020
+ if (pathname === "/api/checkbox/toggle" && req.method === "POST") {
1021
+ const body = await readJsonBody(req, 16 * 1024);
1022
+ const filePath = String(body.filePath || "");
1023
+ const itemText = String(body.itemText || "").trim();
1024
+ const checked = !!body.checked;
1025
+ if (!filePath || !itemText) {
1026
+ return jsonResponse(res, 400, { ok: false, error: "filePath + itemText required" });
1027
+ }
1028
+ // Safety: must be a markdown file. Realpath protects against
1029
+ // symlink escape; vault-scope check enforced by resolveVaultScoped.
1030
+ if (!filePath.endsWith(".md")) {
1031
+ return jsonResponse(res, 400, { ok: false, error: "filePath must be a .md under the vault" });
1032
+ }
1033
+ let scoped;
1034
+ try {
1035
+ scoped = resolveVaultScoped(vault, filePath, { mustExist: true });
1036
+ } catch (e) {
1037
+ const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
1038
+ return jsonResponse(res, code, { ok: false, error: e.message });
1039
+ }
1040
+ const targetAbs = scoped.absPath;
1041
+ try {
1042
+ // T242 bug hunt fix (B#4): the prior read-mutate-write was
1043
+ // unprotected. Two concurrent toggles against the same file
1044
+ // could each parse, then race on writeFileSync — last write
1045
+ // wins, first write's edit lost. Other JSON endpoints use
1046
+ // withFileLock for serialization; markdown writes were
1047
+ // skipped. Wrap in the same mutex.
1048
+ const result = await withFileLock(targetAbs, () => {
1049
+ let found = false;
1050
+ writeNoteAtomic(targetAbs, (text) => {
1051
+ const lines = text.split("\n");
1052
+ let hit = -1;
1053
+ for (let i = 0; i < lines.length; i++) {
1054
+ const m = lines[i].match(/^([\s-*]+\[)([ xX])(\]\s+)(.+)$/);
1055
+ if (m && m[4].trim() === itemText) { hit = i; break; }
1056
+ }
1057
+ if (hit < 0) return null; // item not found → leave the file untouched
1058
+ found = true;
1059
+ const mark = checked ? "x" : " ";
1060
+ lines[hit] = lines[hit].replace(/^([\s-*]+\[)([ xX])(\])/, `$1${mark}$3`);
1061
+ return lines.join("\n");
1062
+ });
1063
+ return { found };
1064
+ });
1065
+ if (!result.found) {
1066
+ return jsonResponse(res, 404, { ok: false, error: "item not found in file" });
1067
+ }
1068
+ // Trigger rebuild so dashboard reflects the change
1069
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1070
+ return jsonResponse(res, 200, { ok: true, filePath, itemText, checked });
1071
+ } catch (e) {
1072
+ return jsonResponse(res, 500, { ok: false, error: "checkbox write failed" });
1073
+ }
1074
+ }
1075
+
1076
+ // QuickLog capture — append a timestamped line to today's Daily Note.
1077
+ // Body: { text: "<single line>", tag?: "<single tag, no #>" }
1078
+ // Writes to {vault}/02_Areas/Engine/Dailies/YYYY-MM/Daily - YYYY-MM-DD.md
1079
+ // under the `## Log` section (created if missing). Sibling to /log,
1080
+ // but inline — no workflow shell-out, no terminal.
1081
+ if (pathname === "/api/capture" && req.method === "POST") {
1082
+ const body = await readJsonBody(req, 8 * 1024);
1083
+ const text = String(body.text || "").trim();
1084
+ // tag is optional — accept "work" or "#work", strip the hash.
1085
+ // Multi-tag input arrives as comma- or space-separated; normalise
1086
+ // each token, drop empties, cap at 6 to keep the line scannable.
1087
+ const rawTag = body.tag == null ? "" : String(body.tag);
1088
+ const tags = rawTag
1089
+ .split(/[\s,]+/)
1090
+ .map(t => t.replace(/^#+/, "").trim())
1091
+ .filter(t => /^[A-Za-z0-9_-]{1,32}$/.test(t))
1092
+ .slice(0, 6);
1093
+ if (!text) {
1094
+ return jsonResponse(res, 400, { ok: false, error: "text required" });
1095
+ }
1096
+ if (text.length > 500) {
1097
+ return jsonResponse(res, 400, { ok: false, error: "text too long (max 500 chars)" });
1098
+ }
1099
+ if (!vault) {
1100
+ return jsonResponse(res, 500, { ok: false, error: "vault not set" });
1101
+ }
1102
+ try {
1103
+ // Compose YYYY-MM-DD in local time so the Daily Note matches what
1104
+ // the user sees on their calendar (UTC drift would file morning
1105
+ // entries under yesterday in late-night timezones).
1106
+ const now = new Date();
1107
+ const yyyy = String(now.getFullYear());
1108
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
1109
+ const dd = String(now.getDate()).padStart(2, "0");
1110
+ const hh = String(now.getHours()).padStart(2, "0");
1111
+ const mi = String(now.getMinutes()).padStart(2, "0");
1112
+ const ymd = `${yyyy}-${mm}-${dd}`;
1113
+ const ymo = `${yyyy}-${mm}`;
1114
+ const hhmm = `${hh}:${mi}`;
1115
+
1116
+ const dailyDir = path.join(vault, "02_Areas", "Engine", "Dailies", ymo);
1117
+ const dailyPath = path.join(dailyDir, `Daily - ${ymd}.md`);
1118
+
1119
+ // Vault-scope safety. Realpath the parent on missing files
1120
+ // (resolveVaultScoped handles the ENOENT case internally).
1121
+ let scoped;
1122
+ try {
1123
+ fs.mkdirSync(dailyDir, { recursive: true });
1124
+ scoped = resolveVaultScoped(vault, dailyPath, { mustExist: false });
1125
+ } catch (e) {
1126
+ const code = e.code === "EESCAPE" ? 400 : 500;
1127
+ return jsonResponse(res, code, { ok: false, error: e.message });
1128
+ }
1129
+ const targetAbs = scoped.absPath;
1130
+
1131
+ const tagSuffix = tags.length ? " " + tags.map(t => `#${t}`).join(" ") : "";
1132
+ const line = `- [${hhmm}] ${text}${tagSuffix}`;
1133
+
1134
+ // T242 bug hunt fix (B#4 cont.): wrap the read-modify-write in
1135
+ // withFileLock so two concurrent captures don't clobber each
1136
+ // other's edits on the same Daily Note.
1137
+ await withFileLock(targetAbs, () => {
1138
+ // Read existing file (or start fresh). If `## Log` exists, append
1139
+ // the new line under the next existing line break; otherwise
1140
+ // append the section at the bottom.
1141
+ writeNoteAtomic(targetAbs, (existing) => {
1142
+ const hasLogSection = /(^|\n)##\s+Log\s*(\n|$)/.test(existing);
1143
+ if (!existing) {
1144
+ return `# Daily - ${ymd}\n\n## Log\n\n${line}\n`;
1145
+ }
1146
+ if (!hasLogSection) {
1147
+ const sep = existing.endsWith("\n") ? "" : "\n";
1148
+ return `${existing}${sep}\n## Log\n\n${line}\n`;
1149
+ }
1150
+ const lines = existing.split("\n");
1151
+ let logIdx = -1;
1152
+ for (let i = 0; i < lines.length; i++) {
1153
+ if (/^##\s+Log\s*$/.test(lines[i])) { logIdx = i; break; }
1154
+ }
1155
+ let insertAt = lines.length;
1156
+ for (let i = logIdx + 1; i < lines.length; i++) {
1157
+ if (/^##\s/.test(lines[i])) { insertAt = i; break; }
1158
+ }
1159
+ while (insertAt - 1 > logIdx && lines[insertAt - 1].trim() === "") {
1160
+ insertAt--;
1161
+ }
1162
+ const before = lines.slice(0, insertAt);
1163
+ const after = lines.slice(insertAt);
1164
+ const blockTail = after.length && after[0].trim() === "" ? after : ["", ...after];
1165
+ return [...before, line, ...blockTail].join("\n");
1166
+ });
1167
+ });
1168
+ // Trigger rebuild so any dashboard surfaces that read Daily
1169
+ // contents (DayRing, ExecutionProtocol) refresh in the next tick.
1170
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1171
+ return jsonResponse(res, 200, { ok: true, filePath: targetAbs, line });
1172
+ } catch (e) {
1173
+ return jsonResponse(res, 500, { ok: false, error: "capture write failed" });
1174
+ }
1175
+ }
1176
+
1177
+ // v6 W3 — Journal close write. Body { mode:'evening'|'morning', answers }.
1178
+ // Writes the mode's `### Evening/Morning · <field>` sub-anchors under
1179
+ // `## Journal` in today's Daily Note (session-day rule: before 05:00 the
1180
+ // day is still yesterday's note). Cross-mode safe — writing Evening
1181
+ // preserves any Morning sub-anchors and vice-versa. Only non-empty
1182
+ // fields are written; an absent field stays absent (parseJournal returns
1183
+ // null — never a fabricated value).
1184
+ if (pathname === "/api/journal" && req.method === "POST") {
1185
+ const body = await readJsonBody(req, 16 * 1024);
1186
+ const mode = String(body.mode || "").trim();
1187
+ const answers = body.answers && typeof body.answers === "object" ? body.answers : null;
1188
+ if (mode !== "evening" && mode !== "morning") {
1189
+ return jsonResponse(res, 400, { ok: false, error: "mode must be 'evening' or 'morning'" });
1190
+ }
1191
+ if (!answers) {
1192
+ return jsonResponse(res, 400, { ok: false, error: "answers required" });
1193
+ }
1194
+ if (!vault) {
1195
+ return jsonResponse(res, 500, { ok: false, error: "vault not set" });
1196
+ }
1197
+ const clip = sanitizeJournalLine; // escapes a leading '#' (round 4)
1198
+ const intInRange = (v) => (Number.isFinite(v) ? Math.max(1, Math.min(10, Math.round(v))) : null);
1199
+ // Build [header, body] pairs for the non-empty fields only.
1200
+ const subAnchors = [];
1201
+ if (mode === "evening") {
1202
+ const score = intInRange(answers.score);
1203
+ const scoreWhy = clip(answers.scoreWhy, 400);
1204
+ const win = clip(answers.winAvoidance, 600);
1205
+ const worked = clip(answers.workedDidnt, 600);
1206
+ const diff = clip(answers.doDifferently, 600);
1207
+ const alibi = clip(answers.alibi, 400);
1208
+ if (score != null) subAnchors.push(["### Evening · score", scoreWhy ? `${score} — ${scoreWhy}` : `${score}`]);
1209
+ if (win) subAnchors.push(["### Evening · win / avoidance", win]);
1210
+ if (worked) subAnchors.push(["### Evening · what worked / what didn't", worked]);
1211
+ if (diff) subAnchors.push(["### Evening · do differently", diff]);
1212
+ if (alibi) subAnchors.push(["### Evening · alibi", alibi]);
1213
+ } else {
1214
+ const wantVsShould = clip(answers.wantVsShould, 600);
1215
+ const energy = intInRange(answers.energy);
1216
+ const grateful = clip(answers.grateful, 600);
1217
+ const picture = clip(answers.picture, 600);
1218
+ if (wantVsShould) subAnchors.push(["### Morning · want vs should", wantVsShould]);
1219
+ if (energy != null) subAnchors.push(["### Morning · energy", `${energy}`]);
1220
+ if (grateful) subAnchors.push(["### Morning · grateful", grateful]);
1221
+ if (picture) subAnchors.push(["### Morning · picture", picture]);
1222
+ }
1223
+ if (!subAnchors.length) {
1224
+ return jsonResponse(res, 400, { ok: false, error: "all answers empty" });
1225
+ }
1226
+ try {
1227
+ // Session-day rule (matches daily-note-resolver): before 05:00 local
1228
+ // the active day is still yesterday's note.
1229
+ const now = new Date();
1230
+ const sessionDay = new Date(now);
1231
+ if (now.getHours() < 5) sessionDay.setDate(sessionDay.getDate() - 1);
1232
+ const yyyy = String(sessionDay.getFullYear());
1233
+ const mm = String(sessionDay.getMonth() + 1).padStart(2, "0");
1234
+ const dd = String(sessionDay.getDate()).padStart(2, "0");
1235
+ const ymd = `${yyyy}-${mm}-${dd}`;
1236
+ const ymo = `${yyyy}-${mm}`;
1237
+ const dailyDir = path.join(vault, "02_Areas", "Engine", "Dailies", ymo);
1238
+ const dailyPath = path.join(dailyDir, `Daily - ${ymd}.md`);
1239
+ let scoped;
1240
+ try {
1241
+ // Codex W3 fix 4: validate vault scope BEFORE creating the month
1242
+ // directory. resolveVaultScoped realpaths the nearest existing
1243
+ // ancestor; if a symlinked ancestor escapes the vault it throws
1244
+ // here, so mkdir never follows it out of the vault.
1245
+ scoped = resolveVaultScoped(vault, dailyPath, { mustExist: false });
1246
+ fs.mkdirSync(dailyDir, { recursive: true });
1247
+ } catch (e) {
1248
+ const code = e.code === "EESCAPE" ? 400 : 500;
1249
+ return jsonResponse(res, code, { ok: false, error: e.message });
1250
+ }
1251
+ const targetAbs = scoped.absPath;
1252
+ const newBlock = subAnchors.map(([h, b]) => `${h}\n${b}`).join("\n\n");
1253
+ // Serialize concurrent journal writes on the same note so a near-
1254
+ // simultaneous evening+morning save can't clobber each other.
1255
+ await withFileLock(targetAbs, () => {
1256
+ writeNoteAtomic(targetAbs, (existing) => mergeJournalSection(existing, ymd, mode, newBlock));
1257
+ });
1258
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1259
+ return jsonResponse(res, 200, { ok: true, filePath: targetAbs, mode });
1260
+ } catch (e) {
1261
+ return jsonResponse(res, 500, { ok: false, error: "journal write failed" });
1262
+ }
1263
+ }
1264
+
1265
+ // v6 W3 — live work feed tail. Same-origin GET (loopback, no token, like
1266
+ // /api/state.json): reads ~/.claude/history.jsonl + sessions fresh per
1267
+ // request for near-real-time polling. Honest shape — NO cost, NO diff.
1268
+ if (pathname === "/api/feed/tail" && req.method === "GET") {
1269
+ if (!vault) return jsonResponse(res, 200, { available: false, caption: "", events: [], activeSessions: [] });
1270
+ const limParam = parseInt(url.searchParams.get("limit"), 10);
1271
+ const limit = Number.isFinite(limParam) ? Math.max(1, Math.min(100, limParam)) : 40;
1272
+ try {
1273
+ const feed = feedParser.compute({ vault, limit });
1274
+ return jsonResponse(res, 200, feed);
1275
+ } catch (e) {
1276
+ return jsonResponse(res, 200, { available: false, caption: "", events: [], activeSessions: [] });
1277
+ }
1278
+ }
1279
+
1280
+ // Snooze a workflow until N hours/days from now.
1281
+ // Body: { workflow: "/log", hours?: 24, reason?: "..." }
1282
+ if (pathname === "/api/snooze" && req.method === "POST") {
1283
+ const body = await readJsonBody(req, 8 * 1024);
1284
+ const workflow = String(body.workflow || "").trim();
1285
+ const hours = Number.isFinite(body.hours) ? Math.max(1, Math.min(168, body.hours)) : 24;
1286
+ const reason = String(body.reason || "").slice(0, 200);
1287
+ if (!workflow.startsWith("/")) {
1288
+ return jsonResponse(res, 400, { ok: false, error: "workflow must start with /" });
1289
+ }
1290
+ try {
1291
+ const os = require("os");
1292
+ const moverDir = path.join(os.homedir(), ".mover", "dashboard");
1293
+ fs.mkdirSync(moverDir, { recursive: true });
1294
+ const snoozePath = path.join(moverDir, "snoozes.json");
1295
+ // Serialize concurrent snooze writes (audit pass 2 #5).
1296
+ const until = await withFileLock(snoozePath, () => {
1297
+ let snoozes = [];
1298
+ try { snoozes = JSON.parse(fs.readFileSync(snoozePath, "utf8")); } catch (_) {}
1299
+ if (!Array.isArray(snoozes)) snoozes = [];
1300
+ // Drop any existing snooze for this workflow, then add new
1301
+ snoozes = snoozes.filter(s => s.workflow !== workflow);
1302
+ const untilIso = new Date(Date.now() + hours * 3600 * 1000).toISOString();
1303
+ snoozes.push({ workflow, until: untilIso, reason: reason || undefined });
1304
+ atomicWriteJson(snoozePath, snoozes);
1305
+ return untilIso;
1306
+ });
1307
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1308
+ return jsonResponse(res, 200, { ok: true, workflow, until });
1309
+ } catch (e) {
1310
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1311
+ }
1312
+ }
1313
+
1314
+ // View Generator MVP — wishlist #64. POST /api/view/generate accepts
1315
+ // a prompt, returns a structured view definition. GET /api/views
1316
+ // lists saved views. POST /api/view/delete?slug=... removes one.
1317
+ if (pathname === "/api/views" && req.method === "GET") {
1318
+ try {
1319
+ const { list } = require("./lib/view-generator");
1320
+ return jsonResponse(res, 200, { ok: true, views: list() });
1321
+ } catch (e) {
1322
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1323
+ }
1324
+ }
1325
+ if (pathname === "/api/view/generate" && req.method === "POST") {
1326
+ try {
1327
+ const body = await readJsonBody(req, 4 * 1024);
1328
+ const prompt = String(body.prompt || "").slice(0, 400).trim();
1329
+ if (!prompt) return jsonResponse(res, 400, { ok: false, error: "prompt required" });
1330
+ const { generate } = require("./lib/view-generator");
1331
+ const result = generate({ prompt, save: true });
1332
+ return jsonResponse(res, result.ok ? 200 : 400, result);
1333
+ } catch (e) {
1334
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1335
+ }
1336
+ }
1337
+ if (pathname === "/api/view/delete" && req.method === "POST") {
1338
+ try {
1339
+ const body = await readJsonBody(req, 1024);
1340
+ const slug = String(body.slug || "").trim();
1341
+ if (!slug || !/^[a-z0-9-]+$/i.test(slug)) {
1342
+ return jsonResponse(res, 400, { ok: false, error: "valid slug required" });
1343
+ }
1344
+ const { remove } = require("./lib/view-generator");
1345
+ return jsonResponse(res, 200, { ok: remove(slug) });
1346
+ } catch (e) {
1347
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1348
+ }
1349
+ }
1350
+
1351
+ // V5.0 activation event sink (Phase 41.0.x — UI-triggered events).
1352
+ // Body: { type: "onboarding_finished" | "file_edit_in_obsidian" |
1353
+ // "referral_mentioned" | "log_completed",
1354
+ // meta?: object }
1355
+ // Validates against the EVENT_TYPES list in activation-log.js;
1356
+ // unknown types return 400. Caps meta at 4KB to keep the log lean.
1357
+ if (pathname === "/api/event" && req.method === "POST") {
1358
+ try {
1359
+ const body = await readJsonBody(req, 4 * 1024);
1360
+ const type = String(body.type || "").trim();
1361
+ // Pass 3 #7 regression fix: arrays and primitives both fail
1362
+ // the "typeof === object && !null" intent. Explicitly reject
1363
+ // arrays so meta can't be a list (would silently break any
1364
+ // future `meta.field` lookup in readStats consumers).
1365
+ const meta = (body.meta && typeof body.meta === "object" && !Array.isArray(body.meta))
1366
+ ? body.meta : undefined;
1367
+ if (!type) return jsonResponse(res, 400, { ok: false, error: "type required" });
1368
+ const ok = logActivationEvent(type, meta);
1369
+ if (!ok) return jsonResponse(res, 400, { ok: false, error: `Unknown or rejected event type: ${type}` });
1370
+ return jsonResponse(res, 200, { ok: true, type });
1371
+ } catch (e) {
1372
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1373
+ }
1374
+ }
1375
+
1376
+ // Log an override decision into the pattern-manifest override_history.
1377
+ // Body: { what: "...", over: "...", reason: "..." }
1378
+ if (pathname === "/api/override" && req.method === "POST") {
1379
+ const body = await readJsonBody(req, 8 * 1024);
1380
+ const what = String(body.what || "").slice(0, 200).trim();
1381
+ const over = String(body.over || "").slice(0, 200).trim();
1382
+ const reason = String(body.reason || "").slice(0, 500).trim();
1383
+ if (!what || !reason) {
1384
+ return jsonResponse(res, 400, { ok: false, error: "what + reason required" });
1385
+ }
1386
+ try {
1387
+ const os = require("os");
1388
+ const manifestPath = path.join(os.homedir(), ".mover", "pattern-manifest.json");
1389
+ // Serialize concurrent override writes (audit pass 2 #6).
1390
+ // Without this lock, two overrides logged in quick succession
1391
+ // (e.g. user clicking twice while server slow) lose one entry.
1392
+ const entry = await withFileLock(manifestPath, () => {
1393
+ let manifest = {};
1394
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); } catch (_) {}
1395
+ if (!Array.isArray(manifest.override_history)) manifest.override_history = [];
1396
+ const ent = {
1397
+ id: `ov-${Date.now()}`,
1398
+ timestamp: new Date().toISOString(),
1399
+ what, over, reason
1400
+ };
1401
+ manifest.override_history.push(ent);
1402
+ // Keep last 50
1403
+ if (manifest.override_history.length > 50) {
1404
+ manifest.override_history = manifest.override_history.slice(-50);
1405
+ }
1406
+ atomicWriteJson(manifestPath, manifest);
1407
+ return ent;
1408
+ });
1409
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1410
+ return jsonResponse(res, 200, { ok: true, entry });
1411
+ } catch (e) {
1412
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1413
+ }
1414
+ }
1415
+
1416
+ // Static file serving — ui/dist (the React UI) is the ONE canonical
1417
+ // dashboard. v6 serve-flip + Codex W4+W5 fix 1: there is NO legacy
1418
+ // fallback. A missing ui/dist/index.html means the UI was never built
1419
+ // — fail loudly with a 503 rather than silently serving a different,
1420
+ // retired dashboard, which is exactly the version-mixup this guards.
1421
+ const safePath = pathname.replace(/^\/+/, "").replace(/\.\.\//g, "");
1422
+ if (!uiDistAvailable()) {
1423
+ res.writeHead(503, { "Content-Type": "text/plain" });
1424
+ return res.end(
1425
+ "Mover Studio UI is not built (ui/dist missing). Build it with " +
1426
+ "`npm --prefix src/dashboard/ui run build`, then restart the server."
1427
+ );
1428
+ }
1429
+ let filePath;
1430
+ let isHtmlEntry = false;
1431
+
1432
+ // SPA routing — every non-asset path falls back to index.html.
1433
+ // Vite emits content-hashed assets under /assets/.
1434
+ const isAsset = /^assets\/|^sw\.js$|^workbox|^registerSW\.js$|\.(js|css|woff2?|svg|png|ico|json|map|webmanifest)$/.test(safePath);
1435
+ if (pathname === "/" || pathname === "/index.html" || !isAsset) {
1436
+ filePath = UI_DIST_INDEX;
1437
+ isHtmlEntry = true;
1438
+ } else {
1439
+ filePath = path.join(UI_DIST_DIR, safePath);
1440
+ // Containment guard: the string-strip above is single-pass, so split
1441
+ // sequences like "....//" re-form "../" after one replace and climb
1442
+ // out of ui/dist. Resolve, then verify the result is still inside the
1443
+ // dist root — the same realpath-contain contract the vault routes use,
1444
+ // which this static surface otherwise bypasses entirely.
1445
+ const rel = path.relative(UI_DIST_DIR, path.resolve(filePath));
1446
+ if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) {
1447
+ res.writeHead(404, { "Content-Type": "text/plain" });
1448
+ return res.end("Not found");
1449
+ }
1450
+ }
1451
+
1452
+ if (!fs.existsSync(filePath)) {
1453
+ // SPA fallback — unknown non-asset routes serve index.html; a
1454
+ // genuinely missing asset under /assets/ is a real 404.
1455
+ if (!isHtmlEntry) {
1456
+ filePath = UI_DIST_INDEX;
1457
+ isHtmlEntry = true;
1458
+ if (!fs.existsSync(filePath)) {
1459
+ res.writeHead(404, { "Content-Type": "text/plain" });
1460
+ return res.end("Not found");
1461
+ }
1462
+ } else {
1463
+ res.writeHead(404, { "Content-Type": "text/plain" });
1464
+ return res.end("Not found");
1465
+ }
1466
+ }
1467
+
1468
+ const stat = fs.statSync(filePath);
1469
+ if (stat.isDirectory()) {
1470
+ res.writeHead(403);
1471
+ return res.end("Forbidden");
1472
+ }
1473
+
1474
+ const ext = path.extname(filePath).toLowerCase();
1475
+ const mime = MIME[ext] || "application/octet-stream";
1476
+
1477
+ // Content-hashed assets in /assets/ are immutable — cache them hard.
1478
+ // HTML entry stays no-cache so version bumps land immediately.
1479
+ const isImmutable = /^\/assets\//.test(pathname);
1480
+ res.writeHead(200, {
1481
+ "Content-Type": mime,
1482
+ "Cache-Control": isImmutable
1483
+ ? "public, max-age=31536000, immutable"
1484
+ : "no-cache",
1485
+ "X-Mover-Os-Version": version || "unknown",
1486
+ "X-Mover-Os-Mode": "studio-react"
1487
+ });
1488
+ fs.createReadStream(filePath).pipe(res);
1489
+ } catch (e) {
1490
+ res.writeHead(500, { "Content-Type": "text/plain" });
1491
+ res.end(`Server error: ${e.message}`);
1492
+ }
1493
+ });
1494
+
1495
+ function jsonResponse(res, code, obj) {
1496
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
1497
+ res.end(JSON.stringify(obj));
1498
+ }
1499
+
1500
+ // Stream `moveros <cmd> <args>` output back to the client over a
1501
+ // chunked HTTP response. Reuses the same SSE-style transport as agents.
1502
+ function runMoverosStream({ req, res, cmd, args, vault }) {
1503
+ res.writeHead(200, {
1504
+ "Content-Type": "text/plain; charset=utf-8",
1505
+ "Cache-Control": "no-cache",
1506
+ "Transfer-Encoding": "chunked"
1507
+ });
1508
+ const exe = process.argv[1] || "moveros";
1509
+ const child = spawn(process.execPath, [exe, cmd, ...args], {
1510
+ cwd: vault,
1511
+ env: { ...process.env, MOVER_DASHBOARD_INVOKED: "1" },
1512
+ stdio: ["ignore", "pipe", "pipe"]
1513
+ });
1514
+ child.stdout.on("data", (d) => res.write(d));
1515
+ child.stderr.on("data", (d) => res.write(d));
1516
+ child.on("error", (e) => { try { res.write(`\n[error] ${e.message}\n`); } catch (_) {} });
1517
+ child.on("close", (code) => {
1518
+ try { res.write(`\n[exit ${code}]\n`); } catch (_) {}
1519
+ res.end();
1520
+ });
1521
+ req.on("close", () => {
1522
+ try { child.kill("SIGTERM"); } catch (_) {}
1523
+ });
1524
+ }
1525
+
1526
+ // Cross-platform: open a command in the user's REAL terminal app.
1527
+ // macOS: AppleScript-driven Terminal.app. Windows: cmd /k. Linux: best-effort.
1528
+ //
1529
+ // Security: vault path (cwd) is user-controlled via ~/.mover/config.json.
1530
+ // Audit C1/C3 (2026-05-24): previous version escaped only `"` and embedded
1531
+ // the path directly into AppleScript / cmd.exe / bash -c strings, allowing
1532
+ // shell injection via a vault path containing backtick, $, ', |, &, etc.
1533
+ // Fix: build a single shell command via shellEscape() and pass it through
1534
+ // each platform's launcher using SAFE arg-passing (no `shell: true`, no
1535
+ // raw interpolation into shell strings).
1536
+ function shellEscape(s) {
1537
+ // POSIX single-quote wrap: '...' (literal), '\'' for embedded apostrophes
1538
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
1539
+ }
1540
+ function aplEscape(s) {
1541
+ // AppleScript double-quoted string: escape " and \
1542
+ return String(s).replace(/[\\"]/g, '\\$&');
1543
+ }
1544
+ function openInTerminal(cmdLine, cwd) {
1545
+ // cmdLine is always our own slash-command (e.g. "/morning"), but we
1546
+ // still treat it as untrusted to make this function safe by default.
1547
+ const cwdAbs = String(cwd || process.cwd());
1548
+ const cmdRaw = String(cmdLine);
1549
+ // Build a safely-escaped shell command line for any platform that
1550
+ // ultimately runs `bash -c`. POSIX-quoted both pieces.
1551
+ const posixCmd = `cd ${shellEscape(cwdAbs)} && ${cmdRaw}`;
1552
+
1553
+ if (process.platform === "darwin") {
1554
+ // AppleScript: embed the already-shell-escaped command as a string.
1555
+ // Two layers of escaping: shell (above) + AppleScript (here).
1556
+ const script = `tell application "Terminal" to do script "${aplEscape(posixCmd)}"`;
1557
+ const child = spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" });
1558
+ child.unref();
1559
+ spawn("osascript", ["-e", 'tell application "Terminal" to activate'], { detached: true, stdio: "ignore" }).unref();
1560
+ } else if (process.platform === "win32") {
1561
+ // Windows cmd.exe doesn't accept POSIX-quoted args. Drop `shell: true`
1562
+ // (which was passing through cmd.exe's metacharacter parsing) and use
1563
+ // explicit arg arrays + start's TitleArg + cwd via /D flag.
1564
+ // Note: Windows path quoting uses double-quotes; backtick/dollar are
1565
+ // literal in cmd. The remaining risk vector (& | > %) is suppressed
1566
+ // by passing pieces as separate args, not embedded in a /C string.
1567
+ // We still wrap cwd in literal quotes for `cd /d`.
1568
+ const winCwd = cwdAbs.replace(/"/g, ''); // strip embedded quotes; refuse rather than escape
1569
+ const startArgs = ["/c", "start", "", "cmd", "/k", `cd /d "${winCwd}" && ${cmdRaw}`];
1570
+ spawn("cmd", startArgs, { detached: true, stdio: "ignore" }).unref();
1571
+ } else {
1572
+ // Linux: prefer the launchers that accept --working-directory (no
1573
+ // shell interpolation needed for cwd). For x-terminal-emulator and
1574
+ // xterm fallbacks, pass the already-POSIX-escaped command.
1575
+ const candidates = [
1576
+ ["gnome-terminal", ["--working-directory", cwdAbs, "--", "bash", "-c", `${cmdRaw}; exec bash`]],
1577
+ ["konsole", ["--workdir", cwdAbs, "-e", "bash", "-c", `${cmdRaw}; exec bash`]],
1578
+ ["x-terminal-emulator", ["-e", "bash", "-c", `${posixCmd}; exec bash`]],
1579
+ ["xterm", ["-e", "bash", "-c", `${posixCmd}; exec bash`]],
1580
+ ];
1581
+ for (const [bin, args] of candidates) {
1582
+ try {
1583
+ const child = spawn(bin, args, { detached: true, stdio: "ignore" });
1584
+ child.unref();
1585
+ return;
1586
+ } catch (_) { /* try next */ }
1587
+ }
1588
+ throw new Error("No supported terminal emulator found (tried gnome-terminal, konsole, x-terminal-emulator, xterm)");
1589
+ }
1590
+ }
1591
+
1592
+ // Authoritative list of CLI commands the dashboard can run as buttons.
1593
+ // Keep in sync with install.js CLI_HANDLERS table.
1594
+ function getCliIndex() {
1595
+ return {
1596
+ cli: [
1597
+ { cmd: "pulse", label: "Open Studio", risk: "LOW", description: "Launch Mover Studio dashboard." },
1598
+ { cmd: "status", label: "Status", risk: "LOW", description: "Full system state: vault, agents, version." },
1599
+ { cmd: "doctor", label: "Doctor", risk: "LOW", description: "Health check across hooks, links, Engine files." },
1600
+ { cmd: "capture", label: "Capture", risk: "LOW", description: "Quick inbox capture from CLI." },
1601
+ { cmd: "who", label: "Who is...", risk: "LOW", description: "Entity lookup across Library/Entities." },
1602
+ { cmd: "diff", label: "Engine Diff", risk: "LOW", description: "Git history of Engine files." },
1603
+ { cmd: "replay", label: "Replay Session", risk: "LOW", description: "Print today's Session Log + progress." },
1604
+ { cmd: "context", label: "Agent Context", risk: "LOW", description: "Debug what each installed agent sees." },
1605
+ { cmd: "sync", label: "Sync Agents", risk: "LOW", description: "Re-sync rules/hooks/skills across installed agents." },
1606
+ { cmd: "settings", label: "Settings", risk: "LOW", description: "View or edit ~/.mover/config.json." },
1607
+ { cmd: "backup", label: "Backup", risk: "LOW", description: "Tar-snapshot ~/.mover/." },
1608
+ { cmd: "restore", label: "Restore", risk: "HIGH", description: "Restore from a Mover OS backup." },
1609
+ { cmd: "prayer", label: "Prayer Times", risk: "LOW", description: "Show today's prayer times (if configured)." },
1610
+ { cmd: "help", label: "Help", risk: "LOW", description: "Interactive guide to Mover OS." },
1611
+ { cmd: "install", label: "Install", risk: "MEDIUM", description: "Fresh install wizard." },
1612
+ { cmd: "update", label: "Update", risk: "MEDIUM", description: "Pull latest Mover OS payload + migrate." },
1613
+ { cmd: "uninstall", label: "Uninstall", risk: "HIGH", description: "Remove Mover OS from vault and agents." },
1614
+ ]
1615
+ };
1616
+ }
1617
+
1618
+ function tryListen(idx) {
1619
+ if (idx >= PORT_FALLBACKS.length) {
1620
+ return reject(new Error("All dashboard ports 3737-3740 in use. Try `lsof -i :3737` to find the process."));
1621
+ }
1622
+ const port = PORT_FALLBACKS[idx];
1623
+ server.once("error", err => {
1624
+ if (err.code === "EADDRINUSE") {
1625
+ server.close();
1626
+ tryListen(idx + 1);
1627
+ } else {
1628
+ reject(err);
1629
+ }
1630
+ });
1631
+ server.listen(port, "127.0.0.1", async () => {
1632
+ const url = `http://127.0.0.1:${port}`;
1633
+ // Initial build
1634
+ try {
1635
+ lastBuild = await safeBuild();
1636
+ } catch (e) {
1637
+ lastError = e.message;
1638
+ console.error(`[mover-dashboard] initial build failed: ${e.message}`);
1639
+ }
1640
+ console.log(`\n Mover OS Dashboard\n ${url}\n`);
1641
+ if (openBrowser) tryOpenBrowser(url);
1642
+ // F21 — register the running marker so agent hooks can surface the URL
1643
+ writeRunningMarker(url);
1644
+ // Phase 41.0.7 — expose the per-process CSRF token to local-only
1645
+ // external scripts (workflow Bash steps, Stop hooks, capture wire-up)
1646
+ // so they can POST /api/event for log_completed, referral_mentioned,
1647
+ // etc. File perms 0o600 so other users can't read it. Loopback-only
1648
+ // server means this is the same trust boundary as the file system.
1649
+ try {
1650
+ const os = require("os");
1651
+ const tokenDir = path.join(os.homedir(), ".mover", "dashboard");
1652
+ fs.mkdirSync(tokenDir, { recursive: true });
1653
+ const tokenFile = path.join(tokenDir, ".token");
1654
+ fs.writeFileSync(tokenFile, JSON.stringify({ token: STUDIO_TOKEN, url, pid: process.pid, startedAt: new Date().toISOString() }, null, 2), { encoding: "utf8", mode: 0o600 });
1655
+ } catch (e) {
1656
+ console.warn(`[mover-dashboard] could not write token file: ${e.message}`);
1657
+ }
1658
+ // T245 — periodic rebuild. Previously the server only rebuilt
1659
+ // state.json on (a) initial start, (b) write endpoints (config
1660
+ // save, checkbox toggle, capture, snooze, override), and (c)
1661
+ // explicit /api/refresh POST. With no writes for an hour the
1662
+ // state goes hours-stale — user reports "NextSteps are severely
1663
+ // outdated" because the parser snapshot was hours old. Now: a
1664
+ // 60s heartbeat rebuild keeps the surface live. safeBuild()'s
1665
+ // mutex prevents overlap with write-triggered rebuilds.
1666
+ const REBUILD_INTERVAL_MS = 60 * 1000;
1667
+ const rebuildTimer = setInterval(async () => {
1668
+ try {
1669
+ const fresh = await safeBuild();
1670
+ if (fresh) lastBuild = fresh;
1671
+ } catch (e) {
1672
+ console.warn(`[mover-dashboard] periodic rebuild failed: ${e.message}`);
1673
+ }
1674
+ }, REBUILD_INTERVAL_MS);
1675
+ // Keep the timer from blocking process exit on SIGINT.
1676
+ if (typeof rebuildTimer.unref === "function") rebuildTimer.unref();
1677
+
1678
+ // SIGINT cleanup
1679
+ const shutdown = async () => {
1680
+ console.log("\n Shutting down dashboard...");
1681
+ clearInterval(rebuildTimer);
1682
+ // Wait for agent children to actually die before the hard-exit timer
1683
+ // below (Codex MF1: exit fired at 2s but SIGKILL escalation at 3s, so
1684
+ // a stubborn child could survive). shutdownAll resolves on child close.
1685
+ try { await agentSessions.shutdownAll(); } catch (_) { /* best-effort */ }
1686
+ try { runRegistry.shutdownAll(); } catch (_) { /* best-effort */ }
1687
+ clearRunningMarker();
1688
+ // Clear the token file so stale scripts don't try to POST against
1689
+ // a dead server.
1690
+ try {
1691
+ const os = require("os");
1692
+ const tokenFile = path.join(os.homedir(), ".mover", "dashboard", ".token");
1693
+ if (fs.existsSync(tokenFile)) fs.unlinkSync(tokenFile);
1694
+ } catch (_) { /* best-effort */ }
1695
+ server.close(() => process.exit(0));
1696
+ setTimeout(() => process.exit(0), 2000);
1697
+ };
1698
+ process.once("SIGINT", shutdown);
1699
+ process.once("SIGTERM", shutdown);
1700
+ resolve({ server, port, url });
1701
+ });
1702
+ }
1703
+
1704
+ tryListen(0);
1705
+ });
1706
+ }
1707
+
1708
+ // F21 — write the auto-running marker on startup so the dashboard-url hook
1709
+ // can find it and surface the dashboard URL to agents on key events.
1710
+ function writeRunningMarker(url) {
1711
+ try {
1712
+ const os = require("os");
1713
+ const dir = path.join(os.homedir(), ".mover", "dashboard");
1714
+ fs.mkdirSync(dir, { recursive: true });
1715
+ fs.writeFileSync(path.join(dir, "auto-running"), url, "utf8");
1716
+ } catch (_) { /* non-fatal */ }
1717
+ }
1718
+ function clearRunningMarker() {
1719
+ try {
1720
+ const os = require("os");
1721
+ const p = path.join(os.homedir(), ".mover", "dashboard", "auto-running");
1722
+ if (fs.existsSync(p)) fs.unlinkSync(p);
1723
+ } catch (_) {}
1724
+ }
1725
+
1726
+ function tryOpenBrowser(url) {
1727
+ const platform = process.platform;
1728
+ let cmd, args;
1729
+ if (platform === "darwin") {
1730
+ cmd = "open"; args = [url];
1731
+ } else if (platform === "win32") {
1732
+ cmd = "cmd"; args = ["/c", "start", "", url];
1733
+ } else {
1734
+ cmd = "xdg-open"; args = [url];
1735
+ }
1736
+ try {
1737
+ execFile(cmd, args, () => { /* swallow */ });
1738
+ } catch {
1739
+ // Fall through silently — URL was already printed
1740
+ }
1741
+ }
1742
+
1743
+ function readJsonBody(req, maxBytes) {
1744
+ return new Promise((resolve, reject) => {
1745
+ // Audit H3: guard against double-resolution. The previous version
1746
+ // called reject() then req.destroy(), which emits 'error' → second
1747
+ // reject. Node ignores native-promise double-rejects but the second
1748
+ // event arrives via an already-resolved Promise's catch chain.
1749
+ let done = false;
1750
+ const settle = (fn, val) => { if (done) return; done = true; fn(val); };
1751
+ let raw = "";
1752
+ req.setEncoding("utf8");
1753
+ req.on("data", chunk => {
1754
+ raw += chunk;
1755
+ if (raw.length > maxBytes) {
1756
+ settle(reject, new Error("Request body too large"));
1757
+ try { req.destroy(); } catch (_) {}
1758
+ }
1759
+ });
1760
+ req.on("end", () => {
1761
+ try {
1762
+ settle(resolve, raw ? JSON.parse(raw) : {});
1763
+ } catch {
1764
+ settle(reject, new Error("Invalid JSON body"));
1765
+ }
1766
+ });
1767
+ req.on("error", (e) => settle(reject, e));
1768
+ });
1769
+ }
1770
+
1771
+ // ── version / update check (server side, for the dashboard "Update ready"
1772
+ // prompt) ─────────────────────────────────────────────────────────────────
1773
+ // Cached, ASYNC npm check (execFile, never execSync — a sync npm call would
1774
+ // block the single-threaded server for seconds). Returns last-known instantly;
1775
+ // refreshes in the background at most every 30 min. Offline → latest stays null
1776
+ // → updateAvailable false (graceful). The free/major split is the v4→v5 seam:
1777
+ // a same-major bump (v4.x → v4.y) is a FREE update; a new major (v4 → v5) is a
1778
+ // paid upgrade, surfaced differently and gated server-side at /api/download.
1779
+ let _verCache = { at: 0, latest: null, checking: false };
1780
+ function _verParts(s) { return String(s == null ? "" : s).split(".").map((n) => parseInt(n, 10) || 0); }
1781
+ function compareSemver(a, b) {
1782
+ const A = _verParts(a), B = _verParts(b);
1783
+ for (let i = 0; i < Math.max(A.length, B.length); i++) { const x = A[i] || 0, y = B[i] || 0; if (x !== y) return x > y ? 1 : -1; }
1784
+ return 0;
1785
+ }
1786
+ function sameMajor(a, b) { return (_verParts(a)[0] || 0) === (_verParts(b)[0] || 0); }
1787
+ function npmLatestVersion() {
1788
+ return new Promise((resolve) => {
1789
+ try {
1790
+ require("child_process").execFile("npm", ["view", "mover-os", "version"], { timeout: 8000 }, (err, stdout) => {
1791
+ resolve(err ? null : (String(stdout).trim() || null));
1792
+ });
1793
+ } catch (_) { resolve(null); }
1794
+ });
1795
+ }
1796
+ function refreshVersionCache() {
1797
+ if (_verCache.checking) return;
1798
+ if (_verCache.latest != null && Date.now() - _verCache.at < 30 * 60 * 1000) return; // still fresh
1799
+ _verCache.checking = true;
1800
+ npmLatestVersion().then((latest) => { _verCache = { at: Date.now(), latest, checking: false }; })
1801
+ .catch(() => { _verCache.checking = false; });
1802
+ }
1803
+ function versionInfo(current) {
1804
+ refreshVersionCache(); // fire-and-forget; this call uses whatever is cached
1805
+ const cur = String(current || "unknown");
1806
+ const latest = _verCache.latest;
1807
+ let updateAvailable = false, isFreeUpgrade = false, major = false;
1808
+ if (latest && cur !== "unknown" && compareSemver(latest, cur) > 0) {
1809
+ updateAvailable = true;
1810
+ isFreeUpgrade = sameMajor(cur, latest); // v4.x → v4.y = free for existing buyers
1811
+ major = !isFreeUpgrade; // v4 → v5 = new major (paid; gating deferred to v5 launch)
1812
+ }
1813
+ return { current: cur, latest, updateAvailable, isFreeUpgrade, major, checking: _verCache.checking };
1814
+ }
1815
+
1816
+ function runAgentStream({ req, res, body, vault, runRegistry }) {
1817
+ // #112: per-run choice first (body.model/effort/approvalMode from the Run
1818
+ // picker); omitted fields fall back to config providerPrefs so the fixed
1819
+ // call sites (Say-it, X-Ray) inherit the user's choice without client edits.
1820
+ let prefs = {};
1821
+ let cfgAgent = null;
1822
+ try {
1823
+ const cfg = require("./lib/config-parser").read();
1824
+ prefs = (cfg && cfg.providerPrefs) || {};
1825
+ cfgAgent = (cfg && cfg.preferredAgent) || null;
1826
+ } catch (_) {}
1827
+ const agent = String(body.agent || cfgAgent || "claude-code");
1828
+ const prompt = String(body.prompt || "").trim();
1829
+ const workflow = String(body.workflow || "").trim();
1830
+ const canonical = AGENT_ALIASES[agent] || agent;
1831
+ const perProvider = (prefs && prefs[canonical]) || {};
1832
+ const command = agentCommand(agent, {
1833
+ model: body.model != null ? body.model : perProvider.model,
1834
+ effort: body.effort != null ? body.effort : perProvider.effort,
1835
+ approvalMode: body.approvalMode != null ? body.approvalMode : perProvider.approvalMode,
1836
+ });
1837
+
1838
+ if (!prompt && !workflow) {
1839
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1840
+ return res.end("Missing prompt or workflow.\n");
1841
+ }
1842
+ if (!command) {
1843
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1844
+ return res.end(`Unsupported agent: ${agent}\n`);
1845
+ }
1846
+
1847
+ // R2: a rejected model/effort must refuse loudly, never silently run the
1848
+ // provider default while the user believes their pick applied (cleanToken
1849
+ // drops strings with spaces or shell metacharacters; the old behavior was a
1850
+ // quiet fallback the run preamble alone had to expose).
1851
+ const apChk = command.applied || {};
1852
+ const reqModel = String(body.model != null ? body.model : (perProvider.model || "")).trim();
1853
+ if (reqModel && !apChk.model) {
1854
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1855
+ return res.end(`Model "${reqModel}" was rejected: letters, digits, . _ : - only, no spaces. The run was not started.\n`);
1856
+ }
1857
+ const reqEffort = String(body.effort != null ? body.effort : (perProvider.effort || "")).trim();
1858
+ if (reqEffort && !apChk.effort && (AGENT_EFFORTS[canonical] || []).length) {
1859
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1860
+ return res.end(`Effort "${reqEffort}" is not one of: ${AGENT_EFFORTS[canonical].join(", ")}. The run was not started.\n`);
1861
+ }
1862
+
1863
+ const repoRoot = path.join(__dirname, "..", "..");
1864
+ const finalPrompt = buildAgentPrompt({ prompt, workflow, vault, repoRoot });
1865
+
1866
+ // The run lives in the RUN REGISTRY, decoupled from this HTTP request. If the
1867
+ // client navigates away or refreshes, the child keeps running and its output
1868
+ // is retained, so the Run tab can reattach (GET /api/runs/stream) — this is
1869
+ // what makes Run persistent like Chat. cwd = the VAULT (Engine/PARA context,
1870
+ // not the bundle dev root); MOVER_STUDIO_RUN=1 mutes the lifecycle hooks
1871
+ // (Stop log-cascade, engine-auto-commit, engine-health) — see src/hooks/*.sh.
1872
+ // The preamble lines are seeded into the buffer (not just written to res) so a
1873
+ // reattach replays an identical view, and the existing client keeps parsing
1874
+ // the exit tail. Audit M10: cwd is aliased — never leak the absolute repo path.
1875
+ const ap = command.applied || {};
1876
+ const tune = [ap.model ? `model=${ap.model}` : "", ap.effort ? `effort=${ap.effort}` : "", ap.approvalMode && ap.approvalMode !== "yolo" ? `approval=${ap.approvalMode}` : ""].filter(Boolean).join(" ");
1877
+ 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
+ });
1889
+
1890
+ res.writeHead(200, {
1891
+ "Content-Type": "text/plain; charset=utf-8",
1892
+ "Cache-Control": "no-cache",
1893
+ "X-Accel-Buffering": "no",
1894
+ "X-Mover-Run-Id": run.id,
1895
+ });
1896
+
1897
+ // Stream this run to the response by subscribing (replay buffer + live). On
1898
+ // client disconnect we ONLY unsubscribe — the run is not killed (an explicit
1899
+ // 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 (_) {} }
1903
+ });
1904
+ req.on("close", () => { try { unsub(); } catch (_) {} });
1905
+ }
1906
+
1907
+ // #112: argv assembly moved to lib/agent-command.js (pure, unit-tested in
1908
+ // test/agent-command.test.mjs) so model/effort/approval flags are one matrix.
1909
+ const { agentCommand, ALIASES: AGENT_ALIASES, EFFORTS: AGENT_EFFORTS } = require("./lib/agent-command");
1910
+
1911
+ function buildAgentPrompt({ prompt, workflow, vault, repoRoot }) {
1912
+ const task = workflow
1913
+ ? `Run or simulate the Mover OS workflow "${workflow}" from this local project.`
1914
+ : prompt;
1915
+ return [
1916
+ "You are being invoked from Mover Studio, a local browser UI for Mover OS.",
1917
+ `Workspace: ${repoRoot}`,
1918
+ `Vault: ${vault || "unknown"}`,
1919
+ "Use local files as source of truth. Be concise, cite files when possible, and do not make network calls unless the workflow genuinely requires them.",
1920
+ "",
1921
+ workflow ? `${task}\nUser context/prompt: ${prompt || "No additional prompt."}` : task
1922
+ ].join("\n");
1923
+ }
1924
+
1925
+ // F37 — OG Image SVG renderer. Pure-SVG (no canvas dep) so it works on
1926
+ // any platform without extra binaries. Returns an SVG string sized for
1927
+ // the requested format. Modern social platforms render SVG og:images
1928
+ // directly (Twitter X, Discord, Slack); older ones may not.
1929
+ function renderOgSvg(state, opts = {}) {
1930
+ const { format = "default", metric = "drift", title } = opts;
1931
+ const dims = {
1932
+ default: { w: 1200, h: 630 },
1933
+ square: { w: 1080, h: 1080 },
1934
+ story: { w: 1080, h: 1920 },
1935
+ };
1936
+ const { w, h } = dims[format] || dims.default;
1937
+
1938
+ const drift = state.driftScore || {};
1939
+ const streak = state.streak || {};
1940
+ const patterns = (state.activeContext && state.activeContext.activePatterns) || [];
1941
+ const singleTest = (state.strategy && state.strategy.singleTest) || (state.activeContext && state.activeContext.singleTest && state.activeContext.singleTest.text) || "Mover OS — your AI that remembers";
1942
+ const version = state.version || "4.7";
1943
+
1944
+ const headline = title || singleTest;
1945
+ const escaped = String(headline).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
1946
+
1947
+ // Pick featured value
1948
+ let bigValue = "—";
1949
+ let bigLabel = "DRIFT SCORE";
1950
+ let bigSub = "/100";
1951
+ if (metric === "streak") {
1952
+ bigValue = String(streak.currentStreak ?? streak.streakThroughYesterday ?? 0);
1953
+ bigLabel = "DAILY NOTE STREAK";
1954
+ bigSub = "DAYS";
1955
+ } else if (metric === "patterns") {
1956
+ bigValue = String(patterns.length);
1957
+ bigLabel = "ACTIVE PATTERNS";
1958
+ bigSub = "tracked";
1959
+ } else {
1960
+ bigValue = String(drift.value ?? "—");
1961
+ }
1962
+
1963
+ const isStory = format === "story";
1964
+ const padX = isStory ? 80 : 80;
1965
+ const padY = isStory ? 200 : 80;
1966
+ const bigSize = isStory ? 320 : 200;
1967
+ const bigY = isStory ? h * 0.4 : h * 0.55;
1968
+
1969
+ return `<?xml version="1.0" encoding="UTF-8"?>
1970
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
1971
+ <defs>
1972
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
1973
+ <stop offset="0%" stop-color="#0a0a0c" />
1974
+ <stop offset="100%" stop-color="#141416" />
1975
+ </linearGradient>
1976
+ <radialGradient id="halo" cx="80%" cy="20%" r="60%">
1977
+ <stop offset="0%" stop-color="#d97a3c" stop-opacity="0.18" />
1978
+ <stop offset="60%" stop-color="#d97a3c" stop-opacity="0.05" />
1979
+ <stop offset="100%" stop-color="#d97a3c" stop-opacity="0" />
1980
+ </radialGradient>
1981
+ <pattern id="grid" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
1982
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.025)" stroke-width="1"/>
1983
+ </pattern>
1984
+ </defs>
1985
+ <rect width="${w}" height="${h}" fill="url(#bg)"/>
1986
+ <rect width="${w}" height="${h}" fill="url(#grid)"/>
1987
+ <rect width="${w}" height="${h}" fill="url(#halo)"/>
1988
+
1989
+ <!-- Eyebrow -->
1990
+ <text x="${padX}" y="${padY}" font-family="ui-monospace, monospace" font-size="${isStory ? 22 : 18}" fill="#d97a3c" letter-spacing="4">
1991
+ MOVER · OS · PUBLIC PROOF
1992
+ </text>
1993
+
1994
+ <!-- Headline -->
1995
+ <text x="${padX}" y="${padY + (isStory ? 70 : 60)}" font-family="Inter, sans-serif" font-size="${isStory ? 56 : 44}" fill="#f5f5f5" font-weight="500">
1996
+ <tspan x="${padX}" dy="0">${escaped.slice(0, isStory ? 32 : 50)}</tspan>
1997
+ ${escaped.length > (isStory ? 32 : 50) ? `<tspan x="${padX}" dy="${isStory ? 68 : 56}">${escaped.slice(isStory ? 32 : 50, isStory ? 64 : 100)}</tspan>` : ''}
1998
+ </text>
1999
+
2000
+ <!-- Big metric -->
2001
+ <text x="${padX}" y="${bigY}" font-family="ui-monospace, monospace" font-size="${bigSize}" fill="#f5f5f5" font-weight="400" letter-spacing="-6">
2002
+ ${bigValue}
2003
+ </text>
2004
+ <text x="${padX + (bigValue.length * (bigSize * 0.55))}" y="${bigY}" font-family="ui-monospace, monospace" font-size="${bigSize * 0.18}" fill="#828282">
2005
+ ${bigSub}
2006
+ </text>
2007
+
2008
+ <!-- Big metric label -->
2009
+ <text x="${padX}" y="${bigY + (bigSize * 0.18)}" font-family="ui-monospace, monospace" font-size="${isStory ? 22 : 18}" fill="#a8a8a8" letter-spacing="4">
2010
+ ${bigLabel}
2011
+ </text>
2012
+
2013
+ <!-- Watermark -->
2014
+ <text x="${padX}" y="${h - 40}" font-family="ui-monospace, monospace" font-size="${isStory ? 18 : 14}" fill="#828282" letter-spacing="3">
2015
+ LOCAL · SCREEN OFF · TELEMETRY NEVER · v${version}
2016
+ </text>
2017
+
2018
+ <!-- Corner crosshairs (top-right + bottom-left) -->
2019
+ <path d="M ${w - 40} 20 L ${w - 20} 20 L ${w - 20} 40" stroke="#d97a3c" stroke-width="1.5" fill="none"/>
2020
+ <path d="M 20 ${h - 40} L 20 ${h - 20} L 40 ${h - 20}" stroke="#d97a3c" stroke-width="1.5" fill="none"/>
2021
+ </svg>`;
2022
+ }
2023
+
2024
+ module.exports = { startServer, mergeJournalSection, sanitizeJournalLine, writeNoteAtomic };