@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.
- package/CHANGELOG.md +72 -0
- package/README.md +2 -2
- package/dist/{build-D9gimz6K.mjs → build-Dd91jjIg.mjs} +2 -2
- package/dist/cli.mjs +12 -3
- package/dist/{daemon-2Lft_lVV.mjs → daemon-B_8BmmSJ.mjs} +486 -15
- package/dist/{dev-CTtkqVo_.mjs → dev-Dv0GlYQq.mjs} +4 -4
- package/dist/{init-8Giknvy1.mjs → init-D2EQEbV6.mjs} +4 -3
- package/dist/{manifest-DJHU7qfu.mjs → manifest-DIsp3ldB.mjs} +44 -12
- package/dist/{plugin-BrBWk2Qn.mjs → plugin-CgLVYTR-.mjs} +100 -3
- package/dist/shot-BRgPaFCs.mjs +254 -0
- package/dist/shot-DkkwuCZ2.mjs +29 -0
- package/package.json +1 -1
- package/src/client/frame-host/main.tsx +8 -1
- package/src/client/shell/Comments.tsx +1 -1
- package/templates/instructions/jam.md +88 -6
|
@@ -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
|
|
142
|
-
const AGENTS = [
|
|
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 !== "
|
|
282
|
-
console.warn(`[marver] design/config.ts: jam.agent ${show(named)} is not an agent marver can spawn - Live Jam is off. Use
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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: '
|
|
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, ' ')
|