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.
- package/README.md +3 -0
- package/bin/deck.js +6 -0
- package/dist/web/assets/{index-zdN41Mgx.js → index-yGg2Xb4o.js} +23 -23
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/server/ccusage.mjs +157 -18
- package/src/server/exec.mjs +62 -10
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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": {
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
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
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
:
|
|
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).
|
|
581
|
-
//
|
|
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" | "
|
|
605
|
-
* Never throws and never blocks on the install itself — a slow
|
|
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.
|
package/src/server/exec.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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;
|