agent-dag 1.34.0 → 1.34.2

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.
@@ -443,6 +443,18 @@ async function restoreActive(num) {
443
443
  * expiry so a copy left behind in clipboard history stops working, and nothing
444
444
  * more: it is not encryption and is not presented as any.
445
445
  *
446
+ * Say the limit of that out loud, because the UI used to imply the opposite.
447
+ * `exp` is a plain number inside plain base64'd JSON with NO key, MAC or
448
+ * signature over it, so anyone holding the text can decode it, write a later
449
+ * `exp`, re-encode, and import it. unwrapShare's check is therefore a check
450
+ * against staleness, not against an adversary — and it cannot be made into one
451
+ * here. A MAC needs a secret both decks hold, and two decks that already shared
452
+ * a secret would not need this function; and even a perfect signature would
453
+ * only stop THIS import path, since the payload it wraps is the credential
454
+ * itself and `cswap import` accepts it unwrapped. The honest fix for a share
455
+ * that got away is to sign the account out and back in. See share-expiry-
456
+ * forgeable.test.ts, which pins the forgery rather than leaving it implied.
457
+ *
446
458
  * The default export shape is used deliberately, never --full, which would
447
459
  * embed the entire ~/.claude.json including every project and MCP server.
448
460
  */
@@ -473,7 +485,19 @@ export async function shareAccount(num) {
473
485
  if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
474
486
  const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
475
487
  if (!r.ok || !r.stdout.trim()) {
476
- return { ok: false, reason: "export_failed", detail: failureText(r, "cswap export") };
488
+ // The failure sentence is built from stderr ALONE for this one command,
489
+ // because its stdout is the credential. `failureText` concatenates
490
+ // `${stderr}\n${stdout}` and `firstUseful` takes the LAST non-empty line —
491
+ // right for every other cswap command, and here it means any stdout at all
492
+ // outranks the real error. claude-swap writes its diagnostics to stderr
493
+ // specifically so stdout stays pure JSON in pipe mode, and it writes the
494
+ // envelope as its last act; a non-zero exit after a partial write would
495
+ // therefore put the tail of `json.dumps(envelope, indent=2)` in front of the
496
+ // user, and one of those lines is the refresh token on its own.
497
+ //
498
+ // Nothing is lost by dropping it: the ENOENT branch keys off `r.code`, which
499
+ // `run` sets, and cmd.exe's "is not recognized" is stderr's.
500
+ return { ok: false, reason: "export_failed", detail: failureText({ ...r, stdout: "" }, "cswap export") };
477
501
  }
478
502
  return { ok: true, blob: wrapShare(r.stdout), expiresAt: Date.now() + SHARE_TTL_MS };
479
503
  }
@@ -546,10 +570,32 @@ export async function removeAccount(num) {
546
570
  });
547
571
  }
548
572
 
573
+ /**
574
+ * What an alias may be made of.
575
+ *
576
+ * Every other argument this module sends to cswap is an integer bounded to
577
+ * 1..999; the alias was the one free-text field, and `.trim()` was the whole of
578
+ * its validation. That is fine on POSIX, where `run` spawns the argument vector
579
+ * untouched, and not fine on Windows: cswap is a `.cmd` shim there, so the
580
+ * vector goes through `cmd.exe /d /s /c` (see viaCmd). Quote-doubling handles
581
+ * `"` and every other metacharacter, which leaves exactly the residual exec.mjs
582
+ * documents — `%VAR%` expands inside quotes and a command line has no escape for
583
+ * it — so `%USERPROFILE%` in an alias stored the user's home path, and an alias
584
+ * carrying an unbalanced quote plus an `&` could end the quoted region early.
585
+ * An interior newline survived `.trim()` untouched as well.
586
+ *
587
+ * The same allowlist discipline cswap-auto.mjs already applies to its model
588
+ * list, and it closes the unbounded-length half too: an alias is a short name
589
+ * shown instead of an email, so 64 characters is not a constraint anyone meets
590
+ * by accident.
591
+ */
592
+ const ALIAS_OK = /^[A-Za-z0-9 ._-]{1,64}$/;
593
+
549
594
  export async function setAlias(num, alias) {
550
595
  const n = Number(num);
551
596
  if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
552
597
  const clean = typeof alias === "string" ? alias.trim() : "";
598
+ if (clean && !ALIAS_OK.test(clean)) return { ok: false, reason: "bad_value" };
553
599
  const args = clean ? ["alias", String(n), clean] : ["alias", String(n), "--unset"];
554
600
  return withStoreLock(async () => {
555
601
  const r = await run(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
@@ -50,8 +50,7 @@ export const isBatch = (file, platform = process.platform) =>
50
50
  * cmd.exe understands inside a quoted string.
51
51
  */
52
52
  export function viaCmd(file, args) {
53
- const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
54
- const line = [file, ...args].map(q).join(" ");
53
+ const line = [file, ...args].map(a => shellQuoteArg(a, "win32")).join(" ");
55
54
  return {
56
55
  file: process.env.comspec || process.env.ComSpec || "cmd.exe",
57
56
  args: ["/d", "/s", "/c", `"${line}"`],
@@ -59,6 +58,42 @@ export function viaCmd(file, args) {
59
58
  };
60
59
  }
61
60
 
61
+ /**
62
+ * Quote ONE argument into a command line a shell will parse.
63
+ *
64
+ * Everything else in this module exists to avoid needing this: an argument
65
+ * vector is handed to spawn untouched and nothing in it is ever read as syntax.
66
+ * Two callers cannot take that route, and they are the reason this is exported.
67
+ * A Claude Code hook is registered as `{type:"command", command:"<string>"}` —
68
+ * the host CLI runs that string THROUGH A SHELL on every tool call, and the
69
+ * format has no argv form to emit instead. So the string has to be built here,
70
+ * correctly, once.
71
+ *
72
+ * The inputs are not user input in the web sense, but they are not constants
73
+ * either: the hook path is built from $CLAUDE_CONFIG_DIR and homedir(), and the
74
+ * node path from process.execPath. Wrapping those in double quotes — what this
75
+ * used to do — is not escaping on POSIX at all: `$(…)`, a backtick and `\` are
76
+ * all still live inside them, so a config dir named `/tmp/a$(id)b` became shell
77
+ * code written into the user's settings.json and executed on every hook fire.
78
+ * A bare `$` in a path is the same bug wearing a duller hat — `$HOME` expands to
79
+ * nothing, the hook path silently becomes wrong, and hooks stop firing with no
80
+ * error anywhere.
81
+ *
82
+ * POSIX gets single quotes, inside which NOTHING is special, with the one
83
+ * escape that form has: close the quote, emit a backslash-quote, reopen.
84
+ * Windows gets cmd.exe's rule — the same one viaCmd applies above, so this file
85
+ * has one Windows quoting rule rather than two — with the same single residual
86
+ * exec.mjs already documents: cmd.exe expands `%VAR%` inside quotes too, and a
87
+ * command line has no escape for it. That is narrower than it sounds, since
88
+ * `%foo%` with no variable `foo` is left alone, and it is a limit of the
89
+ * platform rather than of this function.
90
+ */
91
+ export function shellQuoteArg(arg, platform = process.platform) {
92
+ const s = String(arg ?? "");
93
+ if (platform === "win32") return `"${s.replace(/"/g, '""')}"`;
94
+ return `'${s.split("'").join("'\\''")}'`;
95
+ }
96
+
62
97
  /**
63
98
  * What to hand `spawn`/`execFile` for `file` and `args` on this platform, with
64
99
  * the argument vector intact and no shell.
@@ -204,8 +239,13 @@ const TIMEOUT_TAIL = 8 << 10;
204
239
  * A run stopped by its deadline answers `{ ok: false, code: "ETIMEDOUT",
205
240
  * killed: true, timedOut: true }` — never ok, whatever the child said on its
206
241
  * way out.
242
+ *
243
+ * `env` replaces the child's environment wholesale, the way spawn's does; pass
244
+ * `{...process.env, X: "1"}` to add to it. It exists because the quota probe
245
+ * runs a whole Claude Code and has to mark the run as the deck's own, and that
246
+ * marker is what stops every poll drawing itself onto the canvas.
207
247
  */
208
- export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
248
+ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } = {}) {
209
249
  const tries = candidates(cmd);
210
250
  return new Promise((resolve) => {
211
251
  const attempt = (i) => {
@@ -260,7 +300,16 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
260
300
 
261
301
  try {
262
302
  const cp = execFile(file, argv,
263
- { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...opts }, done);
303
+ { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done);
304
+ // Give the child EOF on stdin straight away, which is what this
305
+ // function's contract has always claimed ("run closes stdin", says
306
+ // runInteractive's header) and what execFile does not do: it leaves the
307
+ // pipe open with nobody at the writing end, so a tool that reads stdin
308
+ // waits for a writer that will never arrive. `claude --print /usage`
309
+ // waits three seconds for exactly that before giving up, which is why
310
+ // the shell command it replaced had to end in `< /dev/null`. Closing
311
+ // the pipe is that redirection without a shell to parse it.
312
+ try { cp.stdin?.on("error", () => {}); cp.stdin?.end(); } catch { /* no stdin to close */ }
264
313
  cp.stdout?.on("data", (d) => { sawOut = (sawOut + d).slice(-TIMEOUT_TAIL); });
265
314
  cp.stderr?.on("data", (d) => { sawErr = (sawErr + d).slice(-TIMEOUT_TAIL); });
266
315
  // The deadline states the outcome itself and only then kills, which is
@@ -638,7 +638,13 @@ function maybeResolveContext(payload) {
638
638
  const CODEX_HOME = process.env.CODEX_HOME
639
639
  ? resolve(process.env.CODEX_HOME)
640
640
  : join(homedir(), ".codex");
641
- const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
641
+ // Exported because bin/deck.js prints this path in the boot banner, and it used
642
+ // to build its own `join(homedir(), ".codex", "sessions")` for the purpose —
643
+ // which ignored CODEX_HOME and so named a directory that does not exist on any
644
+ // machine that sets it. Handing out the binding the watcher itself reads, rather
645
+ // than a second computation of the same rule, is what makes the printed path and
646
+ // the tailed path unable to disagree.
647
+ export const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
642
648
  const codexRolloutPathBySid = new Map();
643
649
  const lastCodexUsageReadAt = new Map();
644
650
  const pendingCodexUsageReads = new Set();
@@ -1293,20 +1299,57 @@ function readBody(req, limit = 64_000) {
1293
1299
  });
1294
1300
  }
1295
1301
 
1302
+ // How long an oversized POST is drained after it has been refused, so the 413
1303
+ // reaches a poster that is still uploading. Generous next to hook.js's own
1304
+ // one-second budget, and finite so nothing can sit on the socket indefinitely.
1305
+ const OVERSIZE_DRAIN_MS = 10_000;
1306
+
1296
1307
  // `persist` is false when the hook posted this event to another deck as well
1297
1308
  // and elected that one to write it to the log they share. The event is still
1298
1309
  // buffered and broadcast here — every matching deck draws it — it is only the
1299
1310
  // second copy on disk that is dropped.
1300
1311
  function handleEventIngest(req, res, persist = true) {
1301
1312
  let body = "";
1313
+ // Set once the cap is hit, because everything after that point is about an
1314
+ // exchange that is already over: more `data` may still be in flight, and
1315
+ // `end` must not go on to parse the truncated half.
1316
+ let refused = false;
1302
1317
  req.setEncoding("utf8");
1303
1318
  req.on("data", c => {
1319
+ if (refused) return;
1304
1320
  body += c;
1305
1321
  if (body.length > 5_000_000) {
1306
- req.destroy();
1322
+ refused = true;
1323
+ body = ""; // nothing will read it now; let it go
1324
+ // ANSWER, rather than vanish. `req.destroy()` on its own tore the socket
1325
+ // down with no status line on it at all, so the poster learned only that
1326
+ // the connection had gone — indistinguishable from a deck that died or
1327
+ // was never there, and nothing in the exchange to tell those apart. `end`
1328
+ // never fires on a destroyed request either, so the handler below got no
1329
+ // second chance to speak. readBody above gets this right already, by
1330
+ // rejecting into a caller that replies.
1331
+ send(res, 413, { error: "event too large" });
1332
+ // Then keep reading, and throw it away. Answering is not enough on its
1333
+ // own, because the poster is still mid-upload when the answer goes out:
1334
+ // hang up now and its next write lands on a dead socket, it aborts with
1335
+ // EPIPE, and the 413 that was already sitting in its receive buffer is
1336
+ // discarded unread — the same disappearance in a different costume.
1337
+ // `Connection: close` is the tempting version of hanging up and has
1338
+ // exactly that effect: Node destroys the socket the moment such a
1339
+ // response flushes. Draining lets the poster finish and then read the
1340
+ // answer, which is the whole point of answering. `body` no longer grows,
1341
+ // so it costs no memory.
1342
+ req.resume();
1343
+ // Bounded, because draining forever is its own denial of service: a
1344
+ // poster that stops without ending would otherwise hold the socket for as
1345
+ // long as it liked.
1346
+ const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
1347
+ grace.unref?.();
1348
+ req.on("close", () => clearTimeout(grace));
1307
1349
  }
1308
1350
  });
1309
1351
  req.on("end", () => {
1352
+ if (refused) return;
1310
1353
  let parsed;
1311
1354
  try { parsed = JSON.parse(body); }
1312
1355
  catch { return send(res, 400, { error: "invalid json" }); }
@@ -1314,7 +1357,11 @@ function handleEventIngest(req, res, persist = true) {
1314
1357
  const evt = pushEvent(parsed, "hook", { persist });
1315
1358
  send(res, 200, { ok: true, seq: evt.seq });
1316
1359
  });
1317
- req.on("error", () => send(res, 400, { error: "bad request" }));
1360
+ // Guarded for the same reason `end` is, and more sharply: destroying the
1361
+ // request above is itself what raises this, and answering a second time on a
1362
+ // response already sent throws ERR_HTTP_HEADERS_SENT out of an error handler,
1363
+ // where nothing is waiting to catch it.
1364
+ req.on("error", () => { if (!refused) send(res, 400, { error: "bad request" }); });
1318
1365
  }
1319
1366
 
1320
1367
  function handleSse(req, res) {
@@ -12,6 +12,7 @@ import { join, resolve, dirname } from "node:path";
12
12
  import { setTimeout as delay } from "node:timers/promises";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { claudeConfigDir } from "./claude-dir.mjs";
15
+ import { shellQuoteArg } from "./exec.mjs";
15
16
  import { PRODUCT } from "./brand.mjs";
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -68,9 +69,35 @@ const MARK_KEY = "__agent-dag";
68
69
  const LEGACY_MARKS = ["__ccgraph", "__agent-flow"];
69
70
  const LEGACY_DIRS = ["ccgraph", "agent-flow", "agent-dag"];
70
71
 
71
- function hookCommand(installedHookPath, provider) {
72
- const node = process.execPath;
73
- return `"${node}" "${installedHookPath}" --provider ${provider}`;
72
+ /**
73
+ * The `command` string Claude Code stores for our forwarder, and runs THROUGH A
74
+ * SHELL on every tool call.
75
+ *
76
+ * The settings.json hook format is a string, not an argv, so this is one of the
77
+ * two places in the codebase that has to build a shell command line by hand —
78
+ * see shellQuoteArg, which is where the escaping rules and their one Windows
79
+ * residual are written down.
80
+ *
81
+ * It used to wrap both paths in double quotes, which on POSIX escapes nothing:
82
+ * `$(…)`, a backtick and `\` are all still live inside them. Both paths come
83
+ * from outside — `installedHookPath` is built from $CLAUDE_CONFIG_DIR (resolved,
84
+ * never validated) or homedir(), and `node` is process.execPath — so a config
85
+ * dir called `/tmp/a$(id)b` was shell code, written into the user's own settings
86
+ * file and executed on every hook fire for as long as it stayed there. The
87
+ * quieter half of the same bug cost nothing but the feature: an ordinary `$` in
88
+ * a path expanded to nothing, the hook pointed at a file that was not there, and
89
+ * hooks stopped firing with no error to explain it.
90
+ *
91
+ * `provider` is a key of PROVIDERS — "claude" or "codex", never anything a
92
+ * caller chose — and is quoted anyway, because that is not a property worth
93
+ * re-deriving at every reading.
94
+ *
95
+ * Exported, with the node path injectable, so the escaping can be checked
96
+ * against a path the test names rather than against whatever ran the suite.
97
+ */
98
+ export function hookCommand(installedHookPath, provider, node = process.execPath) {
99
+ const q = (s) => shellQuoteArg(s);
100
+ return `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
74
101
  }
75
102
 
76
103
  function isOurEntry(g) {
@@ -13,29 +13,29 @@
13
13
  // schedule and writes what it got; reading that file costs nothing and
14
14
  // spends none of the budget. Used whenever it holds a recent enough row.
15
15
  // 2. The OAuth usage API directly, with the token from
16
- // ~/.claude/.credentials.json. Exact and instant. Mechanism
16
+ // .credentials.json inside the Claude config dir — $CLAUDE_CONFIG_DIR when
17
+ // it is set, ~/.claude otherwise. Exact and instant. Mechanism
17
18
  // reverse-engineered from steipete/CodexBar.
18
19
  // 3. `claude --print /usage`, parsed. Used when there is no readable token —
19
20
  // notably on macOS, where Claude Code keeps credentials in the Keychain
20
21
  // and that file does not exist, so this is the ONLY self-service path
21
22
  // there. It is also the most expensive: a whole Claude Code process per
22
- // poll. On Windows the binary may be a .cmd wrapper, so exec()
23
- // (shell-based) is used for correct quoting + stdin.
23
+ // poll. On Windows the binary may be a .cmd wrapper, which spawn cannot
24
+ // launch directly exec.mjs's `run` routes that case through cmd.exe with
25
+ // the argument vector intact, so no shell ever parses a path this module
26
+ // read out of the environment.
24
27
  //
25
28
  // 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
26
29
  // cooldown; 1 is not, because it is a local file read.
27
30
  import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
28
- import { exec } from "node:child_process";
29
- import { promisify } from "node:util";
31
+ import { claudeConfigDir } from "./claude-dir.mjs";
32
+ import { run } from "./exec.mjs";
30
33
  import { existsSync } from "node:fs";
31
34
  import { readFile } from "node:fs/promises";
32
35
  import { join, posix as posixPath, win32 as winPath } from "node:path";
33
36
  import { homedir } from "node:os";
34
37
  import { PRODUCT } from "./brand.mjs";
35
38
 
36
- const execAsync = promisify(exec);
37
-
38
- const CREDS_PATH = join(homedir(), ".claude", ".credentials.json");
39
39
  const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
40
40
  const BETA_HEADER = "oauth-2025-04-20";
41
41
  const WIN_5H_SEC = 18000;
@@ -44,9 +44,34 @@ const WIN_7D_SEC = 604800;
44
44
  // 429 cooldown gate — after a rate-limit, skip the API until this passes.
45
45
  let _rateLimitedUntil = 0;
46
46
 
47
+ /**
48
+ * Where Claude Code keeps the OAuth credentials this module borrows a token
49
+ * from.
50
+ *
51
+ * It is `.credentials.json` inside the Claude config dir, and that dir moves:
52
+ * CLAUDE_CONFIG_DIR replaces ~/.claude wholesale rather than overlaying it, so
53
+ * on a machine where it is set there is no ~/.claude to read at all. Hardcoding
54
+ * ~/.claude here did not fail loudly — it made readOAuthToken() return null
55
+ * forever, which reads exactly like "this machine keeps its credentials in the
56
+ * Keychain", and the quota chain quietly fell through to source 3 on every poll
57
+ * it was allowed to make. See src/server/claude-dir.mjs, which owns the rule
58
+ * and is the only place it is spelled.
59
+ *
60
+ * Resolved per call rather than frozen into a module-level constant, for the
61
+ * same reason claudeConfigDir() is a function: a constant captured at import
62
+ * time is a value nothing can observe or correct afterwards, and this module is
63
+ * imported lazily by the /api/quota route rather than at a point in startup
64
+ * anyone here controls.
65
+ *
66
+ * Exported for tests — it is the whole of the bug, and it is pure.
67
+ */
68
+ export function credentialsPath() {
69
+ return join(claudeConfigDir(), ".credentials.json");
70
+ }
71
+
47
72
  async function readOAuthToken() {
48
73
  try {
49
- const raw = await readFile(CREDS_PATH, "utf8");
74
+ const raw = await readFile(credentialsPath(), "utf8");
50
75
  const auth = JSON.parse(raw)?.claudeAiOauth;
51
76
  if (!auth?.accessToken) return null;
52
77
  // expiresAt is epoch milliseconds. If expired, the CLI fallback handles it.
@@ -147,7 +172,7 @@ async function fetchOAuthUsage() {
147
172
 
148
173
  let _cache = null;
149
174
  let _cacheAt = 0;
150
- let _inflight = null; // deduplicates concurrent exec() calls
175
+ let _inflight = null; // deduplicates concurrent CLI probes
151
176
  let _lastGood = null; // last result that had real quota percentages
152
177
  let _lastSelfPollAt = 0;
153
178
 
@@ -346,35 +371,40 @@ export function quotaClaudeCandidates(platform = process.platform, env = process
346
371
  ];
347
372
  }
348
373
 
349
- /** Build the shell command string for `claude --print /usage`.
374
+ /** Which `claude` to run for `--print /usage`: the first candidate that exists.
375
+ *
376
+ * This used to hand back a whole shell command line — `"<bin>" --print /usage
377
+ * < /dev/null` — for `exec()` to parse. Double quotes are not escaping on
378
+ * POSIX: `$(…)`, backticks and `\` all still work inside them, and every
379
+ * ingredient of that line came from the environment (`%APPDATA%`, `homedir()`),
380
+ * so a home directory named `/home/a$(id)b` was shell code the quota poll ran
381
+ * every minute. A bare `$` was the duller half of the same bug — it expanded
382
+ * to nothing and the probe looked for a binary at a path that did not exist.
350
383
  *
351
- * We use exec() (shell-based) so cmd.exe / sh processes redirects.
352
- * On Windows: `< nul` closes stdin immediately, preventing the 3-second
353
- * "no stdin data" wait the claude CLI does when it detects a pipe.
354
- * On Unix: `< /dev/null` has the same effect.
384
+ * There is nothing left to escape once there is no shell: exec.mjs's `run`
385
+ * spawns the argument vector as given, resolves the Windows `.cmd`/`.exe`
386
+ * spelling itself, and closes the child's stdin which is what `< /dev/null`
387
+ * was for, since `claude --print` waits three seconds on a stdin pipe nobody
388
+ * is writing to.
355
389
  *
356
390
  * Exported, with everything it touches injectable, so the Windows branch is
357
391
  * testable from the platforms this repo is actually developed on.
358
392
  */
359
- export function buildQuotaShellCmd(platform = process.platform, env = process.env,
360
- home = homedir(), exists = existsSync) {
361
- const win = platform === "win32";
362
- const sep = win ? "\\" : "/";
363
- // A bare name is left to the shell's own lookup; a full path is only worth
364
- // naming when it is actually there.
365
- const bin = quotaClaudeCandidates(platform, env, home)
393
+ export function quotaClaudeBin(platform = process.platform, env = process.env,
394
+ home = homedir(), exists = existsSync) {
395
+ const sep = platform === "win32" ? "\\" : "/";
396
+ // A bare name is left to spawn's own PATH lookup (and, on Windows, to the
397
+ // PATHEXT walk exec.mjs does by hand); a full path is only worth naming when
398
+ // it is actually there.
399
+ return quotaClaudeCandidates(platform, env, home)
366
400
  .find(c => !c.includes(sep) || exists(c)) ?? "claude";
367
- // Quote a path — a username can contain a space — but not a bare name, which
368
- // cmd.exe resolves more predictably unquoted.
369
- const quoted = bin.includes(sep) ? `"${bin}"` : bin;
370
- return `${quoted} --print /usage < ${win ? "nul" : "/dev/null"}`;
371
401
  }
372
402
 
373
403
  export async function fetchClaudeQuota({ force = false } = {}) {
374
404
  const now = Date.now();
375
405
  if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
376
406
 
377
- // If another exec() is already in flight, wait for it instead of spawning a
407
+ // If another CLI probe is already in flight, wait for it instead of spawning a
378
408
  // second concurrent process (which can return empty output and overwrite the
379
409
  // good result with 0%).
380
410
  if (_inflight) return _inflight;
@@ -436,30 +466,27 @@ async function nudgeAndReread(previous) {
436
466
  // cliOk — the CLI ran and we recognized its output (preamble present)
437
467
  // parsed — quota percentages object, or null if the "Current session/week"
438
468
  // lines were absent (CLI cold-start, or genuinely <1% usage)
439
- async function _execOnce(shellCmd) {
440
- try {
441
- const { stdout, stderr } = await execAsync(shellCmd, {
442
- timeout: 15_000,
443
- // Marks this Claude Code run as the deck's own. `claude --print /usage`
444
- // is a full invocation, so it fires the hooks we installed, and every
445
- // quota poll was drawing itself onto the canvas as a fresh session with
446
- // no prompt and no tools. Hooks inherit the environment, so hook.js
447
- // sees this and stays quiet.
448
- env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
449
- maxBuffer: 1024 * 1024,
450
- });
451
- const combined = stdout + "\n" + stderr;
452
- const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
453
- return { cliOk, parsed: parseUsageText(combined) };
454
- } catch (err) {
455
- const msg = err?.stderr ? stripAnsi(err.stderr).trim() : (err?.message ?? String(err));
469
+ async function _execOnce(bin) {
470
+ const r = await run(bin, ["--print", "/usage"], {
471
+ timeout: 15_000,
472
+ maxBuffer: 1024 * 1024,
473
+ // Marks this Claude Code run as the deck's own. `claude --print /usage`
474
+ // is a full invocation, so it fires the hooks we installed, and every
475
+ // quota poll was drawing itself onto the canvas as a fresh session with
476
+ // no prompt and no tools. Hooks inherit the environment, so hook.js
477
+ // sees this and stays quiet.
478
+ env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
479
+ });
480
+ // `run` never rejects, so there is one path rather than two — and the output
481
+ // is kept either way, which matters because the CLI writes the quota lines to
482
+ // stdout and can still exit non-zero afterwards.
483
+ const combined = r.stdout + "\n" + r.stderr;
484
+ if (!r.ok) {
485
+ const msg = stripAnsi(r.stderr).trim() || `claude exited ${r.code}`;
456
486
  console.error(`${PRODUCT} quota: claude CLI failed:`, msg);
457
- if (err?.stdout || err?.stderr) {
458
- const combined = (err.stdout ?? "") + "\n" + (err.stderr ?? "");
459
- return { cliOk: /subscription/i.test(combined), parsed: parseUsageText(combined) };
460
- }
461
- return { cliOk: false, parsed: null };
462
487
  }
488
+ const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
489
+ return { cliOk, parsed: parseUsageText(combined) };
463
490
  }
464
491
 
465
492
  async function _doFetch(now, force = false) {
@@ -515,7 +542,7 @@ async function _doFetch(now, force = false) {
515
542
  }
516
543
 
517
544
  // Source 3: parse `claude --print /usage` CLI output.
518
- const shellCmd = buildQuotaShellCmd();
545
+ const bin = quotaClaudeBin();
519
546
 
520
547
  // The CLI sometimes omits the "Current session/week" quota lines on a cold
521
548
  // invocation (right after the server starts, or after the page is hard-
@@ -525,7 +552,7 @@ async function _doFetch(now, force = false) {
525
552
  let parsed = null;
526
553
  for (let attempt = 0; attempt < 3; attempt++) {
527
554
  if (attempt > 0) await sleep(1200);
528
- const r = await _execOnce(shellCmd);
555
+ const r = await _execOnce(bin);
529
556
  cliOk = r.cliOk || cliOk;
530
557
  if (r.parsed) { parsed = r.parsed; break; }
531
558
  }
@@ -17,6 +17,7 @@ import { homedir } from "node:os";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { claudeConfigDir } from "./claude-dir.mjs";
19
19
  import { readSettingsForWrite, writeFileAtomic, installScript } from "./installer.mjs";
20
+ import { shellQuoteArg } from "./exec.mjs";
20
21
 
21
22
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
22
23
  const CLAUDE_DIR = claudeConfigDir();
@@ -34,6 +35,18 @@ const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
34
35
  // what is already there — never to modify or remove it.
35
36
  const SOUND_HINTS = [/\bafplay\b/i, /Media\.SoundPlayer/i, /\bpaplay\b/i, /\baplay\b/i, /canberra-gtk-play/i];
36
37
 
38
+ /**
39
+ * The Stop hook's `command` string, escaped for the shell that will run it.
40
+ *
41
+ * Same shape and same reasoning as installer.mjs's hookCommand — see the note
42
+ * there — kept separate because this entry takes no `--provider` and is written
43
+ * to a different key. Exported, with the node path injectable, so the escaping
44
+ * is checked against a path the test names.
45
+ */
46
+ export function soundHookCommand(notifyPath, node = process.execPath) {
47
+ return `${shellQuoteArg(node)} ${shellQuoteArg(notifyPath)}`;
48
+ }
49
+
37
50
  /**
38
51
  * Read settings.json, refusing to guess at a file that will not parse.
39
52
  *
@@ -338,8 +351,13 @@ export async function setSoundHook(enabled) {
338
351
  hooks: [{
339
352
  type: "command",
340
353
  // Absolute node path, matching how the event hooks are installed: the
341
- // shell a hook runs in does not necessarily have the user's PATH.
342
- command: `"${process.execPath}" "${NOTIFY_PATH}"`,
354
+ // shell a hook runs in does not necessarily have the user's PATH. And
355
+ // properly escaped for that shell, for the reason installer.mjs's
356
+ // hookCommand spells out — NOTIFY_PATH is built from
357
+ // $CLAUDE_CONFIG_DIR, double quotes do not suppress `$(…)` or a
358
+ // backtick on POSIX, and this string is executed at the end of every
359
+ // turn.
360
+ command: soundHookCommand(NOTIFY_PATH),
343
361
  timeout: 5,
344
362
  }],
345
363
  });