agent-dag 1.43.0 → 1.45.0
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 +13 -7
- package/bin/agent-dag.js +87 -9
- package/bin/deck.js +133 -22
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +2 -2
- package/src/server/args.mjs +113 -15
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +163 -9
- package/src/server/cswap-admin.mjs +346 -40
- package/src/server/cswap-auto.mjs +365 -12
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +233 -26
- package/src/server/index.mjs +1994 -157
- package/src/server/installer.mjs +173 -11
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +262 -21
- package/src/server/supervisor.mjs +103 -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
- package/src/server/sound-hook.mjs +0 -390
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-jXBjwwZC.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bdl1LX0-.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/hook/hook.js
CHANGED
|
@@ -34,9 +34,38 @@ function parseProvider(argv) {
|
|
|
34
34
|
}
|
|
35
35
|
const PROVIDER = parseProvider(process.argv.slice(2));
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The one spelling of a directory, so that a path this process reports and a
|
|
39
|
+
* path bin/deck.js published can be compared as strings.
|
|
40
|
+
*
|
|
41
|
+
* Resolving symlinks is the half that is easy to think you can skip, because on
|
|
42
|
+
* POSIX a cwd comes from getcwd(3) and has none left in it. Windows has no such
|
|
43
|
+
* guarantee — GetCurrentDirectoryW returns the string the directory was set
|
|
44
|
+
* with, junction, `subst` drive and all — so a workspace reached that way only
|
|
45
|
+
* matches if BOTH sides go through here. The server's rollout watcher keeps its
|
|
46
|
+
* own copy of this rule under the name canonicalCwd, for the Codex sessions that
|
|
47
|
+
* never reach this file; a test walks one path through both. A path that does
|
|
48
|
+
* not resolve keeps its resolved form, which is also what canonicalWorkspace
|
|
49
|
+
* does with a directory the user has not created yet.
|
|
50
|
+
*
|
|
51
|
+
* `.native` IS THE RULE, not a detail. fs.realpathSync is a JavaScript
|
|
52
|
+
* lstat-and-readlink walk that resolves symlinks and junctions and nothing else;
|
|
53
|
+
* fs.realpathSync.native is GetFinalPathNameByHandleW, which also expands a DOS
|
|
54
|
+
* 8.3 short component to its long form. This used to call the plain one while
|
|
55
|
+
* the server's canonicalCwd went through the native one, so the moment a path
|
|
56
|
+
* arrived short — `%TEMP%` under a shortened profile directory, which is what
|
|
57
|
+
* every GitHub Windows runner has — the two canonicalisers that exist to agree
|
|
58
|
+
* disagreed by a whole path: C:\Users\RUNNER~1\… against C:\Users\runneradmin\….
|
|
59
|
+
* canonicalWorkspace in src/server/index.mjs says the rest of it, including why
|
|
60
|
+
* the long form is the canonical one; all three sites name `.native` out loud.
|
|
61
|
+
*
|
|
62
|
+
* Exported for that test: it is half of what `--workspace` means, and a
|
|
63
|
+
* predicate handed an already-canonical path cannot show that the caller
|
|
64
|
+
* canonicalises.
|
|
65
|
+
*/
|
|
37
66
|
function normPath(p) {
|
|
38
67
|
let r = path.resolve(p);
|
|
39
|
-
try { r = fs.realpathSync(r); } catch {}
|
|
68
|
+
try { r = fs.realpathSync.native(r); } catch {}
|
|
40
69
|
return r;
|
|
41
70
|
}
|
|
42
71
|
|
|
@@ -235,29 +264,33 @@ function sameProof(got, want) {
|
|
|
235
264
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
236
265
|
}
|
|
237
266
|
|
|
238
|
-
// Two round trips
|
|
239
|
-
//
|
|
240
|
-
//
|
|
267
|
+
// Two round trips happen per target, and main()'s hard cap is 1500ms, so the
|
|
268
|
+
// pair has to fit inside it with room to spare. The challenge is a bodyless GET
|
|
269
|
+
// to a loopback port — sub-millisecond when a deck is there, and instant
|
|
241
270
|
// ECONNREFUSED when nothing is.
|
|
271
|
+
//
|
|
272
|
+
// They are now separated by a barrier: every target is challenged, then the
|
|
273
|
+
// election is decided, then the payload goes out (#695). The worst case is
|
|
274
|
+
// unchanged — the challenges run in parallel, so it is still one 400ms deadline
|
|
275
|
+
// followed by one 1000ms deadline. What the barrier does cost is that an honest
|
|
276
|
+
// deck's POST waits for the slowest challenge in the set, which only matters
|
|
277
|
+
// when some OTHER record's port accepts a connection and then says nothing. A
|
|
278
|
+
// ghost port with nothing behind it refuses instantly and delays no one.
|
|
242
279
|
const CHALLENGE_TIMEOUT_MS = 400;
|
|
243
280
|
const POST_TIMEOUT_MS = 1000;
|
|
244
281
|
|
|
245
282
|
/**
|
|
246
|
-
* Ask the listener to prove it is the deck that wrote `d
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* this target is finished.
|
|
283
|
+
* Ask the listener to prove it is the deck that wrote `d`. `cb` is called
|
|
284
|
+
* exactly once with true or false — a refused connection, a silent port and a
|
|
285
|
+
* wrong answer are all just "not the deck this record describes".
|
|
250
286
|
*
|
|
251
|
-
* A deck that advertised no token
|
|
252
|
-
*
|
|
253
|
-
* `persists` is this deck's answer from electWriters: true for the one deck
|
|
254
|
-
* that logs the event, false for every other one it is also drawn on.
|
|
287
|
+
* A deck that advertised no token cannot be asked and passes: see requiresProof.
|
|
255
288
|
*/
|
|
256
|
-
function
|
|
289
|
+
function prove(d, cb) {
|
|
257
290
|
let settled = false;
|
|
258
|
-
const finish =
|
|
291
|
+
const finish = ok => { if (settled) return; settled = true; cb(ok); };
|
|
259
292
|
|
|
260
|
-
if (!requiresProof(d)) return
|
|
293
|
+
if (!requiresProof(d)) return finish(true);
|
|
261
294
|
|
|
262
295
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
263
296
|
const want = challengeProof(d.token, nonce);
|
|
@@ -269,31 +302,79 @@ function deliver(d, body, persists, done) {
|
|
|
269
302
|
method: "GET",
|
|
270
303
|
timeout: CHALLENGE_TIMEOUT_MS,
|
|
271
304
|
}, res => {
|
|
272
|
-
if (res.statusCode !== 200) { res.resume(); return res.on("end", finish); }
|
|
305
|
+
if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
|
|
273
306
|
let answer = "";
|
|
274
307
|
res.setEncoding("utf8");
|
|
275
308
|
res.on("data", c => {
|
|
276
309
|
answer += c;
|
|
277
310
|
// A deck answers in ~100 bytes. Anything pouring data at us is not one,
|
|
278
311
|
// and must not be allowed to grow this buffer without bound.
|
|
279
|
-
if (answer.length > 4096) { req.destroy(); finish(); }
|
|
312
|
+
if (answer.length > 4096) { req.destroy(); finish(false); }
|
|
280
313
|
});
|
|
281
314
|
res.on("end", () => {
|
|
282
315
|
// Already given up on this target — a flood we cut off above. Whatever
|
|
283
316
|
// arrived before that is not an answer we are going to act on.
|
|
284
317
|
if (settled) return;
|
|
285
318
|
let proof;
|
|
286
|
-
try { proof = JSON.parse(answer).proof; } catch { return finish(); }
|
|
287
|
-
|
|
288
|
-
post(d, body, persists, finish);
|
|
319
|
+
try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
|
|
320
|
+
finish(sameProof(proof, want));
|
|
289
321
|
});
|
|
290
322
|
});
|
|
291
|
-
req.on("error", finish);
|
|
323
|
+
req.on("error", () => finish(false));
|
|
292
324
|
req.on("timeout", () => req.destroy());
|
|
293
325
|
req.end();
|
|
294
326
|
}
|
|
295
327
|
|
|
296
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Challenge every target, then hand back the ones that answered — in the order
|
|
330
|
+
* they were given, so the election below is a function of the records alone.
|
|
331
|
+
*
|
|
332
|
+
* WHY THIS RUNS BEFORE THE ELECTION AND NOT AFTER IT (#695). The two round trips
|
|
333
|
+
* per target have always both happened; they used to happen in the wrong order.
|
|
334
|
+
* electWriters ran over every record whose pid was merely alive, and only then
|
|
335
|
+
* did deliver() challenge each target and drop the ones that could not answer.
|
|
336
|
+
* So a record left behind by a deck that is gone — SIGKILL, an OOM kill, a power
|
|
337
|
+
* cut, a console window closed on Windows, none of which run the shutdown that
|
|
338
|
+
* unlinks it — kept passing the one staleness test there is the moment the OS
|
|
339
|
+
* handed its pid to some other long-lived process. If it also named a port below
|
|
340
|
+
* every real deck's, it WON the election, was never posted to because it could
|
|
341
|
+
* not answer, and no other deck was posted to with the flag either: every deck
|
|
342
|
+
* drew the event, all of them were told `?persist=0`, and events.jsonl stopped
|
|
343
|
+
* growing. Silently, for as long as that file sat in the directory.
|
|
344
|
+
*
|
|
345
|
+
* The election has to be decided over the decks that are actually going to be
|
|
346
|
+
* handed the payload, and the only thing that establishes that is the handshake.
|
|
347
|
+
* So: prove, then elect, then post. It costs no extra round trip, only this
|
|
348
|
+
* ordering, and it is the same reordering src/server/index.mjs makes in
|
|
349
|
+
* readLiveDecks for the Codex rollouts no hook ever sees.
|
|
350
|
+
*
|
|
351
|
+
* The record is NOT unlinked when a target fails. A dead pid is proof the deck
|
|
352
|
+
* is gone and is swept above; a failed challenge is not — a deck restarting
|
|
353
|
+
* under its supervisor refuses connections for a moment while its record still
|
|
354
|
+
* stands, and a merely busy one can miss the 400ms deadline. Deleting another
|
|
355
|
+
* deck's registration on that evidence trades a bug that loses log lines for one
|
|
356
|
+
* that loses a whole deck's events, and it buys nothing now that the election no
|
|
357
|
+
* longer believes the record: a ghost that survives on disk costs one instant
|
|
358
|
+
* ECONNREFUSED per hook run and decides nothing.
|
|
359
|
+
*/
|
|
360
|
+
function proveTargets(targets, cb) {
|
|
361
|
+
const ok = new Array(targets.length).fill(false);
|
|
362
|
+
let pending = targets.length;
|
|
363
|
+
const settle = () => { if (--pending <= 0) cb(targets.filter((_, i) => ok[i])); };
|
|
364
|
+
targets.forEach((d, i) => prove(d, answered => { ok[i] = answered; settle(); }));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Hand this deck the payload. `done` runs exactly once, whatever the outcome —
|
|
369
|
+
* a delivered event, a refused connection and a socket that errors after the
|
|
370
|
+
* response are all just "this target is finished".
|
|
371
|
+
*
|
|
372
|
+
* `persists` is this deck's answer from electWriters: true for the one deck that
|
|
373
|
+
* logs the event, false for every other one it is also drawn on.
|
|
374
|
+
*/
|
|
375
|
+
function post(d, body, persists, done) {
|
|
376
|
+
let settled = false;
|
|
377
|
+
const finish = () => { if (settled) return; settled = true; done(); };
|
|
297
378
|
const req = http.request({
|
|
298
379
|
hostname: "127.0.0.1",
|
|
299
380
|
port: d.port,
|
|
@@ -367,9 +448,9 @@ function main() {
|
|
|
367
448
|
let d;
|
|
368
449
|
try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
|
|
369
450
|
if (typeof d.workspace !== "string" || !d.pid || !d.port) continue;
|
|
370
|
-
// A missing token is not a reason to drop the file here —
|
|
371
|
-
//
|
|
372
|
-
//
|
|
451
|
+
// A missing token is not a reason to drop the file here — prove() decides
|
|
452
|
+
// what a target has to prove, and a deck older than the handshake can
|
|
453
|
+
// prove nothing. See requiresProof.
|
|
373
454
|
|
|
374
455
|
if (!isAlive(d.pid)) {
|
|
375
456
|
try { fs.unlinkSync(path.join(DIR, file)); } catch {}
|
|
@@ -388,13 +469,21 @@ function main() {
|
|
|
388
469
|
|
|
389
470
|
if (!targets.length) return process.exit(0);
|
|
390
471
|
|
|
391
|
-
//
|
|
392
|
-
|
|
472
|
+
// Prove, elect, post — in that order, and see proveTargets for what the
|
|
473
|
+
// other order cost. A record whose pid is merely alive has established
|
|
474
|
+
// nothing: it may be a deck that died and had its pid recycled, and electing
|
|
475
|
+
// one of those to write the log meant nobody wrote it (#695).
|
|
476
|
+
proveTargets(targets, proven => {
|
|
477
|
+
if (!proven.length) return process.exit(0);
|
|
393
478
|
|
|
394
|
-
|
|
395
|
-
|
|
479
|
+
// One deck per events log records this event; the others only draw it.
|
|
480
|
+
const writers = electWriters(proven);
|
|
396
481
|
|
|
397
|
-
|
|
482
|
+
let pending = proven.length;
|
|
483
|
+
const done = () => { if (--pending <= 0) process.exit(0); };
|
|
484
|
+
|
|
485
|
+
for (const d of proven) post(d, taggedInput, writers.has(d), done);
|
|
486
|
+
});
|
|
398
487
|
});
|
|
399
488
|
}
|
|
400
489
|
|
|
@@ -403,5 +492,5 @@ function main() {
|
|
|
403
492
|
// require() it exports the rules it decides by — matching, election, the
|
|
404
493
|
// handshake — and starts nothing, which is what lets them be tested without a
|
|
405
494
|
// 1.5s exit timer in the test runner.
|
|
406
|
-
module.exports = { capturesSession, cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
|
|
495
|
+
module.exports = { capturesSession, cwdInWorkspace, foldsCase, normPath, electWriters, challengeProof, requiresProof };
|
|
407
496
|
if (require.main === module) main();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas.
|
|
3
|
+
"version": "1.45.0",
|
|
4
|
+
"description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agents-deck": "bin/agent-dag.js",
|
package/src/server/args.mjs
CHANGED
|
@@ -10,21 +10,97 @@
|
|
|
10
10
|
// bin/deck.js is the only caller. The parser is the whole of the module's
|
|
11
11
|
// surface; the tables it matches against stay inside it.
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Is this token a flag rather than somebody's value?
|
|
15
|
+
*
|
|
16
|
+
* A leading `-` and nothing else, because the alternatives are worse. Matching
|
|
17
|
+
* the known flag list would refuse `--workspace --prot` — a typo eating the next
|
|
18
|
+
* token is the same accident as a real flag eating it, and the one shape that
|
|
19
|
+
* must be caught is the one nobody spelled right. Refusing every value that
|
|
20
|
+
* begins with `-` would refuse `--port -1`, which is a number the user meant and
|
|
21
|
+
* which deserves the port error below rather than a "missing value" one.
|
|
22
|
+
*
|
|
23
|
+
* So: a leading `-`, except a plain negative number. The negative-number carve
|
|
24
|
+
* is the only exception, and it is small on purpose.
|
|
25
|
+
*
|
|
26
|
+
* WHAT THIS DOES ON EACH PLATFORM. Nothing, is the intent, to any real path.
|
|
27
|
+
* POSIX absolute (`/srv/proj`), POSIX relative (`./sub`, `sub`), `~/proj`, a
|
|
28
|
+
* Windows drive letter (`C:\Users\u\proj`, `c:/users/u/proj`), a UNC share
|
|
29
|
+
* (`\\srv\share\proj`), a Windows long path (`\\?\C:\proj`) and a bare
|
|
30
|
+
* `events.jsonl` all begin with something other than `-`, so all of them are
|
|
31
|
+
* values. A drive letter is not a flag on any platform and is never read as one:
|
|
32
|
+
* `C:` starts with `C`. What IS refused is a directory whose name really begins
|
|
33
|
+
* with a dash, which the user can still pass as `./-weird` — a two-character
|
|
34
|
+
* price for catching `--workspace $UNSET --no-persist`.
|
|
35
|
+
*/
|
|
36
|
+
export function looksLikeFlag(token) {
|
|
37
|
+
return typeof token === "string" && token.startsWith("-") && !/^-\d+(?:\.\d+)?$/.test(token);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Is this a port the deck could bind? Digits only, inside the range Node's
|
|
42
|
+
* `listen` accepts.
|
|
43
|
+
*
|
|
44
|
+
* `Number()` alone is far too willing: it takes `" 4500 "`, `0x10e4`, `1e3` and
|
|
45
|
+
* `Infinity`, and turns everything else into the `NaN` that used to reach
|
|
46
|
+
* `listen` and die there. The whole point of asking here is to answer BEFORE the
|
|
47
|
+
* deck has installed hooks and probed for tools, and to answer about the string
|
|
48
|
+
* the user actually typed. See bin/deck.js, which prints the flag and the value
|
|
49
|
+
* back at them.
|
|
50
|
+
*/
|
|
51
|
+
export function isPortValue(raw) {
|
|
52
|
+
if (typeof raw !== "string" && typeof raw !== "number") return false;
|
|
53
|
+
const s = String(raw).trim();
|
|
54
|
+
if (!/^\d+$/.test(s)) return false;
|
|
55
|
+
const n = Number(s);
|
|
56
|
+
return n >= 0 && n <= 65535;
|
|
57
|
+
}
|
|
58
|
+
|
|
13
59
|
/**
|
|
14
60
|
* Parse `process.argv.slice(2)`.
|
|
15
61
|
*
|
|
16
|
-
* Returns the flags that were set, plus
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
62
|
+
* Returns the flags that were set, plus two lists that are always present and
|
|
63
|
+
* always arrays:
|
|
64
|
+
*
|
|
65
|
+
* `unknown` — every token the loop did not recognise, in the order it met
|
|
66
|
+
* them. That list is the point of this module: the loop used
|
|
67
|
+
* to have no `else`, so `ccdeck --prot 4500` booted on 4317 and
|
|
68
|
+
* said nothing, and a typo was indistinguishable from a flag
|
|
69
|
+
* that worked.
|
|
70
|
+
* `incomplete` — every value-taking flag that was given no value it could
|
|
71
|
+
* use, as `{ flag, expects }`, `flag` spelled the way the user
|
|
72
|
+
* spelled it. bin/deck.js prints one row per entry.
|
|
73
|
+
*
|
|
74
|
+
* THE THREE FLAGS THAT TAKE A VALUE (`--port`/`-p`, `--workspace`, `--history`)
|
|
75
|
+
* used to consume the next token with `args[++i]` whatever it was. That is right
|
|
76
|
+
* for `--port 4500` — the value must never be re-examined as a token of its own,
|
|
77
|
+
* or every correct command line would report its own port as an unknown option,
|
|
78
|
+
* and a warning that fires on correct input is a warning everybody learns to
|
|
79
|
+
* ignore. It was wrong for a value that is itself a flag (#697): `ccdeck
|
|
80
|
+
* --workspace $PROJ --no-persist` with `PROJ` unset is, after word splitting,
|
|
81
|
+
* `ccdeck --workspace --no-persist`. The deck scoped itself to a directory
|
|
82
|
+
* called `--no-persist`, wrote to the shared events log anyway, and reported
|
|
83
|
+
* neither — `unknown` stayed empty, because the token that would have gone in it
|
|
84
|
+
* had been eaten.
|
|
85
|
+
*
|
|
86
|
+
* So the consume is conditional now, and it refuses three shapes:
|
|
87
|
+
*
|
|
88
|
+
* * the next token looks like a flag — NOT consumed, so the loop meets it on
|
|
89
|
+
* the next pass and it is parsed as the flag it is, or reported as unknown.
|
|
90
|
+
* That is also what fixes the supervisor case in bin/agent-dag.js: a
|
|
91
|
+
* respawn appends `--port <bound>` to the user's argv, and an argv ending in
|
|
92
|
+
* a bare `--workspace` used to eat the `--port` and drop the deck back on
|
|
93
|
+
* 4317, out from under the tab the user was looking at.
|
|
94
|
+
* * there is no next token at all — the trailing `--workspace`, which used to
|
|
95
|
+
* set `undefined` and mean "the default", silently.
|
|
96
|
+
* * the next token is empty or blank — consumed (it was quoted, so it was
|
|
97
|
+
* meant as the value) but not used. `--workspace ""` is a variable that did
|
|
98
|
+
* not expand, not a request for machine-wide capture, and answering it with
|
|
99
|
+
* the widest possible scope is the one answer that cannot be recovered from.
|
|
21
100
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `4500`, and `--workspace ~/some dir` nothing about the path. That is the way
|
|
26
|
-
* an unknown-flag warning usually goes wrong, and it is asserted rather than
|
|
27
|
-
* assumed; see src/web/__tests__/argv-480.test.ts.
|
|
101
|
+
* In all three the flag is left UNSET, so the deck falls back to its documented
|
|
102
|
+
* default, and the flag is named in `incomplete` so the fallback is said out
|
|
103
|
+
* loud rather than discovered later.
|
|
28
104
|
*
|
|
29
105
|
* A bare word is `unknown` too, and deliberately: the deck takes no positional
|
|
30
106
|
* arguments at all, so `ccdeck ~/proj` is the same mistake as `ccdeck --workpace
|
|
@@ -35,19 +111,41 @@
|
|
|
35
111
|
* one used to be dropped in silence (see launchNpx in bin/agent-dag.js).
|
|
36
112
|
*/
|
|
37
113
|
export function parseArgs(args) {
|
|
38
|
-
const out = { unknown: [] };
|
|
114
|
+
const out = { unknown: [], incomplete: [] };
|
|
39
115
|
for (let i = 0; i < args.length; i++) {
|
|
40
116
|
const a = args[i];
|
|
117
|
+
// The value of the flag just matched, or `undefined` when there is nothing
|
|
118
|
+
// usable there. Closes over `i` so it can decline to advance it: not
|
|
119
|
+
// consuming is what hands the token back to the loop.
|
|
120
|
+
const value = (expects) => {
|
|
121
|
+
const next = args[i + 1];
|
|
122
|
+
if (next === undefined || looksLikeFlag(next)) {
|
|
123
|
+
out.incomplete.push({ flag: a, expects });
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
i++;
|
|
127
|
+
if (String(next).trim() === "") {
|
|
128
|
+
out.incomplete.push({ flag: a, expects });
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return next;
|
|
132
|
+
};
|
|
133
|
+
// Assigned only when there is a value, so an unusable one leaves the key
|
|
134
|
+
// absent and the deck on its default — see the doc comment.
|
|
135
|
+
const set = (key, expects) => {
|
|
136
|
+
const v = value(expects);
|
|
137
|
+
if (v !== undefined) out[key] = v;
|
|
138
|
+
};
|
|
41
139
|
if (a === "-h" || a === "--help") out.help = true;
|
|
42
140
|
else if (a === "-v" || a === "--version") out.version = true;
|
|
43
|
-
else if (a === "-p" || a === "--port")
|
|
141
|
+
else if (a === "-p" || a === "--port") set("port", "a port number");
|
|
44
142
|
else if (a === "--no-open") out.noOpen = true;
|
|
45
143
|
else if (a === "--uninstall") out.uninstall = true;
|
|
46
|
-
else if (a === "--workspace")
|
|
144
|
+
else if (a === "--workspace") set("workspace", "a path");
|
|
47
145
|
else if (a === "--scope") out.scope = true;
|
|
48
146
|
else if (a === "--all") out.all = true; // legacy no-op (now default)
|
|
49
147
|
else if (a === "--no-persist") out.noPersist = true;
|
|
50
|
-
else if (a === "--history")
|
|
148
|
+
else if (a === "--history") set("history", "a path");
|
|
51
149
|
else if (a === "--codex") out.codex = true;
|
|
52
150
|
else if (a === "--no-codex") out.noCodex = true;
|
|
53
151
|
else if (a === "--claude") out.claude = true;
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -30,6 +30,75 @@ const MARKER = path.join(CACHE_DIR, ".last-update-check");
|
|
|
30
30
|
|
|
31
31
|
const _cache = new Map(); // key `${since}|${until}` → { result, at }
|
|
32
32
|
|
|
33
|
+
// How much a read is allowed to cost. /api/ccusage is a GET, deliberately: a
|
|
34
|
+
// cross-site read of the loopback port is an ordinary top-level navigation and
|
|
35
|
+
// isTrustedRead is right not to refuse it. What was missing is a ceiling on
|
|
36
|
+
// what one of those reads may start. Before #544, thirty distinct `since`
|
|
37
|
+
// values were thirty concurrent `node <PKG_DIR>/src/cli.js daily --json`
|
|
38
|
+
// children — each with a ninety-second deadline, each walking the whole
|
|
39
|
+
// ~/.claude log tree, doubled again whenever runDaily retried flagless — and
|
|
40
|
+
// thirty permanent Map entries. isCliDate admits all 10^8 eight-digit strings
|
|
41
|
+
// on purpose (a date outside the logs is ccusage's question to answer, not the
|
|
42
|
+
// deck's to guess at), so the map's key space was 10^8 and its eviction policy
|
|
43
|
+
// was none.
|
|
44
|
+
//
|
|
45
|
+
// Both numbers are set against what the feature does rather than against the
|
|
46
|
+
// attack. The usage-history modal asks for one range at a time and offers three
|
|
47
|
+
// presets, so four ranges outstanding at once is already more than a human
|
|
48
|
+
// clicking as fast as they can, and thirty-two remembered ranges is more than
|
|
49
|
+
// one sitting will ever look at. Whatever exceeds either is not a reader.
|
|
50
|
+
const CACHE_MAX = 32;
|
|
51
|
+
const MAX_OUTSTANDING = 4;
|
|
52
|
+
|
|
53
|
+
/** Ranges being fetched right now, keyed exactly as `_cache` is, so two callers
|
|
54
|
+
* asking the same question wait on one child instead of starting a second. The
|
|
55
|
+
* cache alone could never do this: it is written when a run finishes, and the
|
|
56
|
+
* whole window this is about is the ninety seconds before that. */
|
|
57
|
+
const _inflight = new Map();
|
|
58
|
+
|
|
59
|
+
/** The tail of the run queue, and how many runs are alive behind it.
|
|
60
|
+
*
|
|
61
|
+
* Serialised rather than merely counted, because two ccusage runs are two
|
|
62
|
+
* walks of the same directory tree: running them at once is slower than
|
|
63
|
+
* running them in sequence, so the queue costs a concurrent caller nothing it
|
|
64
|
+
* was actually going to get. What it buys is that a flood is one child at a
|
|
65
|
+
* time rather than a child per request. */
|
|
66
|
+
let _chain = Promise.resolve();
|
|
67
|
+
let _outstanding = 0;
|
|
68
|
+
|
|
69
|
+
/** Run `job` after every run already queued, whatever became of them — a run
|
|
70
|
+
* that threw must not take the queue down with it. */
|
|
71
|
+
function queued(job) {
|
|
72
|
+
_outstanding += 1;
|
|
73
|
+
const started = _chain.then(job, job);
|
|
74
|
+
_chain = started.then(() => {}, () => {});
|
|
75
|
+
return started.finally(() => { _outstanding -= 1; });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Remember one range's answer, and keep the map from being somewhere a caller
|
|
80
|
+
* can grow without limit.
|
|
81
|
+
*
|
|
82
|
+
* Entries past CACHE_MS can never be served again, so they are the ones to drop
|
|
83
|
+
* first; only when dropping all of them is still not enough does the oldest
|
|
84
|
+
* surviving entry go, which Map's insertion order hands over for free.
|
|
85
|
+
* Re-writing a key moves it to the back, so the range someone is actually
|
|
86
|
+
* polling is the last one evicted rather than the first.
|
|
87
|
+
*/
|
|
88
|
+
function rememberRange(key, result, at) {
|
|
89
|
+
_cache.delete(key);
|
|
90
|
+
_cache.set(key, { result, at });
|
|
91
|
+
if (_cache.size <= CACHE_MAX) return;
|
|
92
|
+
for (const [k, v] of _cache) {
|
|
93
|
+
if (_cache.size <= CACHE_MAX) break;
|
|
94
|
+
if (at - v.at >= CACHE_MS) _cache.delete(k);
|
|
95
|
+
}
|
|
96
|
+
for (const k of _cache.keys()) {
|
|
97
|
+
if (_cache.size <= CACHE_MAX) break;
|
|
98
|
+
_cache.delete(k);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
33
102
|
/**
|
|
34
103
|
* The name to hand cmd.exe for one of npm's Windows shims: its full path when
|
|
35
104
|
* one can be found, and the bare name only when none can.
|
|
@@ -835,6 +904,41 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
|
|
|
835
904
|
const cached = _cache.get(key);
|
|
836
905
|
if (!force && cached && now - cached.at < CACHE_MS) return cached.result;
|
|
837
906
|
|
|
907
|
+
// A run for this exact range is already going: join it. `force` joins too,
|
|
908
|
+
// rather than starting a competing child — what ?refresh=1 asks for is a
|
|
909
|
+
// reading newer than the cache, and a run still in progress is one.
|
|
910
|
+
const already = _inflight.get(key);
|
|
911
|
+
if (already) return already;
|
|
912
|
+
|
|
913
|
+
// Refused here, before anything is spawned or remembered. The caller that
|
|
914
|
+
// reaches this is the fifth distinct range in flight at once, which the modal
|
|
915
|
+
// cannot produce; queueing it would mean holding a request open behind up to
|
|
916
|
+
// four ninety-second deadlines, which is a worse answer than saying no.
|
|
917
|
+
if (_outstanding >= MAX_OUTSTANDING) {
|
|
918
|
+
return {
|
|
919
|
+
ok: false,
|
|
920
|
+
reason: "busy",
|
|
921
|
+
error: `${MAX_OUTSTANDING} usage ranges are already being read \u2014 try again in a moment`,
|
|
922
|
+
fetchedAt: now,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const run = queued(() => readRange(sinceArg, until, key))
|
|
927
|
+
.finally(() => { _inflight.delete(key); });
|
|
928
|
+
_inflight.set(key, run);
|
|
929
|
+
return run;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* One ccusage run, once the queue has let it through.
|
|
934
|
+
*
|
|
935
|
+
* `now` is read here rather than carried in from the call, so `fetchedAt` and
|
|
936
|
+
* the cache stamp both mean "when this reading was taken" even for a run that
|
|
937
|
+
* waited its turn. With an empty queue that is the same instant the caller
|
|
938
|
+
* asked, which is what this always did.
|
|
939
|
+
*/
|
|
940
|
+
async function readRange(sinceArg, until, key) {
|
|
941
|
+
const now = Date.now();
|
|
838
942
|
let result;
|
|
839
943
|
let ran = null; // the runner that answered, for stamping a bad_output failure
|
|
840
944
|
try {
|
|
@@ -892,7 +996,7 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
|
|
|
892
996
|
};
|
|
893
997
|
}
|
|
894
998
|
|
|
895
|
-
|
|
999
|
+
rememberRange(key, result, now);
|
|
896
1000
|
return result;
|
|
897
1001
|
}
|
|
898
1002
|
|