@marver-design/marver 0.13.0 → 0.15.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +44 -20
  3. package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
  4. package/dist/cli.mjs +21 -7
  5. package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
  6. package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
  7. package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
  8. package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
  9. package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
  10. package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
  11. package/dist/poster-CbpzSzJu.mjs +143 -0
  12. package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
  13. package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
  14. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  15. package/docs/live-jam.md +177 -0
  16. package/docs/publish.md +270 -0
  17. package/docs/sharing.md +333 -0
  18. package/docs/slides.md +140 -0
  19. package/package.json +3 -1
  20. package/src/client/const.ts +13 -0
  21. package/src/client/content/chart-engine.ts +33 -0
  22. package/src/client/content/chart.tsx +138 -0
  23. package/src/client/content/index.tsx +30 -6
  24. package/src/client/content/slide.tsx +238 -0
  25. package/src/client/content/video.tsx +223 -0
  26. package/src/client/frame-host/bridge.js +6 -1
  27. package/src/client/shell/App.tsx +59 -13
  28. package/src/client/shell/LockedApp.tsx +7 -2
  29. package/src/client/shell/Play.tsx +138 -24
  30. package/src/client/shell/Toolbar.tsx +12 -3
  31. package/src/client/shell/canvas/FrameNode.tsx +5 -3
  32. package/src/client/shell/hash.ts +3 -1
  33. package/src/client/shell/icons.tsx +3 -0
  34. package/src/client/shell/play-order.ts +22 -0
  35. package/src/client/shell/store.ts +80 -9
  36. package/src/client/shell/styles.css +23 -27
  37. package/src/client/stage/main.tsx +54 -3
  38. package/src/shared/utm.ts +3 -2
  39. package/templates/AGENTS-embedded.md +20 -4
  40. package/templates/AGENTS-studio.md +20 -4
  41. package/templates/instructions/boards.md +47 -5
  42. package/templates/instructions/craft.md +17 -0
  43. package/templates/instructions/iterate.md +109 -14
  44. package/templates/instructions/jam.md +18 -2
  45. package/templates/instructions/publish.md +7 -0
  46. package/templates/instructions/reference/deck-layouts.md +230 -0
  47. package/templates/instructions/reference/deck-story.md +110 -0
  48. package/templates/instructions/shape.md +15 -1
  49. package/templates/instructions/slides.md +402 -0
@@ -1,6 +1,7 @@
1
- import { i as ROUTE } from "./cli.mjs";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { a as slideSize, i as ROUTE } from "./cli.mjs";
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { tmpdir } from "node:os";
5
6
  import { spawn } from "node:child_process";
6
7
  //#region src/server/shot.ts
@@ -35,15 +36,23 @@ function findChrome() {
35
36
  }
36
37
  const slug = (frameId) => frameId.replace(/\//g, "--");
37
38
  const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
38
- function planShot(frame, viewports) {
39
- const cw = typeof frame.contentWidth === "number" && Number.isFinite(frame.contentWidth) && frame.contentWidth > 0 ? frame.contentWidth : void 0;
39
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
40
+ function planShot(frame, viewports, override = {}) {
41
+ const cw = num(frame.contentWidth);
40
42
  const vpObj = frame.viewport ? viewports[frame.viewport] : void 0;
43
+ const ow = num(override.w), oh = num(override.h);
44
+ const sl = slideSize(frame);
45
+ if (sl) return {
46
+ width: sl.width,
47
+ initialHeight: sl.height,
48
+ fullHeight: false
49
+ };
41
50
  const fallback = viewports.mobile ?? {
42
51
  width: 390,
43
52
  height: 844
44
53
  };
45
54
  if (cw) {
46
- const width = clamp(vpObj?.width ?? cw, 320, 1600);
55
+ const width = clamp(ow ?? vpObj?.width ?? cw, 320, 1600);
47
56
  return {
48
57
  width,
49
58
  initialHeight: Math.round(width * .75),
@@ -51,6 +60,11 @@ function planShot(frame, viewports) {
51
60
  };
52
61
  }
53
62
  const vp = vpObj ?? fallback;
63
+ if (ow || oh) return {
64
+ width: clamp(ow ?? vp.width, 120, 3840),
65
+ initialHeight: clamp(oh ?? vp.height, 80, 2160),
66
+ fullHeight: false
67
+ };
54
68
  return {
55
69
  width: vp.width,
56
70
  initialHeight: vp.height,
@@ -61,11 +75,15 @@ function planShot(frame, viewports) {
61
75
  * transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
62
76
  * name output identically. `origin` is the dev server's own base URL. */
63
77
  async function shootFrame(opts) {
64
- const { root, viewports, frameId, theme, origin } = opts;
78
+ const { root, viewports, frameId, theme, origin, scale = 2, size } = opts;
65
79
  if (!/^[a-z0-9-]+$/i.test(theme)) return {
66
80
  ok: false,
67
81
  error: "invalid theme"
68
82
  };
83
+ if (!Number.isInteger(scale) || scale < 1 || scale > 4) return {
84
+ ok: false,
85
+ error: "invalid scale"
86
+ };
69
87
  let manifest = {};
70
88
  try {
71
89
  manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
@@ -75,38 +93,99 @@ async function shootFrame(opts) {
75
93
  ok: false,
76
94
  error: `unknown frame "${frameId}" - ids are in design/manifest.json`
77
95
  };
78
- const plan = planShot(frame, viewports);
96
+ const plan = planShot(frame, viewports, size);
97
+ if (frame.kind !== "html") try {
98
+ const { scanAssetRefs } = await import("./build-DfuTQZlY.mjs");
99
+ const { ensurePoster } = await import("./poster-CbpzSzJu.mjs");
100
+ const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
101
+ for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
102
+ } catch {}
79
103
  const shotsDir = join(root, "design", ".local", "shots");
80
104
  mkdirSync(shotsDir, { recursive: true });
81
- const rel = `design/.local/shots/${slug(frameId)}--${theme}.png`;
105
+ sweepTemps(shotsDir);
106
+ const explicit = opts.scale !== void 0;
107
+ const relFor = (used) => {
108
+ const tag = !explicit || scale === 2 && used === 2 ? "" : used === scale ? `@${scale}x` : `@${scale}x-as-${used}x`;
109
+ return `design/.local/shots/${slug(frameId)}--${theme}${tag}.png`;
110
+ };
111
+ const tmp = join(shotsDir, `.${slug(frameId)}--${theme}.${randomBytes(6).toString("hex")}.tmp.png`);
82
112
  const result = await capture({
83
113
  url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
84
114
  width: plan.width,
85
115
  height: plan.initialHeight,
86
- out: join(root, rel),
87
- fullHeight: plan.fullHeight
116
+ out: tmp,
117
+ fullHeight: plan.fullHeight,
118
+ scale
88
119
  });
89
- return result.ok ? {
120
+ if (!result.ok) {
121
+ rmSync(tmp, { force: true });
122
+ return result;
123
+ }
124
+ const rel = relFor(result.scale);
125
+ try {
126
+ try {
127
+ renameSync(tmp, join(root, rel));
128
+ } catch (err) {
129
+ const code = err.code;
130
+ if (code !== "EEXIST" && code !== "EPERM") throw err;
131
+ copyFileSync(tmp, join(root, rel));
132
+ }
133
+ } catch (err) {
134
+ return {
135
+ ok: false,
136
+ error: `could not write the shot - ${err.message}`
137
+ };
138
+ } finally {
139
+ rmSync(tmp, { force: true });
140
+ }
141
+ return {
90
142
  ok: true,
91
143
  path: rel,
92
144
  width: result.width,
93
145
  height: result.height,
94
146
  scale: result.scale,
95
- ...result.truncated ? {
96
- truncated: true,
97
- note: result.note
98
- } : {}
99
- } : result;
147
+ ...result.truncated ? { truncated: true } : {},
148
+ ...result.note ? { note: result.note } : {}
149
+ };
150
+ }
151
+ /** A crashed process can leave a `.tmp.png` behind; sweep them (older than a minute, so an in-
152
+ * flight capture in this process is never touched) at the next shot. */
153
+ const sweptAt = /* @__PURE__ */ new Map();
154
+ function sweepTemps(dir) {
155
+ if (Date.now() - (sweptAt.get(dir) ?? 0) < 6e4) return;
156
+ sweptAt.set(dir, Date.now());
157
+ try {
158
+ for (const f of readdirSync(dir)) {
159
+ if (!f.endsWith(".tmp.png")) continue;
160
+ const p = join(dir, f);
161
+ try {
162
+ if (Date.now() - statSync(p).mtimeMs > 6e4) rmSync(p, { force: true });
163
+ } catch {}
164
+ }
165
+ } catch {}
100
166
  }
101
- /** One capture at a time: shots are seconds apart at most, and a Chrome per concurrent
102
- * request would stampede the machine mid-jam. */
103
- let chain = Promise.resolve();
104
- function capture(req) {
105
- const run = chain.then(() => captureNow(req), () => captureNow(req));
106
- chain = run;
167
+ /** One capture at a time PER LANE: shots are seconds apart at most, and a Chrome per
168
+ * concurrent request would stampede the machine mid-jam. Posters have their own lane - a
169
+ * frame being shot may ask for its poster mid-render, and on the shot's lane that request
170
+ * would wait for the very shot that is waiting for it. */
171
+ const chains = /* @__PURE__ */ new Map();
172
+ function capture(req, lane = "shot") {
173
+ const run = (chains.get(lane) ?? Promise.resolve()).then(() => captureNow(req), () => captureNow(req));
174
+ chains.set(lane, run);
107
175
  return run;
108
176
  }
109
- async function captureNow({ url, width, height, out, fullHeight = false, timeoutMs = 3e4 }) {
177
+ /** The capture budget. Chrome's surface tops out near 16384 device px per SIDE, and a bitmap is
178
+ * also bounded by its AREA: 64M device px (~256 MiB RGBA) is comfortably rendered, encoded and
179
+ * pasted, where a 3840×2160@4 (133M px) can stall the renderer or the paste target. Both limits
180
+ * are exported so the tests hold the same numbers. */
181
+ const SURFACE = 16384;
182
+ const AREA = 64e6;
183
+ const fits = (w, h, dsf) => w * dsf <= 16384 && h * dsf <= 16384 && w * h * dsf * dsf <= 64e6;
184
+ /** The tallest CSS height a full-height capture of `width` can hold at `dsf`. */
185
+ const capFor = (width, dsf) => Math.max(1, Math.min(Math.floor(SURFACE / dsf), Math.floor(AREA / (width * dsf * dsf))));
186
+ async function captureNow({ url, width, height, out, fullHeight = false, timeoutMs = 3e4, scale: wantScale = 2, clip }) {
187
+ let scale = Math.min(4, Math.max(1, Math.round(wantScale)));
188
+ while (scale > 1 && !fits(width, height, scale)) scale--;
110
189
  const bin = findChrome();
111
190
  if (!bin) return {
112
191
  ok: false,
@@ -199,11 +278,14 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
199
278
  }
200
279
  });
201
280
  const sendD = (method, params, ms = 1500) => Promise.race([send(method, params, sessionId), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error(`${method} timed out`)), ms))]);
281
+ const hardBy = Date.now() + timeoutMs + 15e3;
202
282
  watchdog = setTimeout(() => {
203
283
  try {
204
284
  chrome.kill("SIGKILL");
205
285
  } catch {}
206
286
  }, timeoutMs + 15e3);
287
+ const RESERVE = 21e3;
288
+ const discretionary = () => hardBy - RESERVE - Date.now();
207
289
  const { targetId } = await send("Target.createTarget", { url: "about:blank" });
208
290
  const { sessionId } = await send("Target.attachToTarget", {
209
291
  targetId,
@@ -212,7 +294,7 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
212
294
  await send("Emulation.setDeviceMetricsOverride", {
213
295
  width,
214
296
  height,
215
- deviceScaleFactor: 2,
297
+ deviceScaleFactor: scale,
216
298
  mobile: false
217
299
  }, sessionId);
218
300
  await send("Page.enable", {}, sessionId);
@@ -238,12 +320,55 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
238
320
  ok: false,
239
321
  error: `the frame never rendered${lastException ? ` - the page threw: ${lastException}` : " (no exception surfaced - is the dev server reachable from this machine?)"}`
240
322
  };
241
- await send("Runtime.evaluate", {
242
- expression: "document.fonts.ready.then(() => true)",
243
- awaitPromise: true,
244
- returnByValue: true
245
- }, sessionId).catch(() => null);
246
- await new Promise((r2) => setTimeout(r2, 250));
323
+ const SETTLED = `(() => {
324
+ if (typeof window.__mvLodBusy === 'function' && window.__mvLodBusy() > 0) return false
325
+ if (typeof window.__mvPosterBusy === 'function' && window.__mvPosterBusy() > 0) return false
326
+ const H = window.innerHeight, W = window.innerWidth
327
+ for (const im of document.images) {
328
+ if (im.complete) continue
329
+ const r = im.getBoundingClientRect()
330
+ if (r.bottom < 0 || r.top > H || r.right < 0 || r.left > W) continue
331
+ return false
332
+ }
333
+ for (const c of document.querySelectorAll('.mv-chart')) if (!c.querySelector('svg, canvas')) return false
334
+ for (const d of document.querySelectorAll('.mv-diagram')) if (!d.querySelector('.mv-diagram-svg svg, .mv-diagram-err')) return false
335
+ return true
336
+ })()`;
337
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
338
+ const settle = async (budgetMs) => {
339
+ const total = Math.min(budgetMs, discretionary());
340
+ if (total <= 0) return false;
341
+ const by = Date.now() + total;
342
+ const left = () => Math.max(1, by - Date.now());
343
+ await sendD("Runtime.evaluate", {
344
+ expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
345
+ returnByValue: true
346
+ }, left()).catch(() => null);
347
+ await sendD("Runtime.evaluate", {
348
+ expression: "document.fonts.ready.then(() => true)",
349
+ awaitPromise: true,
350
+ returnByValue: true
351
+ }, left()).catch(() => null);
352
+ let ok = false;
353
+ while (Date.now() < by) {
354
+ if ((await sendD("Runtime.evaluate", {
355
+ expression: SETTLED,
356
+ returnByValue: true
357
+ }, left()).catch(() => null))?.result?.value === true) {
358
+ ok = true;
359
+ break;
360
+ }
361
+ await wait(Math.min(100, left()));
362
+ }
363
+ if (Date.now() < by) await sendD("Runtime.evaluate", {
364
+ expression: "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))",
365
+ awaitPromise: true,
366
+ returnByValue: true
367
+ }, Math.min(1e3, left())).catch(() => null);
368
+ if (Date.now() < by) await wait(Math.min(ok ? 150 : 250, left()));
369
+ return ok;
370
+ };
371
+ await settle(3e3);
247
372
  const errEval = await send("Runtime.evaluate", {
248
373
  expression: "window.__mvFrameError || \"\"",
249
374
  returnByValue: true
@@ -253,9 +378,8 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
253
378
  ok: false,
254
379
  error: `the frame rendered an error - ${frameError}`
255
380
  };
256
- let capW = width, capH = height, scale = 2, truncated = false, note = "";
381
+ let capW = width, capH = height, truncated = false, note = "";
257
382
  if (fullHeight) {
258
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
259
383
  const measureH = async () => {
260
384
  const v = (await sendD("Runtime.evaluate", {
261
385
  expression: `Math.ceil((document.querySelector('.mv-doc')||document.getElementById('root')||document.body).getBoundingClientRect().height)`,
@@ -300,46 +424,71 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
300
424
  h = Math.max(m, capped);
301
425
  }
302
426
  };
303
- const CAP2 = 8192, CAP1 = 16384;
304
- const settleDeadline = Date.now() + 6e3;
305
- await growFit(2, CAP2, settleDeadline);
306
- if (measured > CAP2) {
427
+ const settleDeadline = Date.now() + Math.max(0, Math.min(6e3, discretionary()));
428
+ const asked = scale;
429
+ await growFit(scale, capFor(width, scale), settleDeadline);
430
+ if (measured > capFor(width, scale) && scale > 2) {
431
+ scale = 2;
432
+ await growFit(2, capFor(width, 2), settleDeadline);
433
+ }
434
+ if (measured > capFor(width, scale) && scale > 1) {
307
435
  scale = 1;
308
- await growFit(1, CAP1, settleDeadline);
436
+ await growFit(1, capFor(width, 1), settleDeadline);
309
437
  }
310
- const cap = scale === 2 ? CAP2 : CAP1;
438
+ const cap = capFor(width, scale);
311
439
  capW = width;
312
440
  if (measured > cap) {
313
441
  capH = cap;
314
442
  truncated = true;
315
443
  note = `frame is ${measured}px tall; captured the top ${cap}px - split it or reduce its height`;
316
444
  } else capH = clamp(measured || height, 80, cap);
445
+ if (scale < asked && !truncated) note = `frame is ${measured}px tall - too tall for ${asked}x, captured at ${scale}x`;
317
446
  await sendD("Emulation.setDeviceMetricsOverride", {
318
447
  width: capW,
319
448
  height: capH,
320
449
  deviceScaleFactor: scale,
321
450
  mobile: false
322
451
  }, 5e3);
323
- if (Date.now() < settleDeadline) {
324
- await sendD("Runtime.evaluate", {
325
- expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
326
- returnByValue: true
327
- }).catch(() => null);
328
- await wait(300);
329
- for (let j = 0; j < 20 && Date.now() < settleDeadline && !await lodIdle(); j++) await wait(100);
330
- await sendD("Runtime.evaluate", {
331
- expression: "document.fonts.ready.then(()=>true)",
332
- awaitPromise: true,
333
- returnByValue: true
334
- }).catch(() => null);
452
+ await wait(Math.min(300, Math.max(0, discretionary())));
453
+ await settle(Math.max(1200, settleDeadline - Date.now()));
454
+ const m2 = await measureH();
455
+ if (m2 > capH) {
456
+ if (m2 <= cap) capH = m2;
457
+ else {
458
+ capH = cap;
459
+ truncated = true;
460
+ note = `frame is ${m2}px tall; captured the top ${cap}px - split it or reduce its height`;
461
+ }
462
+ await sendD("Emulation.setDeviceMetricsOverride", {
463
+ width: capW,
464
+ height: capH,
465
+ deviceScaleFactor: scale,
466
+ mobile: false
467
+ }, 5e3);
468
+ await settle(600);
335
469
  }
336
470
  }
471
+ let capX = 0, capY = 0;
472
+ if (clip) {
473
+ const box = (await sendD("Runtime.evaluate", {
474
+ expression: `(() => { const el = document.querySelector(${JSON.stringify(clip)}); if (!el) return null; const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height } })()`,
475
+ returnByValue: true
476
+ }, 2e3).catch(() => null))?.result?.value;
477
+ if (!box || box.w < 1 || box.h < 1) return {
478
+ ok: false,
479
+ error: `nothing to capture at "${clip}"`
480
+ };
481
+ capX = Math.max(0, Math.floor(box.x));
482
+ capY = Math.max(0, Math.floor(box.y));
483
+ capW = Math.max(1, Math.round(box.w));
484
+ capH = Math.max(1, Math.round(box.h));
485
+ }
337
486
  const shot = await sendD("Page.captureScreenshot", {
338
487
  format: "png",
339
488
  captureBeyondViewport: true,
340
489
  clip: {
341
- x: 0,
342
- y: 0,
490
+ x: capX,
491
+ y: capY,
343
492
  width: capW,
344
493
  height: capH,
345
494
  scale: 1
@@ -351,10 +500,8 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
351
500
  width: capW,
352
501
  height: capH,
353
502
  scale,
354
- ...truncated ? {
355
- truncated: true,
356
- note
357
- } : {}
503
+ ...truncated ? { truncated: true } : {},
504
+ ...note ? { note } : {}
358
505
  };
359
506
  } catch (err) {
360
507
  return {
@@ -378,4 +525,4 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
378
525
  }
379
526
  }
380
527
  //#endregion
381
- export { shootFrame };
528
+ export { capture, findChrome, shootFrame };
@@ -8,12 +8,13 @@ import { readDevInfo } from "./work-CLrmY-vQ.mjs";
8
8
  * Thin wrapper over the dev server's /api/shot (shot.ts has the capture story).
9
9
  */
10
10
  async function shotCommand(root, frame, opts) {
11
- if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>]`);
11
+ if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>] [--scale 1-4]`);
12
12
  const info = readDevInfo(root);
13
13
  if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
14
14
  const qs = new URLSearchParams({
15
15
  frame,
16
- ...opts.theme ? { theme: opts.theme } : {}
16
+ ...opts.theme ? { theme: opts.theme } : {},
17
+ ...opts.scale != null ? { scale: String(opts.scale) } : {}
17
18
  });
18
19
  let res;
19
20
  try {
@@ -0,0 +1,177 @@
1
+ # Live Jam
2
+
3
+ Tag `@marver` in a canvas comment while `npx marver dev` is running. The dev server spawns
4
+ your own coding-agent CLI headless with that one job, the frame lights up with a working
5
+ glow, the agent edits the real frame source, and its reply lands back in the same thread.
6
+ No round trip to the terminal. Marver ships no AI: the agent that acts is the one you
7
+ already run and pay for.
8
+
9
+ ## It arms itself
10
+
11
+ Live Jam is on by default (since 0.9.0). Marver speaks seven agent CLIs - `claude`,
12
+ `codex`, `cursor`, `droid` (Factory), `opencode`, `grok`, and `pi` - which also covers the
13
+ apps built on them: Factory drives `droid`, Cursor drives `cursor-agent`, Conductor drives
14
+ `claude`. Marver looks for one in this order:
15
+
16
+ 1. **The tool running the process wins.** Most CLIs export env markers into what they spawn
17
+ (`CLAUDECODE` for Claude Code, `CODEX_SANDBOX` for Codex, `CURSOR_AGENT` for Cursor,
18
+ `OPENCODE` for opencode, `PI_CODING_AGENT` for pi), and `marver init` is usually run by
19
+ the agent itself. That is evidence, not a guess. droid and grok set no marker in the
20
+ shells they spawn, so this step cannot see them - name them in config or let PATH decide.
21
+ 2. **Otherwise, whatever is on PATH**, in the order above - `claude` first.
22
+
23
+ That second step is a guess, so the answer is made visible rather than clever: `init` prints
24
+ the agent it chose and writes it into `design/config.ts` in plain sight, and `marver dev`
25
+ names it at boot (`jam: on (claude)`). One word to correct, once per repo.
26
+
27
+ The candidate has to be executable on `PATH` under its bare name, because the daemon spawns
28
+ it without a shell. A shell alias or function is invisible to it. Cursor is the one agent
29
+ whose binary differs from its config name: marver spawns `cursor-agent`, never the bare
30
+ `agent` - both Cursor and grok install an `agent` name, so the short one is a coin flip.
31
+
32
+ With no agent CLI installed, jam stays off and both `init` and `marver dev` say so instead
33
+ of going quiet. Workspaces created before 0.9.0 need no re-init; they resolve the same way
34
+ at every dev boot.
35
+
36
+ ## The config block
37
+
38
+ ```ts
39
+ // design/config.ts
40
+ jam: { agent: "claude", concurrency: 6 },
41
+ ```
42
+
43
+ | Key | Default | What it does |
44
+ |---|---|---|
45
+ | `agent` | detected | The CLI the daemon spawns: `"claude"`, `"codex"`, `"cursor"`, `"droid"`, `"opencode"`, `"grok"`, or `"pi"` |
46
+ | `concurrency` | `6` | Frames worked on at once (1-16). The same frame never gets two agents |
47
+ | `subagents` | `true` | Inside one job, fan out one subagent per frame |
48
+
49
+ Shorthands: `jam: "codex"` names the agent and takes the rest of the defaults, `jam: true`
50
+ is the default block, and **`jam: false` is the off switch**.
51
+
52
+ A named agent is never quietly swapped for another. If `jam.agent` names something marver
53
+ cannot spawn, or names a CLI that is not on PATH, Live Jam turns off with a printed reason
54
+ rather than answering your comments with a tool you did not choose. A `design/config.ts`
55
+ that fails to parse also leaves jam off, since it may have been the file that said
56
+ `jam: false`.
57
+
58
+ ## What the agent may do
59
+
60
+ Be clear-eyed about what this is. The real protection is not a sandbox - it is that the
61
+ agent doing the work is **your own**, running on **your machine**, and **every change it
62
+ makes is a diff you review** before anything is resolved. On top of that, marver removes the
63
+ one tool that would turn a prompt-injected comment into silent damage: an unrestricted
64
+ **shell**. Each CLI is spawned so the model can read and edit files but cannot open a shell
65
+ (or, for Codex and Cursor, only a shell the OS sandbox contains and cuts off from the
66
+ network):
67
+
68
+ | | How it is spawned |
69
+ |---|---|
70
+ | **Claude Code** | `claude -p --permission-mode acceptEdits` with an allowlist of Read, Edit, Write, Glob, Grep, WebSearch, WebFetch - and `--disallowedTools Bash`, so there is no shell |
71
+ | **Codex** | `codex exec -s workspace-write`, its own OS sandbox, which bounds what commands touch and blocks network egress, but still lets the model run shell commands |
72
+ | **Cursor** | `cursor-agent -p --sandbox enabled --trust` - cursor's print mode carries a shell, so like Codex it runs inside the OS sandbox (verified: network egress is blocked); `--trust` only answers the workspace-trust prompt for the repo you already run `marver dev` in, and `--force` is never passed |
73
+ | **droid** | `droid exec --auto low` for file edits, with `--disabled-tools` removing the shell (`Execute`), the delegation tools (`Task`, missions), and the Slack/connector tools |
74
+ | **opencode** | `opencode run --pure` (no external plugins) with a per-run DEFAULT-DENY `OPENCODE_PERMISSION` grant - read/edit/search/web/task allowed by name, everything else (bash included) denied - never its all-approving `--auto` flag |
75
+ | **grok** | `grok -p --tools read_file,search_replace,list_dir,grep,todo_write` - an ALLOWLIST of read/edit tools only, so the shell, web, and subagents are simply absent (a deny-list is a footgun: it can miss a tool's real name); `--permission-mode acceptEdits` auto-applies the edits |
76
+ | **pi** | `pi -p --tools read,edit,write,grep,find,ls --no-extensions --no-skills` - pi has no runtime permission system, so the tool allowlist IS the jail, and bash is not on it |
77
+
78
+ The honest limits: file tools that take a path (Read/Edit/Write, and their equivalents) are
79
+ not themselves jailed to `design/` - on the CLIs without an OS sandbox they can, if a
80
+ comment talks the model into it, touch a file elsewhere in the repo or the machine. And Web
81
+ access, where a CLI keeps it, can carry data outward. This is the same boundary Claude Code
82
+ has always run under, and it is why the two rules above still do the real work: it is your
83
+ agent, and you review the diff. Do not point Live Jam at a repo, or run it on a machine, you
84
+ would not hand that same agent directly.
85
+
86
+ Web access stays on where the CLI offers it (Claude Code, Codex, opencode): reference sites
87
+ and real brand SVGs are how a frame stops looking like a placeholder. The agent never
88
+ resolves a thread; you do that after reviewing.
89
+
90
+ The missing sense that no-shell used to cost - "does my frame actually RENDER?" - is a
91
+ server capability instead, rendered in the machine's own headless Chrome (no bundled
92
+ browser, CDP over Node's built-in WebSocket) and written as a PNG under
93
+ `design/.local/shots/`. Two transports reach it, because the no-shell jail rules out the
94
+ obvious one:
95
+
96
+ - **The file-drop inbox** (works for every agent, including Claude Code, which has no shell
97
+ and whose WebFetch refuses localhost). The agent writes
98
+ `design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}`; the dev
99
+ server renders and writes `<slug>.result.json` with the PNG path or an error, which the
100
+ agent Reads.
101
+ - **`npx marver shot <frame> [--scale 1-4]`** / `GET /api/shot?frame=<id>&theme=<t>&scale=<n>`
102
+ for humans and shell-ful agents - the same renderer, one line. Default 2x; `--scale 4` for a
103
+ print-quality still (a slide comes back 5120×2880). A frame too tall for the asked scale steps
104
+ down and says so in `note`; the file name carries the scale actually used (`…@4x.png`).
105
+ The canvas's **copy as image** (`i` / `⇧i`, the images-square toolbar button) is this same
106
+ renderer with `format=png`, so what a designer pastes and what an agent shoots is one picture.
107
+
108
+ The generated jam instructions tell every agent to shoot and LOOK before replying "done".
109
+ A frame that never mounts, or a dev server that isn't reachable, comes back as an honest
110
+ `{"ok":false,"error":...}` carrying the real cause - never a blank that reads as success.
111
+
112
+ One prerequisite marver cannot arrange: **the CLI has to be logged in** (`droid` and
113
+ `cursor-agent login` and `grok login` each have their own flow; opencode and pi can also
114
+ read provider API keys from the environment). An unauthenticated CLI fails the job; the
115
+ daemon retries once, then replies that it could not finish - the dev log and
116
+ `design/.local/jam-logs/` say why.
117
+
118
+ ## The trust boundary
119
+
120
+ Only comments written on the owner's machine can start work. A mention becomes eligible
121
+ solely by way of the local dev server's owner-gated endpoint (a CSRF double-submit cookie
122
+ plus an Origin allowlist), which appends it to `design/.local/jam-ledger`. The daemon runs
123
+ a job only for an event id that is already in that ledger, so a drive-by comment on a
124
+ published canvas cannot trigger one, and neither can a collaborator comment that arrived
125
+ through `marver comments sync`.
126
+
127
+ The ledger and the job journal both carry a device stamp, because gitignore is a convention
128
+ and not provenance: a repo can force-add its own `.local/`. Jam state that arrived with a
129
+ clone is read as absent. The stamp is derived from the machine rather than stored, so
130
+ marver still writes nothing outside `design/`.
131
+
132
+ The larger caution is unchanged and worth stating plainly: `marver dev` imports and executes
133
+ `design/config.ts`, so running a dev server in a repo you do not trust is already running
134
+ that repo's code. Live Jam adds no new hole to that; it does not make it safe.
135
+
136
+ ## Provenance
137
+
138
+ Every jam reply is stamped with who acted: your dev user, the harness that ran, and the
139
+ model when the agent names one. Claude Code, Cursor, droid, grok, and pi report their model
140
+ in the stream; `codex exec` and `opencode run` report none, so their replies carry the
141
+ harness without a model rather than a guessed one.
142
+
143
+ ## Parallelism
144
+
145
+ Two knobs stack, and they are not the same thing. `jam.concurrency` is how many jobs the
146
+ daemon runs at once - different frames, different comments. `jam.subagents` is fan-out
147
+ *inside* one job, one subagent per frame, which is what makes a five-frame ask land
148
+ together instead of in series. Claude Code, Codex, and opencode fan out (opencode's
149
+ subagents verifiably inherit the jail); pi has no subagent tool, and droid and grok have
150
+ theirs removed in the spawn itself (`--disabled-tools Task`, `--no-subagents`) until a
151
+ child is proven to inherit the parent's confinement. Their jobs simply run the frames in
152
+ sequence - the prompt only ever says the agent MAY fan out.
153
+
154
+ Two honest caveats in the same spirit as the config-execution one above: cursor's own
155
+ permission rules (`~/.cursor/cli-config.json`, a repo's `.cursor/cli.json`) and a repo's
156
+ own `opencode.json` agent block can widen what those CLIs allow - that is your
157
+ configuration speaking, and marver does not override it.
158
+
159
+ ## When a mention does nothing
160
+
161
+ - **It has to be your machine, your repo, with `marver dev` running.** That is the trust
162
+ boundary above, working as intended.
163
+ - **The first boot after upgrading to 0.9.0 rebaselines the job journal**, because an
164
+ existing journal predates the device stamp. Any `@marver` left unprocessed while the
165
+ server was down is marked seen instead of run. Re-comment to pick it up.
166
+ - **Check the boot line.** `marver dev` prints `jam: on (<agent>)` when it is armed, and
167
+ prints the reason when it is not.
168
+ - **Read the raw run log.** Every job's full agent output lands in
169
+ `design/.local/jam-logs/` (last 10 kept) - an auth failure or permission refusal
170
+ explains itself there. The generated `design/instructions/jam.md` carries the full
171
+ troubleshooting drill, written for your agent to run: it checks the boot line, the log,
172
+ and the CLI's own headless auth, fixes what belongs to the workspace, and files what
173
+ belongs to marver at [github.com/TNEP4/marver/issues](https://github.com/TNEP4/marver/issues) -
174
+ with the patch, when it found one while debugging.
175
+
176
+ `npx marver comments list --open --json` reads the same threads without the live loop, for
177
+ catching up or for a one-off answer.