@holdgrenade/cli 1.0.48 → 1.0.50

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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +674 -87
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,7 +29,7 @@ grenade setup
29
29
  3. It offers the relay, for reaching the computer from any network (`grenade relay off`). Push notifications follow that answer: on with the relay, off without it, and setup says which.
30
30
  4. It shows a QR code. Scan it in the Grenade app and the phone is paired, on any network when the relay is on.
31
31
 
32
- Setup touches no agent's settings. Grenade starts Claude Code and Codex with its hooks (`claude --settings …`, `codex -c hooks.…`), so the phone knows when they work and wait. The first Codex session asks once to trust them; the phone shows it as a card, so tap Trust there or pick "Trust all and continue" in the terminal.
32
+ Setup touches no agent's settings. Grenade starts Claude Code and Codex with its hooks (`claude --settings …`, `codex -c hooks.…`), so the phone knows when they work and wait. Claude Code also starts with a status line of Grenade's, which tells grenaded how full the conversation is and how much of your Pro or Max plan is used, and then runs your own status line, if you have one, so your line looks as before (a change to it applies to sessions started after). The first Codex session asks once to trust them; the phone shows it as a card, so tap Trust there or pick "Trust all and continue" in the terminal.
33
33
 
34
34
  `--yes` takes the suggested answer to every question; `--no-hooks`, `--no-service`, `--no-relay` and `--no-pair` leave a step out.
35
35
 
package/dist/cli.js CHANGED
@@ -2127,7 +2127,7 @@ var require_extension = __commonJS({
2127
2127
  if (dest[name] === void 0) dest[name] = [elem];
2128
2128
  else dest[name].push(elem);
2129
2129
  }
2130
- function parse7(header) {
2130
+ function parse8(header) {
2131
2131
  const offers = /* @__PURE__ */ Object.create(null);
2132
2132
  let params = /* @__PURE__ */ Object.create(null);
2133
2133
  let mustUnescape = false;
@@ -2267,7 +2267,7 @@ var require_extension = __commonJS({
2267
2267
  }).join(", ");
2268
2268
  }).join(", ");
2269
2269
  }
2270
- module.exports = { format, parse: parse7 };
2270
+ module.exports = { format, parse: parse8 };
2271
2271
  }
2272
2272
  });
2273
2273
 
@@ -2301,7 +2301,7 @@ var require_websocket = __commonJS({
2301
2301
  var {
2302
2302
  EventTarget: { addEventListener, removeEventListener }
2303
2303
  } = require_event_target();
2304
- var { format, parse: parse7 } = require_extension();
2304
+ var { format, parse: parse8 } = require_extension();
2305
2305
  var { toBuffer } = require_buffer_util();
2306
2306
  var kAborted = /* @__PURE__ */ Symbol("kAborted");
2307
2307
  var protocolVersions = [8, 13];
@@ -2991,7 +2991,7 @@ var require_websocket = __commonJS({
2991
2991
  }
2992
2992
  let extensions;
2993
2993
  try {
2994
- extensions = parse7(secWebSocketExtensions);
2994
+ extensions = parse8(secWebSocketExtensions);
2995
2995
  } catch (err) {
2996
2996
  const message = "Invalid Sec-WebSocket-Extensions header";
2997
2997
  abortHandshake(websocket, socket, message);
@@ -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 open7() {
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 open7() {
3267
3267
  duplex._write(chunk, encoding, callback);
3268
3268
  });
3269
3269
  return;
@@ -3283,7 +3283,7 @@ var require_subprotocol = __commonJS({
3283
3283
  "node_modules/ws/lib/subprotocol.js"(exports, module) {
3284
3284
  "use strict";
3285
3285
  var { tokenChars } = require_validation();
3286
- function parse7(header) {
3286
+ function parse8(header) {
3287
3287
  const protocols = /* @__PURE__ */ new Set();
3288
3288
  let start = -1;
3289
3289
  let end = -1;
@@ -3319,7 +3319,7 @@ var require_subprotocol = __commonJS({
3319
3319
  protocols.add(protocol);
3320
3320
  return protocols;
3321
3321
  }
3322
- module.exports = { parse: parse7 };
3322
+ module.exports = { parse: parse8 };
3323
3323
  }
3324
3324
  });
3325
3325
 
@@ -7024,8 +7024,8 @@ function isKnownAgent(kind) {
7024
7024
  }
7025
7025
 
7026
7026
  // src/cli.ts
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";
7027
+ import { existsSync as existsSync23, mkdirSync as mkdirSync16, readFileSync as readFileSync18, writeFileSync as writeFileSync18 } from "node:fs";
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 {
@@ -12240,18 +12240,18 @@ var validateAsync = async (schema, value, _ctx) => {
12240
12240
  return result.issues.length === 0;
12241
12241
  };
12242
12242
  var _encode = (_Err) => {
12243
- const parse7 = _parse(_Err);
12243
+ const parse8 = _parse(_Err);
12244
12244
  const fn = (schema, value, _ctx, _params) => {
12245
12245
  const ctx = _ctx ? { ..._ctx, direction: "backward" } : { direction: "backward" };
12246
- return parse7(schema, value, ctx, finalizeParams(fn, _params));
12246
+ return parse8(schema, value, ctx, finalizeParams(fn, _params));
12247
12247
  };
12248
12248
  return fn;
12249
12249
  };
12250
12250
  var encode = /* @__PURE__ */ _encode($ZodRealError);
12251
12251
  var _decode = (_Err) => {
12252
- const parse7 = _parse(_Err);
12252
+ const parse8 = _parse(_Err);
12253
12253
  const fn = (schema, value, _ctx, _params) => {
12254
- return parse7(schema, value, _ctx, finalizeParams(fn, _params));
12254
+ return parse8(schema, value, _ctx, finalizeParams(fn, _params));
12255
12255
  };
12256
12256
  return fn;
12257
12257
  };
@@ -13918,7 +13918,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
13918
13918
  }
13919
13919
  return propValues;
13920
13920
  });
13921
- const isObject4 = isObject;
13921
+ const isObject5 = isObject;
13922
13922
  const catchall = def.catchall;
13923
13923
  let value;
13924
13924
  const memo2 = globalConfig.memoizer;
@@ -13926,7 +13926,7 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
13926
13926
  inst._zod.parse = (payload, ctx) => {
13927
13927
  value ?? (value = _normalized.value);
13928
13928
  const input2 = payload.value;
13929
- if (!isObject4(input2)) {
13929
+ if (!isObject5(input2)) {
13930
13930
  payload.issues.push({
13931
13931
  expected: "object",
13932
13932
  code: "invalid_type",
@@ -14062,7 +14062,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
14062
14062
  return doc.compile();
14063
14063
  };
14064
14064
  let fastpass;
14065
- const isObject4 = isObject;
14065
+ const isObject5 = isObject;
14066
14066
  const jit = !globalConfig.jitless;
14067
14067
  const allowsEval2 = allowsEval;
14068
14068
  const fastEnabled = jit && allowsEval2.value;
@@ -14071,7 +14071,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
14071
14071
  inst._zod.parse = (payload, ctx) => {
14072
14072
  value ?? (value = _normalized.value);
14073
14073
  const input2 = payload.value;
14074
- if (!isObject4(input2)) {
14074
+ if (!isObject5(input2)) {
14075
14075
  payload.issues.push({
14076
14076
  expected: "object",
14077
14077
  code: "invalid_type",
@@ -30900,6 +30900,97 @@ var VoiceTokenFrame = external_exports.object({
30900
30900
  once: external_exports.literal(true).optional()
30901
30901
  });
30902
30902
 
30903
+ // ../grenade-protocol/dist/usage.js
30904
+ var LIMITS_MAX = 16;
30905
+ var SessionContext = external_exports.object({
30906
+ used: external_exports.number().int().min(0),
30907
+ size: external_exports.number().int().min(1)
30908
+ });
30909
+ var PlanLimit = external_exports.object({
30910
+ agent: AgentKind,
30911
+ minutes: external_exports.number().int().min(1),
30912
+ usedPercent: external_exports.number().min(0).max(100),
30913
+ resetsAt: external_exports.string().datetime().optional(),
30914
+ at: external_exports.string().datetime()
30915
+ });
30916
+ var LimitsRequestFrame = external_exports.object({ type: external_exports.literal("limits") });
30917
+ var LimitsFrame = external_exports.object({
30918
+ type: external_exports.literal("limits"),
30919
+ limits: external_exports.array(PlanLimit).max(LIMITS_MAX)
30920
+ });
30921
+
30922
+ // ../grenade-protocol/dist/canvas.js
30923
+ var CANVAS_FOLDER = ".grenade/canvas";
30924
+ var CANVAS_BOARDS_MAX = 500;
30925
+ var CANVAS_BOARD_MAX_BYTES = 2 * 1024 * 1024;
30926
+ var CANVAS_BOARD_FRAME_MAX_BYTES = 3e6;
30927
+ var CANVAS_SUBSCRIPTIONS_MAX = 8;
30928
+ var CANVAS_DEFAULT_WIDTH = 1280;
30929
+ var CANVAS_DEFAULT_HEIGHT = 800;
30930
+ var CANVAS_SIZE_MAX = 8e3;
30931
+ var CanvasCwd = external_exports.string().min(1).max(1024).refine((p) => p === "~" || p.startsWith("~/") || p.startsWith("/"), "absolute or starting with ~");
30932
+ 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");
30933
+ var RequestId = external_exports.string().min(1).max(64);
30934
+ var CanvasBoard = external_exports.object({
30935
+ /** Its file name, "R3A · Cards.html". */
30936
+ file: CanvasFileName,
30937
+ /** What it is, from its name: "Cards". The file's name without its extension when the name has nothing after the revision. */
30938
+ name: external_exports.string().min(1).max(255),
30939
+ /** The revision its name starts with: 3 for "R3A · Cards.html". Absent for a name without one. */
30940
+ revision: external_exports.number().int().min(0).max(9999).optional(),
30941
+ /** The letters after the revision, as written: "A" for "R3A · Cards.html". Absent when there are none. */
30942
+ letter: external_exports.string().regex(/^[A-Za-z]{1,3}$/).optional(),
30943
+ /** The size it asks for (`<meta name="board" content="1280x800">`), else 1280 × 800. */
30944
+ width: external_exports.number().int().min(1).max(CANVAS_SIZE_MAX),
30945
+ height: external_exports.number().int().min(1).max(CANVAS_SIZE_MAX),
30946
+ /** When it was last saved. */
30947
+ modified: external_exports.string().datetime(),
30948
+ /** Its size on disk. */
30949
+ bytes: external_exports.number().int().min(0)
30950
+ });
30951
+ var CanvasRequestFrame = external_exports.object({ type: external_exports.literal("canvas"), id: RequestId, cwd: CanvasCwd });
30952
+ var CanvasSubscribeFrame = external_exports.object({ type: external_exports.literal("canvas.subscribe"), id: RequestId, cwd: CanvasCwd });
30953
+ var CanvasUnsubscribeFrame = external_exports.object({ type: external_exports.literal("canvas.unsubscribe"), cwd: CanvasCwd });
30954
+ var CanvasBoardRequestFrame = external_exports.object({ type: external_exports.literal("canvas.board"), id: RequestId, cwd: CanvasCwd, file: CanvasFileName });
30955
+ var CanvasFrame = external_exports.object({
30956
+ type: external_exports.literal("canvas"),
30957
+ /** The request's; absent on a frame sent because the canvas changed. */
30958
+ id: RequestId.optional(),
30959
+ /** As the client sent it. */
30960
+ cwd: external_exports.string().min(1),
30961
+ /** The canvas folder, absolute: `<cwd>/.grenade/canvas`. */
30962
+ folder: external_exports.string().min(1),
30963
+ boards: external_exports.array(CanvasBoard).max(CANVAS_BOARDS_MAX),
30964
+ /** The folder is not there (yet): no session has drawn on this canvas. `boards` is empty. */
30965
+ missing: external_exports.literal(true).optional(),
30966
+ truncated: external_exports.literal(true).optional()
30967
+ });
30968
+ var CanvasBoardFrame = external_exports.object({
30969
+ type: external_exports.literal("canvas.board"),
30970
+ id: RequestId,
30971
+ cwd: external_exports.string().min(1),
30972
+ file: CanvasFileName,
30973
+ /** The whole file, at most `CANVAS_BOARD_MAX_BYTES` bytes on disk. */
30974
+ html: external_exports.string().max(CANVAS_BOARD_MAX_BYTES),
30975
+ modified: external_exports.string().datetime(),
30976
+ bytes: external_exports.number().int().min(0).max(CANVAS_BOARD_MAX_BYTES)
30977
+ });
30978
+ function boardNameOf(file2) {
30979
+ const stem = file2.replace(/\.html?$/i, "") || file2;
30980
+ const match = /^[Rr](\d{1,4})([A-Za-z]{0,3})(?![A-Za-z0-9])/.exec(stem);
30981
+ if (!match)
30982
+ return { name: stem };
30983
+ const rest = stem.slice(match[0].length).replace(/^[\s·•\-–—_:|.]+/u, "").trim();
30984
+ return { name: rest || stem, revision: Number(match[1]), ...match[2] ? { letter: match[2] } : {} };
30985
+ }
30986
+ function boardSizeOf(html) {
30987
+ const meta3 = /<meta[^>]*name=["']board["'][^>]*>/i.exec(html);
30988
+ const content = meta3 && /content=["']\s*(\d{2,5})\s*[x×]\s*(\d{2,5})\s*["']/.exec(meta3[0]);
30989
+ if (!content)
30990
+ return { width: CANVAS_DEFAULT_WIDTH, height: CANVAS_DEFAULT_HEIGHT };
30991
+ return { width: Math.min(Number(content[1]), CANVAS_SIZE_MAX), height: Math.min(Number(content[2]), CANVAS_SIZE_MAX) };
30992
+ }
30993
+
30903
30994
  // ../grenade-protocol/dist/relay.js
30904
30995
  var RELAY_PROTOCOL_VERSION = 1;
30905
30996
  var RELAY_DAEMON_PATH = "/v1/daemon";
@@ -31089,6 +31180,8 @@ var Session = external_exports.object({
31089
31180
  model: external_exports.string().min(1).max(MODEL_NAME_MAX).optional(),
31090
31181
  /** The effort level the agent last answered with, or was last switched to (PROTOCOL.md "Models"). Absent until the daemon has seen one, and for a model that takes none. */
31091
31182
  effort: EffortLevel.optional(),
31183
+ /** How full the session's context is after the agent's last reply, in tokens (PROTOCOL.md "Usage"). Absent until the daemon has read it, and for shell sessions. */
31184
+ context: SessionContext.optional(),
31092
31185
  createdAt: external_exports.string().datetime(),
31093
31186
  /** Sessions with the same group are shown together. Absent (older daemons) means the session is on its own. */
31094
31187
  group: external_exports.string().min(1).optional(),
@@ -31142,6 +31235,10 @@ var DaemonInfo = external_exports.object({
31142
31235
  groupNames: external_exports.literal(1).optional(),
31143
31236
  /** 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
31237
  voice: external_exports.literal(1).optional(),
31238
+ /** 1 when the daemon serves a group's design canvas: `canvas`, `canvas.subscribe`, `canvas.board` (PROTOCOL.md "Canvas"). Absent from older daemons. */
31239
+ canvas: external_exports.literal(1).optional(),
31240
+ /** 1 when the daemon answers `limits` and sends `context` on sessions (PROTOCOL.md "Usage"). Absent from older daemons. */
31241
+ limits: external_exports.literal(1).optional(),
31145
31242
  /** Retired: 1 when the daemon sends `activity` for Codex sessions too. Still sent for apps that read it; new clients read `agents`. */
31146
31243
  codexActivity: external_exports.literal(1).optional(),
31147
31244
  /** Every agent the daemon can start and what it does with each (PROTOCOL.md "Agents"). Absent from older daemons. */
@@ -31282,6 +31379,11 @@ var ClientFrame = external_exports.discriminatedUnion("type", [
31282
31379
  VoiceRequestFrame,
31283
31380
  VoiceKeyFrame,
31284
31381
  VoiceTokenRequestFrame,
31382
+ CanvasRequestFrame,
31383
+ CanvasSubscribeFrame,
31384
+ CanvasUnsubscribeFrame,
31385
+ CanvasBoardRequestFrame,
31386
+ LimitsRequestFrame,
31285
31387
  PingFrame
31286
31388
  ]);
31287
31389
  var ErrorCode = external_exports.enum([
@@ -31307,7 +31409,7 @@ var ErrorFrame = external_exports.object({
31307
31409
  code: ErrorCode,
31308
31410
  message: external_exports.string(),
31309
31411
  ref: external_exports.string().optional(),
31310
- /** The `id` of the frame this error answers, when it had one (an `input`). */
31412
+ /** The `id` of the frame this error answers, when it had one (an `input`, a `voice.token`, a `canvas` request). */
31311
31413
  id: external_exports.string().min(1).max(64).optional()
31312
31414
  });
31313
31415
  var SessionsFrame = external_exports.object({ type: external_exports.literal("sessions"), sessions: external_exports.array(Session) });
@@ -31378,6 +31480,9 @@ var DaemonFrame = external_exports.discriminatedUnion("type", [
31378
31480
  FoldersFrame,
31379
31481
  VoiceFrame,
31380
31482
  VoiceTokenFrame,
31483
+ CanvasFrame,
31484
+ CanvasBoardFrame,
31485
+ LimitsFrame,
31381
31486
  PongFrame
31382
31487
  ]);
31383
31488
  var PairRequest = external_exports.object({ code: external_exports.string().regex(/^\d{6}$/), client: ClientInfo });
@@ -33323,6 +33428,53 @@ function shellQuote(s) {
33323
33428
  return `'${s.replace(/'/g, `'\\''`)}'`;
33324
33429
  }
33325
33430
 
33431
+ // src/usage/claudeStatusLine.ts
33432
+ var STATUS_LINE_PATH = "/hooks/claude/status";
33433
+ function claudeStatusLine(port, user) {
33434
+ const post = `curl -s -m 2 -X POST "http://127.0.0.1:${port}${STATUS_LINE_PATH}?session=$GRENADE_SESSION" -H 'content-type: application/json' --data-binary @-`;
33435
+ const report = `(printf '%s' "$in" | ${post} >/dev/null 2>&1 &)`;
33436
+ const command = user ? `in=$(cat); ${report}; printf '%s' "$in" | sh -c ${shellQuote(user.command)}` : `in=$(cat); ${report}`;
33437
+ return { type: "command", command, ...user?.padding !== void 0 ? { padding: user.padding } : {} };
33438
+ }
33439
+ function userStatusLineIn(settings) {
33440
+ const line = settings?.statusLine;
33441
+ if (!line || line.type !== "command" || typeof line.command !== "string" || !line.command.trim()) return void 0;
33442
+ if (line.command.includes(STATUS_LINE_PATH)) return void 0;
33443
+ return { command: line.command, ...typeof line.padding === "number" ? { padding: line.padding } : {} };
33444
+ }
33445
+ var WINDOWS = { five_hour: 300, seven_day: 10080 };
33446
+ function claudeStatusUsage(payload, at) {
33447
+ const p = payload ?? {};
33448
+ const limits = [];
33449
+ if (isObject3(p.rate_limits)) {
33450
+ for (const [name, minutes] of Object.entries(WINDOWS)) {
33451
+ const w = p.rate_limits[name];
33452
+ if (!isObject3(w) || typeof w.used_percentage !== "number") continue;
33453
+ const resetsAt = typeof w.resets_at === "number" ? new Date(w.resets_at * 1e3).toISOString() : void 0;
33454
+ limits.push({ agent: "claude", minutes, usedPercent: clampPercent(w.used_percentage), ...resetsAt ? { resetsAt } : {}, at });
33455
+ }
33456
+ }
33457
+ const context = contextIn(p.context_window);
33458
+ return { ...context ? { context } : {}, limits };
33459
+ }
33460
+ function contextIn(window) {
33461
+ if (!isObject3(window)) return void 0;
33462
+ const size = window.context_window_size;
33463
+ if (typeof size !== "number" || !(size >= 1)) return void 0;
33464
+ const usage = window.current_usage;
33465
+ if (isObject3(usage)) {
33466
+ const used = tokens(usage.input_tokens) + tokens(usage.cache_creation_input_tokens) + tokens(usage.cache_read_input_tokens);
33467
+ if (used > 0) return { used, size: Math.round(size) };
33468
+ }
33469
+ if (typeof window.used_percentage === "number") return { used: Math.round(clampPercent(window.used_percentage) / 100 * size), size: Math.round(size) };
33470
+ return void 0;
33471
+ }
33472
+ var tokens = (v) => typeof v === "number" && v > 0 ? Math.round(v) : 0;
33473
+ var clampPercent = (v) => Math.max(0, Math.min(100, v));
33474
+ function isObject3(v) {
33475
+ return typeof v === "object" && v !== null && !Array.isArray(v);
33476
+ }
33477
+
33326
33478
  // src/hooks/installHooks.ts
33327
33479
  var HOOK_EVENTS = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Notification", "Stop", "StopFailure", "SessionEnd"];
33328
33480
  var PROMPT_EVENT = "PermissionRequest";
@@ -33344,15 +33496,16 @@ function mergeHooks(input2, port) {
33344
33496
  const command = { type: "command", command: hookCommand(port) };
33345
33497
  return mergeEntries(input2, [...HOOK_EVENTS.map((e) => [e, command]), [PROMPT_EVENT, promptHook(port)]], isGrenadeHook);
33346
33498
  }
33347
- function claudeHookFlags(port) {
33348
- return `--settings ${shellQuote(JSON.stringify(mergeHooks({}, port).settings))}`;
33499
+ function claudeHookFlags(port, userStatusLine) {
33500
+ const settings = { ...mergeHooks({}, port).settings, statusLine: claudeStatusLine(port, userStatusLine) };
33501
+ return `--settings ${shellQuote(JSON.stringify(settings))}`;
33349
33502
  }
33350
33503
  function removeHooks(input2) {
33351
33504
  return removeEntries(input2, isGrenadeHook);
33352
33505
  }
33353
33506
  function mergeEntries(input2, wanted, isOurs) {
33354
- const settings = isObject3(input2) ? structuredClone(input2) : {};
33355
- const hooks = isObject3(settings.hooks) ? settings.hooks : {};
33507
+ const settings = isObject4(input2) ? structuredClone(input2) : {};
33508
+ const hooks = isObject4(settings.hooks) ? settings.hooks : {};
33356
33509
  settings.hooks = hooks;
33357
33510
  let changed = false;
33358
33511
  for (const [event, entry] of wanted) {
@@ -33373,8 +33526,8 @@ function mergeEntries(input2, wanted, isOurs) {
33373
33526
  return { settings, changed };
33374
33527
  }
33375
33528
  function removeEntries(input2, isOurs) {
33376
- const settings = isObject3(input2) ? structuredClone(input2) : {};
33377
- const hooks = isObject3(settings.hooks) ? settings.hooks : {};
33529
+ const settings = isObject4(input2) ? structuredClone(input2) : {};
33530
+ const hooks = isObject4(settings.hooks) ? settings.hooks : {};
33378
33531
  let changed = false;
33379
33532
  for (const [event, matchers] of Object.entries(hooks)) {
33380
33533
  if (!Array.isArray(matchers)) continue;
@@ -33384,7 +33537,7 @@ function removeEntries(input2, isOurs) {
33384
33537
  }
33385
33538
  return { settings, changed };
33386
33539
  }
33387
- function isObject3(v) {
33540
+ function isObject4(v) {
33388
33541
  return typeof v === "object" && v !== null && !Array.isArray(v);
33389
33542
  }
33390
33543
 
@@ -34478,12 +34631,12 @@ function terminalFromEnv() {
34478
34631
  const env = process.env["GRENADE_TERMINAL"];
34479
34632
  return env !== void 0 && isTerminalKind(env) ? env : void 0;
34480
34633
  }
34481
- function relayoutSteps(ordered, open5) {
34634
+ function relayoutSteps(ordered, open7) {
34482
34635
  const [head, ...rest] = ordered;
34483
34636
  if (head === void 0) return [];
34484
- const keepHead = open5.has(head);
34637
+ const keepHead = open7.has(head);
34485
34638
  const steps = [];
34486
- for (const id of keepHead ? rest : ordered) if (open5.has(id)) steps.push({ kind: "close", id });
34639
+ for (const id of keepHead ? rest : ordered) if (open7.has(id)) steps.push({ kind: "close", id });
34487
34640
  if (!keepHead) steps.push({ kind: "tab", id: head });
34488
34641
  rest.forEach((id, i) => steps.push({ kind: "split", id, nextTo: ordered[i] }));
34489
34642
  return steps;
@@ -34637,9 +34790,9 @@ var TerminalMirror = class {
34637
34790
  /** The nearest live member before `s` in group order that has a pane in `a`, else the last one that has. */
34638
34791
  openMemberOf(s, a) {
34639
34792
  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;
34793
+ const open7 = otherMembers(s.group, this.registry.list(), s.id).filter((m) => this.openIn.get(m.id) === a);
34794
+ const before = open7.filter((m) => byGroupOrder(m, s) < 0);
34795
+ return (before.at(-1) ?? open7.at(-1))?.id;
34643
34796
  }
34644
34797
  closeTab(id) {
34645
34798
  const a = this.openIn.get(id);
@@ -34738,7 +34891,7 @@ function underService(env = process.env, pid = process.pid) {
34738
34891
  }
34739
34892
 
34740
34893
  // src/daemon/server.ts
34741
- import { dirname as dirname15, join as join17 } from "node:path";
34894
+ import { dirname as dirname16, join as join18 } from "node:path";
34742
34895
  import { createServer as createServer2 } from "node:http";
34743
34896
  import { networkInterfaces } from "node:os";
34744
34897
 
@@ -35478,6 +35631,18 @@ var SessionRegistry = class extends EventEmitter2 {
35478
35631
  this.persist();
35479
35632
  this.emit("updated", r.session);
35480
35633
  }
35634
+ /**
35635
+ * How full the session's context is (PROTOCOL.md "Usage"): Claude Code's status line, Codex's rollout. Not saved:
35636
+ * the next status line or hook after a restart brings it back.
35637
+ */
35638
+ setContext(id, context) {
35639
+ const r = this.records.get(id);
35640
+ if (!r) return;
35641
+ const now = r.session.context;
35642
+ if (now && now.used === context.used && now.size === context.size) return;
35643
+ r.session = { ...r.session, context: { used: context.used, size: context.size } };
35644
+ this.emit("updated", r.session);
35645
+ }
35481
35646
  /**
35482
35647
  * A client switched the session to `model` (PROTOCOL.md "Models") and the agent took it: the session says so at
35483
35648
  * once, before the agent's next reply. `effort` undefined is a model that takes none.
@@ -36729,13 +36894,13 @@ function switchModelDialog(below) {
36729
36894
  }
36730
36895
  function paragraphs(rows) {
36731
36896
  const out = [];
36732
- let open5 = false;
36897
+ let open7 = false;
36733
36898
  for (const row of rows.map((r) => r.trim())) {
36734
- if (!row) open5 = false;
36735
- else if (open5) out[out.length - 1] += ` ${row}`;
36899
+ if (!row) open7 = false;
36900
+ else if (open7) out[out.length - 1] += ` ${row}`;
36736
36901
  else {
36737
36902
  out.push(row);
36738
- open5 = true;
36903
+ open7 = true;
36739
36904
  }
36740
36905
  }
36741
36906
  return out.map((p) => /[.!?:]$/.test(p) ? p : `${p}.`).join(" ");
@@ -36772,25 +36937,25 @@ async function switchClaudeModel(term, model, effort, timing = DEFAULT_TIMING) {
36772
36937
  throw new ModelSwitchError("Claude Code's prompt is not empty. Send or clear what is typed there, then choose again.");
36773
36938
  }
36774
36939
  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++) {
36940
+ let open7 = await look2(modelPickerIn);
36941
+ if (!open7) throw new ModelSwitchError("Claude Code did not open its model picker.");
36942
+ if (!open7.sessionOnly) await giveUp("This Claude Code cannot switch one session only. Update Claude Code, then choose again.");
36943
+ for (let moves = 0; selectedModel(open7) !== model; moves++) {
36779
36944
  if (moves >= MOST_MOVES) await giveUp(`Claude Code's picker has no ${model}.`);
36780
- const down = rowsDownTo(open5, model);
36945
+ const down = rowsDownTo(open7, model);
36781
36946
  await term.key(down !== null && down > 0 ? "down" : "up");
36782
36947
  await timing.wait(LOOK_EVERY_MS);
36783
- open5 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36948
+ open7 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36784
36949
  }
36785
36950
  if (effort !== void 0) {
36786
- for (let steps = 0; open5.effort !== effort; steps++) {
36951
+ for (let steps = 0; open7.effort !== effort; steps++) {
36787
36952
  if (steps >= MOST_EFFORT_STEPS) await giveUp(`${model} has no ${effort} effort in Claude Code.`);
36788
36953
  await term.type(RIGHT);
36789
36954
  await timing.wait(LOOK_EVERY_MS);
36790
- open5 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36955
+ open7 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36791
36956
  }
36792
36957
  }
36793
- const chosenEffort = open5.effort ?? void 0;
36958
+ const chosenEffort = open7.effort ?? void 0;
36794
36959
  await term.type("s");
36795
36960
  const closed = await look2((lines) => modelPickerIn(lines) ? null : lines);
36796
36961
  if (!closed) await giveUp("Claude Code did not take the choice.");
@@ -38603,6 +38768,271 @@ function switchTimingProblem(session) {
38603
38768
  return null;
38604
38769
  }
38605
38770
 
38771
+ // src/canvas/canvasService.ts
38772
+ import { homedir as homedir9 } from "node:os";
38773
+
38774
+ // src/canvas/canvasAccess.ts
38775
+ import { posix } from "node:path";
38776
+ function normalizeCwd(cwd, home) {
38777
+ const expanded = expandHome(cwd, home);
38778
+ if (!expanded.startsWith("/")) return null;
38779
+ return posix.resolve(expanded);
38780
+ }
38781
+ function sharedFolder(paths2) {
38782
+ if (paths2.length === 0) return null;
38783
+ let shared = paths2[0].split("/").filter(Boolean);
38784
+ for (const path2 of paths2.slice(1)) {
38785
+ const parts = path2.split("/").filter(Boolean);
38786
+ let i = 0;
38787
+ while (i < shared.length && i < parts.length && shared[i] === parts[i]) i++;
38788
+ shared = shared.slice(0, i);
38789
+ }
38790
+ return shared.length === 0 ? null : `/${shared.join("/")}`;
38791
+ }
38792
+ function canvasCwds(sessions, home) {
38793
+ const allowed = /* @__PURE__ */ new Set();
38794
+ const groups = /* @__PURE__ */ new Map();
38795
+ for (const s of sessions) {
38796
+ const cwd = normalizeCwd(s.cwd, home);
38797
+ if (!cwd) continue;
38798
+ allowed.add(cwd);
38799
+ const key = s.group ?? s.id;
38800
+ groups.set(key, [...groups.get(key) ?? [], cwd]);
38801
+ }
38802
+ for (const cwds of groups.values()) {
38803
+ const shared = cwds.length > 1 ? sharedFolder(cwds) : null;
38804
+ if (shared) allowed.add(shared);
38805
+ }
38806
+ return allowed;
38807
+ }
38808
+ function allowedCanvasCwd(cwd, sessions, home) {
38809
+ const normalized = normalizeCwd(cwd, home);
38810
+ return normalized && canvasCwds(sessions, home).has(normalized) ? normalized : null;
38811
+ }
38812
+ function canvasFolderOf(cwd) {
38813
+ return posix.join(cwd, CANVAS_FOLDER);
38814
+ }
38815
+
38816
+ // src/canvas/canvasFolder.ts
38817
+ import { constants as constants3 } from "node:fs";
38818
+ import { lstat, open as open5, readdir as readdir5 } from "node:fs/promises";
38819
+ import { dirname as dirname12, join as join16 } from "node:path";
38820
+
38821
+ // src/canvas/boardListing.ts
38822
+ var finderOrder2 = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
38823
+ function isBoardFile(name) {
38824
+ return CanvasFileName.safeParse(name).success;
38825
+ }
38826
+ function boardsFrom(files, max = CANVAS_BOARDS_MAX) {
38827
+ const sorted = files.filter((f) => isBoardFile(f.file)).sort((a, b) => finderOrder2.compare(a.file, b.file));
38828
+ const boards = sorted.slice(0, max).map((f) => ({
38829
+ file: f.file,
38830
+ ...boardNameOf(f.file),
38831
+ ...boardSizeOf(f.head),
38832
+ modified: new Date(f.mtimeMs).toISOString(),
38833
+ bytes: f.bytes
38834
+ }));
38835
+ return { boards, truncated: sorted.length > max };
38836
+ }
38837
+ function listingKey(listing) {
38838
+ if (listing.missing) return "missing";
38839
+ return listing.boards.map((b) => `${b.file}
38840
+ ${b.modified}
38841
+ ${b.bytes}`).join("\n\n");
38842
+ }
38843
+ function megabytes(bytes) {
38844
+ return (bytes / (1024 * 1024)).toFixed(1);
38845
+ }
38846
+ function boardTooLarge(file2, fileBytes, frameBytes, computer = "Mac") {
38847
+ if (fileBytes <= CANVAS_BOARD_MAX_BYTES && frameBytes <= CANVAS_BOARD_FRAME_MAX_BYTES) return null;
38848
+ const name = file2.replace(/\.html?$/i, "");
38849
+ 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}.`;
38850
+ }
38851
+
38852
+ // src/canvas/canvasFolder.ts
38853
+ var HEAD_BYTES = 8192;
38854
+ var CanvasError = class extends Error {
38855
+ };
38856
+ async function realFolder(path2) {
38857
+ const st = await lstat(path2).catch((e) => {
38858
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
38859
+ throw e;
38860
+ });
38861
+ if (!st) return false;
38862
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new CanvasError(`${path2} is not a folder of its own, so it is not served as a canvas.`);
38863
+ return true;
38864
+ }
38865
+ async function canvasThere(folder) {
38866
+ return await realFolder(dirname12(folder)) && await realFolder(folder);
38867
+ }
38868
+ async function openBoard(path2) {
38869
+ const handle = await open5(path2, constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK).catch(() => null);
38870
+ if (!handle) return null;
38871
+ const st = await handle.stat();
38872
+ if (!st.isFile()) {
38873
+ await handle.close();
38874
+ return null;
38875
+ }
38876
+ return {
38877
+ size: st.size,
38878
+ mtimeMs: st.mtimeMs,
38879
+ async read(max) {
38880
+ const buffer = Buffer.alloc(Math.min(max, st.size));
38881
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
38882
+ return buffer.subarray(0, bytesRead);
38883
+ },
38884
+ close: () => handle.close()
38885
+ };
38886
+ }
38887
+ async function listCanvas(folder, heads = /* @__PURE__ */ new Map()) {
38888
+ if (!await canvasThere(folder)) return { boards: [], missing: true };
38889
+ const entries = await readdir5(folder, { withFileTypes: true });
38890
+ const files = [];
38891
+ const seen = /* @__PURE__ */ new Set();
38892
+ for (const e of entries) {
38893
+ if (!e.isFile() || !isBoardFile(e.name)) continue;
38894
+ const st = await lstat(join16(folder, e.name)).catch(() => null);
38895
+ if (!st?.isFile()) continue;
38896
+ const key = `${e.name}
38897
+ ${st.mtimeMs}
38898
+ ${st.size}`;
38899
+ seen.add(key);
38900
+ let head = heads.get(key);
38901
+ if (head === void 0) {
38902
+ const board = await openBoard(join16(folder, e.name));
38903
+ if (!board) continue;
38904
+ try {
38905
+ head = (await board.read(HEAD_BYTES)).toString("utf8");
38906
+ } finally {
38907
+ await board.close();
38908
+ }
38909
+ heads.set(key, head);
38910
+ }
38911
+ files.push({ file: e.name, mtimeMs: st.mtimeMs, bytes: st.size, head });
38912
+ }
38913
+ for (const key of heads.keys()) if (!seen.has(key)) heads.delete(key);
38914
+ const { boards, truncated } = boardsFrom(files);
38915
+ return { boards, ...truncated ? { truncated: true } : {} };
38916
+ }
38917
+ async function readBoard(folder, file2, computer = "Mac") {
38918
+ const notHere = new CanvasError(`"${file2}" is not on this canvas.`);
38919
+ if (!isBoardFile(file2) || !await canvasThere(folder)) throw notHere;
38920
+ const board = await openBoard(join16(folder, file2));
38921
+ if (!board) throw notHere;
38922
+ try {
38923
+ const tooLarge = boardTooLarge(file2, board.size, 0, computer);
38924
+ if (tooLarge) throw new CanvasError(tooLarge);
38925
+ const data = await board.read(CANVAS_BOARD_MAX_BYTES);
38926
+ return { html: data.toString("utf8"), modified: new Date(board.mtimeMs).toISOString(), bytes: data.length };
38927
+ } finally {
38928
+ await board.close();
38929
+ }
38930
+ }
38931
+
38932
+ // src/canvas/canvasWatcher.ts
38933
+ var CANVAS_POLL_MS = 400;
38934
+ var realEvery = (fn, ms) => {
38935
+ const timer = setInterval(fn, ms);
38936
+ timer.unref?.();
38937
+ return () => clearInterval(timer);
38938
+ };
38939
+ var CanvasWatcher = class {
38940
+ constructor(every = realEvery, list = listCanvas, onError = () => {
38941
+ }) {
38942
+ this.every = every;
38943
+ this.list = list;
38944
+ this.onError = onError;
38945
+ }
38946
+ every;
38947
+ list;
38948
+ onError;
38949
+ folders = /* @__PURE__ */ new Map();
38950
+ /** Starts telling `onChange` of every change to `folder` after `current`, the listing the subscriber has. Returns how to stop. */
38951
+ watch(folder, current, onChange) {
38952
+ let watched = this.folders.get(folder);
38953
+ if (!watched) {
38954
+ const created = { subscribers: /* @__PURE__ */ new Set(), heads: /* @__PURE__ */ new Map(), busy: false, stop: () => {
38955
+ } };
38956
+ created.stop = this.every(() => void this.look(folder, created), CANVAS_POLL_MS);
38957
+ this.folders.set(folder, created);
38958
+ watched = created;
38959
+ }
38960
+ const subscriber = { lastKey: listingKey(current), onChange };
38961
+ watched.subscribers.add(subscriber);
38962
+ const w = watched;
38963
+ return () => {
38964
+ w.subscribers.delete(subscriber);
38965
+ if (w.subscribers.size === 0 && this.folders.get(folder) === w) {
38966
+ w.stop();
38967
+ this.folders.delete(folder);
38968
+ }
38969
+ };
38970
+ }
38971
+ /** How many folders are being polled. */
38972
+ get size() {
38973
+ return this.folders.size;
38974
+ }
38975
+ /** Looks at one folder now; a look still running makes this one a no-op. */
38976
+ async look(folder, watched = this.folders.get(folder)) {
38977
+ if (!watched || watched.busy) return;
38978
+ watched.busy = true;
38979
+ try {
38980
+ const listing = await this.list(folder, watched.heads);
38981
+ const key = listingKey(listing);
38982
+ for (const s of watched.subscribers) {
38983
+ if (s.lastKey === key) continue;
38984
+ s.lastKey = key;
38985
+ s.onChange(listing);
38986
+ }
38987
+ } catch (e) {
38988
+ this.onError(folder, e);
38989
+ } finally {
38990
+ watched.busy = false;
38991
+ }
38992
+ }
38993
+ /** Stops every poll (the daemon is stopping). */
38994
+ stopAll() {
38995
+ for (const w of this.folders.values()) w.stop();
38996
+ this.folders.clear();
38997
+ }
38998
+ };
38999
+
39000
+ // src/canvas/canvasService.ts
39001
+ var CanvasService = class {
39002
+ constructor(sessions, watcher = new CanvasWatcher(), home = homedir9(), computer = computerWord()) {
39003
+ this.sessions = sessions;
39004
+ this.watcher = watcher;
39005
+ this.home = home;
39006
+ this.computer = computer;
39007
+ }
39008
+ sessions;
39009
+ watcher;
39010
+ home;
39011
+ computer;
39012
+ /** The canvas folder of `cwd`, or `CanvasError` when no session works there. */
39013
+ folderOf(cwd) {
39014
+ const allowed = allowedCanvasCwd(cwd, this.sessions(), this.home);
39015
+ if (!allowed) throw new CanvasError(`No session on this ${this.computer} works in ${cwd}, so it has no canvas to show.`);
39016
+ return canvasFolderOf(allowed);
39017
+ }
39018
+ async list(cwd) {
39019
+ const folder = this.folderOf(cwd);
39020
+ return { folder, ...await listCanvas(folder) };
39021
+ }
39022
+ async board(cwd, file2) {
39023
+ return readBoard(this.folderOf(cwd), file2, this.computer);
39024
+ }
39025
+ /** The canvas now, and `onChange` with each change after it until `stop`. */
39026
+ async watch(cwd, onChange) {
39027
+ const reply2 = await this.list(cwd);
39028
+ const stop2 = this.watcher.watch(reply2.folder, reply2, (listing) => onChange({ folder: reply2.folder, ...listing }));
39029
+ return { reply: reply2, stop: stop2 };
39030
+ }
39031
+ stop() {
39032
+ this.watcher.stopAll();
39033
+ }
39034
+ };
39035
+
38606
39036
  // src/daemon/wsHandler.ts
38607
39037
  var HELLO_TIMEOUT_MS = 5e3;
38608
39038
  var NO_PUSH = { type: "push.state", registered: false, delivery: "off", events: [] };
@@ -38629,6 +39059,8 @@ var Connection = class {
38629
39059
  /** Live terminals this client has open, by session. */
38630
39060
  terms = /* @__PURE__ */ new Map();
38631
39061
  stopWatchingPush;
39062
+ /** Canvases this client watches, by `cwd` as it sent it (PROTOCOL.md "Canvas", "Watching"). */
39063
+ canvasWatches = /* @__PURE__ */ new Map();
38632
39064
  helloTimer;
38633
39065
  onUpdated = (s) => this.send({ type: "session.updated", session: s });
38634
39066
  onRemoved = (id) => {
@@ -38676,7 +39108,7 @@ var Connection = class {
38676
39108
  try {
38677
39109
  await this.dispatch(frame);
38678
39110
  } catch (e) {
38679
- const id = frame.type === "input" ? frame.id : void 0;
39111
+ const id = frame.type === "input" || frame.type === "canvas" || frame.type === "canvas.subscribe" || frame.type === "canvas.board" ? frame.id : void 0;
38680
39112
  if (e instanceof UnknownSessionError) return this.fail("unknown_session", e.message, frame.type, false, id);
38681
39113
  if (e instanceof SessionExistsError) return this.fail("tmux_failed", e.message, frame.type, false, id);
38682
39114
  if (e instanceof BadCwdError || e instanceof UnknownGroupError) return this.fail("bad_frame", e.message, frame.type, false, id);
@@ -38699,6 +39131,8 @@ var Connection = class {
38699
39131
  for (const id of this.subscriptions) this.d.registry.unsubscribe(id);
38700
39132
  this.subscriptions.clear();
38701
39133
  for (const id of [...this.terms.keys()]) this.closeTerm(id);
39134
+ for (const stop2 of this.canvasWatches.values()) stop2();
39135
+ this.canvasWatches.clear();
38702
39136
  if (this.authed) {
38703
39137
  this.d.registry.off("updated", this.onUpdated);
38704
39138
  this.d.registry.off("removed", this.onRemoved);
@@ -38751,7 +39185,7 @@ var Connection = class {
38751
39185
  }
38752
39186
  this.d.prompts?.on("opened", this.onPrompt);
38753
39187
  this.d.prompts?.on("closed", this.onPrompt);
38754
- for (const open5 of this.d.prompts?.list() ?? []) this.send(open5);
39188
+ for (const open7 of this.d.prompts?.list() ?? []) this.send(open7);
38755
39189
  this.d.activity?.on("activity", this.onActivity);
38756
39190
  }
38757
39191
  async dispatch(frame) {
@@ -38849,6 +39283,8 @@ ${frame.id}`, () => r.sendText(frame.sessionId, frame.text, frame.submit));
38849
39283
  return this.send({ type: "conversations", conversations: await this.d.conversations.list(this.anyAgentConversations) });
38850
39284
  case "folders":
38851
39285
  return this.send(await listFolders(frame.path));
39286
+ case "limits":
39287
+ return this.send({ type: "limits", limits: this.d.limits?.list() ?? [] });
38852
39288
  case "voice":
38853
39289
  if (!this.d.voice) return this.fail("bad_frame", "this daemon keeps no voice keys", frame.type);
38854
39290
  if (!this.watchesVoice) this.d.voice.on("changed", this.onVoice);
@@ -38875,6 +39311,11 @@ ${frame.id}`, () => r.sendText(frame.sessionId, frame.text, frame.submit));
38875
39311
  throw e;
38876
39312
  }
38877
39313
  }
39314
+ case "canvas":
39315
+ case "canvas.subscribe":
39316
+ case "canvas.unsubscribe":
39317
+ case "canvas.board":
39318
+ return this.handleCanvas(frame);
38878
39319
  case "session.group": {
38879
39320
  const before = r.get(frame.sessionId);
38880
39321
  const after = r.setGroup(frame.sessionId, frame.group, frame.index);
@@ -38930,6 +39371,40 @@ ${frame.id}`, () => r.sendText(frame.sessionId, frame.text, frame.submit));
38930
39371
  }
38931
39372
  }
38932
39373
  }
39374
+ /** The canvas frames (PROTOCOL.md "Canvas"): a refused folder, a missing or too large board is `bad_frame` with the request's `id`. */
39375
+ async handleCanvas(frame) {
39376
+ if (frame.type === "canvas.unsubscribe") {
39377
+ this.canvasWatches.get(frame.cwd)?.();
39378
+ this.canvasWatches.delete(frame.cwd);
39379
+ return;
39380
+ }
39381
+ const canvas = this.d.canvas;
39382
+ if (!canvas) return this.fail("bad_frame", "this daemon serves no canvas", frame.type, false, frame.id);
39383
+ const { cwd, id } = frame;
39384
+ const framed = (reply2, withId) => ({ type: "canvas", ...withId ? { id } : {}, cwd, ...reply2 });
39385
+ try {
39386
+ if (frame.type === "canvas") return this.send(framed(await canvas.list(cwd), true));
39387
+ if (frame.type === "canvas.subscribe") {
39388
+ const again = this.canvasWatches.get(cwd);
39389
+ if (!again && this.canvasWatches.size >= CANVAS_SUBSCRIPTIONS_MAX) {
39390
+ return this.fail("bad_frame", `A connection watches at most ${CANVAS_SUBSCRIPTIONS_MAX} canvases; unsubscribe from one first.`, frame.type, false, id);
39391
+ }
39392
+ const { reply: reply3, stop: stop2 } = await canvas.watch(cwd, (changed) => this.send(framed(changed, false)));
39393
+ if (!this.authed) return stop2();
39394
+ again?.();
39395
+ this.canvasWatches.set(cwd, stop2);
39396
+ return this.send(framed(reply3, true));
39397
+ }
39398
+ const board = await canvas.board(cwd, frame.file);
39399
+ const reply2 = { type: "canvas.board", id, cwd, file: frame.file, ...board };
39400
+ const tooLarge = boardTooLarge(frame.file, board.bytes, Buffer.byteLength(JSON.stringify(reply2)), computerWord());
39401
+ if (tooLarge) return this.fail("bad_frame", tooLarge, frame.type, false, id);
39402
+ return this.send(reply2);
39403
+ } catch (e) {
39404
+ if (e instanceof CanvasError) return this.fail("bad_frame", e.message, frame.type, false, id);
39405
+ throw e;
39406
+ }
39407
+ }
38933
39408
  openTerm(sessionId, cols, rows) {
38934
39409
  if (!this.d.openTerm) return this.fail("bad_frame", "this daemon streams no terminal", "term.open");
38935
39410
  this.requireSession(sessionId);
@@ -39016,7 +39491,7 @@ var PairingWatch = class {
39016
39491
  // src/attachments/attachmentStore.ts
39017
39492
  import { mkdir, writeFile } from "node:fs/promises";
39018
39493
  import { existsSync as existsSync19 } from "node:fs";
39019
- import { join as join16 } from "node:path";
39494
+ import { join as join17 } from "node:path";
39020
39495
 
39021
39496
  // src/attachments/attachmentName.ts
39022
39497
  var EXTENSION_FOR_MIME = {
@@ -39050,7 +39525,7 @@ function stamp(d) {
39050
39525
  function createAttachmentStore(dir, now = () => /* @__PURE__ */ new Date()) {
39051
39526
  return {
39052
39527
  async save(sessionId, name, mime, data) {
39053
- const folder = join16(dir, sessionId);
39528
+ const folder = join17(dir, sessionId);
39054
39529
  await mkdir(folder, { recursive: true });
39055
39530
  const path2 = unusedPath(folder, attachmentFileName(name, mime, now()));
39056
39531
  await writeFile(path2, data, { flag: "wx" });
@@ -39062,8 +39537,8 @@ function unusedPath(folder, fileName) {
39062
39537
  const dot = fileName.lastIndexOf(".");
39063
39538
  const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
39064
39539
  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}`);
39540
+ let candidate = join17(folder, fileName);
39541
+ for (let n = 2; existsSync19(candidate); n++) candidate = join17(folder, `${stem}-${n}${ext}`);
39067
39542
  return candidate;
39068
39543
  }
39069
39544
 
@@ -39078,14 +39553,14 @@ function accessHash(token) {
39078
39553
 
39079
39554
  // src/relay/e2eKey.ts
39080
39555
  import { existsSync as existsSync20, mkdirSync as mkdirSync13, readFileSync as readFileSync14, writeFileSync as writeFileSync15 } from "node:fs";
39081
- import { dirname as dirname12 } from "node:path";
39556
+ import { dirname as dirname13 } from "node:path";
39082
39557
  function loadOrCreateE2EKey(path2) {
39083
39558
  if (existsSync20(path2)) {
39084
39559
  const raw = Buffer.from(readFileSync14(path2, "utf8").trim(), "base64");
39085
39560
  if (raw.length === 32) return x25519FromRaw(raw);
39086
39561
  }
39087
39562
  const pair2 = generateX25519();
39088
- mkdirSync13(dirname12(path2), { recursive: true });
39563
+ mkdirSync13(dirname13(path2), { recursive: true });
39089
39564
  writeFileSync15(path2, privateRaw(pair2.privateKey).toString("base64") + "\n", { mode: 384 });
39090
39565
  return pair2;
39091
39566
  }
@@ -39334,7 +39809,7 @@ var RelayLink = class {
39334
39809
 
39335
39810
  // src/push/boardDevices.ts
39336
39811
  import { existsSync as existsSync21, mkdirSync as mkdirSync14, readFileSync as readFileSync15, writeFileSync as writeFileSync16 } from "node:fs";
39337
- import { dirname as dirname13 } from "node:path";
39812
+ import { dirname as dirname14 } from "node:path";
39338
39813
  var BoardDevices = class {
39339
39814
  constructor(path2) {
39340
39815
  this.path = path2;
@@ -39377,7 +39852,7 @@ var BoardDevices = class {
39377
39852
  }
39378
39853
  save() {
39379
39854
  if (!this.path) return;
39380
- mkdirSync14(dirname13(this.path), { recursive: true });
39855
+ mkdirSync14(dirname14(this.path), { recursive: true });
39381
39856
  writeFileSync16(this.path, JSON.stringify(this.list(), null, 2) + "\n", { mode: 384 });
39382
39857
  }
39383
39858
  };
@@ -39630,7 +40105,7 @@ var BoardPusher = class {
39630
40105
 
39631
40106
  // src/push/pushDevices.ts
39632
40107
  import { existsSync as existsSync22, mkdirSync as mkdirSync15, readFileSync as readFileSync16, writeFileSync as writeFileSync17 } from "node:fs";
39633
- import { dirname as dirname14 } from "node:path";
40108
+ import { dirname as dirname15 } from "node:path";
39634
40109
  var PushDevices = class {
39635
40110
  constructor(path2) {
39636
40111
  this.path = path2;
@@ -39673,7 +40148,7 @@ var PushDevices = class {
39673
40148
  }
39674
40149
  save() {
39675
40150
  if (!this.path) return;
39676
- mkdirSync15(dirname14(this.path), { recursive: true });
40151
+ mkdirSync15(dirname15(this.path), { recursive: true });
39677
40152
  writeFileSync17(this.path, JSON.stringify(this.list(), null, 2) + "\n", { mode: 384 });
39678
40153
  }
39679
40154
  };
@@ -40284,6 +40759,87 @@ function promptText2(prompt) {
40284
40759
  }
40285
40760
  }
40286
40761
 
40762
+ // src/usage/planLimits.ts
40763
+ var PlanLimits = class {
40764
+ byAgent = /* @__PURE__ */ new Map();
40765
+ /** An agent's latest windows replace what it reported before. A report with none changes nothing. */
40766
+ record(agent, limits) {
40767
+ if (limits.length > 0) this.byAgent.set(agent, [...limits]);
40768
+ }
40769
+ /** Every window not yet reset at `now`, shortest first, then by agent. */
40770
+ list(now = Date.now()) {
40771
+ return [...this.byAgent.values()].flat().filter((l) => l.resetsAt === void 0 || Date.parse(l.resetsAt) > now).sort((a, b) => a.minutes - b.minutes || a.agent.localeCompare(b.agent)).slice(0, LIMITS_MAX);
40772
+ }
40773
+ };
40774
+
40775
+ // src/usage/readUsage.ts
40776
+ import { readFileSync as readFileSync17 } from "node:fs";
40777
+ import { open as open6 } from "node:fs/promises";
40778
+
40779
+ // src/usage/codexUsage.ts
40780
+ function codexUsageIn(jsonl) {
40781
+ const lines = jsonl.split("\n");
40782
+ for (let i = lines.length - 1; i >= 0; i--) {
40783
+ const line = lines[i];
40784
+ if (!line.includes('"token_count"')) continue;
40785
+ const entry = parse7(line);
40786
+ if (entry?.payload?.type !== "token_count") continue;
40787
+ const at = typeof entry.timestamp === "string" && !Number.isNaN(Date.parse(entry.timestamp)) ? new Date(entry.timestamp).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
40788
+ const info = entry.payload.info ?? {};
40789
+ const used = info.last_token_usage?.total_tokens;
40790
+ const size = info.model_context_window;
40791
+ const context = typeof used === "number" && typeof size === "number" && size >= 1 ? { used: Math.max(0, Math.round(used)), size: Math.round(size) } : void 0;
40792
+ const limits = [];
40793
+ const rl = entry.payload.rate_limits ?? {};
40794
+ for (const key of ["primary", "secondary"]) {
40795
+ const w = rl[key];
40796
+ if (!w || typeof w.used_percent !== "number" || typeof w.window_minutes !== "number" || w.window_minutes < 1) continue;
40797
+ const resets = typeof w.resets_at === "number" ? w.resets_at * 1e3 : typeof w.resets_in_seconds === "number" ? Date.parse(at) + w.resets_in_seconds * 1e3 : void 0;
40798
+ limits.push({
40799
+ agent: "codex",
40800
+ minutes: Math.round(w.window_minutes),
40801
+ usedPercent: Math.max(0, Math.min(100, w.used_percent)),
40802
+ ...resets !== void 0 ? { resetsAt: new Date(resets).toISOString() } : {},
40803
+ at
40804
+ });
40805
+ }
40806
+ return { ...context ? { context } : {}, limits };
40807
+ }
40808
+ return null;
40809
+ }
40810
+ function parse7(line) {
40811
+ try {
40812
+ return JSON.parse(line);
40813
+ } catch {
40814
+ return null;
40815
+ }
40816
+ }
40817
+
40818
+ // src/usage/readUsage.ts
40819
+ var TAIL_BYTES2 = 256 * 1024;
40820
+ async function readTail(path2) {
40821
+ const file2 = await open6(path2, "r");
40822
+ try {
40823
+ const { size } = await file2.stat();
40824
+ const length = Math.min(size, TAIL_BYTES2);
40825
+ const buffer = Buffer.alloc(length);
40826
+ await file2.read(buffer, 0, length, size - length);
40827
+ return buffer.toString("utf8");
40828
+ } finally {
40829
+ await file2.close();
40830
+ }
40831
+ }
40832
+ async function readCodexUsage(rolloutPath) {
40833
+ return codexUsageIn(await readTail(rolloutPath));
40834
+ }
40835
+ function readUserStatusLine(settingsPath) {
40836
+ try {
40837
+ return userStatusLineIn(JSON.parse(readFileSync17(settingsPath, "utf8")));
40838
+ } catch {
40839
+ return void 0;
40840
+ }
40841
+ }
40842
+
40287
40843
  // src/daemon/server.ts
40288
40844
  async function startDaemon(opts = {}) {
40289
40845
  ensureDir();
@@ -40291,10 +40847,16 @@ async function startDaemon(opts = {}) {
40291
40847
  const controlPort2 = opts.controlPort ?? CONTROL_PORT;
40292
40848
  const log = opts.log ?? createLogger({ file: paths.log, level: process.env["GRENADE_LOG"] ?? "info" });
40293
40849
  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] };
40850
+ 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, limits: 1, codexActivity: 1, agents: [...AGENTS] };
40295
40851
  const allowPlainLan = opts.allowPlainLan === true;
40296
- const tmux = opts.tmux ?? createTmux({ agentFlags: { claude: claudeHookFlags(port), codex: codexHookFlags(port) }, serverScope: underSystemd() });
40297
- const tokens = new TokenStore(opts.tokensPath === null ? void 0 : opts.tokensPath ?? paths.tokens);
40852
+ const tmux = opts.tmux ?? createTmux({ agentFlags: {
40853
+ // Read as each session starts, so a status line the user set up since is the one it runs.
40854
+ get claude() {
40855
+ return claudeHookFlags(port, readUserStatusLine(join18(opts.claudeDir ?? paths.claudeDir, "settings.json")));
40856
+ },
40857
+ codex: codexHookFlags(port)
40858
+ }, serverScope: underSystemd() });
40859
+ const tokens2 = new TokenStore(opts.tokensPath === null ? void 0 : opts.tokensPath ?? paths.tokens);
40298
40860
  const codes = new PairingCodes();
40299
40861
  const pairing = new PairingWatch();
40300
40862
  const registry2 = new SessionRegistry({
@@ -40304,13 +40866,14 @@ async function startDaemon(opts = {}) {
40304
40866
  });
40305
40867
  const summarizer = startSummarizer(registry2, log, opts.summaries ?? process.env["GRENADE_SUMMARIES"] !== "off");
40306
40868
  await registry2.adopt();
40307
- const groupsPath = opts.sessionsPath === null ? void 0 : opts.sessionsPath ? join17(dirname15(opts.sessionsPath), "groups.json") : paths.groups;
40869
+ const groupsPath = opts.sessionsPath === null ? void 0 : opts.sessionsPath ? join18(dirname16(opts.sessionsPath), "groups.json") : paths.groups;
40308
40870
  const groupOrder = new GroupOrderStore(registry2, log, groupsPath);
40309
40871
  const poller = startPoller({ tmux, registry: registry2, log });
40310
40872
  const pinnedTerminal = opts.terminal ?? terminalFromEnv();
40311
40873
  const mirror = new TerminalMirror({ registry: registry2, log, terminal: pinnedTerminal ?? (() => readTerminalSetting()) });
40312
40874
  const terminalStatus = () => ({ ...mirror.status(), pinned: pinnedTerminal !== void 0 });
40313
40875
  mirror.start().catch((e) => log.warn("Could not mirror sessions into a terminal", { error: e }));
40876
+ const planLimits = new PlanLimits();
40314
40877
  const http = createServer2(async (req, res) => {
40315
40878
  const url2 = new URL(req.url ?? "/", "http://localhost");
40316
40879
  try {
@@ -40324,6 +40887,11 @@ async function startDaemon(opts = {}) {
40324
40887
  closePromptsByHook(prompts, session, body);
40325
40888
  return sendJson(res, r.status, r.body);
40326
40889
  }
40890
+ if (req.method === "POST" && url2.pathname === STATUS_LINE_PATH) {
40891
+ if (!isLoopback(req.socket.remoteAddress)) return sendJson(res, 403, { error: "forbidden" });
40892
+ applyStatusLine(url2.searchParams.get("session"), await readBody(req));
40893
+ return sendJson(res, 200, { ok: true });
40894
+ }
40327
40895
  if (req.method === "POST" && url2.pathname === "/hooks/codex") {
40328
40896
  if (!isLoopback(req.socket.remoteAddress)) return sendJson(res, 403, { error: "forbidden" });
40329
40897
  const r = applyCodexHook(url2.searchParams.get("session"), await readBody(req));
@@ -40376,7 +40944,7 @@ async function startDaemon(opts = {}) {
40376
40944
  for (const { id, path: path2 } of registry2.transcripts()) {
40377
40945
  readActivity(id, path2).catch((e) => log.debug("Could not read the activity from a saved transcript", { session: id, path: path2, error: e }));
40378
40946
  }
40379
- const marksPath = opts.sessionsPath === null ? void 0 : opts.sessionsPath ? join17(dirname15(opts.sessionsPath), "conversations.json") : paths.conversations;
40947
+ const marksPath = opts.sessionsPath === null ? void 0 : opts.sessionsPath ? join18(dirname16(opts.sessionsPath), "conversations.json") : paths.conversations;
40380
40948
  const marks = new ConversationMarks(log, marksPath);
40381
40949
  const held = () => heldConversations(registry2.transcripts(), (id) => (registry2.get(id)?.status ?? "gone") !== "gone");
40382
40950
  const conversationIndex = new AllConversations([
@@ -40390,6 +40958,7 @@ async function startDaemon(opts = {}) {
40390
40958
  log,
40391
40959
  ...opts.voiceFetch ? { fetch: opts.voiceFetch } : {}
40392
40960
  });
40961
+ 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
40962
  const models = {
40394
40963
  async switch(session, model, effort) {
40395
40964
  if (switchingModel.has(session.id)) throw new ModelSwitchError("A model switch is already under way in this session.");
@@ -40464,6 +41033,10 @@ async function startDaemon(opts = {}) {
40464
41033
  },
40465
41034
  onTranscript: (id, path2, event) => {
40466
41035
  registry2.setTranscript(id, path2);
41036
+ readCodexUsage(path2).then((usage) => {
41037
+ if (usage?.context) registry2.setContext(id, usage.context);
41038
+ if (usage) planLimits.record("codex", usage.limits);
41039
+ }).catch((e) => log.debug("Could not read the usage from a Codex rollout", { session: id, path: path2, error: e }));
40467
41040
  catchUp.cancel(id);
40468
41041
  codexStopCatchUp.cancel(id);
40469
41042
  readActivity(id, path2).then((entries) => {
@@ -40472,6 +41045,17 @@ async function startDaemon(opts = {}) {
40472
41045
  }).catch((e) => log.debug("Could not read the activity from a Codex rollout", { session: id, path: path2, error: e }));
40473
41046
  }
40474
41047
  });
41048
+ function applyStatusLine(session, body) {
41049
+ let payload;
41050
+ try {
41051
+ payload = JSON.parse(body || "{}");
41052
+ } catch {
41053
+ return;
41054
+ }
41055
+ const usage = claudeStatusUsage(payload, (/* @__PURE__ */ new Date()).toISOString());
41056
+ planLimits.record("claude", usage.limits);
41057
+ if (session && usage.context) registry2.setContext(session, usage.context);
41058
+ }
40475
41059
  function handlePair(raw, res) {
40476
41060
  if (!allowPlainLan) return sendJson(res, 426, { error: "encryption_required" });
40477
41061
  let json2;
@@ -40485,7 +41069,7 @@ async function startDaemon(opts = {}) {
40485
41069
  const verdict = codes.verify(body.data.code);
40486
41070
  if (verdict === "too_many_attempts") return sendJson(res, 429, { error: verdict });
40487
41071
  if (verdict === "invalid_code") return sendJson(res, 400, { error: verdict });
40488
- const token = tokens.issue(body.data.client);
41072
+ const token = tokens2.issue(body.data.client);
40489
41073
  pairing.paired(body.data.client, "lan");
40490
41074
  log.info(`Paired a new phone: ${body.data.client.name}`, { platform: body.data.client.platform });
40491
41075
  sendJson(res, 200, { token, daemon: info });
@@ -40494,7 +41078,7 @@ async function startDaemon(opts = {}) {
40494
41078
  const verdict = codes.verify(credential);
40495
41079
  if (verdict === "too_many_attempts") pairing.voided();
40496
41080
  if (verdict !== "ok") return { ok: false, code: verdict };
40497
- const token = tokens.issue(client, { sealed: true });
41081
+ const token = tokens2.issue(client, { sealed: true });
40498
41082
  pairing.paired(client, route2);
40499
41083
  log.info(`Paired a new phone: ${client.name}`, { platform: client.platform, route: route2 });
40500
41084
  return { ok: true, token };
@@ -40515,7 +41099,7 @@ async function startDaemon(opts = {}) {
40515
41099
  const attachments = createAttachmentStore(opts.attachmentsDir ?? paths.attachments);
40516
41100
  const push2 = startPush({
40517
41101
  registry: registry2,
40518
- tokens,
41102
+ tokens: tokens2,
40519
41103
  daemon: info,
40520
41104
  staticKey: e2eKey,
40521
41105
  relay: () => opts.relay === false ? null : loadRelayConfig(opts.relayPath ?? paths.relay),
@@ -40531,7 +41115,7 @@ async function startDaemon(opts = {}) {
40531
41115
  prompts.on("answered", (id) => registry2.applyHook(id, "working", void 0, true));
40532
41116
  const screenPrompts = new ScreenPrompts(registry2, prompts, log, (id) => switchingModel.has(id));
40533
41117
  watchScreenBackground(registry2);
40534
- const backgroundWatch = new ClaudeBackgroundWatch(registry2, join17(opts.claudeDir ?? paths.claudeDir, "sessions"), log);
41118
+ const backgroundWatch = new ClaudeBackgroundWatch(registry2, join18(opts.claudeDir ?? paths.claudeDir, "sessions"), log);
40535
41119
  backgroundWatch.start();
40536
41120
  registry2.on("removed", (id) => {
40537
41121
  prompts.closeSession(id);
@@ -40552,13 +41136,13 @@ async function startDaemon(opts = {}) {
40552
41136
  return closed;
40553
41137
  }
40554
41138
  const devices = {
40555
- list: () => tokens.list().map((r) => deviceOf(r, live2.routesOf(r.token))),
41139
+ list: () => tokens2.list().map((r) => deviceOf(r, live2.routesOf(r.token))),
40556
41140
  unpair(id) {
40557
- const gone = tokens.revoke(id);
41141
+ const gone = tokens2.revoke(id);
40558
41142
  return gone ? { closed: closeConnectionsOf([gone], `unpaired on the ${computerWord()}`) } : null;
40559
41143
  },
40560
41144
  unpairAll() {
40561
- const gone = tokens.revokeAll();
41145
+ const gone = tokens2.revokeAll();
40562
41146
  return { removed: gone.length, closed: closeConnectionsOf(gone, `unpaired on the ${computerWord()}`) };
40563
41147
  }
40564
41148
  };
@@ -40569,19 +41153,19 @@ async function startDaemon(opts = {}) {
40569
41153
  sentInputs,
40570
41154
  activity,
40571
41155
  interrupted,
40572
- isValidToken: (t) => tokens.has(t),
41156
+ isValidToken: (t) => tokens2.has(t),
40573
41157
  sealed: via.sealed,
40574
41158
  route: via.route,
40575
41159
  // Plain is for phones that were never encrypted, and only while the daemon allows it.
40576
- acceptsPlain: (t) => allowPlainLan && tokens.get(t)?.sealed !== true,
41160
+ acceptsPlain: (t) => allowPlainLan && tokens2.get(t)?.sealed !== true,
40577
41161
  onHello(connection, token, client) {
40578
41162
  live2.add(token, connection);
40579
- tokens.touch(token, { sealed: via.sealed, client });
40580
- if (!via.sealed) log.warn(`${client.name} connected without encryption. Update Grenade on that phone.`, { device: tokens.get(token)?.id });
41163
+ tokens2.touch(token, { sealed: via.sealed, client });
41164
+ if (!via.sealed) log.warn(`${client.name} connected without encryption. Update Grenade on that phone.`, { device: tokens2.get(token)?.id });
40581
41165
  },
40582
41166
  onEnd(connection, token) {
40583
41167
  live2.remove(token, connection);
40584
- tokens.touch(token);
41168
+ tokens2.touch(token);
40585
41169
  },
40586
41170
  pair: pairPhone,
40587
41171
  push: push2.pusher,
@@ -40590,20 +41174,22 @@ async function startDaemon(opts = {}) {
40590
41174
  groups: groupOrder,
40591
41175
  models,
40592
41176
  voice,
41177
+ canvas,
41178
+ limits: planLimits,
40593
41179
  conversations,
40594
- openTerm(open5) {
41180
+ openTerm(open7) {
40595
41181
  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),
41182
+ ...open7,
41183
+ resize: (cols, rows, by) => registry2.resize(open7.sessionId, cols, rows, by),
41184
+ release: (by) => registry2.releaseSize(open7.sessionId, by),
40599
41185
  log
40600
41186
  });
40601
41187
  stream.start();
40602
41188
  return stream;
40603
41189
  },
40604
41190
  unpair(token) {
40605
- const id = tokens.get(token)?.id;
40606
- const gone = id ? tokens.revoke(id) : void 0;
41191
+ const id = tokens2.get(token)?.id;
41192
+ const gone = id ? tokens2.revoke(id) : void 0;
40607
41193
  if (gone) closeConnectionsOf([gone], "unpaired on the phone");
40608
41194
  },
40609
41195
  daemon: info,
@@ -40631,7 +41217,7 @@ async function startDaemon(opts = {}) {
40631
41217
  });
40632
41218
  const deviceIdleMs = opts.deviceIdleMs ?? DEVICE_IDLE_MS;
40633
41219
  const unpairIdle = () => {
40634
- if (deviceIdleMs > 0) closeConnectionsOf(tokens.revokeIdle(deviceIdleMs), "not seen for a long time");
41220
+ if (deviceIdleMs > 0) closeConnectionsOf(tokens2.revokeIdle(deviceIdleMs), "not seen for a long time");
40635
41221
  };
40636
41222
  unpairIdle();
40637
41223
  const idleTimer = setInterval(unpairIdle, 60 * 60 * 1e3);
@@ -40653,7 +41239,7 @@ async function startDaemon(opts = {}) {
40653
41239
  config: config2,
40654
41240
  name: info.name,
40655
41241
  version: VERSION,
40656
- accessHashes: () => [...tokens.list().map((t) => accessHash(t.token)), ...offerAccess()],
41242
+ accessHashes: () => [...tokens2.list().map((t) => accessHash(t.token)), ...offerAccess()],
40657
41243
  localIps: () => localIpv4(networkInterfaces()),
40658
41244
  openPipe: (conn, send, onEnd) => new PhonePipe({ conn, staticKey: e2eKey, send, makeConnection: (out, close) => makeConnection(out, close, { route: "relay", sealed: true }), log, onEnd }),
40659
41245
  log
@@ -40673,7 +41259,7 @@ async function startDaemon(opts = {}) {
40673
41259
  auto: () => opts.updates?.auto ?? readAuto()
40674
41260
  });
40675
41261
  const relayStatus = () => relayLink?.status() ?? { state: "off", phones: 0, ...opts.relay === false ? { disabled: true } : {} };
40676
- tokens.onChange(() => relayLink?.tokensChanged());
41262
+ tokens2.onChange(() => relayLink?.tokensChanged());
40677
41263
  startRelay();
40678
41264
  const control2 = createControlServer({
40679
41265
  registry: registry2,
@@ -40727,6 +41313,7 @@ async function startDaemon(opts = {}) {
40727
41313
  mirror.stop();
40728
41314
  poller.stop();
40729
41315
  relayLink?.stop();
41316
+ canvas.stop();
40730
41317
  push2.stop();
40731
41318
  prompts.closeAll();
40732
41319
  summarizer?.stop();
@@ -40918,18 +41505,18 @@ program2.command("kill <name>").description("end a session").action(async (name)
40918
41505
  console.log(`killed ${id}`);
40919
41506
  });
40920
41507
  program2.command("install-hooks").description("also let a claude typed by hand in a Grenade shell report to grenaded (edits ~/.claude/settings.json); sessions Grenade starts have the hooks already").option("--port <port>", "daemon port the hooks post to", parsePort, DEFAULT_PORT).option("--print", "print the resulting settings instead of writing").option("--remove", "remove Grenade hooks").action((o) => {
40921
- const current = existsSync23(paths.claudeSettings) ? JSON.parse(readFileSync17(paths.claudeSettings, "utf8")) : {};
41508
+ const current = existsSync23(paths.claudeSettings) ? JSON.parse(readFileSync18(paths.claudeSettings, "utf8")) : {};
40922
41509
  const { settings, changed } = o.remove ? removeHooks(current) : mergeHooks(current, o.port);
40923
41510
  if (o.print) return console.log(JSON.stringify(settings, null, 2));
40924
41511
  if (o.remove) removeOldCodexHooks();
40925
41512
  if (!changed) return console.log(`nothing to do: ${paths.claudeSettings} already up to date`);
40926
- mkdirSync16(dirname16(paths.claudeSettings), { recursive: true });
41513
+ mkdirSync16(dirname17(paths.claudeSettings), { recursive: true });
40927
41514
  writeFileSync18(paths.claudeSettings, JSON.stringify(settings, null, 2) + "\n");
40928
41515
  console.log(`${o.remove ? "removed Grenade hooks from" : "installed Grenade hooks in"} ${paths.claudeSettings}`);
40929
41516
  });
40930
41517
  function removeOldCodexHooks() {
40931
41518
  if (!existsSync23(paths.codexHooks)) return;
40932
- const { settings, changed } = removeCodexHooks(JSON.parse(readFileSync17(paths.codexHooks, "utf8")));
41519
+ const { settings, changed } = removeCodexHooks(JSON.parse(readFileSync18(paths.codexHooks, "utf8")));
40933
41520
  if (!changed) return;
40934
41521
  writeFileSync18(paths.codexHooks, JSON.stringify(settings, null, 2) + "\n");
40935
41522
  console.log(`removed Grenade hooks from ${paths.codexHooks}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holdgrenade/cli",
3
- "version": "1.0.48",
3
+ "version": "1.0.50",
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": {