@marver-design/marver 0.9.0 → 0.10.1

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.
@@ -138,15 +138,46 @@ function firstExisting(root, candidates) {
138
138
  * evidence rather than a guess. Whatever wins must still be on PATH - the daemon has to
139
139
  * spawn it.
140
140
  */
141
- /** Ordered by preference when a machine has both installed and neither is running us. */
142
- const AGENTS = ["claude", "codex"];
141
+ /** Ordered by preference when a machine has several installed and none is running us. */
142
+ const AGENTS = [
143
+ "claude",
144
+ "codex",
145
+ "cursor",
146
+ "droid",
147
+ "opencode",
148
+ "grok",
149
+ "pi"
150
+ ];
143
151
  /** Env vars each CLI sets in the processes it spawns. Deliberately NOT CODEX_HOME or
144
152
  * ANTHROPIC_API_KEY: those are configuration a human exports by hand, not evidence that
145
- * the tool is running right now. */
153
+ * the tool is running right now. (opencode's `AGENT=1` and pi's `AI_AGENT` are skipped
154
+ * for the same reason in reverse - too generic to prove WHICH tool is running.)
155
+ * droid and grok set no marker at all in the shells they spawn (verified against the
156
+ * grok-build source and droid 0.200.0), so they are found by PATH order only. */
146
157
  const MARKERS = {
147
158
  claude: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"],
148
- codex: ["CODEX_SANDBOX", "CODEX_THREAD_ID"]
159
+ codex: ["CODEX_SANDBOX", "CODEX_THREAD_ID"],
160
+ cursor: ["CURSOR_AGENT"],
161
+ droid: [],
162
+ opencode: ["OPENCODE", "OPENCODE_PID"],
163
+ grok: [],
164
+ pi: ["PI_CODING_AGENT", "PI_SESSION_ID"]
149
165
  };
166
+ /** The executable each agent name maps to. Only cursor differs: its CLI installs BOTH
167
+ * `cursor-agent` and a bare `agent` - and grok's installer symlinks `agent` too, so the
168
+ * short name is a coin flip on a machine with both. The long name is unambiguous. */
169
+ const AGENT_BIN = {
170
+ claude: "claude",
171
+ codex: "codex",
172
+ cursor: "cursor-agent",
173
+ droid: "droid",
174
+ opencode: "opencode",
175
+ grok: "grok",
176
+ pi: "pi"
177
+ };
178
+ /** The valid `jam.agent` values as an English list - one string, so every surface
179
+ * (config warning, init note, dev boot line) names the same set and none goes stale. */
180
+ const AGENT_NAMES = `${AGENTS.slice(0, -1).map((a) => `"${a}"`).join(", ")}, or "${AGENTS[AGENTS.length - 1]}"`;
150
181
  /** Is `cmd` an executable FILE on PATH? A direct stat per PATH entry - no spawn, no shell,
151
182
  * so this is safe to call on every dev boot. Two deliberate narrowings, both in service of
152
183
  * "found means spawnable", because arming an agent that cannot run is worse than staying off:
@@ -183,8 +214,8 @@ function onPath(cmd, env = process.env) {
183
214
  * that line names the tool it actually is. One word to correct, once per repo. */
184
215
  function detectAgent(env = process.env) {
185
216
  const running = AGENTS.find((a) => MARKERS[a].some((k) => env[k]));
186
- if (running && onPath(running, env)) return running;
187
- return AGENTS.find((a) => onPath(a, env));
217
+ if (running && onPath(AGENT_BIN[running], env)) return running;
218
+ return AGENTS.find((a) => onPath(AGENT_BIN[a], env));
188
219
  }
189
220
  //#endregion
190
221
  //#region src/server/config.ts
@@ -278,15 +309,16 @@ const plainObject = (v) => {
278
309
  * claim every @marver mention, fail it, and never retry. */
279
310
  function resolveAgent(named) {
280
311
  if (named === void 0) return detectAgent() ?? "no-agent";
281
- if (named !== "claude" && named !== "codex") {
282
- console.warn(`[marver] design/config.ts: jam.agent ${show(named)} is not an agent marver can spawn - Live Jam is off. Use "claude" or "codex".`);
312
+ if (typeof named !== "string" || !Object.hasOwn(AGENT_BIN, named)) {
313
+ console.warn(`[marver] design/config.ts: jam.agent ${show(named)} is not an agent marver can spawn - Live Jam is off. Use ${AGENT_NAMES}.`);
283
314
  return "bad-agent";
284
315
  }
285
- if (!onPath(named)) {
286
- console.warn(`[marver] design/config.ts names jam.agent "${named}", which is not on PATH - Live Jam is off until it is installed.`);
316
+ const agent = named;
317
+ if (!onPath(AGENT_BIN[agent])) {
318
+ console.warn(`[marver] design/config.ts names jam.agent "${agent}", but \`${AGENT_BIN[agent]}\` is not on PATH - Live Jam is off until it is installed.`);
287
319
  return "bad-agent";
288
320
  }
289
- return named;
321
+ return agent;
290
322
  }
291
323
  /** Resolve the jam block. Live Jam is ON by default: an absent or partial `jam` resolves
292
324
  * to whatever agent CLI this machine has, so a fresh workspace jams with nothing
@@ -564,4 +596,4 @@ function writeManifest(root, manifest) {
564
596
  }
565
597
  const hash = (s) => createHash("sha256").update(s).digest("hex");
566
598
  //#endregion
567
- export { DEFAULTS as a, detectHost as c, writeManifest as i, readJson as l, hash as n, loadConfig as o, scanFrames as r, detectAgent as s, affectedFrameIds as t };
599
+ export { writeManifest as a, detectAgent as c, toFrameId as i, detectHost as l, hash as n, DEFAULTS as o, scanFrames as r, loadConfig as s, affectedFrameIds as t, readJson as u };
@@ -1,7 +1,7 @@
1
1
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
2
2
  import { n as localProfile, t as isConnected } from "./profile-BkiWglVE.mjs";
3
- import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DJHU7qfu.mjs";
4
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
3
+ import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DIsp3ldB.mjs";
4
+ import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, resolve, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { randomBytes } from "node:crypto";
@@ -82,7 +82,7 @@ function contained(target, base) {
82
82
  return false;
83
83
  }
84
84
  }
85
- function apiMiddleware(root) {
85
+ function apiMiddleware(root, opts = {}) {
86
86
  const boardsDir = join(root, "design", "boards");
87
87
  const boardPath = (name) => {
88
88
  if (!BOARD_NAME.test(name)) return null;
@@ -90,6 +90,31 @@ function apiMiddleware(root) {
90
90
  const p = resolve(boardsDir, `${name}.json`);
91
91
  return contained(p, boardsDir) ? p : null;
92
92
  };
93
+ const validName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
94
+ const underRoot = (dir) => {
95
+ try {
96
+ const rr = realpathSync(root);
97
+ const rd = realpathSync(dir);
98
+ return rd === rr || rd.startsWith(rr + sep);
99
+ } catch {
100
+ return false;
101
+ }
102
+ };
103
+ const notSymlink = (p) => {
104
+ try {
105
+ return !existsSync(p) || !lstatSync(p).isSymbolicLink();
106
+ } catch {
107
+ return false;
108
+ }
109
+ };
110
+ const nodeExists = (p) => {
111
+ try {
112
+ lstatSync(p);
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ };
93
118
  try {
94
119
  for (const f of readdirSync(boardsDir)) if (f.endsWith(".tmp")) rmSync(join(boardsDir, f), { force: true });
95
120
  } catch {}
@@ -115,6 +140,72 @@ function apiMiddleware(root) {
115
140
  };
116
141
  }));
117
142
  }
143
+ if (path === "boards/rename" && req.method === "POST") {
144
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
145
+ const raw = await readBody(req);
146
+ if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(raw);
150
+ } catch {
151
+ return json(res, 400, { error: "malformed JSON" });
152
+ }
153
+ if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
154
+ const { from, to } = parsed;
155
+ if (!validName(from) || !validName(to)) return json(res, 400, { error: "invalid board name" });
156
+ if (to === "all-scenes" || from === "all-scenes" || to === from) return json(res, 400, { error: "invalid rename" });
157
+ if (!underRoot(boardsDir)) return json(res, 400, { error: "boards directory escapes the project" });
158
+ const fromPath = boardPath(from), toPath = boardPath(to);
159
+ if (!fromPath || !toPath) return json(res, 400, { error: "invalid board name" });
160
+ if (!existsSync(fromPath)) return json(res, 404, { error: `board "${from}" does not exist` });
161
+ if (!notSymlink(fromPath)) return json(res, 400, { error: "refusing to rename a symlinked board file" });
162
+ if (isConnected(root)) return json(res, 409, { error: "disconnect before renaming boards - comment sync is board-keyed" });
163
+ if (existsSync(join(root, "design", "comments", `${from}.jsonl`))) return json(res, 409, { error: "rename a board before commenting on it - this board has threads" });
164
+ if (nodeExists(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
165
+ try {
166
+ linkSync(fromPath, toPath);
167
+ } catch (err) {
168
+ const code = err.code;
169
+ if (code === "EEXIST") return json(res, 409, { error: `a board named "${to}" already exists` });
170
+ if (code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw err;
171
+ if (nodeExists(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
172
+ renameSync(fromPath, toPath);
173
+ return json(res, 200, { name: to });
174
+ }
175
+ rmSync(fromPath);
176
+ return json(res, 200, { name: to });
177
+ }
178
+ if (path === "boards/reorder" && req.method === "POST") {
179
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
180
+ const raw = await readBody(req);
181
+ if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(raw);
185
+ } catch {
186
+ return json(res, 400, { error: "malformed JSON" });
187
+ }
188
+ if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
189
+ const order = parsed.order;
190
+ if (!Array.isArray(order) || order.length < 1 || order.length > 200) return json(res, 400, { error: "invalid order" });
191
+ if (!order.every(validName) || order.some((n) => n === "all-scenes")) return json(res, 400, { error: "invalid board name in order" });
192
+ if (new Set(order).size !== order.length) return json(res, 400, { error: "duplicate board in order" });
193
+ if (!underRoot(boardsDir)) return json(res, 400, { error: "boards directory escapes the project" });
194
+ for (let i = 0; i < order.length; i++) {
195
+ const p = boardPath(order[i]);
196
+ if (!p || !existsSync(p) || !notSymlink(p)) continue;
197
+ let obj;
198
+ try {
199
+ obj = JSON.parse(readFileSync(p, "utf8"));
200
+ } catch {
201
+ continue;
202
+ }
203
+ if (!obj || typeof obj !== "object") continue;
204
+ obj.order = i;
205
+ atomicWrite(p, JSON.stringify(obj, null, 2) + "\n");
206
+ }
207
+ return json(res, 200, { ok: true });
208
+ }
118
209
  const boardMatch = /^boards\/([^/]+)$/.exec(path);
119
210
  if (boardMatch) {
120
211
  const p = boardPath(boardMatch[1]);
@@ -167,6 +258,30 @@ function apiMiddleware(root) {
167
258
  return json(res, 200, { sha256: hash(next2) });
168
259
  }
169
260
  }
261
+ if (path === "shot" && req.method === "GET") {
262
+ const { readDevInfo } = await import("./work-CLrmY-vQ.mjs");
263
+ const token = readDevInfo(root)?.token;
264
+ if (!ownerGated(req) && !(token && req.headers["x-mv-work"] === token)) return json(res, 403, { error: "forbidden" });
265
+ const frameId = url.searchParams.get("frame") ?? "";
266
+ const theme = url.searchParams.get("theme") ?? "light";
267
+ const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
268
+ const { shootFrame } = await import("./shot-BRgPaFCs.mjs");
269
+ const r = await shootFrame({
270
+ root,
271
+ viewports: opts.viewports ?? {},
272
+ frameId,
273
+ theme,
274
+ origin
275
+ });
276
+ if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error === "invalid theme" ? 400 : 503, { error: r.error });
277
+ return json(res, 200, {
278
+ path: r.path,
279
+ frame: frameId,
280
+ theme,
281
+ width: r.width,
282
+ height: r.height
283
+ });
284
+ }
170
285
  if (path === "me" && req.method === "GET") return json(res, 200, {
171
286
  user: localProfile(root),
172
287
  role: "owner",
@@ -565,7 +680,15 @@ function marverPlugin(ctx) {
565
680
  }));
566
681
  });
567
682
  });
568
- server.middlewares.use(apiMiddleware(root));
683
+ const listeningOrigin = () => {
684
+ const a = server.httpServer?.address();
685
+ if (!a || typeof a === "string") return null;
686
+ return `http://${a.family === "IPv6" ? a.address === "::" ? "[::1]" : `[${a.address}]` : a.address === "0.0.0.0" ? "127.0.0.1" : a.address}:${a.port}`;
687
+ };
688
+ server.middlewares.use(apiMiddleware(root, {
689
+ viewports: config.viewports,
690
+ origin: listeningOrigin
691
+ }));
569
692
  server.middlewares.use(routesMiddleware(server, clientDir));
570
693
  devServer = server;
571
694
  const regen = debounce(() => {
@@ -630,6 +753,71 @@ function marverPlugin(ctx) {
630
753
  });
631
754
  server.httpServer?.once("close", () => watcher.close());
632
755
  } catch {}
756
+ try {
757
+ const inbox = join(root, "design", ".local", "shots");
758
+ mkdirSync(inbox, { recursive: true });
759
+ const origin = listeningOrigin;
760
+ const inFlight = /* @__PURE__ */ new Set();
761
+ const processRequest = (file) => {
762
+ if (!file.endsWith(".request.json") || inFlight.has(file)) return;
763
+ const reqPath = join(inbox, file);
764
+ if (!existsSync(reqPath)) return;
765
+ inFlight.add(file);
766
+ setTimeout(async () => {
767
+ try {
768
+ let spec;
769
+ try {
770
+ spec = JSON.parse(readFileSync(reqPath, "utf8"));
771
+ } catch {
772
+ return;
773
+ }
774
+ const base = file.replace(/\.request\.json$/, "");
775
+ const resultPath = join(inbox, `${base}.result.json`);
776
+ const frameId = typeof spec.frame === "string" ? spec.frame : "";
777
+ const theme = typeof spec.theme === "string" ? spec.theme : "light";
778
+ const o = origin();
779
+ const write = (r) => {
780
+ try {
781
+ writeFileSync(resultPath, JSON.stringify(r, null, 2));
782
+ } catch {}
783
+ };
784
+ if (!o) {
785
+ write({
786
+ ok: false,
787
+ error: "dev server has no address yet - retry"
788
+ });
789
+ return;
790
+ }
791
+ const { shootFrame } = await import("./shot-BRgPaFCs.mjs");
792
+ write(await shootFrame({
793
+ root,
794
+ viewports: config.viewports,
795
+ frameId,
796
+ theme,
797
+ origin: o
798
+ }));
799
+ try {
800
+ rmSync(reqPath, { force: true });
801
+ } catch {}
802
+ } finally {
803
+ inFlight.delete(file);
804
+ }
805
+ }, 60);
806
+ };
807
+ const inboxWatcher = watch(inbox, { persistent: false }, (_e, file) => {
808
+ if (file) processRequest(file);
809
+ });
810
+ const sweep = setInterval(() => {
811
+ try {
812
+ for (const f of readdirSync(inbox)) processRequest(f);
813
+ } catch {}
814
+ }, 1e3);
815
+ sweep.unref?.();
816
+ server.httpServer?.once("close", () => {
817
+ inboxWatcher.close();
818
+ clearInterval(sweep);
819
+ });
820
+ } catch {}
633
821
  }
634
822
  };
635
823
  }
@@ -0,0 +1,254 @@
1
+ import { i as ROUTE } from "./cli.mjs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { tmpdir } from "node:os";
6
+ //#region src/server/shot.ts
7
+ /**
8
+ * Frame screenshots without a dependency - the system's own Chrome, driven over CDP
9
+ * (Node 22 ships a WebSocket client, so this is ~zero cost).
10
+ *
11
+ * This exists for Live Jam's verify loop: a jam agent has no shell (deliberately - the job
12
+ * packet carries untrusted text), so "look at what you built" must be a capability the dev
13
+ * server provides, not a command the agent runs. The /api/shot endpoint calls this; the
14
+ * `marver shot` CLI and a plain WebFetch both reach that endpoint.
15
+ *
16
+ * Readiness is DETERMINISTIC, not a sleep: poll until #root (or body, for html frames) has
17
+ * children, then wait for fonts. A frame that never mounts fails with the page's own
18
+ * exception text - which is exactly what the agent needs to fix it.
19
+ */
20
+ const CHROMES = [
21
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
22
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
23
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
24
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
25
+ "/usr/bin/google-chrome-stable",
26
+ "/usr/bin/google-chrome",
27
+ "/usr/bin/chromium",
28
+ "/usr/bin/chromium-browser"
29
+ ];
30
+ /** The browser binary to drive, or null. MARVER_CHROME overrides; otherwise first known install. */
31
+ function findChrome() {
32
+ const env = process.env.MARVER_CHROME;
33
+ if (env) return existsSync(env) ? env : null;
34
+ return CHROMES.find((p) => existsSync(p)) ?? null;
35
+ }
36
+ const slug = (frameId) => frameId.replace(/\//g, "--");
37
+ /** Resolve a frame from the manifest and screenshot it - the shared core behind BOTH
38
+ * transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
39
+ * name output identically. `origin` is the dev server's own base URL. */
40
+ async function shootFrame(opts) {
41
+ const { root, viewports, frameId, theme, origin } = opts;
42
+ if (!/^[a-z0-9-]+$/i.test(theme)) return {
43
+ ok: false,
44
+ error: "invalid theme"
45
+ };
46
+ let manifest = {};
47
+ try {
48
+ manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
49
+ } catch {}
50
+ const frame = (manifest.frames ?? []).find((f) => f.id === frameId);
51
+ if (!frame) return {
52
+ ok: false,
53
+ error: `unknown frame "${frameId}" - ids are in design/manifest.json`
54
+ };
55
+ const vp = viewports[frame.viewport ?? ""] ?? viewports.mobile ?? {
56
+ width: 390,
57
+ height: 844
58
+ };
59
+ const shotsDir = join(root, "design", ".local", "shots");
60
+ mkdirSync(shotsDir, { recursive: true });
61
+ const rel = `design/.local/shots/${slug(frameId)}--${theme}.png`;
62
+ const result = await capture({
63
+ url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
64
+ width: vp.width,
65
+ height: vp.height,
66
+ out: join(root, rel)
67
+ });
68
+ return result.ok ? {
69
+ ok: true,
70
+ path: rel,
71
+ width: vp.width,
72
+ height: vp.height
73
+ } : result;
74
+ }
75
+ /** One capture at a time: shots are seconds apart at most, and a Chrome per concurrent
76
+ * request would stampede the machine mid-jam. */
77
+ let chain = Promise.resolve();
78
+ function capture(req) {
79
+ const run = chain.then(() => captureNow(req), () => captureNow(req));
80
+ chain = run;
81
+ return run;
82
+ }
83
+ async function captureNow({ url, width, height, out, timeoutMs = 3e4 }) {
84
+ const bin = findChrome();
85
+ if (!bin) return {
86
+ ok: false,
87
+ error: "no Chrome/Chromium found - install one or set MARVER_CHROME to a browser binary"
88
+ };
89
+ const profile = mkdtempSync(join(tmpdir(), "mv-shot-"));
90
+ const chrome = spawn(bin, [
91
+ "--headless=new",
92
+ "--disable-gpu",
93
+ "--hide-scrollbars",
94
+ "--no-first-run",
95
+ "--no-default-browser-check",
96
+ "--disable-extensions",
97
+ `--user-data-dir=${profile}`,
98
+ "--remote-debugging-port=0",
99
+ "about:blank"
100
+ ], { stdio: [
101
+ "ignore",
102
+ "ignore",
103
+ "pipe"
104
+ ] });
105
+ let ws = null;
106
+ let watchdog;
107
+ try {
108
+ const wsUrl = await new Promise((resolve, reject) => {
109
+ let buf = "";
110
+ const to = setTimeout(() => reject(/* @__PURE__ */ new Error("the browser did not expose devtools in time")), 15e3);
111
+ chrome.stderr?.on("data", (d) => {
112
+ buf += d;
113
+ const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
114
+ if (m) {
115
+ clearTimeout(to);
116
+ resolve(m[1]);
117
+ }
118
+ });
119
+ chrome.on("error", (e) => {
120
+ clearTimeout(to);
121
+ reject(e);
122
+ });
123
+ chrome.on("exit", () => {
124
+ clearTimeout(to);
125
+ reject(/* @__PURE__ */ new Error("the browser exited before exposing devtools"));
126
+ });
127
+ });
128
+ ws = new WebSocket(wsUrl);
129
+ await new Promise((res, rej) => {
130
+ ws.onopen = () => res();
131
+ ws.onerror = () => rej(/* @__PURE__ */ new Error("devtools socket failed"));
132
+ });
133
+ let seq = 0;
134
+ const pending = /* @__PURE__ */ new Map();
135
+ let lastException = "";
136
+ const failPending = (why) => {
137
+ for (const [id, cb] of pending) {
138
+ pending.delete(id);
139
+ cb({ error: { message: why } });
140
+ }
141
+ };
142
+ ws.onclose = () => failPending("devtools socket closed");
143
+ ws.onerror = () => failPending("devtools socket error");
144
+ ws.onmessage = (e) => {
145
+ let m;
146
+ try {
147
+ m = JSON.parse(String(e.data));
148
+ } catch {
149
+ return;
150
+ }
151
+ if (m.id && pending.has(m.id)) {
152
+ pending.get(m.id)(m);
153
+ pending.delete(m.id);
154
+ }
155
+ if (m.method === "Runtime.exceptionThrown") {
156
+ const d = m.params?.exceptionDetails;
157
+ lastException = String(d?.exception?.description ?? d?.text ?? "").split("\n")[0];
158
+ }
159
+ };
160
+ const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
161
+ const id = ++seq;
162
+ pending.set(id, (m) => m.error ? rej(/* @__PURE__ */ new Error(`${method}: ${m.error.message}`)) : res(m.result));
163
+ try {
164
+ ws.send(JSON.stringify({
165
+ id,
166
+ method,
167
+ params,
168
+ sessionId
169
+ }));
170
+ } catch (e) {
171
+ pending.delete(id);
172
+ rej(e);
173
+ }
174
+ });
175
+ watchdog = setTimeout(() => {
176
+ try {
177
+ chrome.kill("SIGKILL");
178
+ } catch {}
179
+ }, timeoutMs + 15e3);
180
+ const { targetId } = await send("Target.createTarget", { url: "about:blank" });
181
+ const { sessionId } = await send("Target.attachToTarget", {
182
+ targetId,
183
+ flatten: true
184
+ });
185
+ await send("Emulation.setDeviceMetricsOverride", {
186
+ width,
187
+ height,
188
+ deviceScaleFactor: 2,
189
+ mobile: false
190
+ }, sessionId);
191
+ await send("Page.enable", {}, sessionId);
192
+ await send("Runtime.enable", {}, sessionId);
193
+ const nav = await send("Page.navigate", { url }, sessionId);
194
+ if (nav?.errorText && nav.errorText !== "net::ERR_ABORTED") return {
195
+ ok: false,
196
+ error: `could not load the frame (${nav.errorText}) - is the dev server reachable at ${new URL(url).origin}?`
197
+ };
198
+ const t0 = Date.now();
199
+ let ready = false;
200
+ while (Date.now() - t0 < timeoutMs) {
201
+ if ((await send("Runtime.evaluate", {
202
+ expression: `(() => { const el = document.getElementById('root') ?? document.body; return !!el && el.childElementCount > 0 && document.readyState !== 'loading' })()`,
203
+ returnByValue: true
204
+ }, sessionId).catch(() => null))?.result?.value) {
205
+ ready = true;
206
+ break;
207
+ }
208
+ await new Promise((r2) => setTimeout(r2, 150));
209
+ }
210
+ if (!ready) return {
211
+ ok: false,
212
+ error: `the frame never rendered${lastException ? ` - the page threw: ${lastException}` : " (no exception surfaced - is the dev server reachable from this machine?)"}`
213
+ };
214
+ await send("Runtime.evaluate", {
215
+ expression: "document.fonts.ready.then(() => true)",
216
+ awaitPromise: true,
217
+ returnByValue: true
218
+ }, sessionId).catch(() => null);
219
+ await new Promise((r2) => setTimeout(r2, 250));
220
+ const errEval = await send("Runtime.evaluate", {
221
+ expression: "window.__mvFrameError || \"\"",
222
+ returnByValue: true
223
+ }, sessionId).catch(() => null);
224
+ const frameError = typeof errEval?.result?.value === "string" ? errEval.result.value : "";
225
+ if (frameError) return {
226
+ ok: false,
227
+ error: `the frame rendered an error - ${frameError}`
228
+ };
229
+ const shot = await send("Page.captureScreenshot", { format: "png" }, sessionId);
230
+ writeFileSync(out, Buffer.from(String(shot.data), "base64"));
231
+ return { ok: true };
232
+ } catch (err) {
233
+ return {
234
+ ok: false,
235
+ error: err.message
236
+ };
237
+ } finally {
238
+ if (watchdog) clearTimeout(watchdog);
239
+ try {
240
+ ws?.close();
241
+ } catch {}
242
+ chrome.kill("SIGKILL");
243
+ chrome.once("exit", () => {
244
+ try {
245
+ rmSync(profile, {
246
+ recursive: true,
247
+ force: true
248
+ });
249
+ } catch {}
250
+ });
251
+ }
252
+ }
253
+ //#endregion
254
+ export { shootFrame };
@@ -0,0 +1,29 @@
1
+ import { n as NAME } from "./cli.mjs";
2
+ import { readDevInfo } from "./work-CLrmY-vQ.mjs";
3
+ //#region src/cli/shot.ts
4
+ /**
5
+ * `marver shot <scene/frame>` - render one frame headless and print the PNG path.
6
+ *
7
+ * The shell-ful agents' door into the same verify loop jam teaches: build, shoot, LOOK.
8
+ * Thin wrapper over the dev server's /api/shot (shot.ts has the capture story).
9
+ */
10
+ async function shotCommand(root, frame, opts) {
11
+ if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>]`);
12
+ const info = readDevInfo(root);
13
+ if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
14
+ const qs = new URLSearchParams({
15
+ frame,
16
+ ...opts.theme ? { theme: opts.theme } : {}
17
+ });
18
+ let res;
19
+ try {
20
+ res = await fetch(`http://localhost:${info.port}/__mv/api/shot?${qs}`, { headers: { "x-mv-work": info.token } });
21
+ } catch {
22
+ throw new Error(`could not reach \`${NAME} dev\` on port ${info.port} - is it still running?`);
23
+ }
24
+ const data = await res.json().catch(() => ({}));
25
+ if (!res.ok || !data.path) throw new Error(data.error ?? `shot failed (${res.status})`);
26
+ console.log(data.path);
27
+ }
28
+ //#endregion
29
+ export { shotCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -20,7 +20,14 @@ document.documentElement.classList.toggle('dark', theme === 'dark')
20
20
 
21
21
  const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
22
22
 
23
+ // A render failure posts sh:error to the shell, but a HEADLESS screenshot has no shell to
24
+ // hear it - so also stamp the error on the document, where the shot capture can read it.
25
+ // This is what lets `marver shot` / the file-drop result report "the frame crashed" to an
26
+ // agent that cannot see the PNG (only the JSON), instead of a clean-looking ok:true.
27
+ const markError = (message: string) => { (window as any).__mvFrameError = message }
28
+
23
29
  function fail(message: string) {
30
+ markError(message)
24
31
  post({ type: 'sh:error', id, message })
25
32
  document.getElementById('root')!.innerHTML =
26
33
  `<div style="font-family:ui-monospace,monospace;font-size:12px;padding:16px;color:#b42318">
@@ -35,7 +42,7 @@ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt
35
42
  class Boundary extends Component<{ children: ReactNode }, { err: Error | null }> {
36
43
  state = { err: null as Error | null }
37
44
  static getDerivedStateFromError(err: Error) { return { err } }
38
- componentDidCatch(err: Error) { post({ type: 'sh:error', id, message: err.message }) }
45
+ componentDidCatch(err: Error) { markError(err.message); post({ type: 'sh:error', id, message: err.message }) }
39
46
  render() {
40
47
  if (!this.state.err) return this.props.children
41
48
  return createElement('div', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12, padding: 16, color: '#b42318' } },