@holdgrenade/cli 1.0.49 → 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 +265 -69
  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 open6() {
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 open6() {
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,7 +7024,7 @@ 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";
7027
+ import { existsSync as existsSync23, mkdirSync as mkdirSync16, readFileSync as readFileSync18, writeFileSync as writeFileSync18 } from "node:fs";
7028
7028
  import { dirname as dirname17 } from "node:path";
7029
7029
 
7030
7030
  // node_modules/commander/lib/error.js
@@ -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,25 @@ 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
+
30903
30922
  // ../grenade-protocol/dist/canvas.js
30904
30923
  var CANVAS_FOLDER = ".grenade/canvas";
30905
30924
  var CANVAS_BOARDS_MAX = 500;
@@ -31161,6 +31180,8 @@ var Session = external_exports.object({
31161
31180
  model: external_exports.string().min(1).max(MODEL_NAME_MAX).optional(),
31162
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. */
31163
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(),
31164
31185
  createdAt: external_exports.string().datetime(),
31165
31186
  /** Sessions with the same group are shown together. Absent (older daemons) means the session is on its own. */
31166
31187
  group: external_exports.string().min(1).optional(),
@@ -31216,6 +31237,8 @@ var DaemonInfo = external_exports.object({
31216
31237
  voice: external_exports.literal(1).optional(),
31217
31238
  /** 1 when the daemon serves a group's design canvas: `canvas`, `canvas.subscribe`, `canvas.board` (PROTOCOL.md "Canvas"). Absent from older daemons. */
31218
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(),
31219
31242
  /** Retired: 1 when the daemon sends `activity` for Codex sessions too. Still sent for apps that read it; new clients read `agents`. */
31220
31243
  codexActivity: external_exports.literal(1).optional(),
31221
31244
  /** Every agent the daemon can start and what it does with each (PROTOCOL.md "Agents"). Absent from older daemons. */
@@ -31360,6 +31383,7 @@ var ClientFrame = external_exports.discriminatedUnion("type", [
31360
31383
  CanvasSubscribeFrame,
31361
31384
  CanvasUnsubscribeFrame,
31362
31385
  CanvasBoardRequestFrame,
31386
+ LimitsRequestFrame,
31363
31387
  PingFrame
31364
31388
  ]);
31365
31389
  var ErrorCode = external_exports.enum([
@@ -31458,6 +31482,7 @@ var DaemonFrame = external_exports.discriminatedUnion("type", [
31458
31482
  VoiceTokenFrame,
31459
31483
  CanvasFrame,
31460
31484
  CanvasBoardFrame,
31485
+ LimitsFrame,
31461
31486
  PongFrame
31462
31487
  ]);
31463
31488
  var PairRequest = external_exports.object({ code: external_exports.string().regex(/^\d{6}$/), client: ClientInfo });
@@ -33403,6 +33428,53 @@ function shellQuote(s) {
33403
33428
  return `'${s.replace(/'/g, `'\\''`)}'`;
33404
33429
  }
33405
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
+
33406
33478
  // src/hooks/installHooks.ts
33407
33479
  var HOOK_EVENTS = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Notification", "Stop", "StopFailure", "SessionEnd"];
33408
33480
  var PROMPT_EVENT = "PermissionRequest";
@@ -33424,15 +33496,16 @@ function mergeHooks(input2, port) {
33424
33496
  const command = { type: "command", command: hookCommand(port) };
33425
33497
  return mergeEntries(input2, [...HOOK_EVENTS.map((e) => [e, command]), [PROMPT_EVENT, promptHook(port)]], isGrenadeHook);
33426
33498
  }
33427
- function claudeHookFlags(port) {
33428
- 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))}`;
33429
33502
  }
33430
33503
  function removeHooks(input2) {
33431
33504
  return removeEntries(input2, isGrenadeHook);
33432
33505
  }
33433
33506
  function mergeEntries(input2, wanted, isOurs) {
33434
- const settings = isObject3(input2) ? structuredClone(input2) : {};
33435
- const hooks = isObject3(settings.hooks) ? settings.hooks : {};
33507
+ const settings = isObject4(input2) ? structuredClone(input2) : {};
33508
+ const hooks = isObject4(settings.hooks) ? settings.hooks : {};
33436
33509
  settings.hooks = hooks;
33437
33510
  let changed = false;
33438
33511
  for (const [event, entry] of wanted) {
@@ -33453,8 +33526,8 @@ function mergeEntries(input2, wanted, isOurs) {
33453
33526
  return { settings, changed };
33454
33527
  }
33455
33528
  function removeEntries(input2, isOurs) {
33456
- const settings = isObject3(input2) ? structuredClone(input2) : {};
33457
- const hooks = isObject3(settings.hooks) ? settings.hooks : {};
33529
+ const settings = isObject4(input2) ? structuredClone(input2) : {};
33530
+ const hooks = isObject4(settings.hooks) ? settings.hooks : {};
33458
33531
  let changed = false;
33459
33532
  for (const [event, matchers] of Object.entries(hooks)) {
33460
33533
  if (!Array.isArray(matchers)) continue;
@@ -33464,7 +33537,7 @@ function removeEntries(input2, isOurs) {
33464
33537
  }
33465
33538
  return { settings, changed };
33466
33539
  }
33467
- function isObject3(v) {
33540
+ function isObject4(v) {
33468
33541
  return typeof v === "object" && v !== null && !Array.isArray(v);
33469
33542
  }
33470
33543
 
@@ -34558,12 +34631,12 @@ function terminalFromEnv() {
34558
34631
  const env = process.env["GRENADE_TERMINAL"];
34559
34632
  return env !== void 0 && isTerminalKind(env) ? env : void 0;
34560
34633
  }
34561
- function relayoutSteps(ordered, open6) {
34634
+ function relayoutSteps(ordered, open7) {
34562
34635
  const [head, ...rest] = ordered;
34563
34636
  if (head === void 0) return [];
34564
- const keepHead = open6.has(head);
34637
+ const keepHead = open7.has(head);
34565
34638
  const steps = [];
34566
- for (const id of keepHead ? rest : ordered) if (open6.has(id)) steps.push({ kind: "close", id });
34639
+ for (const id of keepHead ? rest : ordered) if (open7.has(id)) steps.push({ kind: "close", id });
34567
34640
  if (!keepHead) steps.push({ kind: "tab", id: head });
34568
34641
  rest.forEach((id, i) => steps.push({ kind: "split", id, nextTo: ordered[i] }));
34569
34642
  return steps;
@@ -34717,9 +34790,9 @@ var TerminalMirror = class {
34717
34790
  /** The nearest live member before `s` in group order that has a pane in `a`, else the last one that has. */
34718
34791
  openMemberOf(s, a) {
34719
34792
  if (s.group === void 0) return void 0;
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;
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;
34723
34796
  }
34724
34797
  closeTab(id) {
34725
34798
  const a = this.openIn.get(id);
@@ -35558,6 +35631,18 @@ var SessionRegistry = class extends EventEmitter2 {
35558
35631
  this.persist();
35559
35632
  this.emit("updated", r.session);
35560
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
+ }
35561
35646
  /**
35562
35647
  * A client switched the session to `model` (PROTOCOL.md "Models") and the agent took it: the session says so at
35563
35648
  * once, before the agent's next reply. `effort` undefined is a model that takes none.
@@ -36809,13 +36894,13 @@ function switchModelDialog(below) {
36809
36894
  }
36810
36895
  function paragraphs(rows) {
36811
36896
  const out = [];
36812
- let open6 = false;
36897
+ let open7 = false;
36813
36898
  for (const row of rows.map((r) => r.trim())) {
36814
- if (!row) open6 = false;
36815
- else if (open6) out[out.length - 1] += ` ${row}`;
36899
+ if (!row) open7 = false;
36900
+ else if (open7) out[out.length - 1] += ` ${row}`;
36816
36901
  else {
36817
36902
  out.push(row);
36818
- open6 = true;
36903
+ open7 = true;
36819
36904
  }
36820
36905
  }
36821
36906
  return out.map((p) => /[.!?:]$/.test(p) ? p : `${p}.`).join(" ");
@@ -36852,25 +36937,25 @@ async function switchClaudeModel(term, model, effort, timing = DEFAULT_TIMING) {
36852
36937
  throw new ModelSwitchError("Claude Code's prompt is not empty. Send or clear what is typed there, then choose again.");
36853
36938
  }
36854
36939
  await term.key("enter");
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++) {
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++) {
36859
36944
  if (moves >= MOST_MOVES) await giveUp(`Claude Code's picker has no ${model}.`);
36860
- const down = rowsDownTo(open6, model);
36945
+ const down = rowsDownTo(open7, model);
36861
36946
  await term.key(down !== null && down > 0 ? "down" : "up");
36862
36947
  await timing.wait(LOOK_EVERY_MS);
36863
- open6 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36948
+ open7 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36864
36949
  }
36865
36950
  if (effort !== void 0) {
36866
- for (let steps = 0; open6.effort !== effort; steps++) {
36951
+ for (let steps = 0; open7.effort !== effort; steps++) {
36867
36952
  if (steps >= MOST_EFFORT_STEPS) await giveUp(`${model} has no ${effort} effort in Claude Code.`);
36868
36953
  await term.type(RIGHT);
36869
36954
  await timing.wait(LOOK_EVERY_MS);
36870
- open6 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36955
+ open7 = await picker() ?? await giveUp("Claude Code closed its model picker.");
36871
36956
  }
36872
36957
  }
36873
- const chosenEffort = open6.effort ?? void 0;
36958
+ const chosenEffort = open7.effort ?? void 0;
36874
36959
  await term.type("s");
36875
36960
  const closed = await look2((lines) => modelPickerIn(lines) ? null : lines);
36876
36961
  if (!closed) await giveUp("Claude Code did not take the choice.");
@@ -39100,7 +39185,7 @@ var Connection = class {
39100
39185
  }
39101
39186
  this.d.prompts?.on("opened", this.onPrompt);
39102
39187
  this.d.prompts?.on("closed", this.onPrompt);
39103
- for (const open6 of this.d.prompts?.list() ?? []) this.send(open6);
39188
+ for (const open7 of this.d.prompts?.list() ?? []) this.send(open7);
39104
39189
  this.d.activity?.on("activity", this.onActivity);
39105
39190
  }
39106
39191
  async dispatch(frame) {
@@ -39198,6 +39283,8 @@ ${frame.id}`, () => r.sendText(frame.sessionId, frame.text, frame.submit));
39198
39283
  return this.send({ type: "conversations", conversations: await this.d.conversations.list(this.anyAgentConversations) });
39199
39284
  case "folders":
39200
39285
  return this.send(await listFolders(frame.path));
39286
+ case "limits":
39287
+ return this.send({ type: "limits", limits: this.d.limits?.list() ?? [] });
39201
39288
  case "voice":
39202
39289
  if (!this.d.voice) return this.fail("bad_frame", "this daemon keeps no voice keys", frame.type);
39203
39290
  if (!this.watchesVoice) this.d.voice.on("changed", this.onVoice);
@@ -40672,6 +40759,87 @@ function promptText2(prompt) {
40672
40759
  }
40673
40760
  }
40674
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
+
40675
40843
  // src/daemon/server.ts
40676
40844
  async function startDaemon(opts = {}) {
40677
40845
  ensureDir();
@@ -40679,10 +40847,16 @@ async function startDaemon(opts = {}) {
40679
40847
  const controlPort2 = opts.controlPort ?? CONTROL_PORT;
40680
40848
  const log = opts.log ?? createLogger({ file: paths.log, level: process.env["GRENADE_LOG"] ?? "info" });
40681
40849
  const e2eKey = loadOrCreateE2EKey(opts.e2eKeyPath ?? paths.e2eKey);
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] };
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] };
40683
40851
  const allowPlainLan = opts.allowPlainLan === true;
40684
- const tmux = opts.tmux ?? createTmux({ agentFlags: { claude: claudeHookFlags(port), codex: codexHookFlags(port) }, serverScope: underSystemd() });
40685
- 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);
40686
40860
  const codes = new PairingCodes();
40687
40861
  const pairing = new PairingWatch();
40688
40862
  const registry2 = new SessionRegistry({
@@ -40699,6 +40873,7 @@ async function startDaemon(opts = {}) {
40699
40873
  const mirror = new TerminalMirror({ registry: registry2, log, terminal: pinnedTerminal ?? (() => readTerminalSetting()) });
40700
40874
  const terminalStatus = () => ({ ...mirror.status(), pinned: pinnedTerminal !== void 0 });
40701
40875
  mirror.start().catch((e) => log.warn("Could not mirror sessions into a terminal", { error: e }));
40876
+ const planLimits = new PlanLimits();
40702
40877
  const http = createServer2(async (req, res) => {
40703
40878
  const url2 = new URL(req.url ?? "/", "http://localhost");
40704
40879
  try {
@@ -40712,6 +40887,11 @@ async function startDaemon(opts = {}) {
40712
40887
  closePromptsByHook(prompts, session, body);
40713
40888
  return sendJson(res, r.status, r.body);
40714
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
+ }
40715
40895
  if (req.method === "POST" && url2.pathname === "/hooks/codex") {
40716
40896
  if (!isLoopback(req.socket.remoteAddress)) return sendJson(res, 403, { error: "forbidden" });
40717
40897
  const r = applyCodexHook(url2.searchParams.get("session"), await readBody(req));
@@ -40853,6 +41033,10 @@ async function startDaemon(opts = {}) {
40853
41033
  },
40854
41034
  onTranscript: (id, path2, event) => {
40855
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 }));
40856
41040
  catchUp.cancel(id);
40857
41041
  codexStopCatchUp.cancel(id);
40858
41042
  readActivity(id, path2).then((entries) => {
@@ -40861,6 +41045,17 @@ async function startDaemon(opts = {}) {
40861
41045
  }).catch((e) => log.debug("Could not read the activity from a Codex rollout", { session: id, path: path2, error: e }));
40862
41046
  }
40863
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
+ }
40864
41059
  function handlePair(raw, res) {
40865
41060
  if (!allowPlainLan) return sendJson(res, 426, { error: "encryption_required" });
40866
41061
  let json2;
@@ -40874,7 +41069,7 @@ async function startDaemon(opts = {}) {
40874
41069
  const verdict = codes.verify(body.data.code);
40875
41070
  if (verdict === "too_many_attempts") return sendJson(res, 429, { error: verdict });
40876
41071
  if (verdict === "invalid_code") return sendJson(res, 400, { error: verdict });
40877
- const token = tokens.issue(body.data.client);
41072
+ const token = tokens2.issue(body.data.client);
40878
41073
  pairing.paired(body.data.client, "lan");
40879
41074
  log.info(`Paired a new phone: ${body.data.client.name}`, { platform: body.data.client.platform });
40880
41075
  sendJson(res, 200, { token, daemon: info });
@@ -40883,7 +41078,7 @@ async function startDaemon(opts = {}) {
40883
41078
  const verdict = codes.verify(credential);
40884
41079
  if (verdict === "too_many_attempts") pairing.voided();
40885
41080
  if (verdict !== "ok") return { ok: false, code: verdict };
40886
- const token = tokens.issue(client, { sealed: true });
41081
+ const token = tokens2.issue(client, { sealed: true });
40887
41082
  pairing.paired(client, route2);
40888
41083
  log.info(`Paired a new phone: ${client.name}`, { platform: client.platform, route: route2 });
40889
41084
  return { ok: true, token };
@@ -40904,7 +41099,7 @@ async function startDaemon(opts = {}) {
40904
41099
  const attachments = createAttachmentStore(opts.attachmentsDir ?? paths.attachments);
40905
41100
  const push2 = startPush({
40906
41101
  registry: registry2,
40907
- tokens,
41102
+ tokens: tokens2,
40908
41103
  daemon: info,
40909
41104
  staticKey: e2eKey,
40910
41105
  relay: () => opts.relay === false ? null : loadRelayConfig(opts.relayPath ?? paths.relay),
@@ -40941,13 +41136,13 @@ async function startDaemon(opts = {}) {
40941
41136
  return closed;
40942
41137
  }
40943
41138
  const devices = {
40944
- list: () => tokens.list().map((r) => deviceOf(r, live2.routesOf(r.token))),
41139
+ list: () => tokens2.list().map((r) => deviceOf(r, live2.routesOf(r.token))),
40945
41140
  unpair(id) {
40946
- const gone = tokens.revoke(id);
41141
+ const gone = tokens2.revoke(id);
40947
41142
  return gone ? { closed: closeConnectionsOf([gone], `unpaired on the ${computerWord()}`) } : null;
40948
41143
  },
40949
41144
  unpairAll() {
40950
- const gone = tokens.revokeAll();
41145
+ const gone = tokens2.revokeAll();
40951
41146
  return { removed: gone.length, closed: closeConnectionsOf(gone, `unpaired on the ${computerWord()}`) };
40952
41147
  }
40953
41148
  };
@@ -40958,19 +41153,19 @@ async function startDaemon(opts = {}) {
40958
41153
  sentInputs,
40959
41154
  activity,
40960
41155
  interrupted,
40961
- isValidToken: (t) => tokens.has(t),
41156
+ isValidToken: (t) => tokens2.has(t),
40962
41157
  sealed: via.sealed,
40963
41158
  route: via.route,
40964
41159
  // Plain is for phones that were never encrypted, and only while the daemon allows it.
40965
- acceptsPlain: (t) => allowPlainLan && tokens.get(t)?.sealed !== true,
41160
+ acceptsPlain: (t) => allowPlainLan && tokens2.get(t)?.sealed !== true,
40966
41161
  onHello(connection, token, client) {
40967
41162
  live2.add(token, connection);
40968
- tokens.touch(token, { sealed: via.sealed, client });
40969
- 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 });
40970
41165
  },
40971
41166
  onEnd(connection, token) {
40972
41167
  live2.remove(token, connection);
40973
- tokens.touch(token);
41168
+ tokens2.touch(token);
40974
41169
  },
40975
41170
  pair: pairPhone,
40976
41171
  push: push2.pusher,
@@ -40980,20 +41175,21 @@ async function startDaemon(opts = {}) {
40980
41175
  models,
40981
41176
  voice,
40982
41177
  canvas,
41178
+ limits: planLimits,
40983
41179
  conversations,
40984
- openTerm(open6) {
41180
+ openTerm(open7) {
40985
41181
  const stream = new TermStream({
40986
- ...open6,
40987
- resize: (cols, rows, by) => registry2.resize(open6.sessionId, cols, rows, by),
40988
- release: (by) => registry2.releaseSize(open6.sessionId, by),
41182
+ ...open7,
41183
+ resize: (cols, rows, by) => registry2.resize(open7.sessionId, cols, rows, by),
41184
+ release: (by) => registry2.releaseSize(open7.sessionId, by),
40989
41185
  log
40990
41186
  });
40991
41187
  stream.start();
40992
41188
  return stream;
40993
41189
  },
40994
41190
  unpair(token) {
40995
- const id = tokens.get(token)?.id;
40996
- const gone = id ? tokens.revoke(id) : void 0;
41191
+ const id = tokens2.get(token)?.id;
41192
+ const gone = id ? tokens2.revoke(id) : void 0;
40997
41193
  if (gone) closeConnectionsOf([gone], "unpaired on the phone");
40998
41194
  },
40999
41195
  daemon: info,
@@ -41021,7 +41217,7 @@ async function startDaemon(opts = {}) {
41021
41217
  });
41022
41218
  const deviceIdleMs = opts.deviceIdleMs ?? DEVICE_IDLE_MS;
41023
41219
  const unpairIdle = () => {
41024
- 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");
41025
41221
  };
41026
41222
  unpairIdle();
41027
41223
  const idleTimer = setInterval(unpairIdle, 60 * 60 * 1e3);
@@ -41043,7 +41239,7 @@ async function startDaemon(opts = {}) {
41043
41239
  config: config2,
41044
41240
  name: info.name,
41045
41241
  version: VERSION,
41046
- accessHashes: () => [...tokens.list().map((t) => accessHash(t.token)), ...offerAccess()],
41242
+ accessHashes: () => [...tokens2.list().map((t) => accessHash(t.token)), ...offerAccess()],
41047
41243
  localIps: () => localIpv4(networkInterfaces()),
41048
41244
  openPipe: (conn, send, onEnd) => new PhonePipe({ conn, staticKey: e2eKey, send, makeConnection: (out, close) => makeConnection(out, close, { route: "relay", sealed: true }), log, onEnd }),
41049
41245
  log
@@ -41063,7 +41259,7 @@ async function startDaemon(opts = {}) {
41063
41259
  auto: () => opts.updates?.auto ?? readAuto()
41064
41260
  });
41065
41261
  const relayStatus = () => relayLink?.status() ?? { state: "off", phones: 0, ...opts.relay === false ? { disabled: true } : {} };
41066
- tokens.onChange(() => relayLink?.tokensChanged());
41262
+ tokens2.onChange(() => relayLink?.tokensChanged());
41067
41263
  startRelay();
41068
41264
  const control2 = createControlServer({
41069
41265
  registry: registry2,
@@ -41309,7 +41505,7 @@ program2.command("kill <name>").description("end a session").action(async (name)
41309
41505
  console.log(`killed ${id}`);
41310
41506
  });
41311
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) => {
41312
- const current = existsSync23(paths.claudeSettings) ? JSON.parse(readFileSync17(paths.claudeSettings, "utf8")) : {};
41508
+ const current = existsSync23(paths.claudeSettings) ? JSON.parse(readFileSync18(paths.claudeSettings, "utf8")) : {};
41313
41509
  const { settings, changed } = o.remove ? removeHooks(current) : mergeHooks(current, o.port);
41314
41510
  if (o.print) return console.log(JSON.stringify(settings, null, 2));
41315
41511
  if (o.remove) removeOldCodexHooks();
@@ -41320,7 +41516,7 @@ program2.command("install-hooks").description("also let a claude typed by hand i
41320
41516
  });
41321
41517
  function removeOldCodexHooks() {
41322
41518
  if (!existsSync23(paths.codexHooks)) return;
41323
- const { settings, changed } = removeCodexHooks(JSON.parse(readFileSync17(paths.codexHooks, "utf8")));
41519
+ const { settings, changed } = removeCodexHooks(JSON.parse(readFileSync18(paths.codexHooks, "utf8")));
41324
41520
  if (!changed) return;
41325
41521
  writeFileSync18(paths.codexHooks, JSON.stringify(settings, null, 2) + "\n");
41326
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.49",
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": {