agent-dag 1.34.6 → 1.35.1

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.
@@ -40,7 +40,7 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-G1Xt6iC6.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-BWurW9Rz.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-CRPobBZf.css">
45
45
  </head>
46
46
  <body>
package/hook/hook.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // agent-dag hook forwarder. Invoked by Claude Code or Codex CLI as a command
3
3
  // hook. Reads stdin (event JSON), tags it with the provider passed via
4
- // `--provider <name>`, finds the matching agent-dag server via per-workspace
5
- // discovery files in <claude config dir>/agent-dag/, makes that server prove it
6
- // is the deck the file describes, and POSTs the payload. Dead instances are
7
- // cleaned up.
4
+ // `--provider <name>`, finds every agent-dag server whose workspace contains the
5
+ // session — via the discovery files in <claude config dir>/agent-dag/ — makes
6
+ // each one prove it is the deck its file describes, and POSTs the payload. Dead
7
+ // instances are cleaned up.
8
8
  "use strict";
9
9
 
10
10
  const fs = require("fs");
@@ -86,6 +86,36 @@ function cwdInWorkspace(cwd, workspace, platform = process.platform) {
86
86
  return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
87
87
  }
88
88
 
89
+ /**
90
+ * Does a deck scoped to `workspace` capture a session running in `cwd`? This is
91
+ * the whole of what `--workspace` means, and it is a question about ONE deck: it
92
+ * asks nothing about the others that may also be up, so a deck's answer never
93
+ * depends on who else is running.
94
+ *
95
+ * An empty workspace is the default — machine-wide — and captures everything.
96
+ * It is answered before cwdInWorkspace rather than passed to it because
97
+ * p.resolve("") is the resolving process's own cwd, which here is the agent's,
98
+ * so an unscoped deck would be silently scoped to whatever directory the user
99
+ * happened to run their agent in.
100
+ *
101
+ * A session that never said where it runs is inside no workspace, so only an
102
+ * unscoped deck sees it. Unreachable from main(), which exits before this on a
103
+ * payload with no cwd — it is here because the rule has to be stated the same
104
+ * way on both sides to be pinned against the other one.
105
+ *
106
+ * src/server/log-writer.mjs answers this same question, for the sessions the
107
+ * server builds itself out of Codex's rollout files, under the name
108
+ * codexCwdInWorkspace — this script is copied out of the package and run
109
+ * standalone, so it cannot import that copy. A test walks one table of paths
110
+ * through both: a disagreement between them is `--workspace` meaning two
111
+ * different things depending on which CLI produced the session.
112
+ */
113
+ function capturesSession(cwd, workspace, platform = process.platform) {
114
+ if (!workspace || typeof workspace !== "string") return true;
115
+ if (!cwd || typeof cwd !== "string") return false;
116
+ return cwdInWorkspace(cwd, workspace, platform);
117
+ }
118
+
89
119
  function isAlive(pid) {
90
120
  try { process.kill(pid, 0); return true; }
91
121
  catch (e) { return e && e.code === "EPERM"; }
@@ -315,7 +345,24 @@ function main() {
315
345
  } catch { return process.exit(0); }
316
346
  if (!files.length) return process.exit(0);
317
347
 
318
- const matches = [];
348
+ // Every deck whose workspace contains this cwd, and nothing else decides it.
349
+ //
350
+ // This used to sort the matches by how long each deck's workspace path was
351
+ // and deliver only to the longest — so a deck scoped to /Users/x/proj TOOK
352
+ // that tree's sessions away from a machine-wide deck, which then sat there
353
+ // showing nothing while `--all` promised it captured every session on this
354
+ // machine. Nothing documented that, and the server's own Codex capture never
355
+ // did it: each deck tails the rollout files itself and evaluates its own
356
+ // workspace, so a Codex session inside a scoped tree appeared on both decks
357
+ // while the Claude session beside it appeared on one. One flag, one path,
358
+ // two answers.
359
+ //
360
+ // The fan-out is the documented meaning and the one kept: `--workspace` says
361
+ // which sessions a deck captures, not which sessions it takes from the decks
362
+ // around it. It is also what electWriters below already assumes — several
363
+ // decks drawing one event is the case it exists to keep from being written
364
+ // to one log several times.
365
+ const targets = [];
319
366
  for (const file of files) {
320
367
  let d;
321
368
  try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
@@ -329,29 +376,25 @@ function main() {
329
376
  continue;
330
377
  }
331
378
 
332
- if (d.workspace === "") {
333
- matches.push({ d, wsLen: 0 });
334
- continue;
335
- }
336
- const ws = normPath(d.workspace);
337
- if (cwdInWorkspace(resolvedCwd, ws)) {
338
- matches.push({ d, wsLen: ws.length });
339
- }
379
+ // "" is machine-wide and must never reach normPath: resolving it would
380
+ // produce this hook's own cwd — the agent's — and scope a deck that asked
381
+ // for no scope at all. Any other spelling is canonicalized here, which is
382
+ // now a second pass over a path bin/deck.js already canonicalized before
383
+ // publishing it — kept because a deck old enough to have published a
384
+ // relative one is still entitled to its events.
385
+ const ws = d.workspace === "" ? "" : normPath(d.workspace);
386
+ if (capturesSession(resolvedCwd, ws)) targets.push(d);
340
387
  }
341
388
 
342
- if (!matches.length) return process.exit(0);
343
-
344
- matches.sort((a, b) => b.wsLen - a.wsLen);
345
- const bestLen = matches[0].wsLen;
346
- const targets = matches.filter(m => m.wsLen === bestLen);
389
+ if (!targets.length) return process.exit(0);
347
390
 
348
391
  // One deck per events log records this event; the others only draw it.
349
- const writers = electWriters(targets.map(m => m.d));
392
+ const writers = electWriters(targets);
350
393
 
351
394
  let pending = targets.length;
352
395
  const done = () => { if (--pending <= 0) process.exit(0); };
353
396
 
354
- for (const { d } of targets) deliver(d, taggedInput, writers.has(d), done);
397
+ for (const d of targets) deliver(d, taggedInput, writers.has(d), done);
355
398
  });
356
399
  }
357
400
 
@@ -360,5 +403,5 @@ function main() {
360
403
  // require() it exports the rules it decides by — matching, election, the
361
404
  // handshake — and starts nothing, which is what lets them be tested without a
362
405
  // 1.5s exit timer in the test runner.
363
- module.exports = { cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
406
+ module.exports = { capturesSession, cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
364
407
  if (require.main === module) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.34.6",
3
+ "version": "1.35.1",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
- // Where Claude Code keeps its configuration — and therefore where the
2
- // settings.json our hooks have to be registered in actually lives.
1
+ // Where Claude Code lives on this machine: its configuration directory, its
2
+ // binary, and the question of whether it is here at all.
3
3
  //
4
4
  // CLAUDE_CONFIG_DIR relocates that directory wholesale; it is a replacement for
5
5
  // ~/.claude, not an overlay, so on a machine where it is set there is nothing in
@@ -12,11 +12,195 @@
12
12
  // reads another. hook/hook.js repeats the rule inline rather than importing it:
13
13
  // it is copied out of the package and run standalone by the host CLI, so it has
14
14
  // no way back to this module, but it has to resolve the same directory.
15
+ //
16
+ // The binary list and the presence test moved in beside it for the same reason.
17
+ // The list used to sit in quota.mjs under the name quotaClaudeCandidates, where
18
+ // it read as a detail of one poller; it is nothing of the kind. It is the answer
19
+ // to "where does the `claude` command live", and the deck now asks that question
20
+ // at boot, before any quota exists, to decide whether this is a Claude machine
21
+ // at all. One list, one caller-visible name, no second spelling to drift.
22
+ import { existsSync } from "node:fs";
15
23
  import { homedir } from "node:os";
16
- import { join, resolve } from "node:path";
24
+ import { join, resolve, posix as posixPath, win32 as winPath } from "node:path";
25
+
26
+ /**
27
+ * Absolute path of the Claude Code config dir: $CLAUDE_CONFIG_DIR or ~/.claude.
28
+ *
29
+ * The environment and the home directory are parameters purely so callers that
30
+ * are already working from an injected environment can stay consistent with it;
31
+ * every caller in the deck passes nothing and gets the real machine's answer.
32
+ */
33
+ export function claudeConfigDir(env = process.env, home = homedir()) {
34
+ const override = env.CLAUDE_CONFIG_DIR?.trim();
35
+ return override ? resolve(override) : join(home, ".claude");
36
+ }
37
+
38
+ /**
39
+ * Every place the `claude` CLI is known to live, in the order to try them.
40
+ *
41
+ * Pure, and the platform, environment and home directory are parameters, so the
42
+ * Windows list can be checked from a Mac — which is the only way this list stays
43
+ * right, since it exists entirely for machines the author is not sitting at.
44
+ */
45
+ export function claudeCliCandidates(platform = process.platform, env = process.env, home = homedir()) {
46
+ // The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
47
+ // would emit forward slashes when the Windows list is built on a Mac.
48
+ const { join } = platform === "win32" ? winPath : posixPath;
49
+ if (platform !== "win32") {
50
+ return [
51
+ "claude",
52
+ join(home, ".local", "bin", "claude"),
53
+ "/usr/local/bin/claude",
54
+ "/opt/homebrew/bin/claude",
55
+ ];
56
+ }
57
+ return [
58
+ // The native installer, which ships a bare claude.exe and NO .cmd shim. It
59
+ // was the one install this branch could not reach: the npm path below does
60
+ // not exist on such a machine, and the bare-name fallback used to be spelled
61
+ // `claude.cmd`, which cmd.exe cannot resolve to an .exe — PATHEXT supplies a
62
+ // missing extension, it never substitutes one that is already there.
63
+ join(home, ".local", "bin", "claude.exe"),
64
+ // `npm i -g @anthropic-ai/claude-code`. npm's global prefix is %APPDATA%\npm
65
+ // — Roaming rather than Local, deliberately, since it follows the user
66
+ // between machines — and APPDATA is read from the environment because a
67
+ // roaming profile puts it on a network share, not under the home directory.
68
+ join(env.APPDATA || join(home, "AppData", "Roaming"), "npm", "claude.cmd"),
69
+ // Last resort: the bare name, which cmd.exe resolves through PATH + PATHEXT
70
+ // and so finds claude.exe and claude.cmd alike.
71
+ "claude",
72
+ ];
73
+ }
74
+
75
+ /**
76
+ * The names Claude Code itself writes inside its config dir, and the deck never
77
+ * does. Presence of any one of them is proof Claude Code has actually run here.
78
+ *
79
+ * The exclusions are the entire point. `settings.json` and `agent-dag/` are OURS
80
+ * — installHooks writes the first and keepDiscovery creates the second — so a
81
+ * test that accepted them would answer "Claude is installed" on any machine this
82
+ * deck had ever been started on, including the Codex-only one this whole check
83
+ * exists to recognise. That is not a hypothetical: every Codex-only machine that
84
+ * ran an earlier ccdeck already has both sitting in a ~/.claude the deck created
85
+ * for itself.
86
+ *
87
+ * `.credentials.json` is in the list but cannot carry it alone: macOS keeps the
88
+ * OAuth token in the Keychain and writes no such file, so on the platform where
89
+ * a credentials test is most tempting it is always negative (see #360). It earns
90
+ * its place as one more way to say yes on Linux and Windows, never as the test.
91
+ */
92
+ const CLAUDE_USE_MARKERS = [
93
+ "projects", // one directory per cwd, written from the first session on
94
+ "history.jsonl", // the prompt history, appended to on the first prompt
95
+ "statsig", // written at first launch, before any session completes
96
+ "todos",
97
+ "shell-snapshots",
98
+ "ide",
99
+ "plugins",
100
+ ".credentials.json", // Linux + Windows OAuth store; absent on macOS by design
101
+ ".claude.json", // Claude Code's own state file, when the config dir moved
102
+ ];
103
+
104
+ /**
105
+ * Whether the `claude` binary is on this machine — on PATH, or in one of the
106
+ * places its installers are known to put it.
107
+ *
108
+ * `run()` in exec.mjs can afford to hand a bare name to spawn and let the OS
109
+ * resolve it. This cannot: it has to answer without launching anything. Boot is
110
+ * the wrong place to spend a `claude --version` child process (quota.mjs
111
+ * measured those at ~3.0s each), and a spawn probe would also have to decide
112
+ * what a non-zero exit means, which is a question with no good answer. So the
113
+ * PATH walk is done by hand, with PATHEXT applied on Windows exactly as cmd.exe
114
+ * would — a `claude.cmd` shim from npm and a bare `claude.exe` from the native
115
+ * installer both have to count, and neither is spelled `claude`.
116
+ */
117
+ export function claudeCliOnDisk({
118
+ platform = process.platform,
119
+ env = process.env,
120
+ home = homedir(),
121
+ exists = existsSync,
122
+ } = {}) {
123
+ const path = platform === "win32" ? winPath : posixPath;
124
+ const bare = [];
125
+ for (const candidate of claudeCliCandidates(platform, env, home)) {
126
+ // A full path is worth a single stat; a bare name means "ask PATH", which
127
+ // is the walk below. Same split quotaClaudeBin makes, for the same reason.
128
+ if (candidate.includes(path.sep)) { if (exists(candidate)) return true; }
129
+ else bare.push(candidate);
130
+ }
131
+ if (bare.length === 0) return false;
132
+
133
+ // process.env is case-insensitive on Windows, but an injected plain object in
134
+ // a test is not, and %Path% is how the variable is actually spelled there.
135
+ const rawPath = env.PATH ?? env.Path ?? env.path ?? "";
136
+ // The empty extension stays in the list: a Git-Bash or WSL-style shim on
137
+ // Windows can be an extensionless file, and on POSIX it is the only entry.
138
+ const exts = platform === "win32"
139
+ ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map(e => e.trim()).filter(Boolean)]
140
+ : [""];
141
+
142
+ for (const entry of String(rawPath).split(path.delimiter)) {
143
+ // Windows PATH entries are routinely quoted, and an empty entry means the
144
+ // current directory — which is not a place to go looking for a CLI.
145
+ const dir = entry.trim().replace(/^"+|"+$/g, "");
146
+ if (dir === "") continue;
147
+ for (const name of bare) {
148
+ for (const ext of exts) if (exists(path.join(dir, name + ext))) return true;
149
+ }
150
+ }
151
+ return false;
152
+ }
17
153
 
18
- /** Absolute path of the Claude Code config dir: $CLAUDE_CONFIG_DIR or ~/.claude. */
19
- export function claudeConfigDir() {
20
- const override = process.env.CLAUDE_CONFIG_DIR?.trim();
21
- return override ? resolve(override) : join(homedir(), ".claude");
154
+ /**
155
+ * Whether this machine has Claude Code at all — the mirror of hasCodexInstalled().
156
+ *
157
+ * README offers "Claude Code CLI or OpenAI Codex CLI (or both)", and until now
158
+ * nothing anywhere asked which. A Codex-only machine got a Python
159
+ * account-switcher installed for a CLI it does not have, an accounts panel open
160
+ * on first run, and a banner telling it to sign into that CLI (#402).
161
+ *
162
+ * WHY TWO KINDS OF EVIDENCE, OR'd. Neither half is sufficient on its own:
163
+ *
164
+ * - The binary alone misses the user whose `claude` lives somewhere no list
165
+ * knows (nvm, mise, volta, a corporate wrapper) and whose deck was launched
166
+ * from a desktop shortcut with a PATH that never sourced their shell rc.
167
+ * That user has run Claude Code for months; the config dir proves it.
168
+ * - The config dir alone misses the machine where Claude Code is installed and
169
+ * has never been launched, which is exactly the moment somebody installs
170
+ * both CLIs and starts the deck first. It also cannot be the test on its own
171
+ * for the opposite reason: THE DECK CREATES THAT DIRECTORY ITSELF. Hence
172
+ * CLAUDE_USE_MARKERS above, which is a list of things only Claude Code puts
173
+ * there.
174
+ *
175
+ * WHY NOT CREDENTIALS. A credentials file is not a presence test on macOS at
176
+ * all — the token is in the Keychain and no file exists (#360) — so a machine
177
+ * with Claude Code, signed in and in daily use, would read as Codex-only.
178
+ *
179
+ * WHY NOT "a Claude session was seen". It is the most direct evidence there is,
180
+ * and it arrives far too late: the decision this answers is made at boot, before
181
+ * the server is listening, and a machine whose first session has not started yet
182
+ * would install nothing and show no panel until a restart.
183
+ *
184
+ * The bias is deliberate and one-way. A false yes leaves a Claude-only surface
185
+ * on a Codex machine — today's bug, visible, and the user can pass --no-claude.
186
+ * A false no takes the hooks away from somebody who has Claude Code, which is a
187
+ * deck that stays empty forever. So every check here is generous, the banner
188
+ * says out loud which way it went, and --claude overrides it.
189
+ */
190
+ export function hasClaudeInstalled({
191
+ platform = process.platform,
192
+ env = process.env,
193
+ home = homedir(),
194
+ configDir = claudeConfigDir(env, home),
195
+ exists = existsSync,
196
+ } = {}) {
197
+ if (claudeCliOnDisk({ platform, env, home, exists })) return true;
198
+ const path = platform === "win32" ? winPath : posixPath;
199
+ for (const marker of CLAUDE_USE_MARKERS) {
200
+ if (exists(path.join(configDir, marker))) return true;
201
+ }
202
+ // ~/.claude.json is Claude Code's own state file on every install that never
203
+ // moved the config dir, and it sits BESIDE the home directory rather than
204
+ // inside ~/.claude — so the loop above cannot reach it there.
205
+ return exists(path.join(home, ".claude.json"));
22
206
  }
@@ -2,7 +2,7 @@
2
2
  // Single-file pure Node HTTP server, zero deps.
3
3
  import { createServer } from "node:http";
4
4
  import { readFile, stat, mkdir, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
5
- import { createReadStream, existsSync, readFileSync } from "node:fs";
5
+ import { createReadStream, existsSync, readFileSync, realpathSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { extname, join, resolve, dirname as pdirname } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -1918,6 +1918,52 @@ async function handleSoundHookSet(req, res) {
1918
1918
  // can read before a single event has arrived.
1919
1919
  let _workspace = "";
1920
1920
 
1921
+ // Which CLIs this deck is actually watching. Decided in bin/deck.js — from
1922
+ // whether each one is on the machine, and from --claude/--no-claude and
1923
+ // --codex/--no-codex — and passed in here, because the browser had no way to
1924
+ // learn it and so drew both sides of the UI on every machine.
1925
+ //
1926
+ // That is the whole of #402 and its mirror. A Codex-only machine got the
1927
+ // accounts panel open on first run, telling it to sign into a CLI it does not
1928
+ // have; a Claude-only machine permanently carried "Quota unavailable. / Run
1929
+ // codex login to authenticate." Same missing fact, two directions.
1930
+ //
1931
+ // Defaults are both true, which is what an older deck effectively reported by
1932
+ // saying nothing — and the browser reads a missing field as "could not say" and
1933
+ // shows both, so the two agree.
1934
+ let _providers = { claude: true, codex: true };
1935
+
1936
+ /**
1937
+ * The one spelling of `--workspace` everything downstream compares against.
1938
+ * Empty — including a value that is nothing but spaces — stays empty, which is
1939
+ * how every reader of it says "machine-wide".
1940
+ *
1941
+ * Called once, in bin/deck.js, where the flag arrives: that process's cwd is the
1942
+ * shell the user typed the command in, and it is the only process on either
1943
+ * capture path whose cwd is the one a relative `--workspace ./sub` was written
1944
+ * against. The raw string used to go straight into the discovery file, and
1945
+ * hook.js resolved it inside its own process — which the host CLI runs with the
1946
+ * AGENT's cwd — so `--workspace ./sub` scoped Claude sessions to a `sub`
1947
+ * directory under whatever the agent happened to be working in, a different
1948
+ * directory per agent and none of them the one asked for. The Codex path
1949
+ * resolved the same string in the server process and was right. Resolving here
1950
+ * makes both of them right, and for the same reason bin/deck.js already resolves
1951
+ * the events log before publishing it: what goes in the discovery file is read
1952
+ * by other processes that cannot reconstruct the context it was written in.
1953
+ *
1954
+ * Symlinks are resolved too, because a process's cwd — which is what both
1955
+ * providers report — comes from getcwd() and has none left in it. Without this,
1956
+ * `--workspace /tmp/proj` on a Mac is scoped to /tmp/proj while every session
1957
+ * inside it reports /private/tmp/proj, and the deck stays empty. A path that
1958
+ * does not exist yet keeps its resolved form rather than failing: scoping a deck
1959
+ * to a directory you are about to create is not an error.
1960
+ */
1961
+ export function canonicalWorkspace(raw) {
1962
+ if (typeof raw !== "string" || raw.trim() === "") return "";
1963
+ const abs = resolve(raw);
1964
+ try { return realpathSync(abs); } catch { return abs; }
1965
+ }
1966
+
1921
1967
  function handleHealth(_req, res) {
1922
1968
  send(res, 200, {
1923
1969
  ok: true,
@@ -1926,6 +1972,7 @@ function handleHealth(_req, res) {
1926
1972
  clients: sseClients.size,
1927
1973
  uptimeMs: Math.round(process.uptime() * 1000),
1928
1974
  workspace: _workspace,
1975
+ providers: _providers,
1929
1976
  });
1930
1977
  }
1931
1978
 
@@ -2312,10 +2359,13 @@ let _onRestart = null;
2312
2359
  // ask; the second ask must not re-enter the shutdown.
2313
2360
  let _restarting = false;
2314
2361
 
2315
- export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, onRestart = null } = {}) {
2362
+ export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null } = {}) {
2316
2363
  _onRestart = typeof onRestart === "function" ? onRestart : null;
2317
2364
  _canRestart = _onRestart != null && persist != null;
2318
2365
  _workspace = typeof workspace === "string" ? workspace : "";
2366
+ // `!== false` rather than a cast: a caller that omits the field means "yes",
2367
+ // which is how every embedder that predates this option keeps working.
2368
+ _providers = { claude: claude !== false, codex: codex !== false };
2319
2369
  const removed = await sweepStaleDiscovery();
2320
2370
  if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
2321
2371
  if (persist) {
@@ -73,17 +73,29 @@ export function electWriters(decks, platform = process.platform) {
73
73
 
74
74
  /**
75
75
  * Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
76
- * empty workspace is unscoped and captures every session.
76
+ * empty workspace is unscoped and captures every session; a rollout that never
77
+ * said where it runs is inside no workspace, so only an unscoped deck draws it.
77
78
  *
78
- * Case is folded on every platform here, which the hook's own cwdInWorkspace
79
- * deliberately does not do. That stays as it was, because this predicate now
80
- * answers two questions with one function: whether THIS deck tails a rollout,
81
- * and whether another deck tails it too. Model the other deck's capture with a
82
- * rule stricter than the one it actually runs and the election covers the wrong
83
- * set — a deck that writes without being elected, or an elected deck that never
84
- * saw the file. Being wrong in the over-matching direction on Linux costs a deck
85
- * a sibling tree it was not scoped to; being wrong in either direction here
86
- * costs a duplicated log line or a lost one.
79
+ * This answers two questions with one function — whether THIS deck tails a
80
+ * rollout, and whether another deck tails it too — and that is only sound while
81
+ * the rule below is the rule every deck actually runs. Model another deck's
82
+ * capture with anything else and the election covers the wrong set: a deck that
83
+ * writes without being elected, or an elected deck that never opened the file.
84
+ *
85
+ * It is also the rule hook/hook.js runs for the sessions it delivers, under the
86
+ * name capturesSession — that script is copied out of the package and run
87
+ * standalone, so the two are written twice and pinned equal by a test walking
88
+ * one table of paths through both. They were not equal: case was folded here on
89
+ * every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
90
+ * from /srv/Proj and Claude sessions from neither. Those are two real
91
+ * directories there, and the hook's own comment says what folding them together
92
+ * costs — a deck handed the events of a tree it was not scoped to. So the fold
93
+ * is per-platform on both sides now, and `--workspace` means one thing.
94
+ *
95
+ * (The narrow window that opens: two decks on Linux whose workspaces differ only
96
+ * in case, one of them old enough to still fold, both containing one rollout's
97
+ * cwd. Each models the other as tailing the file; one of them is wrong, and the
98
+ * cost is a single log line written twice.)
87
99
  *
88
100
  * The platform is a parameter, following the hook's cwdInWorkspace and
89
101
  * spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
@@ -93,8 +105,9 @@ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform)
93
105
  if (!workspace || typeof workspace !== "string") return true;
94
106
  if (!cwd || typeof cwd !== "string") return false;
95
107
  const p = platform === "win32" ? win32 : posix;
96
- const a = p.resolve(cwd).toLowerCase();
97
- const b = p.resolve(workspace).toLowerCase();
108
+ const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
109
+ const a = fold(p.resolve(cwd));
110
+ const b = fold(p.resolve(workspace));
98
111
  if (a === b) return true;
99
112
  // A root ("C:\", "/") already ends in the separator; appending a second one
100
113
  // would match nothing.
@@ -106,13 +119,15 @@ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform)
106
119
  * it? `decks` is every deck registered right now, `pid` identifies this one
107
120
  * among them, and `cwd` is the workspace the rollout is running in.
108
121
  *
109
- * The group is every deck that tails this same rollout — the hook's targets are
110
- * narrowed to the longest workspace match, but nothing narrows these: each deck
111
- * decides for itself, so all of them whose workspace contains the cwd read the
112
- * file and all of them would write it. A deck started with `--no-codex` tails
113
- * nothing and is left out; electing it would mean the rollout's events reach no
114
- * log at all. A deck too old to say either way is assumed to be tailing, which
115
- * is what it was doing before this field existed.
122
+ * The group is every deck that tails this same rollout: each deck decides for
123
+ * itself, so all of them whose workspace contains the cwd read the file and all
124
+ * of them would write it. The hook builds the same group the same way for the
125
+ * events it delivers — it used to narrow them to the longest workspace match
126
+ * first, which is the asymmetry the predicate above describes the end of. A deck
127
+ * started with `--no-codex` tails nothing and is left out; electing it would
128
+ * mean the rollout's events reach no log at all. A deck too old to say either
129
+ * way is assumed to be tailing, which is what it was doing before this field
130
+ * existed.
116
131
  */
117
132
  export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
118
133
  const live = Array.isArray(decks) ? decks : [];
@@ -28,11 +28,11 @@
28
28
  // 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
29
29
  // cooldown; 1 is not, because it is a local file read.
30
30
  import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
31
- import { claudeConfigDir } from "./claude-dir.mjs";
31
+ import { claudeCliCandidates, claudeConfigDir } from "./claude-dir.mjs";
32
32
  import { run } from "./exec.mjs";
33
33
  import { existsSync } from "node:fs";
34
34
  import { readFile } from "node:fs/promises";
35
- import { join, posix as posixPath, win32 as winPath } from "node:path";
35
+ import { join } from "node:path";
36
36
  import { homedir } from "node:os";
37
37
  import { PRODUCT } from "./brand.mjs";
38
38
 
@@ -334,42 +334,10 @@ function parseUsageText(raw) {
334
334
  return Object.keys(result).length > 0 ? result : null;
335
335
  }
336
336
 
337
- /**
338
- * Every place the `claude` CLI is known to live, in the order to try them.
339
- *
340
- * Pure, and the platform, environment and home directory are parameters, so the
341
- * Windows list can be checked from a Mac — which is the only way this list stays
342
- * right, since it exists entirely for machines the author is not sitting at.
343
- */
344
- export function quotaClaudeCandidates(platform = process.platform, env = process.env, home = homedir()) {
345
- // The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
346
- // would emit forward slashes when the Windows list is built on a Mac.
347
- const { join } = platform === "win32" ? winPath : posixPath;
348
- if (platform !== "win32") {
349
- return [
350
- "claude",
351
- join(home, ".local", "bin", "claude"),
352
- "/usr/local/bin/claude",
353
- "/opt/homebrew/bin/claude",
354
- ];
355
- }
356
- return [
357
- // The native installer, which ships a bare claude.exe and NO .cmd shim. It
358
- // was the one install this branch could not reach: the npm path below does
359
- // not exist on such a machine, and the bare-name fallback used to be spelled
360
- // `claude.cmd`, which cmd.exe cannot resolve to an .exe — PATHEXT supplies a
361
- // missing extension, it never substitutes one that is already there.
362
- join(home, ".local", "bin", "claude.exe"),
363
- // `npm i -g @anthropic-ai/claude-code`. npm's global prefix is %APPDATA%\npm
364
- // — Roaming rather than Local, deliberately, since it follows the user
365
- // between machines — and APPDATA is read from the environment because a
366
- // roaming profile puts it on a network share, not under the home directory.
367
- join(env.APPDATA || join(home, "AppData", "Roaming"), "npm", "claude.cmd"),
368
- // Last resort: the bare name, which cmd.exe resolves through PATH + PATHEXT
369
- // and so finds claude.exe and claude.cmd alike.
370
- "claude",
371
- ];
372
- }
337
+ // Where the `claude` CLI can be. The list moved to claude-dir.mjs, which is the
338
+ // module that owns every "where does Claude Code live" answer the deck has —
339
+ // the config dir was already there, and the boot-time presence check that reads
340
+ // this same list had no business importing a quota poller to get at it.
373
341
 
374
342
  /** Which `claude` to run for `--print /usage`: the first candidate that exists.
375
343
  *
@@ -396,7 +364,7 @@ export function quotaClaudeBin(platform = process.platform, env = process.env,
396
364
  // A bare name is left to spawn's own PATH lookup (and, on Windows, to the
397
365
  // PATHEXT walk exec.mjs does by hand); a full path is only worth naming when
398
366
  // it is actually there.
399
- return quotaClaudeCandidates(platform, env, home)
367
+ return claudeCliCandidates(platform, env, home)
400
368
  .find(c => !c.includes(sep) || exists(c)) ?? "claude";
401
369
  }
402
370