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.
- package/install.js +688 -68
- package/package.json +15 -3
- package/src/dashboard/build.js +1503 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +177 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +462 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +211 -0
- package/src/dashboard/lib/date-utils.js +35 -0
- package/src/dashboard/lib/distribution-parser.js +78 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +83 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-health.js +170 -0
- package/src/dashboard/lib/engine-writer.js +1685 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +304 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +212 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +470 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +226 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- package/src/dashboard/lib/strategy-parser.js +245 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +331 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2024 -0
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
|
@@ -0,0 +1,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 => ({ "&": "&", "<": "<", ">": ">" })[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 };
|