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.
@@ -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-DBsxIfdM.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-XtT5NdJI.css">
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 now happen per target, and main()'s hard cap is 1500ms, so
239
- // the pair has to fit inside it with room to spare. The challenge is a bodyless
240
- // GET to a loopback port — sub-millisecond when a deck is there, and instant
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`, and POST the payload
247
- * only once it has. `done` runs exactly once, whatever the outcome a refused
248
- * connection, a silent port, a wrong answer and a delivered event all just mean
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 is posted to directly: see requiresProof.
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 deliver(d, body, persists, done) {
289
+ function prove(d, cb) {
257
290
  let settled = false;
258
- const finish = () => { if (settled) return; settled = true; done(); };
291
+ const finish = ok => { if (settled) return; settled = true; cb(ok); };
259
292
 
260
- if (!requiresProof(d)) return post(d, body, persists, finish);
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
- if (!sameProof(proof, want)) return finish();
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
- function post(d, body, persists, finish) {
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 — deliver()
371
- // decides what a target has to prove, and a deck older than the handshake
372
- // can prove nothing. See requiresProof.
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
- // One deck per events log records this event; the others only draw it.
392
- const writers = electWriters(targets);
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
- let pending = targets.length;
395
- const done = () => { if (--pending <= 0) process.exit(0); };
479
+ // One deck per events log records this event; the others only draw it.
480
+ const writers = electWriters(proven);
396
481
 
397
- for (const d of targets) deliver(d, taggedInput, writers.has(d), done);
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.44.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": {
@@ -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 `unknown` every token the loop did
17
- * not recognise, in the order it met them. That list is the point of this
18
- * module: the loop used to have no `else`, so `ccdeck --prot 4500` booted on
19
- * 4317 and said nothing, and a typo was indistinguishable from a flag that
20
- * worked.
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
- * The three flags that TAKE a value (`--port`, `--workspace`, `--history`)
23
- * consume it with `args[++i]`, so the value is never re-examined as a token of
24
- * its own and cannot land in `unknown` — `--port 4500` says nothing about
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") out.port = args[++i];
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") out.workspace = args[++i];
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") out.history = args[++i];
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, realpath } from "node:fs/promises";
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 realpath(AUTH_PATH).catch(() => AUTH_PATH);
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}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-/);
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
- // Replace the last two dashes in time part with colons
251
- const iso = m[1].replace(/T(\d{2})-(\d{2})-(\d{2})$/, "T$1:$2:$3") + "Z";
252
- const t = Date.parse(iso);
253
- return isNaN(t) ? null : t;
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 and a filename
270
- // timestamp that is UTC while the year directory is local time.
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) => {