@holdgrenade/cli 1.0.48 → 1.0.49

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 (2) hide show
  1. package/dist/cli.js +436 -45
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3242,7 +3242,7 @@ var require_stream = __commonJS({
3242
3242
  };
3243
3243
  duplex._final = function(callback) {
3244
3244
  if (ws.readyState === ws.CONNECTING) {
3245
- ws.once("open", function open5() {
3245
+ ws.once("open", function open6() {
3246
3246
  duplex._final(callback);
3247
3247
  });
3248
3248
  return;
@@ -3263,7 +3263,7 @@ var require_stream = __commonJS({
3263
3263
  };
3264
3264
  duplex._write = function(chunk, encoding, callback) {
3265
3265
  if (ws.readyState === ws.CONNECTING) {
3266
- ws.once("open", function open5() {
3266
+ ws.once("open", function open6() {
3267
3267
  duplex._write(chunk, encoding, callback);
3268
3268
  });
3269
3269
  return;
@@ -7025,7 +7025,7 @@ function isKnownAgent(kind) {
7025
7025
 
7026
7026
  // src/cli.ts
7027
7027
  import { existsSync as existsSync23, mkdirSync as mkdirSync16, readFileSync as readFileSync17, writeFileSync as writeFileSync18 } from "node:fs";
7028
- import { dirname as dirname16 } from "node:path";
7028
+ import { dirname as dirname17 } from "node:path";
7029
7029
 
7030
7030
  // node_modules/commander/lib/error.js
7031
7031
  var CommanderError = class extends Error {
@@ -30900,6 +30900,78 @@ var VoiceTokenFrame = external_exports.object({
30900
30900
  once: external_exports.literal(true).optional()
30901
30901
  });
30902
30902
 
30903
+ // ../grenade-protocol/dist/canvas.js
30904
+ var CANVAS_FOLDER = ".grenade/canvas";
30905
+ var CANVAS_BOARDS_MAX = 500;
30906
+ var CANVAS_BOARD_MAX_BYTES = 2 * 1024 * 1024;
30907
+ var CANVAS_BOARD_FRAME_MAX_BYTES = 3e6;
30908
+ var CANVAS_SUBSCRIPTIONS_MAX = 8;
30909
+ var CANVAS_DEFAULT_WIDTH = 1280;
30910
+ var CANVAS_DEFAULT_HEIGHT = 800;
30911
+ var CANVAS_SIZE_MAX = 8e3;
30912
+ var CanvasCwd = external_exports.string().min(1).max(1024).refine((p) => p === "~" || p.startsWith("~/") || p.startsWith("/"), "absolute or starting with ~");
30913
+ var CanvasFileName = external_exports.string().min(1).max(255).refine((f) => !/[/\\\0]/.test(f) && !f.startsWith(".") && /\.html?$/i.test(f), "a board's file name in the canvas folder");
30914
+ var RequestId = external_exports.string().min(1).max(64);
30915
+ var CanvasBoard = external_exports.object({
30916
+ /** Its file name, "R3A · Cards.html". */
30917
+ file: CanvasFileName,
30918
+ /** What it is, from its name: "Cards". The file's name without its extension when the name has nothing after the revision. */
30919
+ name: external_exports.string().min(1).max(255),
30920
+ /** The revision its name starts with: 3 for "R3A · Cards.html". Absent for a name without one. */
30921
+ revision: external_exports.number().int().min(0).max(9999).optional(),
30922
+ /** The letters after the revision, as written: "A" for "R3A · Cards.html". Absent when there are none. */
30923
+ letter: external_exports.string().regex(/^[A-Za-z]{1,3}$/).optional(),
30924
+ /** The size it asks for (`<meta name="board" content="1280x800">`), else 1280 × 800. */
30925
+ width: external_exports.number().int().min(1).max(CANVAS_SIZE_MAX),
30926
+ height: external_exports.number().int().min(1).max(CANVAS_SIZE_MAX),
30927
+ /** When it was last saved. */
30928
+ modified: external_exports.string().datetime(),
30929
+ /** Its size on disk. */
30930
+ bytes: external_exports.number().int().min(0)
30931
+ });
30932
+ var CanvasRequestFrame = external_exports.object({ type: external_exports.literal("canvas"), id: RequestId, cwd: CanvasCwd });
30933
+ var CanvasSubscribeFrame = external_exports.object({ type: external_exports.literal("canvas.subscribe"), id: RequestId, cwd: CanvasCwd });
30934
+ var CanvasUnsubscribeFrame = external_exports.object({ type: external_exports.literal("canvas.unsubscribe"), cwd: CanvasCwd });
30935
+ var CanvasBoardRequestFrame = external_exports.object({ type: external_exports.literal("canvas.board"), id: RequestId, cwd: CanvasCwd, file: CanvasFileName });
30936
+ var CanvasFrame = external_exports.object({
30937
+ type: external_exports.literal("canvas"),
30938
+ /** The request's; absent on a frame sent because the canvas changed. */
30939
+ id: RequestId.optional(),
30940
+ /** As the client sent it. */
30941
+ cwd: external_exports.string().min(1),
30942
+ /** The canvas folder, absolute: `<cwd>/.grenade/canvas`. */
30943
+ folder: external_exports.string().min(1),
30944
+ boards: external_exports.array(CanvasBoard).max(CANVAS_BOARDS_MAX),
30945
+ /** The folder is not there (yet): no session has drawn on this canvas. `boards` is empty. */
30946
+ missing: external_exports.literal(true).optional(),
30947
+ truncated: external_exports.literal(true).optional()
30948
+ });
30949
+ var CanvasBoardFrame = external_exports.object({
30950
+ type: external_exports.literal("canvas.board"),
30951
+ id: RequestId,
30952
+ cwd: external_exports.string().min(1),
30953
+ file: CanvasFileName,
30954
+ /** The whole file, at most `CANVAS_BOARD_MAX_BYTES` bytes on disk. */
30955
+ html: external_exports.string().max(CANVAS_BOARD_MAX_BYTES),
30956
+ modified: external_exports.string().datetime(),
30957
+ bytes: external_exports.number().int().min(0).max(CANVAS_BOARD_MAX_BYTES)
30958
+ });
30959
+ function boardNameOf(file2) {
30960
+ const stem = file2.replace(/\.html?$/i, "") || file2;
30961
+ const match = /^[Rr](\d{1,4})([A-Za-z]{0,3})(?![A-Za-z0-9])/.exec(stem);
30962
+ if (!match)
30963
+ return { name: stem };
30964
+ const rest = stem.slice(match[0].length).replace(/^[\s·•\-–—_:|.]+/u, "").trim();
30965
+ return { name: rest || stem, revision: Number(match[1]), ...match[2] ? { letter: match[2] } : {} };
30966
+ }
30967
+ function boardSizeOf(html) {
30968
+ const meta3 = /<meta[^>]*name=["']board["'][^>]*>/i.exec(html);
30969
+ const content = meta3 && /content=["']\s*(\d{2,5})\s*[x×]\s*(\d{2,5})\s*["']/.exec(meta3[0]);
30970
+ if (!content)
30971
+ return { width: CANVAS_DEFAULT_WIDTH, height: CANVAS_DEFAULT_HEIGHT };
30972
+ return { width: Math.min(Number(content[1]), CANVAS_SIZE_MAX), height: Math.min(Number(content[2]), CANVAS_SIZE_MAX) };
30973
+ }
30974
+
30903
30975
  // ../grenade-protocol/dist/relay.js
30904
30976
  var RELAY_PROTOCOL_VERSION = 1;
30905
30977
  var RELAY_DAEMON_PATH = "/v1/daemon";
@@ -31142,6 +31214,8 @@ var DaemonInfo = external_exports.object({
31142
31214
  groupNames: external_exports.literal(1).optional(),
31143
31215
  /** 1 when the daemon keeps voice providers' keys and answers `voice`, `voice.key` and `voice.token` (PROTOCOL.md "Voice providers"). Absent from older daemons. */
31144
31216
  voice: external_exports.literal(1).optional(),
31217
+ /** 1 when the daemon serves a group's design canvas: `canvas`, `canvas.subscribe`, `canvas.board` (PROTOCOL.md "Canvas"). Absent from older daemons. */
31218
+ canvas: external_exports.literal(1).optional(),
31145
31219
  /** Retired: 1 when the daemon sends `activity` for Codex sessions too. Still sent for apps that read it; new clients read `agents`. */
31146
31220
  codexActivity: external_exports.literal(1).optional(),
31147
31221
  /** Every agent the daemon can start and what it does with each (PROTOCOL.md "Agents"). Absent from older daemons. */
@@ -31282,6 +31356,10 @@ var ClientFrame = external_exports.discriminatedUnion("type", [
31282
31356
  VoiceRequestFrame,
31283
31357
  VoiceKeyFrame,
31284
31358
  VoiceTokenRequestFrame,
31359
+ CanvasRequestFrame,
31360
+ CanvasSubscribeFrame,
31361
+ CanvasUnsubscribeFrame,
31362
+ CanvasBoardRequestFrame,
31285
31363
  PingFrame
31286
31364
  ]);
31287
31365
  var ErrorCode = external_exports.enum([
@@ -31307,7 +31385,7 @@ var ErrorFrame = external_exports.object({
31307
31385
  code: ErrorCode,
31308
31386
  message: external_exports.string(),
31309
31387
  ref: external_exports.string().optional(),
31310
- /** The `id` of the frame this error answers, when it had one (an `input`). */
31388
+ /** The `id` of the frame this error answers, when it had one (an `input`, a `voice.token`, a `canvas` request). */
31311
31389
  id: external_exports.string().min(1).max(64).optional()
31312
31390
  });
31313
31391
  var SessionsFrame = external_exports.object({ type: external_exports.literal("sessions"), sessions: external_exports.array(Session) });
@@ -31378,6 +31456,8 @@ var DaemonFrame = external_exports.discriminatedUnion("type", [
31378
31456
  FoldersFrame,
31379
31457
  VoiceFrame,
31380
31458
  VoiceTokenFrame,
31459
+ CanvasFrame,
31460
+ CanvasBoardFrame,
31381
31461
  PongFrame
31382
31462
  ]);
31383
31463
  var PairRequest = external_exports.object({ code: external_exports.string().regex(/^\d{6}$/), client: ClientInfo });
@@ -34478,12 +34558,12 @@ function terminalFromEnv() {
34478
34558
  const env = process.env["GRENADE_TERMINAL"];
34479
34559
  return env !== void 0 && isTerminalKind(env) ? env : void 0;
34480
34560
  }
34481
- function relayoutSteps(ordered, open5) {
34561
+ function relayoutSteps(ordered, open6) {
34482
34562
  const [head, ...rest] = ordered;
34483
34563
  if (head === void 0) return [];
34484
- const keepHead = open5.has(head);
34564
+ const keepHead = open6.has(head);
34485
34565
  const steps = [];
34486
- for (const id of keepHead ? rest : ordered) if (open5.has(id)) steps.push({ kind: "close", id });
34566
+ for (const id of keepHead ? rest : ordered) if (open6.has(id)) steps.push({ kind: "close", id });
34487
34567
  if (!keepHead) steps.push({ kind: "tab", id: head });
34488
34568
  rest.forEach((id, i) => steps.push({ kind: "split", id, nextTo: ordered[i] }));
34489
34569
  return steps;
@@ -34637,9 +34717,9 @@ var TerminalMirror = class {
34637
34717
  /** The nearest live member before `s` in group order that has a pane in `a`, else the last one that has. */
34638
34718
  openMemberOf(s, a) {
34639
34719
  if (s.group === void 0) return void 0;
34640
- const open5 = otherMembers(s.group, this.registry.list(), s.id).filter((m) => this.openIn.get(m.id) === a);
34641
- const before = open5.filter((m) => byGroupOrder(m, s) < 0);
34642
- return (before.at(-1) ?? open5.at(-1))?.id;
34720
+ const open6 = otherMembers(s.group, this.registry.list(), s.id).filter((m) => this.openIn.get(m.id) === a);
34721
+ const before = open6.filter((m) => byGroupOrder(m, s) < 0);
34722
+ return (before.at(-1) ?? open6.at(-1))?.id;
34643
34723
  }
34644
34724
  closeTab(id) {
34645
34725
  const a = this.openIn.get(id);
@@ -34738,7 +34818,7 @@ function underService(env = process.env, pid = process.pid) {
34738
34818
  }
34739
34819
 
34740
34820
  // src/daemon/server.ts
34741
- import { dirname as dirname15, join as join17 } from "node:path";
34821
+ import { dirname as dirname16, join as join18 } from "node:path";
34742
34822
  import { createServer as createServer2 } from "node:http";
34743
34823
  import { networkInterfaces } from "node:os";
34744
34824
 
@@ -36729,13 +36809,13 @@ function switchModelDialog(below) {
36729
36809
  }
36730
36810
  function paragraphs(rows) {
36731
36811
  const out = [];
36732
- let open5 = false;
36812
+ let open6 = false;
36733
36813
  for (const row of rows.map((r) => r.trim())) {
36734
- if (!row) open5 = false;
36735
- else if (open5) out[out.length - 1] += ` ${row}`;
36814
+ if (!row) open6 = false;
36815
+ else if (open6) out[out.length - 1] += ` ${row}`;
36736
36816
  else {
36737
36817
  out.push(row);
36738
- open5 = true;
36818
+ open6 = true;
36739
36819
  }
36740
36820
  }
36741
36821
  return out.map((p) => /[.!?:]$/.test(p) ? p : `${p}.`).join(" ");
@@ -36772,25 +36852,25 @@ async function switchClaudeModel(term, model, effort, timing = DEFAULT_TIMING) {
36772
36852
  throw new ModelSwitchError("Claude Code's prompt is not empty. Send or clear what is typed there, then choose again.");
36773
36853
  }
36774
36854
  await term.key("enter");
36775
- let open5 = await look2(modelPickerIn);
36776
- if (!open5) throw new ModelSwitchError("Claude Code did not open its model picker.");
36777
- if (!open5.sessionOnly) await giveUp("This Claude Code cannot switch one session only. Update Claude Code, then choose again.");
36778
- for (let moves = 0; selectedModel(open5) !== model; moves++) {
36855
+ let open6 = await look2(modelPickerIn);
36856
+ if (!open6) throw new ModelSwitchError("Claude Code did not open its model picker.");
36857
+ if (!open6.sessionOnly) await giveUp("This Claude Code cannot switch one session only. Update Claude Code, then choose again.");
36858
+ for (let moves = 0; selectedModel(open6) !== model; moves++) {
36779
36859
  if (moves >= MOST_MOVES) await giveUp(`Claude Code's picker has no ${model}.`);
36780
- const down = rowsDownTo(open5, model);
36860
+ const down = rowsDownTo(open6, model);
36781
36861
  await term.key(down !== null && down > 0 ? "down" : "up");
36782
36862
  await timing.wait(LOOK_EVERY_MS);
36783
- open5 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36863
+ open6 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36784
36864
  }
36785
36865
  if (effort !== void 0) {
36786
- for (let steps = 0; open5.effort !== effort; steps++) {
36866
+ for (let steps = 0; open6.effort !== effort; steps++) {
36787
36867
  if (steps >= MOST_EFFORT_STEPS) await giveUp(`${model} has no ${effort} effort in Claude Code.`);
36788
36868
  await term.type(RIGHT);
36789
36869
  await timing.wait(LOOK_EVERY_MS);
36790
- open5 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36870
+ open6 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36791
36871
  }
36792
36872
  }
36793
- const chosenEffort = open5.effort ?? void 0;
36873
+ const chosenEffort = open6.effort ?? void 0;
36794
36874
  await term.type("s");
36795
36875
  const closed = await look2((lines) => modelPickerIn(lines) ? null : lines);
36796
36876
  if (!closed) await giveUp("Claude Code did not take the choice.");
@@ -38603,6 +38683,271 @@ function switchTimingProblem(session) {
38603
38683
  return null;
38604
38684
  }
38605
38685
 
38686
+ // src/canvas/canvasService.ts
38687
+ import { homedir as homedir9 } from "node:os";
38688
+
38689
+ // src/canvas/canvasAccess.ts
38690
+ import { posix } from "node:path";
38691
+ function normalizeCwd(cwd, home) {
38692
+ const expanded = expandHome(cwd, home);
38693
+ if (!expanded.startsWith("/")) return null;
38694
+ return posix.resolve(expanded);
38695
+ }
38696
+ function sharedFolder(paths2) {
38697
+ if (paths2.length === 0) return null;
38698
+ let shared = paths2[0].split("/").filter(Boolean);
38699
+ for (const path2 of paths2.slice(1)) {
38700
+ const parts = path2.split("/").filter(Boolean);
38701
+ let i = 0;
38702
+ while (i < shared.length && i < parts.length && shared[i] === parts[i]) i++;
38703
+ shared = shared.slice(0, i);
38704
+ }
38705
+ return shared.length === 0 ? null : `/${shared.join("/")}`;
38706
+ }
38707
+ function canvasCwds(sessions, home) {
38708
+ const allowed = /* @__PURE__ */ new Set();
38709
+ const groups = /* @__PURE__ */ new Map();
38710
+ for (const s of sessions) {
38711
+ const cwd = normalizeCwd(s.cwd, home);
38712
+ if (!cwd) continue;
38713
+ allowed.add(cwd);
38714
+ const key = s.group ?? s.id;
38715
+ groups.set(key, [...groups.get(key) ?? [], cwd]);
38716
+ }
38717
+ for (const cwds of groups.values()) {
38718
+ const shared = cwds.length > 1 ? sharedFolder(cwds) : null;
38719
+ if (shared) allowed.add(shared);
38720
+ }
38721
+ return allowed;
38722
+ }
38723
+ function allowedCanvasCwd(cwd, sessions, home) {
38724
+ const normalized = normalizeCwd(cwd, home);
38725
+ return normalized && canvasCwds(sessions, home).has(normalized) ? normalized : null;
38726
+ }
38727
+ function canvasFolderOf(cwd) {
38728
+ return posix.join(cwd, CANVAS_FOLDER);
38729
+ }
38730
+
38731
+ // src/canvas/canvasFolder.ts
38732
+ import { constants as constants3 } from "node:fs";
38733
+ import { lstat, open as open5, readdir as readdir5 } from "node:fs/promises";
38734
+ import { dirname as dirname12, join as join16 } from "node:path";
38735
+
38736
+ // src/canvas/boardListing.ts
38737
+ var finderOrder2 = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
38738
+ function isBoardFile(name) {
38739
+ return CanvasFileName.safeParse(name).success;
38740
+ }
38741
+ function boardsFrom(files, max = CANVAS_BOARDS_MAX) {
38742
+ const sorted = files.filter((f) => isBoardFile(f.file)).sort((a, b) => finderOrder2.compare(a.file, b.file));
38743
+ const boards = sorted.slice(0, max).map((f) => ({
38744
+ file: f.file,
38745
+ ...boardNameOf(f.file),
38746
+ ...boardSizeOf(f.head),
38747
+ modified: new Date(f.mtimeMs).toISOString(),
38748
+ bytes: f.bytes
38749
+ }));
38750
+ return { boards, truncated: sorted.length > max };
38751
+ }
38752
+ function listingKey(listing) {
38753
+ if (listing.missing) return "missing";
38754
+ return listing.boards.map((b) => `${b.file}
38755
+ ${b.modified}
38756
+ ${b.bytes}`).join("\n\n");
38757
+ }
38758
+ function megabytes(bytes) {
38759
+ return (bytes / (1024 * 1024)).toFixed(1);
38760
+ }
38761
+ function boardTooLarge(file2, fileBytes, frameBytes, computer = "Mac") {
38762
+ if (fileBytes <= CANVAS_BOARD_MAX_BYTES && frameBytes <= CANVAS_BOARD_FRAME_MAX_BYTES) return null;
38763
+ const name = file2.replace(/\.html?$/i, "");
38764
+ return `"${name}" is too large to show here (${megabytes(Math.max(fileBytes, frameBytes))} MB; at most ${megabytes(CANVAS_BOARD_MAX_BYTES).replace(/\.0$/, "")} MB). Open it on the ${computer}.`;
38765
+ }
38766
+
38767
+ // src/canvas/canvasFolder.ts
38768
+ var HEAD_BYTES = 8192;
38769
+ var CanvasError = class extends Error {
38770
+ };
38771
+ async function realFolder(path2) {
38772
+ const st = await lstat(path2).catch((e) => {
38773
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
38774
+ throw e;
38775
+ });
38776
+ if (!st) return false;
38777
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new CanvasError(`${path2} is not a folder of its own, so it is not served as a canvas.`);
38778
+ return true;
38779
+ }
38780
+ async function canvasThere(folder) {
38781
+ return await realFolder(dirname12(folder)) && await realFolder(folder);
38782
+ }
38783
+ async function openBoard(path2) {
38784
+ const handle = await open5(path2, constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK).catch(() => null);
38785
+ if (!handle) return null;
38786
+ const st = await handle.stat();
38787
+ if (!st.isFile()) {
38788
+ await handle.close();
38789
+ return null;
38790
+ }
38791
+ return {
38792
+ size: st.size,
38793
+ mtimeMs: st.mtimeMs,
38794
+ async read(max) {
38795
+ const buffer = Buffer.alloc(Math.min(max, st.size));
38796
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
38797
+ return buffer.subarray(0, bytesRead);
38798
+ },
38799
+ close: () => handle.close()
38800
+ };
38801
+ }
38802
+ async function listCanvas(folder, heads = /* @__PURE__ */ new Map()) {
38803
+ if (!await canvasThere(folder)) return { boards: [], missing: true };
38804
+ const entries = await readdir5(folder, { withFileTypes: true });
38805
+ const files = [];
38806
+ const seen = /* @__PURE__ */ new Set();
38807
+ for (const e of entries) {
38808
+ if (!e.isFile() || !isBoardFile(e.name)) continue;
38809
+ const st = await lstat(join16(folder, e.name)).catch(() => null);
38810
+ if (!st?.isFile()) continue;
38811
+ const key = `${e.name}
38812
+ ${st.mtimeMs}
38813
+ ${st.size}`;
38814
+ seen.add(key);
38815
+ let head = heads.get(key);
38816
+ if (head === void 0) {
38817
+ const board = await openBoard(join16(folder, e.name));
38818
+ if (!board) continue;
38819
+ try {
38820
+ head = (await board.read(HEAD_BYTES)).toString("utf8");
38821
+ } finally {
38822
+ await board.close();
38823
+ }
38824
+ heads.set(key, head);
38825
+ }
38826
+ files.push({ file: e.name, mtimeMs: st.mtimeMs, bytes: st.size, head });
38827
+ }
38828
+ for (const key of heads.keys()) if (!seen.has(key)) heads.delete(key);
38829
+ const { boards, truncated } = boardsFrom(files);
38830
+ return { boards, ...truncated ? { truncated: true } : {} };
38831
+ }
38832
+ async function readBoard(folder, file2, computer = "Mac") {
38833
+ const notHere = new CanvasError(`"${file2}" is not on this canvas.`);
38834
+ if (!isBoardFile(file2) || !await canvasThere(folder)) throw notHere;
38835
+ const board = await openBoard(join16(folder, file2));
38836
+ if (!board) throw notHere;
38837
+ try {
38838
+ const tooLarge = boardTooLarge(file2, board.size, 0, computer);
38839
+ if (tooLarge) throw new CanvasError(tooLarge);
38840
+ const data = await board.read(CANVAS_BOARD_MAX_BYTES);
38841
+ return { html: data.toString("utf8"), modified: new Date(board.mtimeMs).toISOString(), bytes: data.length };
38842
+ } finally {
38843
+ await board.close();
38844
+ }
38845
+ }
38846
+
38847
+ // src/canvas/canvasWatcher.ts
38848
+ var CANVAS_POLL_MS = 400;
38849
+ var realEvery = (fn, ms) => {
38850
+ const timer = setInterval(fn, ms);
38851
+ timer.unref?.();
38852
+ return () => clearInterval(timer);
38853
+ };
38854
+ var CanvasWatcher = class {
38855
+ constructor(every = realEvery, list = listCanvas, onError = () => {
38856
+ }) {
38857
+ this.every = every;
38858
+ this.list = list;
38859
+ this.onError = onError;
38860
+ }
38861
+ every;
38862
+ list;
38863
+ onError;
38864
+ folders = /* @__PURE__ */ new Map();
38865
+ /** Starts telling `onChange` of every change to `folder` after `current`, the listing the subscriber has. Returns how to stop. */
38866
+ watch(folder, current, onChange) {
38867
+ let watched = this.folders.get(folder);
38868
+ if (!watched) {
38869
+ const created = { subscribers: /* @__PURE__ */ new Set(), heads: /* @__PURE__ */ new Map(), busy: false, stop: () => {
38870
+ } };
38871
+ created.stop = this.every(() => void this.look(folder, created), CANVAS_POLL_MS);
38872
+ this.folders.set(folder, created);
38873
+ watched = created;
38874
+ }
38875
+ const subscriber = { lastKey: listingKey(current), onChange };
38876
+ watched.subscribers.add(subscriber);
38877
+ const w = watched;
38878
+ return () => {
38879
+ w.subscribers.delete(subscriber);
38880
+ if (w.subscribers.size === 0 && this.folders.get(folder) === w) {
38881
+ w.stop();
38882
+ this.folders.delete(folder);
38883
+ }
38884
+ };
38885
+ }
38886
+ /** How many folders are being polled. */
38887
+ get size() {
38888
+ return this.folders.size;
38889
+ }
38890
+ /** Looks at one folder now; a look still running makes this one a no-op. */
38891
+ async look(folder, watched = this.folders.get(folder)) {
38892
+ if (!watched || watched.busy) return;
38893
+ watched.busy = true;
38894
+ try {
38895
+ const listing = await this.list(folder, watched.heads);
38896
+ const key = listingKey(listing);
38897
+ for (const s of watched.subscribers) {
38898
+ if (s.lastKey === key) continue;
38899
+ s.lastKey = key;
38900
+ s.onChange(listing);
38901
+ }
38902
+ } catch (e) {
38903
+ this.onError(folder, e);
38904
+ } finally {
38905
+ watched.busy = false;
38906
+ }
38907
+ }
38908
+ /** Stops every poll (the daemon is stopping). */
38909
+ stopAll() {
38910
+ for (const w of this.folders.values()) w.stop();
38911
+ this.folders.clear();
38912
+ }
38913
+ };
38914
+
38915
+ // src/canvas/canvasService.ts
38916
+ var CanvasService = class {
38917
+ constructor(sessions, watcher = new CanvasWatcher(), home = homedir9(), computer = computerWord()) {
38918
+ this.sessions = sessions;
38919
+ this.watcher = watcher;
38920
+ this.home = home;
38921
+ this.computer = computer;
38922
+ }
38923
+ sessions;
38924
+ watcher;
38925
+ home;
38926
+ computer;
38927
+ /** The canvas folder of `cwd`, or `CanvasError` when no session works there. */
38928
+ folderOf(cwd) {
38929
+ const allowed = allowedCanvasCwd(cwd, this.sessions(), this.home);
38930
+ if (!allowed) throw new CanvasError(`No session on this ${this.computer} works in ${cwd}, so it has no canvas to show.`);
38931
+ return canvasFolderOf(allowed);
38932
+ }
38933
+ async list(cwd) {
38934
+ const folder = this.folderOf(cwd);
38935
+ return { folder, ...await listCanvas(folder) };
38936
+ }
38937
+ async board(cwd, file2) {
38938
+ return readBoard(this.folderOf(cwd), file2, this.computer);
38939
+ }
38940
+ /** The canvas now, and `onChange` with each change after it until `stop`. */
38941
+ async watch(cwd, onChange) {
38942
+ const reply2 = await this.list(cwd);
38943
+ const stop2 = this.watcher.watch(reply2.folder, reply2, (listing) => onChange({ folder: reply2.folder, ...listing }));
38944
+ return { reply: reply2, stop: stop2 };
38945
+ }
38946
+ stop() {
38947
+ this.watcher.stopAll();
38948
+ }
38949
+ };
38950
+
38606
38951
  // src/daemon/wsHandler.ts
38607
38952
  var HELLO_TIMEOUT_MS = 5e3;
38608
38953
  var NO_PUSH = { type: "push.state", registered: false, delivery: "off", events: [] };
@@ -38629,6 +38974,8 @@ var Connection = class {
38629
38974
  /** Live terminals this client has open, by session. */
38630
38975
  terms = /* @__PURE__ */ new Map();
38631
38976
  stopWatchingPush;
38977
+ /** Canvases this client watches, by `cwd` as it sent it (PROTOCOL.md "Canvas", "Watching"). */
38978
+ canvasWatches = /* @__PURE__ */ new Map();
38632
38979
  helloTimer;
38633
38980
  onUpdated = (s) => this.send({ type: "session.updated", session: s });
38634
38981
  onRemoved = (id) => {
@@ -38676,7 +39023,7 @@ var Connection = class {
38676
39023
  try {
38677
39024
  await this.dispatch(frame);
38678
39025
  } catch (e) {
38679
- const id = frame.type === "input" ? frame.id : void 0;
39026
+ const id = frame.type === "input" || frame.type === "canvas" || frame.type === "canvas.subscribe" || frame.type === "canvas.board" ? frame.id : void 0;
38680
39027
  if (e instanceof UnknownSessionError) return this.fail("unknown_session", e.message, frame.type, false, id);
38681
39028
  if (e instanceof SessionExistsError) return this.fail("tmux_failed", e.message, frame.type, false, id);
38682
39029
  if (e instanceof BadCwdError || e instanceof UnknownGroupError) return this.fail("bad_frame", e.message, frame.type, false, id);
@@ -38699,6 +39046,8 @@ var Connection = class {
38699
39046
  for (const id of this.subscriptions) this.d.registry.unsubscribe(id);
38700
39047
  this.subscriptions.clear();
38701
39048
  for (const id of [...this.terms.keys()]) this.closeTerm(id);
39049
+ for (const stop2 of this.canvasWatches.values()) stop2();
39050
+ this.canvasWatches.clear();
38702
39051
  if (this.authed) {
38703
39052
  this.d.registry.off("updated", this.onUpdated);
38704
39053
  this.d.registry.off("removed", this.onRemoved);
@@ -38751,7 +39100,7 @@ var Connection = class {
38751
39100
  }
38752
39101
  this.d.prompts?.on("opened", this.onPrompt);
38753
39102
  this.d.prompts?.on("closed", this.onPrompt);
38754
- for (const open5 of this.d.prompts?.list() ?? []) this.send(open5);
39103
+ for (const open6 of this.d.prompts?.list() ?? []) this.send(open6);
38755
39104
  this.d.activity?.on("activity", this.onActivity);
38756
39105
  }
38757
39106
  async dispatch(frame) {
@@ -38875,6 +39224,11 @@ ${frame.id}`, () => r.sendText(frame.sessionId, frame.text, frame.submit));
38875
39224
  throw e;
38876
39225
  }
38877
39226
  }
39227
+ case "canvas":
39228
+ case "canvas.subscribe":
39229
+ case "canvas.unsubscribe":
39230
+ case "canvas.board":
39231
+ return this.handleCanvas(frame);
38878
39232
  case "session.group": {
38879
39233
  const before = r.get(frame.sessionId);
38880
39234
  const after = r.setGroup(frame.sessionId, frame.group, frame.index);
@@ -38930,6 +39284,40 @@ ${frame.id}`, () => r.sendText(frame.sessionId, frame.text, frame.submit));
38930
39284
  }
38931
39285
  }
38932
39286
  }
39287
+ /** The canvas frames (PROTOCOL.md "Canvas"): a refused folder, a missing or too large board is `bad_frame` with the request's `id`. */
39288
+ async handleCanvas(frame) {
39289
+ if (frame.type === "canvas.unsubscribe") {
39290
+ this.canvasWatches.get(frame.cwd)?.();
39291
+ this.canvasWatches.delete(frame.cwd);
39292
+ return;
39293
+ }
39294
+ const canvas = this.d.canvas;
39295
+ if (!canvas) return this.fail("bad_frame", "this daemon serves no canvas", frame.type, false, frame.id);
39296
+ const { cwd, id } = frame;
39297
+ const framed = (reply2, withId) => ({ type: "canvas", ...withId ? { id } : {}, cwd, ...reply2 });
39298
+ try {
39299
+ if (frame.type === "canvas") return this.send(framed(await canvas.list(cwd), true));
39300
+ if (frame.type === "canvas.subscribe") {
39301
+ const again = this.canvasWatches.get(cwd);
39302
+ if (!again && this.canvasWatches.size >= CANVAS_SUBSCRIPTIONS_MAX) {
39303
+ return this.fail("bad_frame", `A connection watches at most ${CANVAS_SUBSCRIPTIONS_MAX} canvases; unsubscribe from one first.`, frame.type, false, id);
39304
+ }
39305
+ const { reply: reply3, stop: stop2 } = await canvas.watch(cwd, (changed) => this.send(framed(changed, false)));
39306
+ if (!this.authed) return stop2();
39307
+ again?.();
39308
+ this.canvasWatches.set(cwd, stop2);
39309
+ return this.send(framed(reply3, true));
39310
+ }
39311
+ const board = await canvas.board(cwd, frame.file);
39312
+ const reply2 = { type: "canvas.board", id, cwd, file: frame.file, ...board };
39313
+ const tooLarge = boardTooLarge(frame.file, board.bytes, Buffer.byteLength(JSON.stringify(reply2)), computerWord());
39314
+ if (tooLarge) return this.fail("bad_frame", tooLarge, frame.type, false, id);
39315
+ return this.send(reply2);
39316
+ } catch (e) {
39317
+ if (e instanceof CanvasError) return this.fail("bad_frame", e.message, frame.type, false, id);
39318
+ throw e;
39319
+ }
39320
+ }
38933
39321
  openTerm(sessionId, cols, rows) {
38934
39322
  if (!this.d.openTerm) return this.fail("bad_frame", "this daemon streams no terminal", "term.open");
38935
39323
  this.requireSession(sessionId);
@@ -39016,7 +39404,7 @@ var PairingWatch = class {
39016
39404
  // src/attachments/attachmentStore.ts
39017
39405
  import { mkdir, writeFile } from "node:fs/promises";
39018
39406
  import { existsSync as existsSync19 } from "node:fs";
39019
- import { join as join16 } from "node:path";
39407
+ import { join as join17 } from "node:path";
39020
39408
 
39021
39409
  // src/attachments/attachmentName.ts
39022
39410
  var EXTENSION_FOR_MIME = {
@@ -39050,7 +39438,7 @@ function stamp(d) {
39050
39438
  function createAttachmentStore(dir, now = () => /* @__PURE__ */ new Date()) {
39051
39439
  return {
39052
39440
  async save(sessionId, name, mime, data) {
39053
- const folder = join16(dir, sessionId);
39441
+ const folder = join17(dir, sessionId);
39054
39442
  await mkdir(folder, { recursive: true });
39055
39443
  const path2 = unusedPath(folder, attachmentFileName(name, mime, now()));
39056
39444
  await writeFile(path2, data, { flag: "wx" });
@@ -39062,8 +39450,8 @@ function unusedPath(folder, fileName) {
39062
39450
  const dot = fileName.lastIndexOf(".");
39063
39451
  const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
39064
39452
  const ext = dot > 0 ? fileName.slice(dot) : "";
39065
- let candidate = join16(folder, fileName);
39066
- for (let n = 2; existsSync19(candidate); n++) candidate = join16(folder, `${stem}-${n}${ext}`);
39453
+ let candidate = join17(folder, fileName);
39454
+ for (let n = 2; existsSync19(candidate); n++) candidate = join17(folder, `${stem}-${n}${ext}`);
39067
39455
  return candidate;
39068
39456
  }
39069
39457
 
@@ -39078,14 +39466,14 @@ function accessHash(token) {
39078
39466
 
39079
39467
  // src/relay/e2eKey.ts
39080
39468
  import { existsSync as existsSync20, mkdirSync as mkdirSync13, readFileSync as readFileSync14, writeFileSync as writeFileSync15 } from "node:fs";
39081
- import { dirname as dirname12 } from "node:path";
39469
+ import { dirname as dirname13 } from "node:path";
39082
39470
  function loadOrCreateE2EKey(path2) {
39083
39471
  if (existsSync20(path2)) {
39084
39472
  const raw = Buffer.from(readFileSync14(path2, "utf8").trim(), "base64");
39085
39473
  if (raw.length === 32) return x25519FromRaw(raw);
39086
39474
  }
39087
39475
  const pair2 = generateX25519();
39088
- mkdirSync13(dirname12(path2), { recursive: true });
39476
+ mkdirSync13(dirname13(path2), { recursive: true });
39089
39477
  writeFileSync15(path2, privateRaw(pair2.privateKey).toString("base64") + "\n", { mode: 384 });
39090
39478
  return pair2;
39091
39479
  }
@@ -39334,7 +39722,7 @@ var RelayLink = class {
39334
39722
 
39335
39723
  // src/push/boardDevices.ts
39336
39724
  import { existsSync as existsSync21, mkdirSync as mkdirSync14, readFileSync as readFileSync15, writeFileSync as writeFileSync16 } from "node:fs";
39337
- import { dirname as dirname13 } from "node:path";
39725
+ import { dirname as dirname14 } from "node:path";
39338
39726
  var BoardDevices = class {
39339
39727
  constructor(path2) {
39340
39728
  this.path = path2;
@@ -39377,7 +39765,7 @@ var BoardDevices = class {
39377
39765
  }
39378
39766
  save() {
39379
39767
  if (!this.path) return;
39380
- mkdirSync14(dirname13(this.path), { recursive: true });
39768
+ mkdirSync14(dirname14(this.path), { recursive: true });
39381
39769
  writeFileSync16(this.path, JSON.stringify(this.list(), null, 2) + "\n", { mode: 384 });
39382
39770
  }
39383
39771
  };
@@ -39630,7 +40018,7 @@ var BoardPusher = class {
39630
40018
 
39631
40019
  // src/push/pushDevices.ts
39632
40020
  import { existsSync as existsSync22, mkdirSync as mkdirSync15, readFileSync as readFileSync16, writeFileSync as writeFileSync17 } from "node:fs";
39633
- import { dirname as dirname14 } from "node:path";
40021
+ import { dirname as dirname15 } from "node:path";
39634
40022
  var PushDevices = class {
39635
40023
  constructor(path2) {
39636
40024
  this.path = path2;
@@ -39673,7 +40061,7 @@ var PushDevices = class {
39673
40061
  }
39674
40062
  save() {
39675
40063
  if (!this.path) return;
39676
- mkdirSync15(dirname14(this.path), { recursive: true });
40064
+ mkdirSync15(dirname15(this.path), { recursive: true });
39677
40065
  writeFileSync17(this.path, JSON.stringify(this.list(), null, 2) + "\n", { mode: 384 });
39678
40066
  }
39679
40067
  };
@@ -40291,7 +40679,7 @@ async function startDaemon(opts = {}) {
40291
40679
  const controlPort2 = opts.controlPort ?? CONTROL_PORT;
40292
40680
  const log = opts.log ?? createLogger({ file: paths.log, level: process.env["GRENADE_LOG"] ?? "info" });
40293
40681
  const e2eKey = loadOrCreateE2EKey(opts.e2eKeyPath ?? paths.e2eKey);
40294
- const info = { id: loadDaemonId(), name: opts.name ?? defaultName(), version: VERSION, os: daemonOs(), key: e2eKey.publicKey.toString("base64"), e2e: 1, inputSent: 1, conversations: 1, conversationDelete: 1, term: 1, folders: 1, board: 1, groupNames: 1, voice: 1, codexActivity: 1, agents: [...AGENTS] };
40682
+ const info = { id: loadDaemonId(), name: opts.name ?? defaultName(), version: VERSION, os: daemonOs(), key: e2eKey.publicKey.toString("base64"), e2e: 1, inputSent: 1, conversations: 1, conversationDelete: 1, term: 1, folders: 1, board: 1, groupNames: 1, voice: 1, canvas: 1, codexActivity: 1, agents: [...AGENTS] };
40295
40683
  const allowPlainLan = opts.allowPlainLan === true;
40296
40684
  const tmux = opts.tmux ?? createTmux({ agentFlags: { claude: claudeHookFlags(port), codex: codexHookFlags(port) }, serverScope: underSystemd() });
40297
40685
  const tokens = new TokenStore(opts.tokensPath === null ? void 0 : opts.tokensPath ?? paths.tokens);
@@ -40304,7 +40692,7 @@ async function startDaemon(opts = {}) {
40304
40692
  });
40305
40693
  const summarizer = startSummarizer(registry2, log, opts.summaries ?? process.env["GRENADE_SUMMARIES"] !== "off");
40306
40694
  await registry2.adopt();
40307
- const groupsPath = opts.sessionsPath === null ? void 0 : opts.sessionsPath ? join17(dirname15(opts.sessionsPath), "groups.json") : paths.groups;
40695
+ const groupsPath = opts.sessionsPath === null ? void 0 : opts.sessionsPath ? join18(dirname16(opts.sessionsPath), "groups.json") : paths.groups;
40308
40696
  const groupOrder = new GroupOrderStore(registry2, log, groupsPath);
40309
40697
  const poller = startPoller({ tmux, registry: registry2, log });
40310
40698
  const pinnedTerminal = opts.terminal ?? terminalFromEnv();
@@ -40376,7 +40764,7 @@ async function startDaemon(opts = {}) {
40376
40764
  for (const { id, path: path2 } of registry2.transcripts()) {
40377
40765
  readActivity(id, path2).catch((e) => log.debug("Could not read the activity from a saved transcript", { session: id, path: path2, error: e }));
40378
40766
  }
40379
- const marksPath = opts.sessionsPath === null ? void 0 : opts.sessionsPath ? join17(dirname15(opts.sessionsPath), "conversations.json") : paths.conversations;
40767
+ const marksPath = opts.sessionsPath === null ? void 0 : opts.sessionsPath ? join18(dirname16(opts.sessionsPath), "conversations.json") : paths.conversations;
40380
40768
  const marks = new ConversationMarks(log, marksPath);
40381
40769
  const held = () => heldConversations(registry2.transcripts(), (id) => (registry2.get(id)?.status ?? "gone") !== "gone");
40382
40770
  const conversationIndex = new AllConversations([
@@ -40390,6 +40778,7 @@ async function startDaemon(opts = {}) {
40390
40778
  log,
40391
40779
  ...opts.voiceFetch ? { fetch: opts.voiceFetch } : {}
40392
40780
  });
40781
+ const canvas = new CanvasService(() => registry2.list(), new CanvasWatcher(void 0, void 0, (folder, error62) => log.debug("Could not look at a canvas", { folder, error: error62 instanceof Error ? error62.message : String(error62) })));
40393
40782
  const models = {
40394
40783
  async switch(session, model, effort) {
40395
40784
  if (switchingModel.has(session.id)) throw new ModelSwitchError("A model switch is already under way in this session.");
@@ -40531,7 +40920,7 @@ async function startDaemon(opts = {}) {
40531
40920
  prompts.on("answered", (id) => registry2.applyHook(id, "working", void 0, true));
40532
40921
  const screenPrompts = new ScreenPrompts(registry2, prompts, log, (id) => switchingModel.has(id));
40533
40922
  watchScreenBackground(registry2);
40534
- const backgroundWatch = new ClaudeBackgroundWatch(registry2, join17(opts.claudeDir ?? paths.claudeDir, "sessions"), log);
40923
+ const backgroundWatch = new ClaudeBackgroundWatch(registry2, join18(opts.claudeDir ?? paths.claudeDir, "sessions"), log);
40535
40924
  backgroundWatch.start();
40536
40925
  registry2.on("removed", (id) => {
40537
40926
  prompts.closeSession(id);
@@ -40590,12 +40979,13 @@ async function startDaemon(opts = {}) {
40590
40979
  groups: groupOrder,
40591
40980
  models,
40592
40981
  voice,
40982
+ canvas,
40593
40983
  conversations,
40594
- openTerm(open5) {
40984
+ openTerm(open6) {
40595
40985
  const stream = new TermStream({
40596
- ...open5,
40597
- resize: (cols, rows, by) => registry2.resize(open5.sessionId, cols, rows, by),
40598
- release: (by) => registry2.releaseSize(open5.sessionId, by),
40986
+ ...open6,
40987
+ resize: (cols, rows, by) => registry2.resize(open6.sessionId, cols, rows, by),
40988
+ release: (by) => registry2.releaseSize(open6.sessionId, by),
40599
40989
  log
40600
40990
  });
40601
40991
  stream.start();
@@ -40727,6 +41117,7 @@ async function startDaemon(opts = {}) {
40727
41117
  mirror.stop();
40728
41118
  poller.stop();
40729
41119
  relayLink?.stop();
41120
+ canvas.stop();
40730
41121
  push2.stop();
40731
41122
  prompts.closeAll();
40732
41123
  summarizer?.stop();
@@ -40923,7 +41314,7 @@ program2.command("install-hooks").description("also let a claude typed by hand i
40923
41314
  if (o.print) return console.log(JSON.stringify(settings, null, 2));
40924
41315
  if (o.remove) removeOldCodexHooks();
40925
41316
  if (!changed) return console.log(`nothing to do: ${paths.claudeSettings} already up to date`);
40926
- mkdirSync16(dirname16(paths.claudeSettings), { recursive: true });
41317
+ mkdirSync16(dirname17(paths.claudeSettings), { recursive: true });
40927
41318
  writeFileSync18(paths.claudeSettings, JSON.stringify(settings, null, 2) + "\n");
40928
41319
  console.log(`${o.remove ? "removed Grenade hooks from" : "installed Grenade hooks in"} ${paths.claudeSettings}`);
40929
41320
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holdgrenade/cli",
3
- "version": "1.0.48",
3
+ "version": "1.0.49",
4
4
  "description": "Watch and drive AI coding agents (Claude Code, Codex, a shell) in your computer's terminals from your phone.",
5
5
  "type": "module",
6
6
  "bin": {