@marver-design/marver 0.9.0 → 0.10.0

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,6 +1,6 @@
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";
3
+ import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DIsp3ldB.mjs";
4
4
  import { copyFileSync, existsSync, 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";
@@ -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;
@@ -167,6 +167,30 @@ function apiMiddleware(root) {
167
167
  return json(res, 200, { sha256: hash(next2) });
168
168
  }
169
169
  }
170
+ if (path === "shot" && req.method === "GET") {
171
+ const { readDevInfo } = await import("./work-CLrmY-vQ.mjs");
172
+ const token = readDevInfo(root)?.token;
173
+ if (!ownerGated(req) && !(token && req.headers["x-mv-work"] === token)) return json(res, 403, { error: "forbidden" });
174
+ const frameId = url.searchParams.get("frame") ?? "";
175
+ const theme = url.searchParams.get("theme") ?? "light";
176
+ const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
177
+ const { shootFrame } = await import("./shot-BRgPaFCs.mjs");
178
+ const r = await shootFrame({
179
+ root,
180
+ viewports: opts.viewports ?? {},
181
+ frameId,
182
+ theme,
183
+ origin
184
+ });
185
+ if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error === "invalid theme" ? 400 : 503, { error: r.error });
186
+ return json(res, 200, {
187
+ path: r.path,
188
+ frame: frameId,
189
+ theme,
190
+ width: r.width,
191
+ height: r.height
192
+ });
193
+ }
170
194
  if (path === "me" && req.method === "GET") return json(res, 200, {
171
195
  user: localProfile(root),
172
196
  role: "owner",
@@ -565,7 +589,15 @@ function marverPlugin(ctx) {
565
589
  }));
566
590
  });
567
591
  });
568
- server.middlewares.use(apiMiddleware(root));
592
+ const listeningOrigin = () => {
593
+ const a = server.httpServer?.address();
594
+ if (!a || typeof a === "string") return null;
595
+ return `http://${a.family === "IPv6" ? a.address === "::" ? "[::1]" : `[${a.address}]` : a.address === "0.0.0.0" ? "127.0.0.1" : a.address}:${a.port}`;
596
+ };
597
+ server.middlewares.use(apiMiddleware(root, {
598
+ viewports: config.viewports,
599
+ origin: listeningOrigin
600
+ }));
569
601
  server.middlewares.use(routesMiddleware(server, clientDir));
570
602
  devServer = server;
571
603
  const regen = debounce(() => {
@@ -630,6 +662,71 @@ function marverPlugin(ctx) {
630
662
  });
631
663
  server.httpServer?.once("close", () => watcher.close());
632
664
  } catch {}
665
+ try {
666
+ const inbox = join(root, "design", ".local", "shots");
667
+ mkdirSync(inbox, { recursive: true });
668
+ const origin = listeningOrigin;
669
+ const inFlight = /* @__PURE__ */ new Set();
670
+ const processRequest = (file) => {
671
+ if (!file.endsWith(".request.json") || inFlight.has(file)) return;
672
+ const reqPath = join(inbox, file);
673
+ if (!existsSync(reqPath)) return;
674
+ inFlight.add(file);
675
+ setTimeout(async () => {
676
+ try {
677
+ let spec;
678
+ try {
679
+ spec = JSON.parse(readFileSync(reqPath, "utf8"));
680
+ } catch {
681
+ return;
682
+ }
683
+ const base = file.replace(/\.request\.json$/, "");
684
+ const resultPath = join(inbox, `${base}.result.json`);
685
+ const frameId = typeof spec.frame === "string" ? spec.frame : "";
686
+ const theme = typeof spec.theme === "string" ? spec.theme : "light";
687
+ const o = origin();
688
+ const write = (r) => {
689
+ try {
690
+ writeFileSync(resultPath, JSON.stringify(r, null, 2));
691
+ } catch {}
692
+ };
693
+ if (!o) {
694
+ write({
695
+ ok: false,
696
+ error: "dev server has no address yet - retry"
697
+ });
698
+ return;
699
+ }
700
+ const { shootFrame } = await import("./shot-BRgPaFCs.mjs");
701
+ write(await shootFrame({
702
+ root,
703
+ viewports: config.viewports,
704
+ frameId,
705
+ theme,
706
+ origin: o
707
+ }));
708
+ try {
709
+ rmSync(reqPath, { force: true });
710
+ } catch {}
711
+ } finally {
712
+ inFlight.delete(file);
713
+ }
714
+ }, 60);
715
+ };
716
+ const inboxWatcher = watch(inbox, { persistent: false }, (_e, file) => {
717
+ if (file) processRequest(file);
718
+ });
719
+ const sweep = setInterval(() => {
720
+ try {
721
+ for (const f of readdirSync(inbox)) processRequest(f);
722
+ } catch {}
723
+ }, 1e3);
724
+ sweep.unref?.();
725
+ server.httpServer?.once("close", () => {
726
+ inboxWatcher.close();
727
+ clearInterval(sweep);
728
+ });
729
+ } catch {}
633
730
  }
634
731
  };
635
732
  }
@@ -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.0",
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' } },
@@ -271,7 +271,7 @@ function CommentBody({ body, owner }: { body?: string; owner: boolean }) {
271
271
  )
272
272
  }
273
273
 
274
- const HARNESS: Record<string, string> = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', droid: 'Factory Droid' }
274
+ const HARNESS: Record<string, string> = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', opencode: 'opencode', droid: 'Factory Droid', grok: 'Grok', pi: 'pi' }
275
275
  /** claude-opus-5 → Opus 5; gpt-5.1-codex → Gpt 5.1 Codex; unknown → as-is. */
276
276
  function prettyModel(m: string): string {
277
277
  const cleaned = m.replace(/^claude-/, '').replace(/-/g, ' ')