agent-dag 1.44.1 → 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 +3 -1
- package/bin/agent-dag.js +25 -1
- package/bin/deck.js +98 -15
- package/dist/web/assets/{index-XtT5NdJI.css → index-Bdl1LX0-.css} +1 -1
- 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 +1 -1
- package/src/server/args.mjs +113 -15
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-usage.mjs +65 -7
- package/src/server/cswap-admin.mjs +166 -34
- package/src/server/cswap-auto.mjs +156 -2
- package/src/server/exec.mjs +103 -15
- package/src/server/index.mjs +1793 -163
- package/src/server/installer.mjs +119 -30
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/supervisor.mjs +67 -0
- package/dist/web/assets/index-DBsxIfdM.js +0 -78
- package/hook/notify.mjs +0 -104
- package/src/server/sound-hook.mjs +0 -518
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.45.0",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
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;
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
// immediately before it is spent, a response that does not clearly carry a
|
|
13
13
|
// new access token is never treated as success, and nothing here throws —
|
|
14
14
|
// a rejected promise from a background poll would take the server down.
|
|
15
|
-
import { readFile, chmod, unlink
|
|
15
|
+
import { readFile, chmod, unlink } from "node:fs/promises";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
18
|
-
import { createTemp, renameWithRetry } from "./installer.mjs";
|
|
18
|
+
import { createTemp, renameWithRetry, resolveWriteTarget } from "./installer.mjs";
|
|
19
19
|
import { PRODUCT } from "./brand.mjs";
|
|
20
20
|
|
|
21
21
|
// This file used to resolve CODEX_HOME itself, as `process.env.CODEX_HOME ??
|
|
@@ -149,7 +149,12 @@ async function readAuthFile() {
|
|
|
149
149
|
*
|
|
150
150
|
* Resolves symlinks first — `~/.codex/auth.json` is often a link into a
|
|
151
151
|
* dotfiles repo or an encrypted volume, and renaming onto the link would
|
|
152
|
-
* replace it with a regular file, quietly detaching the user's setup.
|
|
152
|
+
* replace it with a regular file, quietly detaching the user's setup. That
|
|
153
|
+
* resolution is the installer's resolveWriteTarget rather than a bare realpath
|
|
154
|
+
* here, because settings.json needed the identical rule (#673) and a rule
|
|
155
|
+
* written twice is a rule that drifts: the shared one also follows a DANGLING
|
|
156
|
+
* link to the file it names, which a realpath cannot answer at all and which is
|
|
157
|
+
* exactly the state a dotfiles repo is in before its first apply.
|
|
153
158
|
*
|
|
154
159
|
* The temp file comes from the installer's createTemp, which numbers every
|
|
155
160
|
* write and creates it with O_EXCL, rather than from a name built out of the
|
|
@@ -177,7 +182,7 @@ async function readAuthFile() {
|
|
|
177
182
|
* happen" rather than swallowing it.
|
|
178
183
|
*/
|
|
179
184
|
async function persistAuth(auth) {
|
|
180
|
-
const target = await
|
|
185
|
+
const target = await resolveWriteTarget(AUTH_PATH);
|
|
181
186
|
const { tmp, handle } = await createTemp(target, { mode: 0o600 });
|
|
182
187
|
|
|
183
188
|
let ok = false;
|
|
@@ -243,14 +243,62 @@ function windowDelta(series, windowStartMs) {
|
|
|
243
243
|
// Parse session start time from rollout filename.
|
|
244
244
|
// Format: rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl
|
|
245
245
|
// The timestamp portion uses dashes instead of colons (Windows-safe).
|
|
246
|
+
//
|
|
247
|
+
// THAT WALL CLOCK IS LOCAL. This used to append a "Z" and hand the result to
|
|
248
|
+
// `Date.parse`, which declares it UTC, and every rollout the walk below
|
|
249
|
+
// considered was therefore mis-dated by the machine's offset — the whole
|
|
250
|
+
// membership test shifted by however far the machine sits from Greenwich
|
|
251
|
+
// (#609). Measured against the ten rollouts under `$CODEX_HOME` on this
|
|
252
|
+
// machine, TZ=Europe/Chisinau, offset +3: read as UTC the filename sits
|
|
253
|
+
// 179.3, 173.4, 178.5, 179.6, 179.7, 180.0, 179.8, 180.0 and 179.9 minutes
|
|
254
|
+
// ahead of the first event in its own file; read as local it lands 0.0 to 6.6
|
|
255
|
+
// minutes BEFORE it, which is the gap between naming a file and writing the
|
|
256
|
+
// first line into it. Ten out of ten, and the sign is the tell — a session
|
|
257
|
+
// cannot log an event before it starts.
|
|
258
|
+
//
|
|
259
|
+
// The tenth file is the one worth spelling out, because it is the reason this
|
|
260
|
+
// keys off the name and not the contents. In
|
|
261
|
+
// `rollout-2026-08-18T08-00-24-01a0133d-…` the envelope timestamp on line 1 is
|
|
262
|
+
// 06:33:07.513Z — 92 minutes AFTER the name, since the session sat idle before
|
|
263
|
+
// its first turn — while the `session_meta` payload nested inside that same
|
|
264
|
+
// line reads 05:00:24.355Z, which is 08:00:24 local, the filename to the
|
|
265
|
+
// second. So the outer timestamp is when the file was first APPENDED TO and
|
|
266
|
+
// the name is when the session STARTED; the two differ by as much as the user
|
|
267
|
+
// leaves the prompt sitting there.
|
|
268
|
+
//
|
|
269
|
+
// Reading that inner field would mean opening every rollout in the tree just
|
|
270
|
+
// to decide which rollouts to open, which is the one cost this function exists
|
|
271
|
+
// to avoid: the module's own measurements put a week at 280 files, and the
|
|
272
|
+
// files ruled out by the name are exactly the ones never touched again. It
|
|
273
|
+
// would also need an answer for a rollout whose first line is truncated,
|
|
274
|
+
// unparseable or simply not there yet — and the only two answers are to open
|
|
275
|
+
// it anyway (paying the cost the filter was for) or to drop it (a silent
|
|
276
|
+
// undercount, which is the bug being fixed here wearing a different hat). The
|
|
277
|
+
// name is on disk, free to read, and by the measurement above it is the more
|
|
278
|
+
// accurate of the two.
|
|
246
279
|
function parseRolloutTime(filename) {
|
|
247
280
|
// e.g. rollout-2026-06-17T12-39-01-019ed4f2-c821-...jsonl
|
|
248
|
-
const m = filename.match(/^rollout-(\d{4}
|
|
281
|
+
const m = filename.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-/);
|
|
249
282
|
if (!m) return null;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
283
|
+
const [y, mo, d, h, mi, s] = m.slice(1).map(Number);
|
|
284
|
+
// Built from parts rather than parsed from a string, so the conversion uses
|
|
285
|
+
// the zone rules in force ON THAT DATE rather than any single offset. An
|
|
286
|
+
// offset is not a constant: America/Los_Angeles is -8 in January and -7 in
|
|
287
|
+
// July, so a fix that subtracted `new Date().getTimezoneOffset()` would be
|
|
288
|
+
// wrong for half the window it filters, twice a year, and wrong by an hour
|
|
289
|
+
// for the whole of it on the days either side of a transition.
|
|
290
|
+
const dt = new Date(y, mo - 1, d, h, mi, s);
|
|
291
|
+
// `Date.parse` used to reject a nonsense date for free; the constructor
|
|
292
|
+
// instead rolls it over (month 13 becomes next January), which would turn a
|
|
293
|
+
// file that is not a rollout at all into one dated in the future — and a
|
|
294
|
+
// future date passes the window test below. Month and day are enough to
|
|
295
|
+
// catch that, and deliberately not the hour: a local time inside a
|
|
296
|
+
// spring-forward gap does not exist, and V8 normalises it to the hour after,
|
|
297
|
+
// which is the right answer and not a rollover. It subsumes the `isNaN` test
|
|
298
|
+
// that used to stand at the end of this function, since an invalid Date
|
|
299
|
+
// answers NaN to `getMonth()` and NaN matches nothing.
|
|
300
|
+
if (dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
|
|
301
|
+
return dt.getTime();
|
|
254
302
|
}
|
|
255
303
|
|
|
256
304
|
// List rollout files whose start times fall within the given window.
|
|
@@ -266,8 +314,18 @@ async function listRolloutFiles(sinceMs) {
|
|
|
266
314
|
const nowMs = Date.now();
|
|
267
315
|
// Years arrive newest-first, so the first one that cannot hold a file in the
|
|
268
316
|
// window ends the walk: everything after it is older still. The extra day of
|
|
269
|
-
// slack covers a session that started just before the window
|
|
270
|
-
//
|
|
317
|
+
// slack covers a session that started just before the window.
|
|
318
|
+
//
|
|
319
|
+
// It used to also claim to cover "a filename timestamp that is UTC while the
|
|
320
|
+
// year directory is local time", which asserted the opposite of what the
|
|
321
|
+
// files say — see parseRolloutTime. Both are local now and `getFullYear()`
|
|
322
|
+
// here is local too, so the two sides of this comparison finally speak the
|
|
323
|
+
// same clock. What the day of slack still earns, beyond the session that
|
|
324
|
+
// started just before the window: an ambiguous local time on the day the
|
|
325
|
+
// clocks go back happens twice, V8 resolves it to the first of the two, and
|
|
326
|
+
// a session started during the second is dated an hour early. That is a
|
|
327
|
+
// one-hour error on one or two days a year against a seven-day window,
|
|
328
|
+
// where the old bug was an offset-wide error on every day of it.
|
|
271
329
|
const oldestYear = new Date(nowMs - sinceMs - 86400000).getFullYear();
|
|
272
330
|
await walkRolloutDays(
|
|
273
331
|
(dir, files) => {
|