agent-dag 3.22.0 → 3.22.3
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 +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-3FWd7g_W.css +0 -1
- package/dist/web/assets/index-BOwtoP02.js +0 -266
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -392
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/ccusage.mjs
DELETED
|
@@ -1,1168 +0,0 @@
|
|
|
1
|
-
// Fetches historical usage from the `ccusage` CLI (https://github.com/ccusage/ccusage).
|
|
2
|
-
// ccusage reads the local ~/.claude (and other agent) logs and reports cost +
|
|
3
|
-
// token usage grouped by day.
|
|
4
|
-
//
|
|
5
|
-
// Performance: we do NOT run `npx -y ccusage@latest` on every call — that hits
|
|
6
|
-
// the npm registry to resolve `@latest` (and re-downloads when the npx cache is
|
|
7
|
-
// cold), so each modal open waited seconds. Instead we keep our OWN managed
|
|
8
|
-
// install under ~/.agents-deck/ccusage and invoke it directly with
|
|
9
|
-
// `node <pkg>/src/cli.js` (no npx, no registry round-trip). A throttled
|
|
10
|
-
// once-per-day background check upgrades it when a newer ccusage ships, while
|
|
11
|
-
// the current call always serves from the already-installed copy. If the
|
|
12
|
-
// managed install is missing/broken we fall back to the old npx path so the
|
|
13
|
-
// feature still works on a fresh machine.
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
-
import path from "node:path";
|
|
17
|
-
import os from "node:os";
|
|
18
|
-
import { killTree, pathLookup, shimPath, spawnSpec } from "./exec.mjs";
|
|
19
|
-
import { oneLine, termColumns } from "./term.mjs";
|
|
20
|
-
import { PRODUCT } from "./brand.mjs";
|
|
21
|
-
|
|
22
|
-
// 60s, and it is the panel's poll interval rather than a number of its own: the
|
|
23
|
-
// Usage panel asks once a minute and expects a reading that has actually moved,
|
|
24
|
-
// so a longer cache would hand the same figure back and make the interval a
|
|
25
|
-
// lie. Two tabs polling out of phase still share one run, which is what the
|
|
26
|
-
// cache is for here. The modal is manual-open and unaffected either way.
|
|
27
|
-
const CACHE_MS = 60_000;
|
|
28
|
-
const TIMEOUT_MS = 90_000;
|
|
29
|
-
const INSTALL_TIMEOUT_MS = 120_000; // first-run npm install can be slow
|
|
30
|
-
const UPDATE_CHECK_MS = 24 * 3600_000; // check npm for a newer ccusage once/day
|
|
31
|
-
|
|
32
|
-
const CACHE_DIR = path.join(os.homedir(), ".agents-deck", "ccusage");
|
|
33
|
-
const PKG_DIR = path.join(CACHE_DIR, "node_modules", "ccusage");
|
|
34
|
-
const MARKER = path.join(CACHE_DIR, ".last-update-check");
|
|
35
|
-
|
|
36
|
-
const _cache = new Map(); // key `${since}|${until}` → { result, at }
|
|
37
|
-
|
|
38
|
-
// How much a read is allowed to cost. /api/ccusage is a GET, deliberately: a
|
|
39
|
-
// cross-site read of the loopback port is an ordinary top-level navigation and
|
|
40
|
-
// isTrustedRead is right not to refuse it. What was missing is a ceiling on
|
|
41
|
-
// what one of those reads may start. Before #544, thirty distinct `since`
|
|
42
|
-
// values were thirty concurrent `node <PKG_DIR>/src/cli.js daily --json`
|
|
43
|
-
// children — each with a ninety-second deadline, each walking the whole
|
|
44
|
-
// ~/.claude log tree, doubled again whenever runDaily retried flagless — and
|
|
45
|
-
// thirty permanent Map entries. isCliDate admits all 10^8 eight-digit strings
|
|
46
|
-
// on purpose (a date outside the logs is ccusage's question to answer, not the
|
|
47
|
-
// deck's to guess at), so the map's key space was 10^8 and its eviction policy
|
|
48
|
-
// was none.
|
|
49
|
-
//
|
|
50
|
-
// Both numbers are set against what the feature does rather than against the
|
|
51
|
-
// attack. The usage-history modal asks for one range at a time and offers three
|
|
52
|
-
// presets, so four ranges outstanding at once is already more than a human
|
|
53
|
-
// clicking as fast as they can, and thirty-two remembered ranges is more than
|
|
54
|
-
// one sitting will ever look at. Whatever exceeds either is not a reader.
|
|
55
|
-
const CACHE_MAX = 32;
|
|
56
|
-
const MAX_OUTSTANDING = 4;
|
|
57
|
-
|
|
58
|
-
/** Ranges being fetched right now, keyed exactly as `_cache` is, so two callers
|
|
59
|
-
* asking the same question wait on one child instead of starting a second. The
|
|
60
|
-
* cache alone could never do this: it is written when a run finishes, and the
|
|
61
|
-
* whole window this is about is the ninety seconds before that. */
|
|
62
|
-
const _inflight = new Map();
|
|
63
|
-
|
|
64
|
-
/** The tail of the run queue, and how many runs are alive behind it.
|
|
65
|
-
*
|
|
66
|
-
* Serialised rather than merely counted, because two ccusage runs are two
|
|
67
|
-
* walks of the same directory tree: running them at once is slower than
|
|
68
|
-
* running them in sequence, so the queue costs a concurrent caller nothing it
|
|
69
|
-
* was actually going to get. What it buys is that a flood is one child at a
|
|
70
|
-
* time rather than a child per request. */
|
|
71
|
-
let _chain = Promise.resolve();
|
|
72
|
-
let _outstanding = 0;
|
|
73
|
-
|
|
74
|
-
/** Run `job` after every run already queued, whatever became of them — a run
|
|
75
|
-
* that threw must not take the queue down with it. */
|
|
76
|
-
function queued(job) {
|
|
77
|
-
_outstanding += 1;
|
|
78
|
-
const started = _chain.then(job, job);
|
|
79
|
-
_chain = started.then(() => {}, () => {});
|
|
80
|
-
return started.finally(() => { _outstanding -= 1; });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Remember one range's answer, and keep the map from being somewhere a caller
|
|
85
|
-
* can grow without limit.
|
|
86
|
-
*
|
|
87
|
-
* Entries past CACHE_MS can never be served again, so they are the ones to drop
|
|
88
|
-
* first; only when dropping all of them is still not enough does the oldest
|
|
89
|
-
* surviving entry go, which Map's insertion order hands over for free.
|
|
90
|
-
* Re-writing a key moves it to the back, so the range someone is actually
|
|
91
|
-
* polling is the last one evicted rather than the first.
|
|
92
|
-
*/
|
|
93
|
-
function rememberRange(key, result, at) {
|
|
94
|
-
_cache.delete(key);
|
|
95
|
-
_cache.set(key, { result, at });
|
|
96
|
-
if (_cache.size <= CACHE_MAX) return;
|
|
97
|
-
for (const [k, v] of _cache) {
|
|
98
|
-
if (_cache.size <= CACHE_MAX) break;
|
|
99
|
-
if (at - v.at >= CACHE_MS) _cache.delete(k);
|
|
100
|
-
}
|
|
101
|
-
for (const k of _cache.keys()) {
|
|
102
|
-
if (_cache.size <= CACHE_MAX) break;
|
|
103
|
-
_cache.delete(k);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The name to hand cmd.exe for one of npm's Windows shims: its full path when
|
|
109
|
-
* one can be found, and the bare name only when none can.
|
|
110
|
-
*
|
|
111
|
-
* The full path is not a tidiness preference, it is the fix for #456. A shim
|
|
112
|
-
* launched by bare name computes `%~dp0` — which is where it looks for
|
|
113
|
-
* npm-prefix.js, npm-cli.js and npx-cli.js — from the deck's WORKING DIRECTORY
|
|
114
|
-
* rather than from its own, so on a deck started from `C:\Users\vceban` both
|
|
115
|
-
* shims died with `Cannot find module 'C:\Users\vceban\node_modules\npm\bin\…'`
|
|
116
|
-
* on a machine whose npm was perfectly healthy. shimPath in exec.mjs carries
|
|
117
|
-
* the whole account; what matters here is that BOTH the managed install and the
|
|
118
|
-
* npx fallback are launched this way, so both failed, and diagnosing either
|
|
119
|
-
* half alone could never have explained the other.
|
|
120
|
-
*
|
|
121
|
-
* Falling back to the bare name is deliberate: it is exactly what this did
|
|
122
|
-
* before, so a layout shimPath cannot see is no worse off than it was, and on
|
|
123
|
-
* such a machine cmd.exe's own PATH search still gets its turn.
|
|
124
|
-
*/
|
|
125
|
-
const winShim = (name, deps) => shimPath(name, deps) ?? name;
|
|
126
|
-
|
|
127
|
-
// npm is a .cmd shim on Windows, which spawn can only launch through cmd.exe.
|
|
128
|
-
// `shell: true` is the tempting way to get there and the wrong one: Node then
|
|
129
|
-
// joins file and args with single spaces and no quoting, so on a profile like
|
|
130
|
-
// C:\Users\John Smith the `--prefix <CACHE_DIR>` below arrived as
|
|
131
|
-
// `--prefix C:\Users\John` plus a bogus package spec `Smith\.agents-deck\...`,
|
|
132
|
-
// npm exited non-zero, and the managed install never materialised. spawnSpec
|
|
133
|
-
// routes the .cmd through cmd.exe with every argument quoted, and hands back
|
|
134
|
-
// the argument vector untouched everywhere else.
|
|
135
|
-
//
|
|
136
|
-
// POSIX is untouched by any of this: `npm` there is a real executable on PATH,
|
|
137
|
-
// not a batch file, so isBatch is false, viaCmd never runs, and the vector goes
|
|
138
|
-
// to spawn exactly as it always has.
|
|
139
|
-
function npmSpec(args, platform = process.platform, deps) {
|
|
140
|
-
return spawnSpec(platform === "win32" ? winShim("npm.cmd", deps) : "npm", args, platform);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* What `spawn` gets for `npm install ccusage@<spec>`.
|
|
145
|
-
* Exported for tests: the platform is a parameter so the Windows command line
|
|
146
|
-
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
147
|
-
* the shim lookup asks about.
|
|
148
|
-
*/
|
|
149
|
-
export const installSpec = (spec = "latest", platform = process.platform, deps) =>
|
|
150
|
-
npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
|
|
151
|
-
"--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform, deps);
|
|
152
|
-
|
|
153
|
-
// npx is the same kind of shim as npm, and the fallback run needs the same
|
|
154
|
-
// treatment for a second, sharper reason: its argument vector carries a
|
|
155
|
-
// user-supplied `--since`. `shell: true` handed `[file, ...args].join(" ")` to
|
|
156
|
-
// /bin/sh -c, so `GET /api/ccusage?since=1;id;` ran `id` — and because that
|
|
157
|
-
// route is a GET, any page open in the user's browser could aim it at the
|
|
158
|
-
// loopback port with no CORS, no preflight and no need to read the answer.
|
|
159
|
-
// spawnSpec quotes each argument into the cmd.exe line on Windows and spawns
|
|
160
|
-
// the vector untouched everywhere else, so nothing in it is ever parsed as
|
|
161
|
-
// syntax. Naming the file `npx.cmd` on Windows is what makes that work: only a
|
|
162
|
-
// .cmd/.bat file routes through cmd.exe, and a bare `npx` there is not a file
|
|
163
|
-
// spawn can launch at all (PATHEXT is a shell's job), so asking for the
|
|
164
|
-
// extensionless name would trade a shell injection for an ENOENT. What it must
|
|
165
|
-
// NOT be is the bare `npx.cmd` this said until #456 — see winShim.
|
|
166
|
-
function npxSpec(args, platform = process.platform, deps) {
|
|
167
|
-
return spawnSpec(platform === "win32" ? winShim("npx.cmd", deps) : "npx", args, platform);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* What `spawn` gets for the portable `npx -y ccusage@latest <args>` fallback.
|
|
172
|
-
* Exported for tests: the platform is a parameter so the Windows command line
|
|
173
|
-
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
174
|
-
* the shim lookup asks about.
|
|
175
|
-
*/
|
|
176
|
-
export const fallbackSpec = (args = [], platform = process.platform, deps) =>
|
|
177
|
-
npxSpec(["-y", "ccusage@latest", ...args], platform, deps);
|
|
178
|
-
|
|
179
|
-
let _installing = null; // Promise guard so concurrent calls share one install
|
|
180
|
-
let _checkedThisRun = false; // only kick the daily check once per process boot
|
|
181
|
-
|
|
182
|
-
// The last `npm install` that failed, in one line of its own words.
|
|
183
|
-
//
|
|
184
|
-
// Module scope rather than a value thrown out of the install, because the two
|
|
185
|
-
// callers that matter never see that throw. primeCcusage runs the install at
|
|
186
|
-
// boot and only logs the rejection; a second modal open that arrives while an
|
|
187
|
-
// install is in flight awaits the SHARED promise, whose rejection has already
|
|
188
|
-
// been handled by the first. Both then land on the npx fallback with nothing to
|
|
189
|
-
// say about why they were there — which is precisely how three rounds of
|
|
190
|
-
// debugging went to the fallback's stderr while the install's own account
|
|
191
|
-
// stayed on a terminal row the deck had already painted over.
|
|
192
|
-
//
|
|
193
|
-
// One line, not the whole dump: this is written into a sentence in a 46ch box.
|
|
194
|
-
// The full text still goes to the terminal through note() below.
|
|
195
|
-
let _lastInstallError = null;
|
|
196
|
-
const INSTALL_ERROR_ROOM = 240;
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Start the one shared install, remembering how it ended.
|
|
200
|
-
*
|
|
201
|
-
* Deduped through `_installing` the way it always was, so concurrent callers
|
|
202
|
-
* cost one `npm install` between them, and the outcome survives the promise.
|
|
203
|
-
*
|
|
204
|
-
* This used to read `_installing = (async () => { installSync(spec); })()`, and
|
|
205
|
-
* that wrapper was the whole of #476: an async function body runs synchronously
|
|
206
|
-
* up to its first `await`, and there was none, so the IIFE turned a throw into
|
|
207
|
-
* a rejection and bought no asynchrony at all. `installSync` was a `spawnSync`
|
|
208
|
-
* of `npm install` with a two-minute deadline, and it ran on the caller's
|
|
209
|
-
* stack — which at boot is bin/deck.js's, beside jobs that really are async.
|
|
210
|
-
* primeCcusage answered `{ state: "installing" }`, a sentence that says the
|
|
211
|
-
* wait was deferred, while the process could not accept an HTTP connection,
|
|
212
|
-
* write an SSE frame, ingest a hook or repaint the pulse line until npm exited.
|
|
213
|
-
* On a first run, with nothing cached, that is the one boot a new user judges
|
|
214
|
-
* the tool by.
|
|
215
|
-
*
|
|
216
|
-
* `install` below is the fix, and there is now exactly one install mechanism in
|
|
217
|
-
* this module rather than a synchronous one and a background one.
|
|
218
|
-
*/
|
|
219
|
-
function startInstall(spec = "latest") {
|
|
220
|
-
if (!_installing) {
|
|
221
|
-
_installing = install(spec)
|
|
222
|
-
.then(() => { _lastInstallError = null; })
|
|
223
|
-
.catch(e => {
|
|
224
|
-
_lastInstallError = oneLine(e?.message ?? e, INSTALL_ERROR_ROOM);
|
|
225
|
-
note("install failed", e);
|
|
226
|
-
})
|
|
227
|
-
.finally(() => { _installing = null; });
|
|
228
|
-
}
|
|
229
|
-
return _installing;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// AGENTS_DECK_NO_INSTALL=1 is documented as "never install or update
|
|
233
|
-
// claude-swap / ccusage, and never ask npm about releases", so it has to hold
|
|
234
|
-
// on the lazy path too — opening the usage-history modal must not be a way to
|
|
235
|
-
// pull ccusage off the registry behind the user's back. Read per call rather
|
|
236
|
-
// than once at import, because the module is imported lazily and a test (or an
|
|
237
|
-
// embedder) may set it after load.
|
|
238
|
-
function installsDisabled() {
|
|
239
|
-
return process.env.AGENTS_DECK_NO_INSTALL === "1";
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// A run can end four ways that mean four different things to whoever opened the
|
|
243
|
-
// modal — installs are forbidden, the deadline expired, the output was not usage
|
|
244
|
-
// data, the CLI exited on its own — and all four used to arrive as one `error`
|
|
245
|
-
// string, leaving the modal nothing to say but whatever `err.message` happened
|
|
246
|
-
// to be. The code rides on the error and comes back out as `reason`; `error`
|
|
247
|
-
// keeps the raw text, which the modal shows only on hover.
|
|
248
|
-
function tagged(reason, message) {
|
|
249
|
-
return Object.assign(new Error(message), { reason });
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Everything this module says out loud goes through here, and it says one line.
|
|
253
|
-
//
|
|
254
|
-
// Both failures below carry a subprocess's entire stderr as their `message`,
|
|
255
|
-
// and on a machine whose npm shim is broken that is a fifteen-line Node stack
|
|
256
|
-
// trace. Handed to console.error whole, it landed across the deck's own status
|
|
257
|
-
// rows and its `\r`-repainted pulse line — and because primeCcusage runs at
|
|
258
|
-
// boot, it was the first thing such a machine ever showed (#432). The evidence
|
|
259
|
-
// is not lost by shortening this: a failed RUN carries its full text back to
|
|
260
|
-
// the browser in `error`, which the usage-history modal keeps on the status
|
|
261
|
-
// line's title, one hover away — the same division of labour admin-failure.ts
|
|
262
|
-
// states for claude-swap's output. What this line is for is the operator
|
|
263
|
-
// watching the terminal, who needs to know which of the two things failed and
|
|
264
|
-
// why, not to read a stack.
|
|
265
|
-
//
|
|
266
|
-
// The width is read per call: a terminal can be resized while the deck runs,
|
|
267
|
-
// and this can fire hours in.
|
|
268
|
-
function note(what, err) {
|
|
269
|
-
const head = `${PRODUCT} ccusage: ${what}: `;
|
|
270
|
-
console.error(head + oneLine(err?.message ?? err, termColumns(process.stderr) - head.length));
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Node saying it could not load a file it was pointed at.
|
|
275
|
-
*
|
|
276
|
-
* Both spellings are here because both happen: CommonJS throws
|
|
277
|
-
* `MODULE_NOT_FOUND`, ESM throws `ERR_MODULE_NOT_FOUND` with the wording
|
|
278
|
-
* "Cannot find package" for a bare specifier, and ccusage's entry point is ESM
|
|
279
|
-
* while the npm/npx shims it may be launched through are not.
|
|
280
|
-
*
|
|
281
|
-
* Exported for tests. The one caller is the managed-install branch of
|
|
282
|
-
* runCcusage, where the child is `node <our entry>` and nothing else — so a
|
|
283
|
-
* module Node cannot resolve is by construction a file of OURS that is missing,
|
|
284
|
-
* never the user's npm.
|
|
285
|
-
*/
|
|
286
|
-
export const cannotLoadModule = (text) =>
|
|
287
|
-
/\b(?:ERR_)?MODULE_NOT_FOUND\b|cannot find (?:module|package)/i.test(String(text ?? ""));
|
|
288
|
-
|
|
289
|
-
// ── managed install ─────────────────────────────────────────────────────────
|
|
290
|
-
|
|
291
|
-
// Absolute path to ccusage's CLI entry inside our managed install, or null if
|
|
292
|
-
// not installed. Reads the package's `bin` field (currently "./src/cli.js").
|
|
293
|
-
//
|
|
294
|
-
// `bin` is a string out of a package.json downloaded from the registry, and it
|
|
295
|
-
// is joined onto PKG_DIR and then handed to `spawn(node, [entry])` — so a `bin`
|
|
296
|
-
// of "../../../evil.js" names a file outside the managed install and gets run.
|
|
297
|
-
// existsSync alone does not notice: it is asked whether the escaped path is
|
|
298
|
-
// there, and for an attacker who put something there the answer is yes.
|
|
299
|
-
//
|
|
300
|
-
// The narrowness is worth stating rather than leaving implied: a package that
|
|
301
|
-
// can choose its own `bin` can also ship an install script, so this is not the
|
|
302
|
-
// weak link in a hostile-package scenario. What it does close is the case where
|
|
303
|
-
// only the file is influenced — a tampered or half-written package.json in the
|
|
304
|
-
// cache dir, or a `bin` that walks out of the install by accident — and it
|
|
305
|
-
// costs one comparison.
|
|
306
|
-
//
|
|
307
|
-
// Exported for tests: the containment rule is the whole point of the function
|
|
308
|
-
// and there is no other way to reach it without an install on disk.
|
|
309
|
-
export function resolveEntry() {
|
|
310
|
-
try {
|
|
311
|
-
const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
|
|
312
|
-
let rel = pkg.bin;
|
|
313
|
-
if (rel && typeof rel === "object") rel = rel.ccusage ?? Object.values(rel)[0];
|
|
314
|
-
if (typeof rel !== "string") return null;
|
|
315
|
-
const entry = path.resolve(PKG_DIR, rel);
|
|
316
|
-
// path.resolve, not path.join, so an ABSOLUTE `bin` is measured as the
|
|
317
|
-
// absolute path it is rather than being silently re-rooted under PKG_DIR
|
|
318
|
-
// and passing on a technicality. The trailing separator is what stops a
|
|
319
|
-
// sibling directory whose name merely starts with PKG_DIR's — and it also
|
|
320
|
-
// rejects PKG_DIR itself, which is a directory and no kind of entry point.
|
|
321
|
-
if (!entry.startsWith(path.resolve(PKG_DIR) + path.sep)) return null;
|
|
322
|
-
return existsSync(entry) ? { entry, version: pkg.version } : null;
|
|
323
|
-
} catch {
|
|
324
|
-
return null;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Everything a failed install is known to have said, in the order the answer is
|
|
330
|
-
* usually in.
|
|
331
|
-
*
|
|
332
|
-
* The old line read `(r.stderr || "").trim() || r.status`, which threw away the
|
|
333
|
-
* two things most likely to be the whole story. `error` is where a failure to
|
|
334
|
-
* LAUNCH lands — ENOENT for a comspec that is not there, EINVAL for a .cmd Node
|
|
335
|
-
* refuses to spawn directly, and the expired deadline — and it comes with no
|
|
336
|
-
* status at all, so what reached the terminal in every one of those cases was
|
|
337
|
-
* the word "null". And npm has never confined itself to stderr: a shim that
|
|
338
|
-
* dies before npm starts writes wherever Node chose, and `npm ERR!` blocks have
|
|
339
|
-
* landed on stdout across majors.
|
|
340
|
-
*
|
|
341
|
-
* The four fields are spawnSync's, because that is where they were first read
|
|
342
|
-
* off; `install` above now fills the same shape from the 'error' event, the
|
|
343
|
-
* exit status and the two collected streams.
|
|
344
|
-
*/
|
|
345
|
-
function installFailureText(r) {
|
|
346
|
-
const parts = [];
|
|
347
|
-
if (r?.error?.message) parts.push(String(r.error.message));
|
|
348
|
-
const stderr = String(r?.stderr ?? "").trim();
|
|
349
|
-
const stdout = String(r?.stdout ?? "").trim();
|
|
350
|
-
if (stderr) parts.push(stderr);
|
|
351
|
-
if (stdout) parts.push(stdout);
|
|
352
|
-
if (!parts.length) parts.push(r?.status === null || r?.status === undefined
|
|
353
|
-
? "npm exited without a status and said nothing"
|
|
354
|
-
: `npm exited ${r.status} and said nothing`);
|
|
355
|
-
return parts.join(" — ");
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Which level of the managed install `npm install --prefix` actually produced.
|
|
360
|
-
*
|
|
361
|
-
* This is the honest answer to the question nobody could answer from a
|
|
362
|
-
* screenshot: an install that exits 0 and leaves a tree resolveEntry cannot use
|
|
363
|
-
* is reported as SUCCESS by an exit code alone, and #432 found that exact shape
|
|
364
|
-
* once already. Naming the first level that is not there turns "ccusage could
|
|
365
|
-
* not report usage" into a sentence about this machine's disk — whether npm
|
|
366
|
-
* wrote nothing, wrote a node_modules with no ccusage in it, or wrote a package
|
|
367
|
-
* whose entry point this deck refuses.
|
|
368
|
-
*/
|
|
369
|
-
function installTreeReport() {
|
|
370
|
-
const levels = [
|
|
371
|
-
[CACHE_DIR, "the prefix directory"],
|
|
372
|
-
[path.join(CACHE_DIR, "node_modules"), "node_modules under it"],
|
|
373
|
-
[PKG_DIR, "node_modules/ccusage"],
|
|
374
|
-
[path.join(PKG_DIR, "package.json"), "node_modules/ccusage/package.json"],
|
|
375
|
-
];
|
|
376
|
-
for (const [where, name] of levels) {
|
|
377
|
-
if (!existsSync(where)) return `${name} is not there`;
|
|
378
|
-
}
|
|
379
|
-
// Every level exists, so resolveEntry refused for one of its own reasons: an
|
|
380
|
-
// unreadable or bin-less package.json, or a `bin` pointing outside the
|
|
381
|
-
// package. All three are about the package rather than about npm.
|
|
382
|
-
return "the package is there but its bin entry could not be read or does not point inside it";
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Run `npm install ccusage@<spec> --prefix CACHE_DIR`, off this stack.
|
|
387
|
-
*
|
|
388
|
-
* The ONE install in this module, awaited by startInstall and dropped on the
|
|
389
|
-
* floor by the daily update path below. It was two — a `spawnSync` for the cold
|
|
390
|
-
* path and a fire-and-forget `spawn` for the background one — and the sync half
|
|
391
|
-
* is what #476 removes: nothing here needs an answer before the next tick, and
|
|
392
|
-
* a two-minute `spawnSync` at boot is two minutes of a dead process.
|
|
393
|
-
*
|
|
394
|
-
* Everything the sync path was careful about is carried over unchanged, because
|
|
395
|
-
* every one of those cares was bought with a bug report:
|
|
396
|
-
*
|
|
397
|
-
* - the vector is `installSpec`'s, spread into spawn exactly as spawnSync got
|
|
398
|
-
* it, which is what keeps #456's absolute `npm.cmd` and #362's per-argument
|
|
399
|
-
* quoting on Windows. `opts` is spread LAST for the same reason it was
|
|
400
|
-
* before: it carries windowsVerbatimArguments, and nothing above it may win.
|
|
401
|
-
* - `windowsHide`, so no console window flashes up.
|
|
402
|
-
* - INSTALL_TIMEOUT_MS, as a deadline this module enforces itself rather than
|
|
403
|
-
* spawn's own `timeout` option. That option sends one signal to the process
|
|
404
|
-
* it started, and on Windows a `.cmd` runs THROUGH cmd.exe — so the signal
|
|
405
|
-
* would land on the wrapper and leave npm downloading, which is the same
|
|
406
|
-
* distinction exec.mjs's `run` states and the reason killTree exists. Same
|
|
407
|
-
* shape as runOnce below, so this file has one deadline pattern rather than
|
|
408
|
-
* two.
|
|
409
|
-
*
|
|
410
|
-
* Diagnosis is unchanged too: `installFailureText` is handed the same four
|
|
411
|
-
* fields spawnSync used to hand it — a failure to LAUNCH in `error`, the exit
|
|
412
|
-
* status, and both output streams, because npm has never confined itself to
|
|
413
|
-
* stderr.
|
|
414
|
-
*/
|
|
415
|
-
function install(spec = "latest") {
|
|
416
|
-
return new Promise((resolve, reject) => {
|
|
417
|
-
mkdirSync(CACHE_DIR, { recursive: true });
|
|
418
|
-
const { file, args, opts } = installSpec(spec);
|
|
419
|
-
const child = spawn(file, args, { windowsHide: true, ...opts });
|
|
420
|
-
let out = "", err = "", settled = false;
|
|
421
|
-
let timer = null;
|
|
422
|
-
const finish = (fn, value) => {
|
|
423
|
-
if (settled) return;
|
|
424
|
-
settled = true;
|
|
425
|
-
clearTimeout(timer);
|
|
426
|
-
fn(value);
|
|
427
|
-
};
|
|
428
|
-
// A child that dies emits 'error' AND THEN 'close', and a deadline that
|
|
429
|
-
// fires kills the child and so provokes both — hence `settled`. Whichever
|
|
430
|
-
// arrives first is the account that travels.
|
|
431
|
-
const failed = (r) => finish(reject, new Error(`npm install ccusage failed: ${installFailureText(r)}`));
|
|
432
|
-
timer = setTimeout(() => {
|
|
433
|
-
// The verdict is stated before the kill, the order exec.mjs's `run` uses:
|
|
434
|
-
// the answer must not depend on the killed child cooperating.
|
|
435
|
-
failed({
|
|
436
|
-
error: { message: `npm install timed out after ${INSTALL_TIMEOUT_MS}ms` },
|
|
437
|
-
stdout: out,
|
|
438
|
-
stderr: err,
|
|
439
|
-
});
|
|
440
|
-
killTree(child);
|
|
441
|
-
}, INSTALL_TIMEOUT_MS);
|
|
442
|
-
timer.unref?.();
|
|
443
|
-
child.stdout?.on("data", d => { out += d; });
|
|
444
|
-
child.stderr?.on("data", d => { err += d; });
|
|
445
|
-
child.on("error", e => failed({ error: e, stdout: out, stderr: err }));
|
|
446
|
-
child.on("close", status => {
|
|
447
|
-
if (status !== 0) return failed({ status, stdout: out, stderr: err });
|
|
448
|
-
// A zero exit is npm's opinion, not a fact about the disk, and the caller
|
|
449
|
-
// treats "the install resolved" as "there is something to run". Checking
|
|
450
|
-
// here is what stops a silent success: getRunner used to call
|
|
451
|
-
// resolveEntry(), get null, and fall through to the npx fallback with
|
|
452
|
-
// NOTHING recorded anywhere about why — so the modal explained the
|
|
453
|
-
// fallback's stderr and the install's half of the story was never written
|
|
454
|
-
// down at all.
|
|
455
|
-
if (!resolveEntry()) {
|
|
456
|
-
return finish(reject, new Error(
|
|
457
|
-
`npm install ccusage exited 0 but left nothing runnable under ${CACHE_DIR}: `
|
|
458
|
-
+ `${installTreeReport()}`,
|
|
459
|
-
));
|
|
460
|
-
}
|
|
461
|
-
finish(resolve, undefined);
|
|
462
|
-
});
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// The daily update path's install: the same one above, with nobody listening.
|
|
467
|
-
//
|
|
468
|
-
// It has no shared promise and no `_lastInstallError` on purpose. This runs
|
|
469
|
-
// behind a ccusage that already works, so a failed upgrade is not news the
|
|
470
|
-
// modal should lead with — the copy on disk still answers, and the check comes
|
|
471
|
-
// round again tomorrow.
|
|
472
|
-
function installInBackground(spec = "latest") {
|
|
473
|
-
install(spec).catch(() => { /* best-effort */ });
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// True at most once per UPDATE_CHECK_MS, gated by the marker file's mtime so the
|
|
477
|
-
// throttle survives restarts.
|
|
478
|
-
function updateCheckDue() {
|
|
479
|
-
try {
|
|
480
|
-
return Date.now() - statSync(MARKER).mtimeMs > UPDATE_CHECK_MS;
|
|
481
|
-
} catch {
|
|
482
|
-
return true; // no marker yet → due
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
// (Re)write the marker so its mtime marks "now" as the last check time.
|
|
486
|
-
function touchMarker() {
|
|
487
|
-
try {
|
|
488
|
-
mkdirSync(CACHE_DIR, { recursive: true });
|
|
489
|
-
writeFileSync(MARKER, String(Date.now()));
|
|
490
|
-
} catch { /* ignore */ }
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Non-blocking: compare installed version to npm `latest`; install if newer.
|
|
494
|
-
function maybeBackgroundUpdate(installedVersion) {
|
|
495
|
-
if (installsDisabled()) return; // no `npm view`, no upgrade install
|
|
496
|
-
if (_checkedThisRun || !updateCheckDue()) return;
|
|
497
|
-
_checkedThisRun = true;
|
|
498
|
-
touchMarker();
|
|
499
|
-
try {
|
|
500
|
-
const { file, args, opts } = npmSpec(["view", "ccusage", "version"]);
|
|
501
|
-
const child = spawn(file, args, { windowsHide: true, ...opts });
|
|
502
|
-
let out = "";
|
|
503
|
-
child.stdout.on("data", d => { out += d; });
|
|
504
|
-
child.on("error", () => {});
|
|
505
|
-
child.on("close", () => {
|
|
506
|
-
const latest = out.trim();
|
|
507
|
-
if (latest && latest !== installedVersion) installInBackground(latest);
|
|
508
|
-
});
|
|
509
|
-
} catch { /* ignore */ }
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// ── the user's own copy ─────────────────────────────────────────────────────
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* A ccusage the USER put somewhere, either by naming it outright or by having
|
|
516
|
-
* it on PATH — or null when there is no such thing.
|
|
517
|
-
*
|
|
518
|
-
* This is #433, and the case for it is not that a PATH search is nice to have.
|
|
519
|
-
* The deck has been telling people "put ccusage on PATH yourself" in
|
|
520
|
-
* admin-failure.ts for as long as that sentence has existed, and nothing
|
|
521
|
-
* anywhere ever looked: a user who did exactly what they were told, and could
|
|
522
|
-
* prove it with `ccusage --version` in their own shell, got the identical
|
|
523
|
-
* failure on the next click. Two ways out of that, and the other one is to
|
|
524
|
-
* delete the promise. It is kept because there is a configuration that needs it
|
|
525
|
-
* and has no other:
|
|
526
|
-
*
|
|
527
|
-
* - `AGENTS_DECK_NO_INSTALL=1` is documented as "never install or update
|
|
528
|
-
* claude-swap / ccusage". Someone who sets it AND installs ccusage
|
|
529
|
-
* themselves has done the only thing that flag can sensibly mean, and until
|
|
530
|
-
* now the deck answered "ccusage is not installed" while it was installed
|
|
531
|
-
* and on their PATH. There was NO combination of settings that made a
|
|
532
|
-
* self-managed ccusage work.
|
|
533
|
-
* - The #432 machine — npm and npx both unusable on disk — cannot create the
|
|
534
|
-
* managed install and cannot run the npx fallback. A copy the user already
|
|
535
|
-
* has is the only route left, and it was the one route the deck refused to
|
|
536
|
-
* look down.
|
|
537
|
-
*
|
|
538
|
-
* The override wins over PATH the way AGENTS_DECK_CSWAP does over cswap's own
|
|
539
|
-
* search, and is never remembered, because somebody debugging a bad resolution
|
|
540
|
-
* needs a change to it to take effect on the next click rather than the next
|
|
541
|
-
* restart. Nothing here is cached for the same reason, and it is affordable:
|
|
542
|
-
* a lookup is a handful of stats, once per uncached fetch, against a process
|
|
543
|
-
* spawn that follows it.
|
|
544
|
-
*
|
|
545
|
-
* `platform` and `deps` are parameters, and this is exported, for the reason
|
|
546
|
-
* every other Windows answer in this module is: the PATHEXT walk and the
|
|
547
|
-
* `.cmd` spelling have to be checkable from a machine that cannot run Windows.
|
|
548
|
-
*/
|
|
549
|
-
export function userCcusage(platform = process.platform, deps, env = process.env) {
|
|
550
|
-
const named = env.AGENTS_DECK_CCUSAGE;
|
|
551
|
-
if (named) {
|
|
552
|
-
// A path the user typed is used as typed — pathLookup would refuse it
|
|
553
|
-
// anyway, since re-rooting a name that carries a directory is a way to run
|
|
554
|
-
// something other than what was asked for. Whether it EXISTS is the
|
|
555
|
-
// caller's question, because "the path you gave me is not there" is a
|
|
556
|
-
// different sentence from "you have no ccusage".
|
|
557
|
-
return { file: String(named), named: true };
|
|
558
|
-
}
|
|
559
|
-
const found = pathLookup("ccusage", platform, deps);
|
|
560
|
-
return found ? { file: found, named: false } : null;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/** Does the file an explicit override names actually exist? Split out so the
|
|
564
|
-
* existence check is injectable alongside the lookup above. */
|
|
565
|
-
const overrideIsThere = (file, { exists = existsSync } = {}) => {
|
|
566
|
-
try {
|
|
567
|
-
return exists(file);
|
|
568
|
-
} catch {
|
|
569
|
-
return false;
|
|
570
|
-
}
|
|
571
|
-
};
|
|
572
|
-
|
|
573
|
-
// Ensure a runnable ccusage. Returns { kind:"node", entry } for the managed
|
|
574
|
-
// install, { kind:"path", file } for a copy the user provided, or { kind:"npx" }
|
|
575
|
-
// as the portable fallback.
|
|
576
|
-
//
|
|
577
|
-
// The order is the whole design decision, so it is stated rather than implied.
|
|
578
|
-
//
|
|
579
|
-
// An explicit AGENTS_DECK_CCUSAGE is first and is never fallen through: a user
|
|
580
|
-
// who pointed the deck at a file and got silently ignored has been given a
|
|
581
|
-
// setting that does nothing, which is worse than not having one. If it does not
|
|
582
|
-
// resolve, the failure names THAT — "npx is not on this deck's PATH" would be
|
|
583
|
-
// both true and completely beside the point.
|
|
584
|
-
//
|
|
585
|
-
// The managed install then comes BEFORE the PATH copy, which is the opposite of
|
|
586
|
-
// what #433 proposed, and deliberately. Preferring PATH would silently change
|
|
587
|
-
// which ccusage runs on every machine that already has both — a deck that works
|
|
588
|
-
// today would start running a copy it has never run, and an old global ccusage
|
|
589
|
-
// without `daily --json` would turn a working panel into a broken one for no
|
|
590
|
-
// reason the user asked for. The PATH copy is an ESCAPE ROUTE, and it wants to
|
|
591
|
-
// be reached exactly when the managed install is not there; someone who wants
|
|
592
|
-
// their own copy to win over the deck's says so with AGENTS_DECK_CCUSAGE, which
|
|
593
|
-
// is unambiguous in a way a precedence rule never is.
|
|
594
|
-
//
|
|
595
|
-
// PATH comes before INSTALLING, though. Downloading a second copy of a tool
|
|
596
|
-
// that is already on the machine is not something to do to somebody, and it is
|
|
597
|
-
// what makes the order identical with and without AGENTS_DECK_NO_INSTALL=1 —
|
|
598
|
-
// that flag now removes a step rather than changing the sequence.
|
|
599
|
-
async function getRunner() {
|
|
600
|
-
const mine = userCcusage();
|
|
601
|
-
if (mine?.named) {
|
|
602
|
-
if (!overrideIsThere(mine.file)) {
|
|
603
|
-
throw tagged("bad_override",
|
|
604
|
-
`AGENTS_DECK_CCUSAGE points at ${mine.file}, and there is no such file`);
|
|
605
|
-
}
|
|
606
|
-
return { kind: "path", file: mine.file, named: true };
|
|
607
|
-
}
|
|
608
|
-
let resolved = resolveEntry();
|
|
609
|
-
if (resolved) {
|
|
610
|
-
maybeBackgroundUpdate(resolved.version);
|
|
611
|
-
return { kind: "node", entry: resolved.entry };
|
|
612
|
-
}
|
|
613
|
-
if (mine) return { kind: "path", file: mine.file, named: false };
|
|
614
|
-
// Nothing installed, nothing of the user's to run, and installs are
|
|
615
|
-
// forbidden. The npx fallback is not an escape hatch — `npx -y ccusage@latest`
|
|
616
|
-
// downloads and runs the same package — so there is no runner to hand back.
|
|
617
|
-
// Fail with the reason, which the usage-history modal shows, instead of
|
|
618
|
-
// quietly installing.
|
|
619
|
-
if (installsDisabled()) {
|
|
620
|
-
throw tagged("no_install", "ccusage is not installed, and installs are off (AGENTS_DECK_NO_INSTALL=1)");
|
|
621
|
-
}
|
|
622
|
-
// Cold: install once (deduped across concurrent callers).
|
|
623
|
-
await startInstall("latest");
|
|
624
|
-
resolved = resolveEntry();
|
|
625
|
-
if (resolved) { touchMarker(); return { kind: "node", entry: resolved.entry }; }
|
|
626
|
-
// npm unavailable / offline → fall back to npx, carrying WHY the managed
|
|
627
|
-
// install is not here. Without that the modal can only describe the fallback,
|
|
628
|
-
// and the fallback is the second thing that failed.
|
|
629
|
-
return { kind: "npx", installError: _lastInstallError };
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/** Which of ccusage's three paths a failure came from, in the deck's own words.
|
|
633
|
-
* `stage` travels to the browser; the modal leads with it rather than making
|
|
634
|
-
* the reader guess from a stack trace which half of this module they are
|
|
635
|
-
* looking at. */
|
|
636
|
-
const STAGE = { node: "managed", path: "path", npx: "npx" };
|
|
637
|
-
|
|
638
|
-
/** Mark a failure with the path that produced it, and with the managed
|
|
639
|
-
* install's own account when that is why this path was taken at all. Set once:
|
|
640
|
-
* the retry below re-stamps with the runner that actually failed, and an error
|
|
641
|
-
* that already knows where it came from is not overwritten. */
|
|
642
|
-
function stamp(err, runner) {
|
|
643
|
-
if (err && typeof err === "object") {
|
|
644
|
-
if (!err.stage) err.stage = STAGE[runner?.kind] ?? "npx";
|
|
645
|
-
// Which file, when the answer is a file the deck did not put there. The
|
|
646
|
-
// stage alone says "your own copy failed" and leaves the reader to work out
|
|
647
|
-
// WHICH copy, and on a machine with an override, a PATH entry and a managed
|
|
648
|
-
// install that is exactly the question they cannot answer from here.
|
|
649
|
-
if (runner?.kind === "path" && err.bin === undefined) err.bin = runner.file;
|
|
650
|
-
if (runner?.installError && err.install === undefined) err.install = runner.installError;
|
|
651
|
-
}
|
|
652
|
-
return err;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// ── invocation ──────────────────────────────────────────────────────────────
|
|
656
|
-
|
|
657
|
-
// At most one repair per process, so a package that is simply unrunnable —
|
|
658
|
-
// reinstalled and still broken — cannot put this into a loop of npm installs.
|
|
659
|
-
let _repairedThisRun = false;
|
|
660
|
-
|
|
661
|
-
/**
|
|
662
|
-
* Throw away a managed install that resolves but cannot run, so the next
|
|
663
|
-
* attempt builds a new one.
|
|
664
|
-
*
|
|
665
|
-
* This is the only genuinely permanent failure in this module, and it is the
|
|
666
|
-
* one a broken npm creates: `npm install` that dies partway leaves a
|
|
667
|
-
* package.json and an entry file on disk, resolveEntry answers "installed", and
|
|
668
|
-
* getRunner then hands back that entry FOREVER. Nothing re-checks it —
|
|
669
|
-
* maybeBackgroundUpdate only reinstalls when the registry has a newer version,
|
|
670
|
-
* so even a repaired npm never repaired the install. Every run failed
|
|
671
|
-
* identically, the modal said "try again", and the only thing that actually
|
|
672
|
-
* worked was deleting ~/.agents-deck/ccusage by hand, which nothing in the
|
|
673
|
-
* product tells anyone to do.
|
|
674
|
-
*
|
|
675
|
-
* Only the managed branch qualifies: the npx fallback's failures are npm's own
|
|
676
|
-
* and none of this deck's business to delete anything over.
|
|
677
|
-
*
|
|
678
|
-
* Not done under AGENTS_DECK_NO_INSTALL=1. That variable is a promise not to
|
|
679
|
-
* fetch, and removing the only copy on a machine that cannot replace it would
|
|
680
|
-
* turn a broken feature into an absent one.
|
|
681
|
-
*/
|
|
682
|
-
function discardDamagedInstall(runner, err) {
|
|
683
|
-
if (_repairedThisRun || runner.kind !== "node" || installsDisabled()) return false;
|
|
684
|
-
if (!cannotLoadModule(err?.message)) return false;
|
|
685
|
-
// THE FLAG IS SPENT ON A REPAIR THAT HAPPENED, not on one that was attempted
|
|
686
|
-
// (#790). It used to be set here, before the try — and the rm below fails on
|
|
687
|
-
// Windows for the reason its own maxRetries comment gives: the `node <entry>`
|
|
688
|
-
// child that just exited still holds a handle, the unlink marks the file
|
|
689
|
-
// delete-pending, and rmdir answers ENOTEMPTY past all ten retries. The catch
|
|
690
|
-
// returned false with NOTHING removed, and every later modal open and every
|
|
691
|
-
// 60s poll for the life of the deck then short-circuited on this same flag —
|
|
692
|
-
// including seconds later, once the handle was gone and the rm would have
|
|
693
|
-
// worked. The user's only way out was deleting ~/.agents-deck/ccusage by
|
|
694
|
-
// hand, which nothing tells them.
|
|
695
|
-
//
|
|
696
|
-
// The budget exists to stop a loop of INSTALLS. Here it was being consumed by
|
|
697
|
-
// a repair that never happened and never reached an install.
|
|
698
|
-
try {
|
|
699
|
-
// maxRetries because of what has just happened: the deck ran `node <entry>`
|
|
700
|
-
// out of this very directory a moment ago, and on Windows a file any handle
|
|
701
|
-
// still holds cannot be deleted — the unlink marks it delete-pending, the
|
|
702
|
-
// name stays, and rmdir on the parent answers ENOTEMPTY. A child still
|
|
703
|
-
// exiting is exactly that handle. Node retries EBUSY, EMFILE, ENFILE,
|
|
704
|
-
// ENOTEMPTY and EPERM with a linear backoff when asked to; unasked, it
|
|
705
|
-
// tries once and gives up, which turned a repairable install into the
|
|
706
|
-
// permanent failure below.
|
|
707
|
-
rmSync(PKG_DIR, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 });
|
|
708
|
-
} catch {
|
|
709
|
-
// Windows holds a lock on a file inside a directory being removed more
|
|
710
|
-
// readily than POSIX does, and a half-removed install is still a resolvable
|
|
711
|
-
// one. Say the repair did not happen so the caller reports the real failure
|
|
712
|
-
// rather than retrying into the same broken entry point.
|
|
713
|
-
return false;
|
|
714
|
-
}
|
|
715
|
-
const gone = !resolveEntry();
|
|
716
|
-
// Only now. The directory is really gone, so this deck has spent its one
|
|
717
|
-
// repair and the budget is doing its job. A failed `rm` returned false above
|
|
718
|
-
// without touching the flag, so the next poll — seconds later, once the
|
|
719
|
-
// handle is released — is free to try again.
|
|
720
|
-
if (gone) _repairedThisRun = true;
|
|
721
|
-
return gone;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/**
|
|
725
|
-
* What `spawn` gets for a ccusage the user provided.
|
|
726
|
-
*
|
|
727
|
-
* `file` is always an absolute path by the time it reaches here — pathLookup
|
|
728
|
-
* resolves the directory and an override is a path the user typed — and that is
|
|
729
|
-
* load-bearing rather than tidy. On Windows the thing on PATH is `ccusage.cmd`,
|
|
730
|
-
* a batch file, so spawnSpec routes it through cmd.exe; a batch file launched
|
|
731
|
-
* by BARE name computes `%~dp0` from the deck's working directory and goes
|
|
732
|
-
* looking for its payload there, which is #456 exactly. Resolving first and
|
|
733
|
-
* quoting second is the same order the npm and npx shims go through.
|
|
734
|
-
*
|
|
735
|
-
* Exported for tests: the platform is a parameter so the Windows command line
|
|
736
|
-
* can be checked from any OS.
|
|
737
|
-
*/
|
|
738
|
-
export const userSpec = (file, args = [], platform = process.platform) =>
|
|
739
|
-
spawnSpec(file, args, platform);
|
|
740
|
-
|
|
741
|
-
// One attempt with one runner. No branch gets a shell. The managed install is
|
|
742
|
-
// `node <entry> …`, which never needed one; the user's own copy and the npx
|
|
743
|
-
// fallback are routed through spawnSpec instead — see npxSpec and userSpec, and
|
|
744
|
-
// note that `args` here ends in whatever /api/ccusage was asked for.
|
|
745
|
-
function runOnce(runner, args) {
|
|
746
|
-
const { file, args: full, opts } = runner.kind === "node"
|
|
747
|
-
? { file: process.execPath, args: [runner.entry, ...args], opts: {} }
|
|
748
|
-
: runner.kind === "path"
|
|
749
|
-
? userSpec(runner.file, args)
|
|
750
|
-
: fallbackSpec(args);
|
|
751
|
-
return new Promise((resolve, reject) => {
|
|
752
|
-
const child = spawn(file, full, { windowsHide: true, ...opts });
|
|
753
|
-
let out = "", err = "";
|
|
754
|
-
const timer = setTimeout(() => {
|
|
755
|
-
// The npx fallback goes through cmd.exe on Windows, so `child` is the
|
|
756
|
-
// wrapper there and npx is a grandchild that a plain kill would leave
|
|
757
|
-
// downloading.
|
|
758
|
-
killTree(child);
|
|
759
|
-
reject(tagged("timeout", "ccusage timed out"));
|
|
760
|
-
}, TIMEOUT_MS);
|
|
761
|
-
child.stdout.on("data", d => { out += d; });
|
|
762
|
-
child.stderr.on("data", d => { err += d; });
|
|
763
|
-
child.on("error", e => { clearTimeout(timer); reject(e); });
|
|
764
|
-
child.on("close", code => {
|
|
765
|
-
clearTimeout(timer);
|
|
766
|
-
if (code === 0) resolve(out);
|
|
767
|
-
else reject(new Error(err.trim() || `ccusage exited ${code}`));
|
|
768
|
-
});
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Run ccusage with the given args, resolve raw stdout AND the runner that
|
|
773
|
-
// produced it. One retry, and only for the one failure that is otherwise
|
|
774
|
-
// permanent — see discardDamagedInstall. The second getRunner() is what rebuilds
|
|
775
|
-
// the install, or falls through to npx when npm cannot.
|
|
776
|
-
//
|
|
777
|
-
// The runner comes back with the output because the caller has to say which
|
|
778
|
-
// path answered: `extractJson` below can fail on a run that started perfectly
|
|
779
|
-
// well, and "ccusage ran but printed no usage data" reads differently depending
|
|
780
|
-
// on which ccusage that was.
|
|
781
|
-
async function runCcusage(args) {
|
|
782
|
-
const runner = await getRunner();
|
|
783
|
-
try {
|
|
784
|
-
return { out: await runOnce(runner, args), runner };
|
|
785
|
-
} catch (err) {
|
|
786
|
-
if (!discardDamagedInstall(runner, err)) throw stamp(err, runner);
|
|
787
|
-
note("managed install was unusable, rebuilding it", err);
|
|
788
|
-
const rebuilt = await getRunner();
|
|
789
|
-
try {
|
|
790
|
-
return { out: await runOnce(rebuilt, args), runner: rebuilt };
|
|
791
|
-
} catch (again) {
|
|
792
|
-
throw stamp(again, rebuilt);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// ── the per-agent split ─────────────────────────────────────────────────────
|
|
798
|
-
|
|
799
|
-
/**
|
|
800
|
-
* The flag that stops ccusage merging every CLI it read into one row.
|
|
801
|
-
*
|
|
802
|
-
* ccusage groups by day and, by default, adds Claude Code's spend to Codex's —
|
|
803
|
-
* and to OpenCode's, Amp's, Gemini CLI's and the dozen other sources it now
|
|
804
|
-
* reads — before it prints anything. The deck then drew that single number under
|
|
805
|
-
* a subtitle naming two CLIs, so "how much of this is Codex?" had no answer
|
|
806
|
-
* anywhere on the panel (#431).
|
|
807
|
-
*
|
|
808
|
-
* Measured against ccusage 20.0.20 rather than read off its README, because the
|
|
809
|
-
* whole reason to send this is what comes back. Two runs over the same range,
|
|
810
|
-
* with and without it, differ in exactly one way: each day gains an `agents`
|
|
811
|
-
* array, one entry per CLI, carrying that CLI's own `totalCost`, token counts
|
|
812
|
-
* and `modelBreakdowns`. Every key the parser below already reads survives byte
|
|
813
|
-
* for byte, the day's `totalCost` is unchanged and still equals the sum of its
|
|
814
|
-
* agents, the day count is the same and `totals` is untouched. The flag is
|
|
815
|
-
* purely additive, which is what makes it safe to send unconditionally: the
|
|
816
|
-
* merged view the deck has always drawn stays available for free, and a browser
|
|
817
|
-
* that ignores `agents` sees the reply it has always seen.
|
|
818
|
-
*/
|
|
819
|
-
const BY_AGENT = "--by-agent";
|
|
820
|
-
|
|
821
|
-
/**
|
|
822
|
-
* A ccusage on this machine that does not know `--by-agent`, remembered.
|
|
823
|
-
*
|
|
824
|
-
* The npx fallback resolves `ccusage@latest` and so is never the stale case,
|
|
825
|
-
* but a managed install that has not had its once-a-day update yet can be, and
|
|
826
|
-
* a copy the user put on PATH themselves can be any age at all. Process-scoped,
|
|
827
|
-
* like `_checkedThisRun` and `_repairedThisRun`: a deck restarted after
|
|
828
|
-
* upgrading ccusage asks again.
|
|
829
|
-
*/
|
|
830
|
-
let _byAgentUnsupported = false;
|
|
831
|
-
|
|
832
|
-
/**
|
|
833
|
-
* A failure that is about this flag rather than about this machine.
|
|
834
|
-
*
|
|
835
|
-
* Every argument parser that rejects an unknown option quotes the option back —
|
|
836
|
-
* "Unknown option '--by-agent'", "unrecognized option --by-agent", "unexpected
|
|
837
|
-
* argument '--by-agent' found", "Unknown argument: by-agent" — so the flag's own
|
|
838
|
-
* name is the one token they all agree on, and matching it needs no table of
|
|
839
|
-
* parsers or versions. The dashes are deliberately not part of the match, since
|
|
840
|
-
* one of those spellings drops them.
|
|
841
|
-
*
|
|
842
|
-
* This decides only whether to REMEMBER, never whether to retry: a run that
|
|
843
|
-
* failed for its own reasons and then happened to succeed on the second attempt
|
|
844
|
-
* must not leave the deck convinced, for the rest of the process, that this
|
|
845
|
-
* ccusage cannot report a split it can report perfectly well.
|
|
846
|
-
*/
|
|
847
|
-
const blamesByAgent = (text) => /by-agent/i.test(String(text ?? ""));
|
|
848
|
-
|
|
849
|
-
/**
|
|
850
|
-
* BOTH REPORTS FROM ONE LOAD.
|
|
851
|
-
*
|
|
852
|
-
* `daily` answers "what did this range cost" and `session` answers "which
|
|
853
|
-
* session spent it". They used to be two commands and therefore two children —
|
|
854
|
-
* two npx resolutions, two Node starts, and two full walks of every transcript
|
|
855
|
-
* on the machine for the same set of files.
|
|
856
|
-
*
|
|
857
|
-
* `--sections` is ccusage's own answer to that: one load, several report
|
|
858
|
-
* sections in one JSON object. Measured against this machine's logs, asking for
|
|
859
|
-
* `daily,session` returns exactly what the two runs returned — `daily` (with
|
|
860
|
-
* its `agents` split when `--by-agent` rides along), `session`, and one
|
|
861
|
-
* `totals` — so the deck reads the same fields off one child.
|
|
862
|
-
*
|
|
863
|
-
* Not on `session` alone: the panel's totals and its per-model split come from
|
|
864
|
-
* `daily`, so `daily` is the command and the sessions are the extra section.
|
|
865
|
-
*/
|
|
866
|
-
const SECTIONS = ["--sections", "daily,session"];
|
|
867
|
-
const blamesSections = (text) => /sections/i.test(String(text ?? ""));
|
|
868
|
-
|
|
869
|
-
/**
|
|
870
|
-
* A ccusage too old for `--sections`, remembered for the life of the process —
|
|
871
|
-
* the same narrow memory `_byAgentUnsupported` keeps, for the same reason: the
|
|
872
|
-
* retry costs one process on a machine that is already failing, and the memory
|
|
873
|
-
* costs the extra section for as long as the deck runs.
|
|
874
|
-
*/
|
|
875
|
-
let _sectionsUnsupported = false;
|
|
876
|
-
|
|
877
|
-
/**
|
|
878
|
-
* Run `daily` for a range, asking for the per-agent split, and give up the
|
|
879
|
-
* split rather than the whole answer when this ccusage will not produce one.
|
|
880
|
-
*
|
|
881
|
-
* Retrying is chosen over gating on `resolveEntry().version`, which the module
|
|
882
|
-
* already has in hand, for one reason: a version table only knows about the
|
|
883
|
-
* ccusage versions that existed when it was written, and two of the three
|
|
884
|
-
* runners here are copies the deck did not install and cannot date — an
|
|
885
|
-
* AGENTS_DECK_CCUSAGE override and whatever is on PATH. Retrying degrades
|
|
886
|
-
* correctly for ANY unknown ccusage rather than only for old ones.
|
|
887
|
-
*
|
|
888
|
-
* The retry is narrow, because a second attempt is a second process and on a
|
|
889
|
-
* genuinely broken machine that is a second wait. It fires only when the CLI
|
|
890
|
-
* itself failed — an untagged error, which is this module's word for "the child
|
|
891
|
-
* exited non-zero, or never started" — and never for the four failures that are
|
|
892
|
-
* already understood: `timeout` (where a retry would cost another 90 seconds
|
|
893
|
-
* for nothing), `no_install` and `bad_override` (thrown before any process
|
|
894
|
-
* runs), and `bad_output` (thrown after a run that ccusage considered a
|
|
895
|
-
* success, so the flag was accepted). An old ccusage lands squarely in the
|
|
896
|
-
* untagged case: measured, it exits 2 with an empty stdout and
|
|
897
|
-
* `Unknown option '--by-agent'` on stderr, which is unambiguous — it cannot be
|
|
898
|
-
* confused with a successful run that happened to have no data.
|
|
899
|
-
*
|
|
900
|
-
* When the retry ALSO fails, its failure is the one that travels, not the first
|
|
901
|
-
* one. Both runs failed, and the one without the deck's flag on it is the
|
|
902
|
-
* honest account of this machine: reporting the first would blame a flag that
|
|
903
|
-
* has just been shown to make no difference. Nothing is remembered in that case
|
|
904
|
-
* either, so the split is asked for again on the next attempt.
|
|
905
|
-
*
|
|
906
|
-
* The retry is therefore broad and the MEMORY of it is narrow — see
|
|
907
|
-
* blamesByAgent. Those are different questions with different costs: guessing
|
|
908
|
-
* wrong about whether to retry costs one process on a machine that is already
|
|
909
|
-
* failing, and guessing wrong about whether to remember costs the split for the
|
|
910
|
-
* life of the deck.
|
|
911
|
-
*/
|
|
912
|
-
async function runDaily(args) {
|
|
913
|
-
if (_byAgentUnsupported) return runCcusage(args);
|
|
914
|
-
try {
|
|
915
|
-
return await runCcusage([...args, BY_AGENT]);
|
|
916
|
-
} catch (err) {
|
|
917
|
-
if (err?.reason !== undefined) throw err;
|
|
918
|
-
const plain = await runCcusage(args);
|
|
919
|
-
if (blamesByAgent(err?.message)) _byAgentUnsupported = true;
|
|
920
|
-
return plain;
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// ccusage prints the JSON object somewhere in stdout; slice first { to last }.
|
|
925
|
-
function extractJson(out) {
|
|
926
|
-
const start = out.indexOf("{");
|
|
927
|
-
const end = out.lastIndexOf("}");
|
|
928
|
-
if (start === -1 || end === -1) throw tagged("bad_output", "no JSON in ccusage output");
|
|
929
|
-
try {
|
|
930
|
-
return JSON.parse(out.slice(start, end + 1));
|
|
931
|
-
} catch (err) {
|
|
932
|
-
// A ccusage that printed a progress line containing braces, or was cut off
|
|
933
|
-
// mid-object, lands here rather than on the branch above. Same failure to
|
|
934
|
-
// the reader either way: it ran, and what came back was not usage data.
|
|
935
|
-
throw tagged("bad_output", `unreadable ccusage output: ${err?.message ?? err}`);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
// YYYYMMDD for the CLI's --since/--until.
|
|
940
|
-
function toCliDate(d) {
|
|
941
|
-
return d.toISOString().slice(0, 10).replace(/-/g, "");
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
/**
|
|
945
|
-
* Fetch daily usage from ccusage for a date range.
|
|
946
|
-
* @param {{ since?: string, until?: string, force?: boolean }} opts
|
|
947
|
-
* since/until are YYYYMMDD strings (CLI format). Defaults to last 30 days.
|
|
948
|
-
* @returns {{ ok, days, totals, since, until, fetchedAt } | { ok:false, reason, error }}
|
|
949
|
-
*/
|
|
950
|
-
export async function fetchCcusageDaily({ since, until, force = false } = {}) {
|
|
951
|
-
const now = Date.now();
|
|
952
|
-
const sinceArg = since || toCliDate(new Date(now - 30 * 86400_000));
|
|
953
|
-
const key = `${sinceArg}|${until ?? ""}`;
|
|
954
|
-
|
|
955
|
-
const cached = _cache.get(key);
|
|
956
|
-
if (!force && cached && now - cached.at < CACHE_MS) return cached.result;
|
|
957
|
-
|
|
958
|
-
// A run for this exact range is already going: join it. `force` joins too,
|
|
959
|
-
// rather than starting a competing child — what ?refresh=1 asks for is a
|
|
960
|
-
// reading newer than the cache, and a run still in progress is one.
|
|
961
|
-
const already = _inflight.get(key);
|
|
962
|
-
if (already) return already;
|
|
963
|
-
|
|
964
|
-
// Refused here, before anything is spawned or remembered. The caller that
|
|
965
|
-
// reaches this is the fifth distinct range in flight at once, which the modal
|
|
966
|
-
// cannot produce; queueing it would mean holding a request open behind up to
|
|
967
|
-
// four ninety-second deadlines, which is a worse answer than saying no.
|
|
968
|
-
if (_outstanding >= MAX_OUTSTANDING) {
|
|
969
|
-
return {
|
|
970
|
-
ok: false,
|
|
971
|
-
reason: "busy",
|
|
972
|
-
error: `${MAX_OUTSTANDING} usage ranges are already being read \u2014 try again in a moment`,
|
|
973
|
-
fetchedAt: now,
|
|
974
|
-
};
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
const run = queued(() => readRange(sinceArg, until, key))
|
|
978
|
-
.finally(() => { _inflight.delete(key); });
|
|
979
|
-
_inflight.set(key, run);
|
|
980
|
-
return run;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
/**
|
|
984
|
-
* One ccusage run, once the queue has let it through.
|
|
985
|
-
*
|
|
986
|
-
* `now` is read here rather than carried in from the call, so `fetchedAt` and
|
|
987
|
-
* the cache stamp both mean "when this reading was taken" even for a run that
|
|
988
|
-
* waited its turn. With an empty queue that is the same instant the caller
|
|
989
|
-
* asked, which is what this always did.
|
|
990
|
-
*/
|
|
991
|
-
/**
|
|
992
|
-
* The same range, grouped by session rather than by day.
|
|
993
|
-
*
|
|
994
|
-
* Returns `[]` for every failure, including a ccusage too old to have the
|
|
995
|
-
* subcommand. The panel's totals and its per-model split come from `daily`, and
|
|
996
|
-
* losing the session names must not lose those — an empty list draws one
|
|
997
|
-
* section short, which is the same thing that happens on a machine with no
|
|
998
|
-
* ccusage at all and is already a state the panel knows.
|
|
999
|
-
*
|
|
1000
|
-
* WHAT `period` IS HERE, and it is the whole reason this is worth a second
|
|
1001
|
-
* child: on a session row ccusage puts the SESSION ID in `period` — the same
|
|
1002
|
-
* uuid Claude Code writes into every hook payload, and therefore the same key
|
|
1003
|
-
* the canvas already files its agents under. So these rows join to the board by
|
|
1004
|
-
* id, which is what lets the panel show ccusage's money against the deck's own
|
|
1005
|
-
* project names. Without that join a session row is a uuid and a number.
|
|
1006
|
-
*/
|
|
1007
|
-
async function readSessions(sinceArg, until) {
|
|
1008
|
-
try {
|
|
1009
|
-
const args = ["session", "--json", "--since", sinceArg];
|
|
1010
|
-
if (until) args.push("--until", until);
|
|
1011
|
-
const ran = await runCcusage(args);
|
|
1012
|
-
const raw = extractJson(ran.out);
|
|
1013
|
-
// `session`, singular — ccusage names the array after the command, not
|
|
1014
|
-
// after its contents, and `sessions` reads as the obvious guess and is
|
|
1015
|
-
// always undefined.
|
|
1016
|
-
return Array.isArray(raw.session) ? raw.session : [];
|
|
1017
|
-
} catch (err) {
|
|
1018
|
-
note("session read failed", err);
|
|
1019
|
-
return [];
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
async function readRange(sinceArg, until, key) {
|
|
1024
|
-
const now = Date.now();
|
|
1025
|
-
let result;
|
|
1026
|
-
let ran = null; // the runner that answered, for stamping a bad_output failure
|
|
1027
|
-
try {
|
|
1028
|
-
const args = ["daily", "--json", "--since", sinceArg];
|
|
1029
|
-
if (until) args.push("--until", until);
|
|
1030
|
-
let raw;
|
|
1031
|
-
if (_sectionsUnsupported) {
|
|
1032
|
-
ran = await runDaily(args);
|
|
1033
|
-
raw = extractJson(ran.out);
|
|
1034
|
-
} else {
|
|
1035
|
-
try {
|
|
1036
|
-
ran = await runDaily([...args, ...SECTIONS]);
|
|
1037
|
-
raw = extractJson(ran.out);
|
|
1038
|
-
} catch (err) {
|
|
1039
|
-
// A ccusage that could not run at all fails the same way with or
|
|
1040
|
-
// without the flag, so only a complaint naming the flag is worth a
|
|
1041
|
-
// second child — and `reason` set means the run never started, which is
|
|
1042
|
-
// not something a flag can fix.
|
|
1043
|
-
if (err?.reason !== undefined || !blamesSections(err?.message)) throw err;
|
|
1044
|
-
_sectionsUnsupported = true;
|
|
1045
|
-
ran = await runDaily(args);
|
|
1046
|
-
raw = extractJson(ran.out);
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
// Already here on any ccusage that knows `--sections`: one load answered
|
|
1050
|
-
// both questions. The second child is the fallback for the older ones, and
|
|
1051
|
-
// it stays deliberately after the first rather than beside it — a failure
|
|
1052
|
-
// to name the sessions must not cost the totals, which are what the panel
|
|
1053
|
-
// is mostly for, and two ccusage processes at once on a cold machine is the
|
|
1054
|
-
// shape #476 spent a release removing from the boot path.
|
|
1055
|
-
// The second child belongs to the OLD ccusage and to nothing else. A build
|
|
1056
|
-
// that took `--sections` answered with the section — empty for a range with
|
|
1057
|
-
// no sessions in it — so a missing `session` key there means this deck
|
|
1058
|
-
// asked for something that build does not report, and asking again as a
|
|
1059
|
-
// separate command would get the same silence for the price of a second
|
|
1060
|
-
// walk of every transcript on the machine.
|
|
1061
|
-
const sessions = Array.isArray(raw.session)
|
|
1062
|
-
? raw.session
|
|
1063
|
-
: (_sectionsUnsupported ? await readSessions(sinceArg, until) : []);
|
|
1064
|
-
// Passed through whole, `agents` array and all. Every day ccusage returns
|
|
1065
|
-
// under `--by-agent` is a superset of the day it returns without one, so
|
|
1066
|
-
// there is nothing here to reshape: the browser reads the merged totals it
|
|
1067
|
-
// always read, and reads the split when it is there. A run that fell back
|
|
1068
|
-
// to the flagless form simply carries days with no `agents`, which the
|
|
1069
|
-
// usage-history modal treats the same way it treats a range with one CLI in
|
|
1070
|
-
// it — no split, and no new chrome.
|
|
1071
|
-
const days = Array.isArray(raw.daily) ? raw.daily : [];
|
|
1072
|
-
result = {
|
|
1073
|
-
ok: true,
|
|
1074
|
-
days,
|
|
1075
|
-
sessions,
|
|
1076
|
-
totals: raw.totals ?? null,
|
|
1077
|
-
since: sinceArg,
|
|
1078
|
-
until: until ?? null,
|
|
1079
|
-
fetchedAt: now,
|
|
1080
|
-
};
|
|
1081
|
-
} catch (err) {
|
|
1082
|
-
// `extractJson` throws about a run that started fine, so it arrives here
|
|
1083
|
-
// knowing nothing about which ccusage it read. The runner does.
|
|
1084
|
-
if (ran) stamp(err, ran.runner);
|
|
1085
|
-
// Naming the path in the terminal too. "fetch failed" alone was true of
|
|
1086
|
-
// both, and an operator reading a boot report has the same question the
|
|
1087
|
-
// modal's reader has: which of the two.
|
|
1088
|
-
note(err?.stage === "managed" ? "fetch failed (managed install)"
|
|
1089
|
-
: err?.stage === "npx" ? "fetch failed (npx fallback)"
|
|
1090
|
-
: "fetch failed", err);
|
|
1091
|
-
// Anything untagged got here from the child itself — a non-zero exit, or a
|
|
1092
|
-
// spawn that never started one — which is exactly what run_failed means.
|
|
1093
|
-
// `error` keeps the child's WHOLE output, stack trace and all: it is the
|
|
1094
|
-
// modal's hover title, which is where the raw bytes are meant to live, and
|
|
1095
|
-
// it is what somebody pastes into an issue. Only the terminal gets a line.
|
|
1096
|
-
//
|
|
1097
|
-
// `stage` and `install` are the halves that used to be lost. They are what
|
|
1098
|
-
// let the modal say WHICH path failed and why, on screen, instead of
|
|
1099
|
-
// guessing it from the shape of a stack trace — the guess that shipped two
|
|
1100
|
-
// wrong diagnoses in a row (#432, #450). `bin` joined them for #433: with a
|
|
1101
|
-
// third path, "your own copy failed" is only half an answer until it names
|
|
1102
|
-
// which file that was. Undefined when nothing ran, and JSON.stringify drops
|
|
1103
|
-
// them, so an older browser sees the reply it expects.
|
|
1104
|
-
result = {
|
|
1105
|
-
ok: false,
|
|
1106
|
-
reason: err?.reason ?? "run_failed",
|
|
1107
|
-
stage: err?.stage,
|
|
1108
|
-
install: err?.install,
|
|
1109
|
-
bin: err?.bin,
|
|
1110
|
-
error: String(err?.message ?? err),
|
|
1111
|
-
fetchedAt: now,
|
|
1112
|
-
};
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
rememberRange(key, result, now);
|
|
1116
|
-
return result;
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
/**
|
|
1120
|
-
* Get ccusage ready at startup instead of on first use.
|
|
1121
|
-
*
|
|
1122
|
-
* The lazy path is fine for correctness but means the first person to open the
|
|
1123
|
-
* usage-history modal on a fresh machine waits out an npm install with no
|
|
1124
|
-
* explanation. Called from the CLI so that cost is paid while the deck is
|
|
1125
|
-
* still booting.
|
|
1126
|
-
*
|
|
1127
|
-
* Returns { state: "present" | "user" | "installing" | "updating" |
|
|
1128
|
-
* "unavailable" }. Never throws and never blocks on the install itself — a slow
|
|
1129
|
-
* registry must not hold up the server. That second half was a claim rather
|
|
1130
|
-
* than a fact until #476: `startInstall` wrapped a `spawnSync` in an async IIFE
|
|
1131
|
-
* with nothing to await in it, so `{ state: "installing" }` was returned only
|
|
1132
|
-
* after the install had already happened, on this stack.
|
|
1133
|
-
*
|
|
1134
|
-
* The order here is getRunner's order, and it has to be: a boot that installs a
|
|
1135
|
-
* managed copy while the user already has one on PATH would make getRunner's
|
|
1136
|
-
* "PATH before installing" true only until the first restart, and would spend a
|
|
1137
|
-
* download saying so. `user` is reported without a version because finding one
|
|
1138
|
-
* means RUNNING the thing, and the boot is not the place to spawn a process to
|
|
1139
|
-
* fill in a status row.
|
|
1140
|
-
*/
|
|
1141
|
-
export function primeCcusage() {
|
|
1142
|
-
const mine = userCcusage();
|
|
1143
|
-
// An override that names a file which is not there is not reported here at
|
|
1144
|
-
// all: the boot has nothing useful to say about it and getRunner will say it
|
|
1145
|
-
// properly, with the path, the first time the modal is opened.
|
|
1146
|
-
if (mine && (!mine.named || overrideIsThere(mine.file))) {
|
|
1147
|
-
const resolved = resolveEntry();
|
|
1148
|
-
// The managed install still wins when it exists — see getRunner — so an
|
|
1149
|
-
// existing deck's boot row does not change.
|
|
1150
|
-
if (mine.named || !resolved) return { state: "user", bin: mine.file };
|
|
1151
|
-
}
|
|
1152
|
-
// The CLI already skips this call under AGENTS_DECK_NO_INSTALL=1; repeating
|
|
1153
|
-
// the check here keeps the promise a property of the module rather than of
|
|
1154
|
-
// one caller.
|
|
1155
|
-
if (installsDisabled()) {
|
|
1156
|
-
const have = resolveEntry();
|
|
1157
|
-
return have ? { state: "present", version: have.version } : { state: "unavailable" };
|
|
1158
|
-
}
|
|
1159
|
-
const resolved = resolveEntry();
|
|
1160
|
-
if (resolved) {
|
|
1161
|
-
// Already installed: the daily check may still queue a background upgrade.
|
|
1162
|
-
const due = !_checkedThisRun && updateCheckDue();
|
|
1163
|
-
maybeBackgroundUpdate(resolved.version);
|
|
1164
|
-
return { state: due ? "updating" : "present", version: resolved.version };
|
|
1165
|
-
}
|
|
1166
|
-
startInstall("latest");
|
|
1167
|
-
return { state: "installing" };
|
|
1168
|
-
}
|