agent-dag 1.35.28 → 1.35.29

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-zdN41Mgx.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-yGg2Xb4o.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-CjSKIkNs.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.28",
3
+ "version": "1.35.29",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@ import { spawn, spawnSync } from "node:child_process";
15
15
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
16
  import path from "node:path";
17
17
  import os from "node:os";
18
- import { killTree, shimPath, spawnSpec } from "./exec.mjs";
18
+ import { killTree, pathLookup, shimPath, spawnSpec } from "./exec.mjs";
19
19
  import { oneLine, termColumns } from "./term.mjs";
20
20
  import { PRODUCT } from "./brand.mjs";
21
21
 
@@ -368,18 +368,113 @@ function maybeBackgroundUpdate(installedVersion) {
368
368
  } catch { /* ignore */ }
369
369
  }
370
370
 
371
+ // ── the user's own copy ─────────────────────────────────────────────────────
372
+
373
+ /**
374
+ * A ccusage the USER put somewhere, either by naming it outright or by having
375
+ * it on PATH — or null when there is no such thing.
376
+ *
377
+ * This is #433, and the case for it is not that a PATH search is nice to have.
378
+ * The deck has been telling people "put ccusage on PATH yourself" in
379
+ * admin-failure.ts for as long as that sentence has existed, and nothing
380
+ * anywhere ever looked: a user who did exactly what they were told, and could
381
+ * prove it with `ccusage --version` in their own shell, got the identical
382
+ * failure on the next click. Two ways out of that, and the other one is to
383
+ * delete the promise. It is kept because there is a configuration that needs it
384
+ * and has no other:
385
+ *
386
+ * - `AGENTS_DECK_NO_INSTALL=1` is documented as "never install or update
387
+ * claude-swap / ccusage". Someone who sets it AND installs ccusage
388
+ * themselves has done the only thing that flag can sensibly mean, and until
389
+ * now the deck answered "ccusage is not installed" while it was installed
390
+ * and on their PATH. There was NO combination of settings that made a
391
+ * self-managed ccusage work.
392
+ * - The #432 machine — npm and npx both unusable on disk — cannot create the
393
+ * managed install and cannot run the npx fallback. A copy the user already
394
+ * has is the only route left, and it was the one route the deck refused to
395
+ * look down.
396
+ *
397
+ * The override wins over PATH the way AGENTS_DECK_CSWAP does over cswap's own
398
+ * search, and is never remembered, because somebody debugging a bad resolution
399
+ * needs a change to it to take effect on the next click rather than the next
400
+ * restart. Nothing here is cached for the same reason, and it is affordable:
401
+ * a lookup is a handful of stats, once per uncached fetch, against a process
402
+ * spawn that follows it.
403
+ *
404
+ * `platform` and `deps` are parameters, and this is exported, for the reason
405
+ * every other Windows answer in this module is: the PATHEXT walk and the
406
+ * `.cmd` spelling have to be checkable from a machine that cannot run Windows.
407
+ */
408
+ export function userCcusage(platform = process.platform, deps, env = process.env) {
409
+ const named = env.AGENTS_DECK_CCUSAGE;
410
+ if (named) {
411
+ // A path the user typed is used as typed — pathLookup would refuse it
412
+ // anyway, since re-rooting a name that carries a directory is a way to run
413
+ // something other than what was asked for. Whether it EXISTS is the
414
+ // caller's question, because "the path you gave me is not there" is a
415
+ // different sentence from "you have no ccusage".
416
+ return { file: String(named), named: true };
417
+ }
418
+ const found = pathLookup("ccusage", platform, deps);
419
+ return found ? { file: found, named: false } : null;
420
+ }
421
+
422
+ /** Does the file an explicit override names actually exist? Split out so the
423
+ * existence check is injectable alongside the lookup above. */
424
+ const overrideIsThere = (file, { exists = existsSync } = {}) => {
425
+ try {
426
+ return exists(file);
427
+ } catch {
428
+ return false;
429
+ }
430
+ };
431
+
371
432
  // Ensure a runnable ccusage. Returns { kind:"node", entry } for the managed
372
- // install, or { kind:"npx" } as the portable fallback.
433
+ // install, { kind:"path", file } for a copy the user provided, or { kind:"npx" }
434
+ // as the portable fallback.
435
+ //
436
+ // The order is the whole design decision, so it is stated rather than implied.
437
+ //
438
+ // An explicit AGENTS_DECK_CCUSAGE is first and is never fallen through: a user
439
+ // who pointed the deck at a file and got silently ignored has been given a
440
+ // setting that does nothing, which is worse than not having one. If it does not
441
+ // resolve, the failure names THAT — "npx is not on this deck's PATH" would be
442
+ // both true and completely beside the point.
443
+ //
444
+ // The managed install then comes BEFORE the PATH copy, which is the opposite of
445
+ // what #433 proposed, and deliberately. Preferring PATH would silently change
446
+ // which ccusage runs on every machine that already has both — a deck that works
447
+ // today would start running a copy it has never run, and an old global ccusage
448
+ // without `daily --json` would turn a working panel into a broken one for no
449
+ // reason the user asked for. The PATH copy is an ESCAPE ROUTE, and it wants to
450
+ // be reached exactly when the managed install is not there; someone who wants
451
+ // their own copy to win over the deck's says so with AGENTS_DECK_CCUSAGE, which
452
+ // is unambiguous in a way a precedence rule never is.
453
+ //
454
+ // PATH comes before INSTALLING, though. Downloading a second copy of a tool
455
+ // that is already on the machine is not something to do to somebody, and it is
456
+ // what makes the order identical with and without AGENTS_DECK_NO_INSTALL=1 —
457
+ // that flag now removes a step rather than changing the sequence.
373
458
  async function getRunner() {
459
+ const mine = userCcusage();
460
+ if (mine?.named) {
461
+ if (!overrideIsThere(mine.file)) {
462
+ throw tagged("bad_override",
463
+ `AGENTS_DECK_CCUSAGE points at ${mine.file}, and there is no such file`);
464
+ }
465
+ return { kind: "path", file: mine.file, named: true };
466
+ }
374
467
  let resolved = resolveEntry();
375
468
  if (resolved) {
376
469
  maybeBackgroundUpdate(resolved.version);
377
470
  return { kind: "node", entry: resolved.entry };
378
471
  }
379
- // Nothing installed and installs are forbidden. The npx fallback is not an
380
- // escape hatch — `npx -y ccusage@latest` downloads and runs the same package
381
- // — so there is no runner to hand back. Fail with the reason, which the
382
- // usage-history modal shows, instead of quietly installing.
472
+ if (mine) return { kind: "path", file: mine.file, named: false };
473
+ // Nothing installed, nothing of the user's to run, and installs are
474
+ // forbidden. The npx fallback is not an escape hatch — `npx -y ccusage@latest`
475
+ // downloads and runs the same package — so there is no runner to hand back.
476
+ // Fail with the reason, which the usage-history modal shows, instead of
477
+ // quietly installing.
383
478
  if (installsDisabled()) {
384
479
  throw tagged("no_install", "ccusage is not installed, and installs are off (AGENTS_DECK_NO_INSTALL=1)");
385
480
  }
@@ -393,11 +488,11 @@ async function getRunner() {
393
488
  return { kind: "npx", installError: _lastInstallError };
394
489
  }
395
490
 
396
- /** Which of ccusage's two paths a failure came from, in the deck's own words.
491
+ /** Which of ccusage's three paths a failure came from, in the deck's own words.
397
492
  * `stage` travels to the browser; the modal leads with it rather than making
398
493
  * the reader guess from a stack trace which half of this module they are
399
494
  * looking at. */
400
- const STAGE = { node: "managed", npx: "npx" };
495
+ const STAGE = { node: "managed", path: "path", npx: "npx" };
401
496
 
402
497
  /** Mark a failure with the path that produced it, and with the managed
403
498
  * install's own account when that is why this path was taken at all. Set once:
@@ -406,6 +501,11 @@ const STAGE = { node: "managed", npx: "npx" };
406
501
  function stamp(err, runner) {
407
502
  if (err && typeof err === "object") {
408
503
  if (!err.stage) err.stage = STAGE[runner?.kind] ?? "npx";
504
+ // Which file, when the answer is a file the deck did not put there. The
505
+ // stage alone says "your own copy failed" and leaves the reader to work out
506
+ // WHICH copy, and on a machine with an override, a PATH entry and a managed
507
+ // install that is exactly the question they cannot answer from here.
508
+ if (runner?.kind === "path" && err.bin === undefined) err.bin = runner.file;
409
509
  if (runner?.installError && err.install === undefined) err.install = runner.installError;
410
510
  }
411
511
  return err;
@@ -454,14 +554,33 @@ function discardDamagedInstall(runner, err) {
454
554
  return !resolveEntry();
455
555
  }
456
556
 
457
- // One attempt with one runner. Neither branch gets a shell. The managed install
458
- // is `node <entry> …`, which never needed one; the npx fallback is routed
459
- // through spawnSpec instead — see npxSpec, and note that `args` here ends in
460
- // whatever /api/ccusage was asked for.
557
+ /**
558
+ * What `spawn` gets for a ccusage the user provided.
559
+ *
560
+ * `file` is always an absolute path by the time it reaches here — pathLookup
561
+ * resolves the directory and an override is a path the user typed — and that is
562
+ * load-bearing rather than tidy. On Windows the thing on PATH is `ccusage.cmd`,
563
+ * a batch file, so spawnSpec routes it through cmd.exe; a batch file launched
564
+ * by BARE name computes `%~dp0` from the deck's working directory and goes
565
+ * looking for its payload there, which is #456 exactly. Resolving first and
566
+ * quoting second is the same order the npm and npx shims go through.
567
+ *
568
+ * Exported for tests: the platform is a parameter so the Windows command line
569
+ * can be checked from any OS.
570
+ */
571
+ export const userSpec = (file, args = [], platform = process.platform) =>
572
+ spawnSpec(file, args, platform);
573
+
574
+ // One attempt with one runner. No branch gets a shell. The managed install is
575
+ // `node <entry> …`, which never needed one; the user's own copy and the npx
576
+ // fallback are routed through spawnSpec instead — see npxSpec and userSpec, and
577
+ // note that `args` here ends in whatever /api/ccusage was asked for.
461
578
  function runOnce(runner, args) {
462
579
  const { file, args: full, opts } = runner.kind === "node"
463
580
  ? { file: process.execPath, args: [runner.entry, ...args], opts: {} }
464
- : fallbackSpec(args);
581
+ : runner.kind === "path"
582
+ ? userSpec(runner.file, args)
583
+ : fallbackSpec(args);
465
584
  return new Promise((resolve, reject) => {
466
585
  const child = spawn(file, full, { windowsHide: true, ...opts });
467
586
  let out = "", err = "";
@@ -577,13 +696,16 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
577
696
  // `stage` and `install` are the halves that used to be lost. They are what
578
697
  // let the modal say WHICH path failed and why, on screen, instead of
579
698
  // guessing it from the shape of a stack trace — the guess that shipped two
580
- // wrong diagnoses in a row (#432, #450). Undefined when nothing ran, and
581
- // JSON.stringify drops them, so an older browser sees the reply it expects.
699
+ // wrong diagnoses in a row (#432, #450). `bin` joined them for #433: with a
700
+ // third path, "your own copy failed" is only half an answer until it names
701
+ // which file that was. Undefined when nothing ran, and JSON.stringify drops
702
+ // them, so an older browser sees the reply it expects.
582
703
  result = {
583
704
  ok: false,
584
705
  reason: err?.reason ?? "run_failed",
585
706
  stage: err?.stage,
586
707
  install: err?.install,
708
+ bin: err?.bin,
587
709
  error: String(err?.message ?? err),
588
710
  fetchedAt: now,
589
711
  };
@@ -601,11 +723,28 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
601
723
  * explanation. Called from the CLI so that cost is paid while the deck is
602
724
  * still booting.
603
725
  *
604
- * Returns { state: "present" | "installing" | "updating" | "unavailable" }.
605
- * Never throws and never blocks on the install itself — a slow registry must
606
- * not hold up the server.
726
+ * Returns { state: "present" | "user" | "installing" | "updating" |
727
+ * "unavailable" }. Never throws and never blocks on the install itself — a slow
728
+ * registry must not hold up the server.
729
+ *
730
+ * The order here is getRunner's order, and it has to be: a boot that installs a
731
+ * managed copy while the user already has one on PATH would make getRunner's
732
+ * "PATH before installing" true only until the first restart, and would spend a
733
+ * download saying so. `user` is reported without a version because finding one
734
+ * means RUNNING the thing, and the boot is not the place to spawn a process to
735
+ * fill in a status row.
607
736
  */
608
737
  export function primeCcusage() {
738
+ const mine = userCcusage();
739
+ // An override that names a file which is not there is not reported here at
740
+ // all: the boot has nothing useful to say about it and getRunner will say it
741
+ // properly, with the path, the first time the modal is opened.
742
+ if (mine && (!mine.named || overrideIsThere(mine.file))) {
743
+ const resolved = resolveEntry();
744
+ // The managed install still wins when it exists — see getRunner — so an
745
+ // existing deck's boot row does not change.
746
+ if (mine.named || !resolved) return { state: "user", bin: mine.file };
747
+ }
609
748
  // The CLI already skips this call under AGENTS_DECK_NO_INSTALL=1; repeating
610
749
  // the check here keeps the promise a property of the module rather than of
611
750
  // one caller.
@@ -151,31 +151,83 @@ export function shellQuoteArg(arg, platform = process.platform) {
151
151
  * so a Windows layout checked from macOS would come back with forward slashes.
152
152
  * `execPath`, `pathEnv` and `exists` are injected for the same reason — the
153
153
  * Windows answer has to be checkable from an OS that cannot run it.
154
+ *
155
+ * The walk itself now lives in `pathLookup` below, because #433 needed the same
156
+ * one for a tool that is not a shim; this is that walk with the two answers a
157
+ * shim needs — Windows, and node's own directory first.
154
158
  */
155
- export function shimPath(name, {
159
+ export const shimPath = (name, deps) =>
160
+ pathLookup(name, "win32", { besideNode: true, ...deps });
161
+
162
+ /**
163
+ * Where `name` actually lives on PATH, as an absolute path, or null when
164
+ * nothing on PATH answers to it.
165
+ *
166
+ * This is the general form of shimPath above, and it exists because #433 asked
167
+ * for a third way to reach ccusage: the copy a user installed themselves. The
168
+ * deck's own error text has told people to "put ccusage on PATH yourself" since
169
+ * before there was anything that looked, so the choice was to either look or
170
+ * stop saying it — see getRunner in ccusage.mjs for which way that went.
171
+ *
172
+ * Writing it as ONE walk rather than a second one is the point. A PATH search
173
+ * on Windows is not a PATH search plus a note: `ccusage` there is `ccusage.cmd`
174
+ * or `ccusage.exe` and never the bare name, because PATHEXT is a shell's job
175
+ * and spawn is not a shell — which is the whole reason `candidates` exists, so
176
+ * that is what supplies the spellings here. And a `.cmd` found this way is
177
+ * returned as a FULL PATH, which is what keeps #456 fixed: launched by its bare
178
+ * name through cmd.exe, a shim computes `%~dp0` from the deck's working
179
+ * directory and goes hunting for its payload there.
180
+ *
181
+ * `besideNode` is the one thing a shim wants and a tool does not. npm ships
182
+ * beside node, so looking there first is right for `npm.cmd`; for anything else
183
+ * it would quietly overrule the order the user put their own PATH in, which is
184
+ * the one statement of preference they actually made.
185
+ *
186
+ * The check is existence, not executability. That is what `run`'s candidate
187
+ * loop effectively asks too — it spawns and moves on if the spawn fails — and
188
+ * an executable bit is not a thing Windows has. The residue is a directory on
189
+ * PATH that happens to be named after the tool; it would be resolved here and
190
+ * fail on spawn, with the failure naming the path, which is a better place to
191
+ * find out than a silent miss.
192
+ */
193
+ export function pathLookup(name, platform = process.platform, {
156
194
  execPath = process.execPath,
157
195
  pathEnv = process.env.PATH ?? process.env.Path ?? "",
158
196
  exists = existsSync,
197
+ besideNode = false,
159
198
  } = {}) {
160
199
  // A name that already carries a directory needs no lookup, and re-rooting it
161
200
  // would be a way to run something else entirely.
162
201
  if (typeof name !== "string" || !name || /[\\/]/.test(name)) return null;
202
+ const win = platform === "win32";
203
+ const sep = win ? "\\" : "/";
163
204
  const dirs = [];
164
- const beside = String(execPath ?? "").split(/[\\/]/).slice(0, -1).join("\\");
165
- if (beside) dirs.push(beside);
166
- for (const raw of String(pathEnv ?? "").split(";")) {
205
+ if (besideNode) {
206
+ const beside = String(execPath ?? "").split(/[\\/]/).slice(0, -1).join(sep);
207
+ if (beside) dirs.push(beside);
208
+ }
209
+ // `;` on Windows, `:` everywhere else. Splitting on the wrong one is not a
210
+ // near miss: a POSIX PATH read with `;` is one enormous directory that
211
+ // exists nowhere, so every lookup would answer null and the feature would
212
+ // look like it had never been written.
213
+ for (const raw of String(pathEnv ?? "").split(win ? ";" : ":")) {
167
214
  // A PATH entry may be quoted, and may end in a separator; neither is part
168
215
  // of the directory, and both would produce a path nothing exists at.
169
216
  const dir = raw.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "");
170
217
  if (dir) dirs.push(dir);
171
218
  }
219
+ // On POSIX this is `[name]`, so the inner loop runs once and the cost is one
220
+ // stat per directory, exactly as before.
221
+ const spellings = candidates(name, platform);
172
222
  for (const dir of dirs) {
173
- const full = `${dir}\\${name}`;
174
- try {
175
- if (exists(full)) return full;
176
- } catch {
177
- // An entry that cannot even be stat'ed — a disconnected network drive is
178
- // the usual one — is a miss, not a reason to stop looking.
223
+ for (const spelling of spellings) {
224
+ const full = `${dir}${sep}${spelling}`;
225
+ try {
226
+ if (exists(full)) return full;
227
+ } catch {
228
+ // An entry that cannot even be stat'ed — a disconnected network drive
229
+ // is the usual one — is a miss, not a reason to stop looking.
230
+ }
179
231
  }
180
232
  }
181
233
  return null;