agent-dag 1.35.0 → 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-CMZYhML1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.35.0",
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
  }
@@ -1918,6 +1918,21 @@ 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
+
1921
1936
  /**
1922
1937
  * The one spelling of `--workspace` everything downstream compares against.
1923
1938
  * Empty — including a value that is nothing but spaces — stays empty, which is
@@ -1957,6 +1972,7 @@ function handleHealth(_req, res) {
1957
1972
  clients: sseClients.size,
1958
1973
  uptimeMs: Math.round(process.uptime() * 1000),
1959
1974
  workspace: _workspace,
1975
+ providers: _providers,
1960
1976
  });
1961
1977
  }
1962
1978
 
@@ -2343,10 +2359,13 @@ let _onRestart = null;
2343
2359
  // ask; the second ask must not re-enter the shutdown.
2344
2360
  let _restarting = false;
2345
2361
 
2346
- 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 } = {}) {
2347
2363
  _onRestart = typeof onRestart === "function" ? onRestart : null;
2348
2364
  _canRestart = _onRestart != null && persist != null;
2349
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 };
2350
2369
  const removed = await sweepStaleDiscovery();
2351
2370
  if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
2352
2371
  if (persist) {
@@ -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