agent-dag 1.43.0 → 1.44.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.
- package/README.md +10 -6
- package/bin/agent-dag.js +63 -9
- package/bin/deck.js +38 -10
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/notify.mjs +104 -0
- package/package.json +2 -2
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +98 -2
- package/src/server/cswap-admin.mjs +180 -6
- package/src/server/cswap-auto.mjs +209 -10
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +130 -11
- package/src/server/index.mjs +226 -19
- package/src/server/installer.mjs +81 -8
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/self-update.mjs +262 -21
- package/src/server/sound-hook.mjs +152 -24
- package/src/server/supervisor.mjs +36 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +43 -11
- package/dist/web/assets/index-BxAZQc7O.css +0 -1
- package/dist/web/assets/index-DRgZVqF-.js +0 -78
- package/hook/notify.js +0 -60
|
@@ -26,7 +26,7 @@ import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFi
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
28
|
import { dirname, join, resolve } from "node:path";
|
|
29
|
-
import { killTree } from "./exec.mjs";
|
|
29
|
+
import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
30
30
|
|
|
31
31
|
// Once an hour, not once a day.
|
|
32
32
|
//
|
|
@@ -67,6 +67,68 @@ const LEGACY_MARKER = join(MARKER_DIR, ".self-update-check");
|
|
|
67
67
|
// names are two different questions and must not be answered with one answer.
|
|
68
68
|
const _inflight = new Map();
|
|
69
69
|
|
|
70
|
+
// ── what a forced check may cost ─────────────────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// `_inflight` deduplicates callers that OVERLAP and nothing else. `checkDue`
|
|
73
|
+
// answers `true` on `force` before it asks anything else, so a caller that
|
|
74
|
+
// waited for one check to settle and then asked again got a fresh
|
|
75
|
+
// `https://registry.npmjs.org/…/dist-tags` every time. Reads on this server are
|
|
76
|
+
// deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
|
|
77
|
+
// that `isTrustedMutation` does, because a cross-site read of
|
|
78
|
+
// `http://127.0.0.1:4317` is an ordinary top-level navigation — so
|
|
79
|
+
//
|
|
80
|
+
// (async function spin() {
|
|
81
|
+
// for (;;) await fetch("http://127.0.0.1:4317/api/version?refresh=1",
|
|
82
|
+
// { mode: "no-cors" });
|
|
83
|
+
// })();
|
|
84
|
+
//
|
|
85
|
+
// from any page the user had open was one registry request per turn, as fast as
|
|
86
|
+
// the round trip allows, and every so often two of them: `runCheck` confirms a
|
|
87
|
+
// tag it has not seen before against the version document. The requests are
|
|
88
|
+
// small — the whole reason the dist-tags endpoint is used here rather than the
|
|
89
|
+
// packument — but they leave the user's address, carrying the user-agent this
|
|
90
|
+
// deck sets, and they are aimed at a third party rather than at the machine the
|
|
91
|
+
// loop is running on. That makes it #580's shape with the cost pointed
|
|
92
|
+
// outwards, which if anything is the worse direction.
|
|
93
|
+
//
|
|
94
|
+
// Nothing above this line was going to stop it. The hour is written to a marker
|
|
95
|
+
// FILE, and `force` walks past it; `first` walks past it too; and on a machine
|
|
96
|
+
// where `~/.agents-deck` cannot be written `writeMarker` swallows the failure,
|
|
97
|
+
// so `checkDue` sees "never checked" on every single call and the hour is not
|
|
98
|
+
// really there at all.
|
|
99
|
+
//
|
|
100
|
+
// So the floor sits under all of it, in this process's own memory, between the
|
|
101
|
+
// last rule that admitted a check and the request it admitted. It is quota.mjs's
|
|
102
|
+
// number and codex-quota.mjs's and codex-usage.mjs's — the five routes in this
|
|
103
|
+
// deck's router that accept `?refresh=1` and can pay for it have no business
|
|
104
|
+
// disagreeing about what it costs.
|
|
105
|
+
const FORCE_POLL_MS = 60_000;
|
|
106
|
+
|
|
107
|
+
// Stamped when a check STARTS rather than when npm answers, because what the
|
|
108
|
+
// floor rations is the request. Per package name, like `_inflight` and like the
|
|
109
|
+
// markers: two names are two different questions, and one of them being asked
|
|
110
|
+
// is not a reason to refuse the other. Deliberately in memory rather than on
|
|
111
|
+
// disk — the marker is shared by every deck running that package and this is
|
|
112
|
+
// about what THIS process is sending.
|
|
113
|
+
const _lastAskAt = new Map();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether we may spend a registry request right now.
|
|
117
|
+
*
|
|
118
|
+
* Exported for tests, for the same reason quota.mjs exports `maySelfPoll`,
|
|
119
|
+
* codex-quota.mjs `mayFetchQuota` and codex-usage.mjs `mayScanUsage`: this is
|
|
120
|
+
* the rule, it is pure, and it is worth pinning down away from the request it
|
|
121
|
+
* guards.
|
|
122
|
+
*/
|
|
123
|
+
export function mayAskNpm({ now, lastAskAt }) {
|
|
124
|
+
// A stamp from the future is a clock that moved, not a check that just ran —
|
|
125
|
+
// the same case `checkDue` answers twice below with `> now`, and the same
|
|
126
|
+
// answer. Without it, a machine whose clock corrects backwards by an hour
|
|
127
|
+
// would go an hour without a version check.
|
|
128
|
+
if (lastAskAt > now) return true;
|
|
129
|
+
return now - lastAskAt >= FORCE_POLL_MS;
|
|
130
|
+
}
|
|
131
|
+
|
|
70
132
|
// ── version comparison ───────────────────────────────────────────────────────
|
|
71
133
|
|
|
72
134
|
/** True when `a` sorts before `b`. Numeric-segment compare — non-numeric
|
|
@@ -113,9 +175,24 @@ function readManifest(dir) {
|
|
|
113
175
|
|
|
114
176
|
/** Version currently written in the package's own package.json. Deliberately
|
|
115
177
|
* read fresh on every call: that is the whole point — it changes under a
|
|
116
|
-
* running process when npm replaces the install.
|
|
178
|
+
* running process when npm replaces the install.
|
|
179
|
+
*
|
|
180
|
+
* And sometimes the directory changes with it. A `npm i -g ccdeck` performed
|
|
181
|
+
* before #340 left the deck nested inside a launcher package; upgrading such an
|
|
182
|
+
* install now writes the flat tarball over that launcher, and npm's reify takes
|
|
183
|
+
* the nested copy — the one this process is running out of — with it. Reading
|
|
184
|
+
* our own manifest then answers null, which pickNotice reads as "nothing is
|
|
185
|
+
* installed" and turns into the same upgrade offered forever, with the restart
|
|
186
|
+
* notice this module exists for never firing at all.
|
|
187
|
+
*
|
|
188
|
+
* The version is not lost, it moved one directory up. successorRoot is where
|
|
189
|
+
* to, and it answers null in every layout where nothing moved — so the normal
|
|
190
|
+
* case still reads exactly one manifest. */
|
|
117
191
|
export function installedVersion(pkgRoot) {
|
|
118
|
-
const
|
|
192
|
+
const own = readManifest(pkgRoot);
|
|
193
|
+
if (typeof own?.version === "string") return own.version;
|
|
194
|
+
const moved = successorRoot(pkgRoot);
|
|
195
|
+
const v = moved ? readManifest(moved)?.version : null;
|
|
119
196
|
return typeof v === "string" ? v : null;
|
|
120
197
|
}
|
|
121
198
|
|
|
@@ -139,7 +216,16 @@ export function isNpxInstall(pkgRoot) {
|
|
|
139
216
|
* returning "git pull && npm run build", `upgradeName` refusing to move a
|
|
140
217
|
* checkout onto a published alias, and `startUpgrade` refusing with
|
|
141
218
|
* `git_checkout`. A test importing the predicate would restate what those three
|
|
142
|
-
* already prove, one level further from the behaviour a user can see.
|
|
219
|
+
* already prove, one level further from the behaviour a user can see.
|
|
220
|
+
*
|
|
221
|
+
* `existsSync` and not a directory test, deliberately (#587). Git writes `.git`
|
|
222
|
+
* as a directory only for an ordinary clone; a linked worktree and a submodule
|
|
223
|
+
* each get a FILE whose whole content is one `gitdir:` line, and all three are
|
|
224
|
+
* checkouts nobody may install over. Nothing here reads that line, which is
|
|
225
|
+
* also what keeps the rule identical on Windows, where the path inside it
|
|
226
|
+
* carries a drive letter and backslashes. Both shapes are covered in
|
|
227
|
+
* worktree-git-file-587.test.ts and beside every checkout fixture in the
|
|
228
|
+
* suite — narrowing this to `.isDirectory()` fails them. */
|
|
143
229
|
function isGitCheckout(pkgRoot) {
|
|
144
230
|
try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
|
|
145
231
|
}
|
|
@@ -245,8 +331,9 @@ export function npxRestartSpec(pkgRoot, name = "agents-deck") {
|
|
|
245
331
|
/** Every name this deck is published under.
|
|
246
332
|
*
|
|
247
333
|
* `agents-deck` and `agent-dag` are one tarball published twice — see
|
|
248
|
-
* .github/workflows/publish.yml, which renames it between the two — and
|
|
249
|
-
* `ccdeck` is
|
|
334
|
+
* .github/workflows/publish.yml, which renames it between the two — and since
|
|
335
|
+
* #340 `ccdeck` is a third rename of the same tarball rather than a launcher
|
|
336
|
+
* package in front of it. The same three strings as
|
|
250
337
|
* invoked-as.mjs's COMMANDS, and deliberately not that list: this is the set
|
|
251
338
|
* of npm PACKAGES a `npm i -g` may name, that is the set of bin commands a
|
|
252
339
|
* user may type. They are equal only because the rename made them so, and a
|
|
@@ -330,6 +417,43 @@ export function hostPackage(pkgRoot, name = "agents-deck") {
|
|
|
330
417
|
return host ? { root, name: host } : null;
|
|
331
418
|
}
|
|
332
419
|
|
|
420
|
+
/**
|
|
421
|
+
* The directory that holds this install's code AFTER an upgrade replaced it,
|
|
422
|
+
* or null when nothing has been replaced.
|
|
423
|
+
*
|
|
424
|
+
* One layout produces this and it is a transitional one. Before #340, `npm i -g
|
|
425
|
+
* ccdeck` installed a launcher package with the deck nested inside it:
|
|
426
|
+
*
|
|
427
|
+
* <prefix>/lib/node_modules/ccdeck/ the launcher
|
|
428
|
+
* <prefix>/lib/node_modules/ccdeck/node_modules/agents-deck/ pkgRoot
|
|
429
|
+
*
|
|
430
|
+
* upgradeName reads the host's declared dependency and correctly answers
|
|
431
|
+
* `ccdeck`, so the upgrade runs `npm i -g ccdeck@latest` — which since #340
|
|
432
|
+
* installs the deck itself over the host directory and removes everything that
|
|
433
|
+
* was under it, including pkgRoot. The process keeps running (POSIX keeps an
|
|
434
|
+
* open inode alive, and the modules are already loaded) out of a directory that
|
|
435
|
+
* no longer exists.
|
|
436
|
+
*
|
|
437
|
+
* Deliberately NOT hostPackage. That function recognises a host by the
|
|
438
|
+
* dependency it declares on us, and the whole point here is that the host has
|
|
439
|
+
* just stopped declaring one — it is no longer a launcher, it is the deck. What
|
|
440
|
+
* identifies it instead is its name, confined to the three we publish, for the
|
|
441
|
+
* same reason installedName confines its answer: this decides what a version
|
|
442
|
+
* report says about the user's machine, and a directory that merely happens to
|
|
443
|
+
* sit above us is not evidence.
|
|
444
|
+
*
|
|
445
|
+
* Guarded on our own manifest being unreadable, so nothing changes for an
|
|
446
|
+
* install that is intact — including the ordinary nested layout before it is
|
|
447
|
+
* upgraded, where pkgRoot answers for itself and this is never consulted.
|
|
448
|
+
*/
|
|
449
|
+
export function successorRoot(pkgRoot) {
|
|
450
|
+
if (readManifest(pkgRoot)) return null;
|
|
451
|
+
const root = hostRoot(pkgRoot);
|
|
452
|
+
if (!root) return null;
|
|
453
|
+
const name = readManifest(root)?.name;
|
|
454
|
+
return typeof name === "string" && ALIAS_PACKAGES.includes(name) ? root : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
333
457
|
/** The package an upgrade would actually install here — the only package worth
|
|
334
458
|
* asking npm about.
|
|
335
459
|
*
|
|
@@ -372,7 +496,21 @@ export function upgradeName(pkgRoot, name = "agents-deck") {
|
|
|
372
496
|
// the deck is nested one level down inside a package it is not named after,
|
|
373
497
|
// and this build's own name for `npm i -g agents-deck` and `npm i -g
|
|
374
498
|
// agent-dag`, where the deck IS the whole package and nothing is above it.
|
|
375
|
-
|
|
499
|
+
//
|
|
500
|
+
// successorRoot is the third case, and it is the second half of the same
|
|
501
|
+
// question. Once that stub install HAS been upgraded, the host has stopped
|
|
502
|
+
// declaring a dependency on us — which is the only thing hostPackage
|
|
503
|
+
// recognises a host by — so this fell through to `self`, and `self` is the
|
|
504
|
+
// fallback `agents-deck` because our own manifest went with the directory.
|
|
505
|
+
// The user was then shown `npm i -g agents-deck@latest`: a different package,
|
|
506
|
+
// a second global tree, and their `ccdeck` binary left exactly where it was.
|
|
507
|
+
// That is #358 verbatim, arriving through the upgrade that was supposed to be
|
|
508
|
+
// the end of it. The successor's own manifest names it, and that name is what
|
|
509
|
+
// the next upgrade has to install.
|
|
510
|
+
const host = hostPackage(pkgRoot, self)?.name;
|
|
511
|
+
if (host) return host;
|
|
512
|
+
const moved = successorRoot(pkgRoot);
|
|
513
|
+
return moved ? installedName(moved, self) : self;
|
|
376
514
|
}
|
|
377
515
|
|
|
378
516
|
/** The exact line the user can paste, for the way THIS copy was installed. */
|
|
@@ -602,7 +740,8 @@ export function checkDue({ at, failedAt, pendingAt, now, first = false, force =
|
|
|
602
740
|
* cached answer immediately when the window has not elapsed.
|
|
603
741
|
*
|
|
604
742
|
* `force` skips the window: the first call in this process, and an explicit
|
|
605
|
-
* "check now" from the UI.
|
|
743
|
+
* "check now" from the UI. What it does not skip is FORCE_POLL_MS — see
|
|
744
|
+
* mayAskNpm, and the note above it for what a forced call used to cost. */
|
|
606
745
|
async function latestOnNpm(name, now, force = false) {
|
|
607
746
|
const m = readMarker(name);
|
|
608
747
|
const key = markerFileName(name);
|
|
@@ -611,8 +750,18 @@ async function latestOnNpm(name, now, force = false) {
|
|
|
611
750
|
if (!checkDue({ at: m?.at, failedAt: m?.failedAt, pendingAt: m?.pendingAt, now, first, force })) {
|
|
612
751
|
return m?.version ?? null;
|
|
613
752
|
}
|
|
753
|
+
// Offered before the floor: a check that has not answered yet is a lookup
|
|
754
|
+
// newer than the marker, which is what refresh asked for, and joining it costs
|
|
755
|
+
// nothing.
|
|
614
756
|
const inflight = _inflight.get(key);
|
|
615
757
|
if (inflight) return inflight;
|
|
758
|
+
// The floor, under every rule above it. A refused check is answered with the
|
|
759
|
+
// version we already hold — the same string an ordinary cached call returns,
|
|
760
|
+
// carrying the marker's own `checkedAt` rather than the moment of the read
|
|
761
|
+
// that was refused, so /api/version reports exactly what it reported a moment
|
|
762
|
+
// ago and no surface learns a new failure mode from being asked twice.
|
|
763
|
+
if (!mayAskNpm({ now, lastAskAt: _lastAskAt.get(key) ?? 0 })) return m?.version ?? null;
|
|
764
|
+
_lastAskAt.set(key, now);
|
|
616
765
|
const run = runCheck(name, m, now)
|
|
617
766
|
// Record the outcome, not just the moment, and record it against THIS
|
|
618
767
|
// package: only an answer stamps `at`; a failure takes the short retry
|
|
@@ -663,13 +812,50 @@ export function pickNotice({ running, installed, latest }) {
|
|
|
663
812
|
|
|
664
813
|
// ── installing ───────────────────────────────────────────────────────────────
|
|
665
814
|
|
|
666
|
-
// npm is a .cmd shim on Windows, which spawn can only launch through a shell.
|
|
667
|
-
// Everywhere else shell:false — with a shell, Node warns that arguments are
|
|
668
|
-
// concatenated rather than escaped. Same pair the ccusage installer uses.
|
|
669
|
-
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
670
|
-
const NPM_SHELL = process.platform === "win32";
|
|
671
815
|
const INSTALL_TIMEOUT_MS = 300_000; // a cold global install on a slow line
|
|
672
816
|
|
|
817
|
+
/**
|
|
818
|
+
* What `spawn` gets for `npm install -g <target>@latest`.
|
|
819
|
+
*
|
|
820
|
+
* This was the last caller still spelling it the way #362 and #456 were written
|
|
821
|
+
* to remove: `spawn("npm.cmd", args, { shell: true })`, with a comment claiming
|
|
822
|
+
* it was "the same pair the ccusage installer uses". ccusage stopped using that
|
|
823
|
+
* pair when #456 fixed it, and this one was never revisited — it does not go
|
|
824
|
+
* through `run`/`runInteractive`/`runDetached`, so #457's sweep of their callers
|
|
825
|
+
* could not see it and exec-shim-callers.test.ts never listed it.
|
|
826
|
+
*
|
|
827
|
+
* Both halves of the old spelling were wrong on Windows and only one of them
|
|
828
|
+
* bites today.
|
|
829
|
+
*
|
|
830
|
+
* The one that bites: a `.cmd` shim locates its payload relative to `%~dp0`,
|
|
831
|
+
* the drive and path of the command token cmd.exe was handed, and a BARE
|
|
832
|
+
* `npm.cmd` carries no directory — so `%~dp0` came out as the deck's working
|
|
833
|
+
* directory and npm's shim went looking for `node_modules\npm\bin\npm-cli.js`
|
|
834
|
+
* underneath it. A user with a global install, deck started from
|
|
835
|
+
* `C:\Users\vceban`, clicks Update now and npm dies with `Cannot find module
|
|
836
|
+
* 'C:\Users\vceban\node_modules\npm\bin\npm-cli.js'` — on a machine whose npm
|
|
837
|
+
* is perfectly healthy, and where typing the same command at the same prompt
|
|
838
|
+
* works. shimPath is the answer, and `?? "npm.cmd"` keeps a layout it cannot see
|
|
839
|
+
* exactly as well off as it was.
|
|
840
|
+
*
|
|
841
|
+
* The one that does not, yet: `shell: true` makes Node join file and args with
|
|
842
|
+
* single spaces and no quoting. These arguments contain no spaces, so it has
|
|
843
|
+
* never mattered here — but it is the #362 defect sitting one argument away, and
|
|
844
|
+
* spawnSpec removes it by quoting every token into the cmd.exe line.
|
|
845
|
+
*
|
|
846
|
+
* POSIX is untouched: `npm` there is a real executable, isBatch is false, and
|
|
847
|
+
* the vector goes to spawn exactly as it always has.
|
|
848
|
+
*
|
|
849
|
+
* Exported for tests: the platform is a parameter so the Windows command line
|
|
850
|
+
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
851
|
+
* the shim lookup asks about. The same shape ccusage.mjs's installSpec has.
|
|
852
|
+
*/
|
|
853
|
+
export function upgradeSpec(target, platform = process.platform, deps) {
|
|
854
|
+
const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
|
|
855
|
+
const file = platform === "win32" ? (shimPath("npm.cmd", deps) ?? "npm.cmd") : "npm";
|
|
856
|
+
return { ...spawnSpec(file, args, platform), plain: args };
|
|
857
|
+
}
|
|
858
|
+
|
|
673
859
|
/**
|
|
674
860
|
* Why an in-app upgrade would be wrong here, or null when it is fine.
|
|
675
861
|
*
|
|
@@ -725,7 +911,15 @@ export function upgradeBlock(pkgRoot) {
|
|
|
725
911
|
// reason upgradeName is: the host is recognised by the dependency it declares
|
|
726
912
|
// on us, and "us" is whatever the manifest here says — `agents-deck` under the
|
|
727
913
|
// stub npm publishes today, but nothing in this rule should assume that.
|
|
728
|
-
|
|
914
|
+
//
|
|
915
|
+
// successorRoot covers the case after such an upgrade has run: pkgRoot is
|
|
916
|
+
// gone, so hostPackage cannot recognise a host that no longer declares us,
|
|
917
|
+
// and asking accessSync about a deleted directory answers ENOENT — which this
|
|
918
|
+
// function reported as `not_writable`, telling the user their npm prefix was
|
|
919
|
+
// read-only when it was not.
|
|
920
|
+
const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root
|
|
921
|
+
?? successorRoot(pkgRoot)
|
|
922
|
+
?? pkgRoot;
|
|
729
923
|
return upgradeBlockedReason({
|
|
730
924
|
git: isGitCheckout(pkgRoot),
|
|
731
925
|
npx: isNpxInstall(pkgRoot),
|
|
@@ -934,13 +1128,17 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
|
|
|
934
1128
|
// there wrote a tree this process never reads, so the version on disk never
|
|
935
1129
|
// moved and the same update was offered forever.
|
|
936
1130
|
const target = upgradeName(pkgRoot, name);
|
|
937
|
-
const
|
|
938
|
-
|
|
1131
|
+
const spec = upgradeSpec(target);
|
|
1132
|
+
// The LOGICAL vector, not the cmd.exe line: this string is shown to the user
|
|
1133
|
+
// and is the one they can paste. `cmd /d /s /c "…"` is an implementation
|
|
1134
|
+
// detail of how this platform reaches npm, and pasting it would be advice
|
|
1135
|
+
// about the deck rather than about their install.
|
|
1136
|
+
const command = `npm ${spec.plain.join(" ")}`;
|
|
939
1137
|
_upgrade = { state: "running", command, error: null, at: Date.now() };
|
|
940
1138
|
|
|
941
1139
|
let child;
|
|
942
1140
|
try {
|
|
943
|
-
child = spawn(
|
|
1141
|
+
child = spawn(spec.file, spec.args, { ...spec.opts, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
944
1142
|
} catch (err) {
|
|
945
1143
|
_upgrade = { state: "failed", command, error: err?.message ?? String(err), at: Date.now() };
|
|
946
1144
|
return { ok: false, reason: "spawn_failed", command };
|
|
@@ -955,7 +1153,7 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
|
|
|
955
1153
|
|
|
956
1154
|
// The deadline states the outcome itself, and only then kills.
|
|
957
1155
|
//
|
|
958
|
-
// npm is a .cmd shim on Windows and is therefore spawned through
|
|
1156
|
+
// npm is a .cmd shim on Windows and is therefore spawned through cmd.exe, so
|
|
959
1157
|
// `child` is cmd.exe and npm itself is a grandchild — a plain kill would
|
|
960
1158
|
// report the install as timed out while it carried on writing to
|
|
961
1159
|
// node_modules. killTree is what reaches it (taskkill /T there, the same
|
|
@@ -1008,12 +1206,55 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
|
|
|
1008
1206
|
return { ok: true, command };
|
|
1009
1207
|
}
|
|
1010
1208
|
|
|
1011
|
-
|
|
1209
|
+
// The furniture around a Node crash, none of which is the reason anything
|
|
1210
|
+
// failed. npm's own log needs none of this — it is `npm ERR!` lines and a rule —
|
|
1211
|
+
// but the two get mixed the moment the thing npm's shim tried to load is
|
|
1212
|
+
// missing, which is what #535's bare `npm.cmd` produced on every attempt.
|
|
1213
|
+
const CRASH_NOISE = [
|
|
1214
|
+
/^\s*at\s/, // stack frames
|
|
1215
|
+
/^\s*\^+\s*$/, // the caret under the throw
|
|
1216
|
+
/^node:internal\//, // the frame node leads with
|
|
1217
|
+
/^\s*throw\s/,
|
|
1218
|
+
/^Node\.js v/, // the last line, and the one that was quoted
|
|
1219
|
+
/^\s*[{}]\s*$/, // the error object's braces
|
|
1220
|
+
/^\s*(code|errno|syscall|path|requireStack|stack):/,
|
|
1221
|
+
/^Require stack:/,
|
|
1222
|
+
/^-+$/,
|
|
1223
|
+
/^A complete log/,
|
|
1224
|
+
];
|
|
1225
|
+
|
|
1226
|
+
/** npm's real complaint, for the banner: the last line that is not furniture.
|
|
1227
|
+
*
|
|
1228
|
+
* Last rather than first, and that is the whole reason this is not npx.mjs's
|
|
1229
|
+
* summariser under another name. The two read different documents. npm's log
|
|
1230
|
+
* opens with its codes — `code EACCES`, `syscall mkdir` — and ends with the
|
|
1231
|
+
* sentence a person can act on, so the last line wins. An npx failure is a Node
|
|
1232
|
+
* crash dump, which opens with the sentence and ends with a stack, a brace and
|
|
1233
|
+
* a version banner, so the first signal line wins there. Sharing one function
|
|
1234
|
+
* would mean picking one of those and being wrong about the other half the
|
|
1235
|
+
* time; sharing the NOISE list would be the copy this comment exists instead
|
|
1236
|
+
* of.
|
|
1237
|
+
*
|
|
1238
|
+
* What was wrong was not the rule but its list. `lastMeaningfulLine` dropped
|
|
1239
|
+
* `npm ERR!` prefixes, rules and "A complete log", and nothing else — so when
|
|
1240
|
+
* #535's spawn failed with a MODULE_NOT_FOUND dump, the last surviving line was
|
|
1241
|
+
* `Node.js v22.11.0`, and that string was the entire explanation the UI gave
|
|
1242
|
+
* for a failed upgrade. */
|
|
1012
1243
|
export function lastMeaningfulLine(text) {
|
|
1013
1244
|
const lines = String(text ?? "").split(/\r?\n/)
|
|
1014
1245
|
.map(l => l.replace(/^npm (ERR!|WARN)\s*/, "").trim())
|
|
1015
|
-
.filter(l => l &&
|
|
1016
|
-
|
|
1246
|
+
.filter(l => l && !CRASH_NOISE.some(re => re.test(l)));
|
|
1247
|
+
if (!lines.length) return "";
|
|
1248
|
+
// A line that names an error outranks a later line that does not, because
|
|
1249
|
+
// what survives the filter after one is usually its context rather than its
|
|
1250
|
+
// successor: `Require stack:` is furniture, but the paths listed under it are
|
|
1251
|
+
// not shaped like furniture and would otherwise be the last thing standing.
|
|
1252
|
+
// Still the LAST such line, not the first — npm's own log builds up to its
|
|
1253
|
+
// sentence, and picking the first would answer `code EACCES` where the next
|
|
1254
|
+
// line says which directory and why.
|
|
1255
|
+
const named = lines.filter(l => /(^|\s)[A-Za-z]*Error:/.test(l));
|
|
1256
|
+
const pick = named.length ? named[named.length - 1] : lines[lines.length - 1];
|
|
1257
|
+
return pick.slice(0, 300);
|
|
1017
1258
|
}
|
|
1018
1259
|
|
|
1019
1260
|
/** Full answer for GET /api/version. Never throws, and answers the local half
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// command — `afplay …` on macOS, a PowerShell one-liner on Windows — ending in
|
|
5
5
|
// `|| true`. Each is a silent no-op on every other machine, so a settings.json
|
|
6
6
|
// synced across devices ends up with several of them stacked, none of which
|
|
7
|
-
// work everywhere. This installs a single entry pointing at notify.
|
|
7
|
+
// work everywhere. This installs a single entry pointing at notify.mjs, which
|
|
8
8
|
// picks its own player at run time.
|
|
9
9
|
//
|
|
10
10
|
// Only ever touches its own entry, tagged `__agent-dag-sound`. Hooks the user
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// learn a second provider, src/web/provider-copy.ts's finishSoundTitle is the
|
|
22
22
|
// sentence that has to move with it, and finish-sound-scope.test.ts fails until
|
|
23
23
|
// it does (#394).
|
|
24
|
-
import { readFile, mkdir } from "node:fs/promises";
|
|
24
|
+
import { readFile, mkdir, rm } from "node:fs/promises";
|
|
25
25
|
import { existsSync } from "node:fs";
|
|
26
26
|
import { join, dirname } from "node:path";
|
|
27
27
|
import { homedir } from "node:os";
|
|
@@ -34,7 +34,32 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
34
34
|
const CLAUDE_DIR = claudeConfigDir();
|
|
35
35
|
const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
|
|
36
36
|
const INSTALL_DIR = join(CLAUDE_DIR, "agent-dag");
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
// `.mjs`, and the extension is the whole feature on the machines it matters on.
|
|
39
|
+
//
|
|
40
|
+
// This script is ESM — `import { spawn } from "node:child_process"` on its first
|
|
41
|
+
// executable line — and in the package that is settled by package.json's
|
|
42
|
+
// `"type": "module"` two directories up. Installed, it lands in <claude config
|
|
43
|
+
// dir>/agent-dag/, where there is normally no package.json between it and the
|
|
44
|
+
// filesystem root, so the extension alone decides the format and a `.js` with
|
|
45
|
+
// nothing above it is CommonJS. Node's module-syntax detection rescued that, but
|
|
46
|
+
// only where detection is on by default: v20.19.0 and v22.7.0 and later. The
|
|
47
|
+
// package's own `engines` says `>=18`, and on 18.x, 19.x, 20.0–20.18.x, 21.x and
|
|
48
|
+
// 22.0–22.6.x the Stop hook was a `SyntaxError: Cannot use import statement
|
|
49
|
+
// outside a module` printed at the end of every turn instead of a sound.
|
|
50
|
+
//
|
|
51
|
+
// `.mjs` is ESM on every Node that has ever had ESM, with nothing above it
|
|
52
|
+
// consulted and no detection involved. A package.json beside the script would
|
|
53
|
+
// have been the other spelling of the fix and is the wrong one here: hook.js
|
|
54
|
+
// lives in this same directory, is deliberately CommonJS because that is what
|
|
55
|
+
// this directory's layout means, and a `{"type":"module"}` next to it would
|
|
56
|
+
// break the event forwarder to fix the sound.
|
|
57
|
+
const NOTIFY_NAME = "notify.mjs";
|
|
58
|
+
const PACKAGED_NOTIFY = join(PKG_ROOT, "hook", NOTIFY_NAME);
|
|
59
|
+
const NOTIFY_PATH = join(INSTALL_DIR, NOTIFY_NAME);
|
|
60
|
+
// What the same script was called before it declared its own format. Swept once
|
|
61
|
+
// the entry that named it has been rewritten — see sweepLegacySoundScript.
|
|
62
|
+
const LEGACY_NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
|
|
38
63
|
|
|
39
64
|
const MARK = "__agent-dag-sound";
|
|
40
65
|
const EVENT = "Stop";
|
|
@@ -287,7 +312,7 @@ export async function restoreParkedSoundHooks() {
|
|
|
287
312
|
* Take the toggle off the machine entirely, for `agents-deck --uninstall`.
|
|
288
313
|
*
|
|
289
314
|
* uninstallHooks only knows the `__agent-dag` mark the event forwarders carry;
|
|
290
|
-
* this entry is marked `__agent-dag-sound` and its command points at notify.
|
|
315
|
+
* this entry is marked `__agent-dag-sound` and its command points at notify.mjs,
|
|
291
316
|
* so it used to survive an uninstall and keep playing a sound on every turn. The
|
|
292
317
|
* user's own hooks were the worse half: parked here when the toggle went on,
|
|
293
318
|
* they stayed in a file under ~/.agents-deck that nothing left on the machine
|
|
@@ -320,6 +345,116 @@ export async function uninstallSoundHook() {
|
|
|
320
345
|
return { ok: true, removed, restored: restore.restored ?? 0 };
|
|
321
346
|
}
|
|
322
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Our Stop entry, built fresh from this machine's node and this machine's paths.
|
|
350
|
+
*
|
|
351
|
+
* One function rather than an object literal in each writer, because the entry
|
|
352
|
+
* has to be IDENTICAL wherever it comes from: reassertSoundHook decides whether
|
|
353
|
+
* to rewrite by comparing what is in settings.json against what this returns, so
|
|
354
|
+
* a second copy of the shape that drifted by a field would make every boot look
|
|
355
|
+
* like a change and rewrite the user's settings.json forever.
|
|
356
|
+
*/
|
|
357
|
+
function soundHookEntry() {
|
|
358
|
+
return {
|
|
359
|
+
[MARK]: true,
|
|
360
|
+
hooks: [{
|
|
361
|
+
type: "command",
|
|
362
|
+
// Absolute node path, matching how the event hooks are installed: the
|
|
363
|
+
// shell a hook runs in does not necessarily have the user's PATH. And
|
|
364
|
+
// properly escaped for that shell, for the reason installer.mjs's
|
|
365
|
+
// hookCommand spells out — NOTIFY_PATH is built from $CLAUDE_CONFIG_DIR,
|
|
366
|
+
// double quotes do not suppress `$(…)` or a backtick on POSIX, and this
|
|
367
|
+
// string is executed at the end of every turn.
|
|
368
|
+
command: soundHookCommand(NOTIFY_PATH),
|
|
369
|
+
timeout: 5,
|
|
370
|
+
}],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Bring the installed sound hook back up to the packaged one, in a settings
|
|
376
|
+
* object the caller is about to write.
|
|
377
|
+
*
|
|
378
|
+
* The forwarder gets this for free: installHooks re-asserts hook.js on every
|
|
379
|
+
* boot, so a machine that upgrades the deck upgrades the script Claude Code
|
|
380
|
+
* actually executes. notify.js had exactly one installer — setSoundHook(true) —
|
|
381
|
+
* so the copy on disk was whatever shipped in the release the user last TOGGLED
|
|
382
|
+
* THE SOUND ON WITH, and every later release stopped at the package directory.
|
|
383
|
+
* That is not a theoretical drift: #548 replaced a `printf "\a"` player that
|
|
384
|
+
* spawned a BEL into `stdio: "ignore"` — silent by construction — and could not
|
|
385
|
+
* reach a single machine that already had the toggle on, which is precisely the
|
|
386
|
+
* set of machines it was written for.
|
|
387
|
+
*
|
|
388
|
+
* The presence of our entry in settings.json is the whole of the permission
|
|
389
|
+
* check, and it is deliberately the only one. A user who turned the sound OFF
|
|
390
|
+
* has no entry, so nothing here writes a script into their config dir on a boot
|
|
391
|
+
* they asked nothing of; a user who has it ON has already consented to this file
|
|
392
|
+
* existing, and keeping it current is the deck's job rather than theirs.
|
|
393
|
+
*
|
|
394
|
+
* The entry is rebuilt rather than inspected, which is the other half of the
|
|
395
|
+
* report. settings.json is commonly synced between machines and the command
|
|
396
|
+
* bakes in `process.execPath` and this machine's $CLAUDE_CONFIG_DIR — so a file
|
|
397
|
+
* carried over from a laptop names that laptop's node binary inside that
|
|
398
|
+
* laptop's home directory, soundHookStatus reports `enabled: true`, and the turn
|
|
399
|
+
* ends in an ENOENT nobody sees. Rewriting it from soundHookEntry() re-derives
|
|
400
|
+
* both against the machine the deck is running on.
|
|
401
|
+
*
|
|
402
|
+
* Mutates `settings` and returns what it did; the caller owns the write, so a
|
|
403
|
+
* boot that would otherwise change nothing still changes nothing.
|
|
404
|
+
*/
|
|
405
|
+
export async function reassertSoundHook(settings) {
|
|
406
|
+
const group = settings?.hooks?.[EVENT];
|
|
407
|
+
if (!Array.isArray(group) || !group.some(isOurs)) return { present: false, script: false, entry: false };
|
|
408
|
+
|
|
409
|
+
if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
|
|
410
|
+
// Same reason the event forwarder is installed this way: the Stop hook fires
|
|
411
|
+
// this file from sessions that are already running, so replacing it must not
|
|
412
|
+
// leave one of them executing a half-copied program. installScript renames a
|
|
413
|
+
// finished copy over the name, and skips the write entirely when the bytes
|
|
414
|
+
// already match — which is every boot after the first.
|
|
415
|
+
const script = await installScript(PACKAGED_NOTIFY, NOTIFY_PATH);
|
|
416
|
+
|
|
417
|
+
const rebuilt = soundHookEntry();
|
|
418
|
+
const wanted = JSON.stringify(rebuilt);
|
|
419
|
+
const next = [];
|
|
420
|
+
let entry = false;
|
|
421
|
+
let kept = false;
|
|
422
|
+
for (const g of group) {
|
|
423
|
+
if (!isOurs(g)) { next.push(g); continue; }
|
|
424
|
+
// More than one of ours is a settings.json that has been merged by hand or
|
|
425
|
+
// by a sync tool. One sound per turn, so the extras are dropped rather than
|
|
426
|
+
// rewritten alongside the first.
|
|
427
|
+
if (kept) { entry = true; continue; }
|
|
428
|
+
kept = true;
|
|
429
|
+
if (JSON.stringify(g) !== wanted) entry = true;
|
|
430
|
+
next.push(rebuilt);
|
|
431
|
+
}
|
|
432
|
+
settings.hooks[EVENT] = next;
|
|
433
|
+
return { present: true, script, entry };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Delete the `notify.js` an older deck installed, now that nothing names it.
|
|
438
|
+
*
|
|
439
|
+
* Called AFTER settings.json has been written, and that ordering is the point:
|
|
440
|
+
* until the new entry is on disk the old command is still what a live Claude
|
|
441
|
+
* Code session will run at the end of its next turn, and deleting the file it
|
|
442
|
+
* names would turn a stale sound into a "Cannot find module" in the user's
|
|
443
|
+
* session. Best-effort on the way out — a Windows lock or a read-only config dir
|
|
444
|
+
* leaves one stale file behind, which is litter, not a failure worth reporting
|
|
445
|
+
* over a hook that is now installed correctly.
|
|
446
|
+
*/
|
|
447
|
+
export async function sweepLegacySoundScript() {
|
|
448
|
+
if (LEGACY_NOTIFY_PATH === NOTIFY_PATH) return false;
|
|
449
|
+
try {
|
|
450
|
+
if (!existsSync(LEGACY_NOTIFY_PATH)) return false;
|
|
451
|
+
await rm(LEGACY_NOTIFY_PATH, { force: true });
|
|
452
|
+
return true;
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
323
458
|
export async function setSoundHook(enabled) {
|
|
324
459
|
// Read before anything else. A file we cannot parse stops the toggle here,
|
|
325
460
|
// with nothing parked, nothing copied and settings.json untouched.
|
|
@@ -356,24 +491,10 @@ export async function setSoundHook(enabled) {
|
|
|
356
491
|
if (enabled) {
|
|
357
492
|
if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
|
|
358
493
|
// Same reason the event forwarder is installed this way: the Stop hook fires
|
|
359
|
-
// notify.
|
|
494
|
+
// notify.mjs from sessions that are already running, and toggling the sound
|
|
360
495
|
// on must not leave one of them executing a half-copied file.
|
|
361
|
-
await installScript(
|
|
362
|
-
others.push(
|
|
363
|
-
[MARK]: true,
|
|
364
|
-
hooks: [{
|
|
365
|
-
type: "command",
|
|
366
|
-
// Absolute node path, matching how the event hooks are installed: the
|
|
367
|
-
// shell a hook runs in does not necessarily have the user's PATH. And
|
|
368
|
-
// properly escaped for that shell, for the reason installer.mjs's
|
|
369
|
-
// hookCommand spells out — NOTIFY_PATH is built from
|
|
370
|
-
// $CLAUDE_CONFIG_DIR, double quotes do not suppress `$(…)` or a
|
|
371
|
-
// backtick on POSIX, and this string is executed at the end of every
|
|
372
|
-
// turn.
|
|
373
|
-
command: soundHookCommand(NOTIFY_PATH),
|
|
374
|
-
timeout: 5,
|
|
375
|
-
}],
|
|
376
|
-
});
|
|
496
|
+
await installScript(PACKAGED_NOTIFY, NOTIFY_PATH);
|
|
497
|
+
others.push(soundHookEntry());
|
|
377
498
|
settings.hooks[EVENT] = others;
|
|
378
499
|
} else if (others.length) {
|
|
379
500
|
settings.hooks[EVENT] = others;
|
|
@@ -382,9 +503,16 @@ export async function setSoundHook(enabled) {
|
|
|
382
503
|
}
|
|
383
504
|
|
|
384
505
|
await writeSettings(settings);
|
|
506
|
+
// Last, and after the write, for the reason sweepLegacySoundScript gives: the
|
|
507
|
+
// file an older deck installed is only safe to delete once nothing in
|
|
508
|
+
// settings.json still points at it.
|
|
509
|
+
if (enabled) await sweepLegacySoundScript();
|
|
385
510
|
return { ok: true, enabled };
|
|
386
511
|
}
|
|
387
512
|
|
|
388
|
-
//
|
|
389
|
-
// before it writes anything — the real ones are the user's own settings.
|
|
390
|
-
|
|
513
|
+
// All four paths are exported so a test can prove it is pointed at a sandbox
|
|
514
|
+
// before it writes anything — the real ones are the user's own settings. The two
|
|
515
|
+
// script paths are also the only honest way for a test to ask where the sound
|
|
516
|
+
// hook ACTUALLY lands: rebuilding `<config dir>/agent-dag/notify.mjs` in the test
|
|
517
|
+
// would keep passing on the day this module started installing somewhere else.
|
|
518
|
+
export { SETTINGS_PATH, PARKED_PATH, NOTIFY_PATH, LEGACY_NOTIFY_PATH };
|
|
@@ -97,6 +97,42 @@ export function signalExitAction(signal, platform = process.platform, numbers =
|
|
|
97
97
|
*
|
|
98
98
|
* `proc` is a parameter so the whole sequence can be driven in a test child.
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* What to say when the upgrade that just succeeded took this install with it,
|
|
102
|
+
* or null when that is not what happened.
|
|
103
|
+
*
|
|
104
|
+
* `npm i -g ccdeck` performed before #340 nests the deck inside a launcher
|
|
105
|
+
* package. Upgrading such an install now writes the deck itself over that
|
|
106
|
+
* launcher, and npm's reify removes everything that was underneath — including
|
|
107
|
+
* the directory this supervisor and its worker are running from. The process
|
|
108
|
+
* survives, because everything it needs is already loaded. The next spawn does
|
|
109
|
+
* not: bin/deck.js is a path, and npm has just deleted it.
|
|
110
|
+
*
|
|
111
|
+
* That reached the user as `could not start …/bin/deck.js: ENOENT` and an exit
|
|
112
|
+
* 1 — a dead deck and an errno, one click after an upgrade that reported
|
|
113
|
+
* success. Nothing was broken. The new version is on the machine, and the
|
|
114
|
+
* command they typed already points at it, because npm rewrote the bin shim in
|
|
115
|
+
* the same install. The only thing missing was a sentence saying so.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately not a hand-off. This process could spawn the successor's worker
|
|
118
|
+
* instead, and that would be a new worker under an old supervisor — a pair
|
|
119
|
+
* nothing has ever tested together, decided at the worst possible moment. One
|
|
120
|
+
* command the user runs themselves is the smaller promise and the one that
|
|
121
|
+
* cannot go wrong.
|
|
122
|
+
*
|
|
123
|
+
* Pure, and given its two filesystem answers rather than asking for them,
|
|
124
|
+
* because the state it describes only ever exists in a process whose own files
|
|
125
|
+
* were deleted after it started — which no test can produce by spawning one.
|
|
126
|
+
*/
|
|
127
|
+
export function replacedNote({ workerExists, moved, product = "ccdeck", command = "ccdeck" }) {
|
|
128
|
+
if (workerExists || !moved) return null;
|
|
129
|
+
return [
|
|
130
|
+
`${product}: the upgrade replaced this install, so it cannot restart in place.`,
|
|
131
|
+
` the new version is in ${moved}`,
|
|
132
|
+
` run \`${command}\` again to start it`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
100
136
|
export function dieOfSignal(signal, proc = process) {
|
|
101
137
|
const { reraise, code } = signalExitAction(signal, proc.platform);
|
|
102
138
|
if (reraise) {
|