ccdeck 3.32.1 → 3.33.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.
Files changed (73) hide show
  1. package/README.md +14 -12
  2. package/bin/agent-dag.js +6 -0
  3. package/bin/cli/help.js +2 -0
  4. package/bin/cli/one-shot.js +32 -13
  5. package/bin/cli/second-start.js +1 -0
  6. package/bin/cli/startup.js +34 -0
  7. package/bin/cli/uninstall.js +47 -2
  8. package/bin/deck.js +12 -1
  9. package/dist/web/assets/BrowserWatchModal-B3UjvY82.js +1 -0
  10. package/dist/web/assets/UsageHistoryModal-DxNI8gR6.js +1 -0
  11. package/dist/web/assets/index-BIANlQ9A.css +1 -0
  12. package/dist/web/assets/index-OLDoLpOW.js +299 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +40 -0
  16. package/src/server/account-projects.mjs +11 -2
  17. package/src/server/args.mjs +12 -2
  18. package/src/server/auto-update.mjs +14 -1
  19. package/src/server/best-of-nostalgia.mjs +2 -1
  20. package/src/server/block-notify.mjs +114 -11
  21. package/src/server/browser-history.mjs +81 -4
  22. package/src/server/browser-presence.mjs +98 -2
  23. package/src/server/browser-react.mjs +39 -4
  24. package/src/server/browser-watch-seen.mjs +35 -1
  25. package/src/server/browser-watch.mjs +105 -18
  26. package/src/server/cafe-music-bgm.mjs +2 -1
  27. package/src/server/ccusage-install.mjs +8 -4
  28. package/src/server/ccusage.mjs +3 -1
  29. package/src/server/claude-accounts.mjs +38 -16
  30. package/src/server/codex-dir.mjs +8 -6
  31. package/src/server/codex-quota.mjs +4 -2
  32. package/src/server/codex-usage.mjs +93 -38
  33. package/src/server/codex-watch.mjs +150 -18
  34. package/src/server/cswap-auto-loop.mjs +15 -2
  35. package/src/server/cswap-auto-readers.mjs +10 -4
  36. package/src/server/cswap-auto.mjs +6 -6
  37. package/src/server/cswap-install.mjs +118 -3
  38. package/src/server/deck-prefs.mjs +4 -0
  39. package/src/server/event-pipeline.mjs +3 -0
  40. package/src/server/event-ring.mjs +2 -2
  41. package/src/server/good-life-radio.mjs +2 -1
  42. package/src/server/index.mjs +3 -2
  43. package/src/server/installer.mjs +62 -12
  44. package/src/server/lan-beacon.mjs +6 -4
  45. package/src/server/lan-call.mjs +4 -0
  46. package/src/server/lan-dials.mjs +10 -1
  47. package/src/server/lan-engine.mjs +221 -44
  48. package/src/server/lan-requests.mjs +88 -5
  49. package/src/server/lan-round-record.mjs +21 -4
  50. package/src/server/lan-socket.mjs +105 -15
  51. package/src/server/lan-sync.mjs +18 -4
  52. package/src/server/lifecycle.mjs +21 -2
  53. package/src/server/live-radio-mix.mjs +2 -1
  54. package/src/server/log-replay.mjs +55 -24
  55. package/src/server/login-service.mjs +38 -3
  56. package/src/server/metrics-run.mjs +5 -3
  57. package/src/server/network-sampler.mjs +25 -2
  58. package/src/server/npm-upgrade.mjs +8 -1
  59. package/src/server/npx.mjs +32 -5
  60. package/src/server/process-list.mjs +103 -13
  61. package/src/server/purge-key.mjs +52 -24
  62. package/src/server/request-gates.mjs +47 -14
  63. package/src/server/retire-sound-hook.mjs +56 -9
  64. package/src/server/ring-bounds.mjs +10 -0
  65. package/src/server/running-deck.mjs +72 -2
  66. package/src/server/session-enrichment.mjs +40 -8
  67. package/src/server/stop-deck.mjs +13 -4
  68. package/src/server/system-metrics.mjs +2 -2
  69. package/src/server/transcript-scan.mjs +27 -6
  70. package/dist/web/assets/BrowserWatchModal-ohP7oQCN.js +0 -1
  71. package/dist/web/assets/UsageHistoryModal-CNjMaM59.js +0 -1
  72. package/dist/web/assets/index-DKn7nc15.css +0 -1
  73. package/dist/web/assets/index-P2zWn3Hw.js +0 -299
@@ -11,7 +11,7 @@ import { createReadStream } from "node:fs";
11
11
  // single line of this module runs and takes the whole deck down with it. Caught
12
12
  // by the timezone probe, which spawns a child that imports this file.
13
13
  import zlib from "node:zlib";
14
- import { STOP, walkRolloutDays } from "./codex-dir.mjs";
14
+ import { walkRolloutDays } from "./codex-dir.mjs";
15
15
  import { PRODUCT } from "./brand.mjs";
16
16
  // The shape of a token_count record, read where the translation reads it — see
17
17
  // codex-translate.mjs.
@@ -46,6 +46,10 @@ const CACHE_MS = 60_000;
46
46
  // silent undercount rather than an error. Unbounded calls let that EMFILE back
47
47
  // in through the door the pool does not cover.
48
48
  //
49
+ // The route is a guarded read now (GUARDED_READS in request-gates.mjs), so a
50
+ // page on another site is refused before it gets here. The bounds stay: the
51
+ // deck's own page and any client holding the token still reach this.
52
+ //
49
53
  // The two things between a caller and a scan are the ones quota.mjs established
50
54
  // and codex-quota.mjs adopted in #597, spelled the same way in all three:
51
55
  //
@@ -401,7 +405,14 @@ function parseRolloutTime(filename) {
401
405
  return dt.getTime();
402
406
  }
403
407
 
404
- // List rollout files whose start times fall within the given window.
408
+ // How many rollouts named before the window are statted at once, to learn
409
+ // whether one of them was written to inside it. A stat holds no descriptor
410
+ // past its own call, so this can sit well above MAX_PARALLEL_READS; it is a
411
+ // bound on the fan-out over a history that grows by a file per session.
412
+ const MAX_PARALLEL_STATS = 16;
413
+
414
+ // List rollout files whose sessions started within the given window — and the
415
+ // ones that started before it but were written to inside it.
405
416
  //
406
417
  // The walk over $CODEX_HOME/sessions is shared with the rollout lookup in
407
418
  // codex-enrichment.mjs and the watcher in codex-watch.mjs (codex-dir.mjs) rather
@@ -410,42 +421,72 @@ function parseRolloutTime(filename) {
410
421
  // verbatim copies of the same four nested readdirs over two verbatim copies of
411
422
  // the same CODEX_SESSIONS_DIR — which is how one of them came to resolve a
412
423
  // relative CODEX_HOME differently from the other (#375).
424
+ //
425
+ // THE NAME IS WHEN A SESSION STARTED, NOT WHEN IT LAST SPENT (#1731). `codex
426
+ // resume` appends to the rollout the session started in — codex-enrichment.mjs
427
+ // leans on the same fact — so a session begun twenty days ago and resumed an
428
+ // hour ago has all of this hour's tokens in a file whose name says twenty days.
429
+ // This used to stop there, and to end the walk at the first year too old to
430
+ // hold a name inside the window, so that file was never opened and those
431
+ // tokens counted toward neither window. Now every plain rollout named before
432
+ // the window is statted, and one whose mtime lies inside the window is read
433
+ // with the rest; windowDelta, not the mtime, judges how much of it counts, so a
434
+ // file whose mtime moved without a token being spent — a restore, a sync —
435
+ // contributes nothing. The walk therefore reads the whole tree, one readdir per
436
+ // day directory, which the minute-long floor in front of every scan rations.
437
+ //
438
+ // Plain `.jsonl` only, for that second test. A `.jsonl.zst` is one Codex has
439
+ // already judged cold and rewritten, and the rewrite is itself what moves its
440
+ // mtime: counting the compression as activity would reopen, and decompress,
441
+ // every compressed rollout in the history on the scan after Codex compressed
442
+ // them.
413
443
  async function listRolloutFiles(sinceMs) {
414
444
  const out = [];
445
+ const before = [];
415
446
  const nowMs = Date.now();
416
- // Years arrive newest-first, so the first one that cannot hold a file in the
417
- // window ends the walk: everything after it is older still. The extra day of
418
- // slack covers a session that started just before the window.
419
- //
420
- // It used to also claim to cover "a filename timestamp that is UTC while the
421
- // year directory is local time", which asserted the opposite of what the
422
- // files say — see parseRolloutTime. Both are local now and `getFullYear()`
423
- // here is local too, so the two sides of this comparison finally speak the
424
- // same clock. What the day of slack still earns, beyond the session that
425
- // started just before the window: an ambiguous local time on the day the
426
- // clocks go back happens twice, V8 resolves it to the first of the two, and
427
- // a session started during the second is dated an hour early. That is a
428
- // one-hour error on one or two days a year against a seven-day window,
429
- // where the old bug was an offset-wide error on every day of it.
430
- const oldestYear = new Date(nowMs - sinceMs - 86400000).getFullYear();
431
- await walkRolloutDays(
432
- (dir, files) => {
433
- for (const f of files) {
434
- // Both spellings. A cold rollout is `rollout-….jsonl.zst` and its name
435
- // still carries the timestamp parseRolloutTime reads, so nothing else
436
- // in this function has to know.
437
- if (!f.endsWith(".jsonl") && !f.endsWith(COMPRESSED)) continue;
438
- const t = parseRolloutTime(f);
439
- if (t != null && nowMs - t <= sinceMs) {
440
- out.push({ path: join(dir, f), startMs: t });
441
- }
442
- }
443
- },
444
- { onYear: y => (parseInt(y, 10) < oldestYear ? STOP : undefined) },
445
- );
447
+ await walkRolloutDays((dir, files) => {
448
+ for (const f of files) {
449
+ // Both spellings. A cold rollout is `rollout-….jsonl.zst` and its name
450
+ // still carries the timestamp parseRolloutTime reads, so nothing else
451
+ // in this function has to know.
452
+ if (!f.endsWith(".jsonl") && !f.endsWith(COMPRESSED)) continue;
453
+ const t = parseRolloutTime(f);
454
+ if (t == null) continue;
455
+ if (nowMs - t <= sinceMs) out.push({ path: join(dir, f), startMs: t });
456
+ else if (f.endsWith(".jsonl")) before.push({ path: join(dir, f), startMs: t });
457
+ }
458
+ });
459
+ await forEachLimited(before, MAX_PARALLEL_STATS, async (file) => {
460
+ let st;
461
+ try { st = await stat(file.path); } catch { return; }
462
+ if (nowMs - st.mtimeMs <= sinceMs) out.push({ ...file, mtimeMs: st.mtimeMs, size: st.size });
463
+ });
446
464
  return out;
447
465
  }
448
466
 
467
+ // What was read out of each rollout named before the window and written to
468
+ // inside it, the last time it was read, by path: the file's mtime and size then,
469
+ // and the part of its series a later scan can still need. A scan re-reads such a file only when either has
470
+ // moved. Without it, a restore or a sync that brushed every old rollout's mtime
471
+ // would have every one of them read in full once a minute for a week; with it,
472
+ // each is read once. Rebuilt on every scan from the files that scan kept, so a
473
+ // file that is deleted or drifts out of the window leaves with it.
474
+ let _beforeWindowReads = new Map();
475
+
476
+ // The tail of `series` that windowDelta can still need for any window opening
477
+ // at `startMs` or later: from the last snapshot before `startMs` onward. Every
478
+ // snapshot ahead of that one is older still, and a later window's baseline is
479
+ // that one or a snapshot after it — so what is dropped can never be the answer.
480
+ function seriesSince(series, startMs) {
481
+ let from = 0;
482
+ for (let i = 0; i < series.length; i++) {
483
+ const ts = series[i].ts;
484
+ if (ts != null && ts < startMs) from = i;
485
+ else if (ts != null) break;
486
+ }
487
+ return series.slice(from);
488
+ }
489
+
449
490
  function emptyWindow() {
450
491
  return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
451
492
  }
@@ -484,22 +525,36 @@ async function scanCodexUsage(now) {
484
525
  };
485
526
 
486
527
  try {
487
- // Files whose session *started* within 7d. A long session that started up
488
- // to 7d ago but is still active is captured here too, and its share of the
489
- // 5h window is recovered via the cumulative delta below — so bucketing no
490
- // longer drops active-but-old sessions or over-counts the pre-window tail.
528
+ // Files whose session *started* within 7d, and files that started before
529
+ // it and were written to inside it (#1731). A long or resumed session is
530
+ // captured either way, and its share of each window is recovered via the
531
+ // cumulative delta below — so bucketing no longer drops active-but-old
532
+ // sessions or over-counts the pre-window tail.
491
533
  const files = await listRolloutFiles(WINDOW_7D_MS);
534
+ const kept = new Map();
492
535
 
493
536
  // Bounded fan-out: a week of rollouts is an open-ended list, and opening
494
537
  // every one of them at once also risked EMFILE, which readTokenSeries
495
538
  // swallows into a silent undercount.
496
- await forEachLimited(files, MAX_PARALLEL_READS, async ({ path }) => {
497
- const series = await readTokenSeries(path);
539
+ await forEachLimited(files, MAX_PARALLEL_READS, async ({ path, mtimeMs, size }) => {
540
+ let series;
541
+ if (mtimeMs == null) series = await readTokenSeries(path);
542
+ else {
543
+ const was = _beforeWindowReads.get(path);
544
+ series = was && was.mtimeMs === mtimeMs && was.size === size
545
+ ? was.series
546
+ : await readTokenSeries(path).then(s => (s ? seriesSince(s, start7d) : null));
547
+ // A null is not kept: readTokenSeries answers null for a read that
548
+ // failed as well as for a file with nothing in it, and keeping the first
549
+ // would carry one bad read forward for as long as the file sat still.
550
+ if (series) kept.set(path, { mtimeMs, size, series });
551
+ }
498
552
  if (!series) return;
499
553
  // Same series feeds both windows; baseline differs per window start.
500
554
  addTo(w5h, windowDelta(series, start5h));
501
555
  addTo(w7d, windowDelta(series, start7d));
502
556
  });
557
+ _beforeWindowReads = kept;
503
558
  } catch (err) {
504
559
  console.error(`${PRODUCT} codex-usage: scan failed:`, err?.message ?? err);
505
560
  const result = { ok: false, fetchedAt: now };
@@ -67,11 +67,16 @@ import { pushEvent } from "./event-sink.mjs";
67
67
  // broadcasts them exactly like a hook event, and persists them when this deck is
68
68
  // the one elected to log this rollout — see writesCodexLog. This path is
69
69
  // entirely additive — the Claude hook flow is untouched.
70
- // path -> { offset, sid, cwd, skip, sawBeginning, rootOpened, seenAt }
70
+ // path -> { offset, sid, cwd, skip, sawBeginning, rootOpened, seenAt, mtimeMs }
71
71
  const codexFileState = new Map();
72
- // How long a rollout's tail cursor is kept after it stops showing up in the
73
- // listing. The listing covers two day-directories, so anything missing from it
74
- // is at least a day old and will never be appended to again.
72
+ // How long a rollout's tail cursor is kept once nothing has moved it: not
73
+ // listed, and its mtime not changing. Leaving the listing is NOT the end of a
74
+ // rollout (#1730). `codex resume` appends to the file the session started in,
75
+ // in whatever day folder that was, and a session that runs across two later
76
+ // calendar days is still writing when its folder drops out of the newest two.
77
+ // So a cursor outside the listing is statted every tick and kept while its
78
+ // mtime moves, and one that is swept hands its offset to codexOlderRollouts
79
+ // below, which is how a rollout that falls silent and then resumes is found.
75
80
  const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
76
81
  // Every rollout path this PROCESS has ever held a cursor for, which the TTL
77
82
  // sweep deliberately does not clear — that is the whole of its job.
@@ -100,6 +105,49 @@ function rememberCodexPath(path) {
100
105
  codexSeenEver.delete(codexSeenEver.values().next().value);
101
106
  }
102
107
  }
108
+ // Every rollout outside the newest two day directories that holds no cursor,
109
+ // by path, with the size it had when last looked at — null before the first
110
+ // look. The listing below reads two day directories a tick and must stay that
111
+ // cheap; this is how the rest of the tree is watched without walking it every
112
+ // tick (#1730).
113
+ //
114
+ // The whole tree is walked for it once at boot and again once per
115
+ // CODEX_STATE_TTL_MS, a readdir per day directory, which adds the paths it has
116
+ // not got. Each tick then stats at most CODEX_OLDER_STATS_PER_TICK of them, in
117
+ // turn, and a rollout that has GROWN since its last look is opened joined late
118
+ // from the size it had then: no SessionStart, since the deck did not watch the
119
+ // session begin, and nothing before that size, which the deck had either
120
+ // already drawn or had never been going to. A path whose first look is still
121
+ // to come has no size to grow from, so what is appended before that look is
122
+ // skipped, the trade the boot catalog already makes. A directory's mtime would
123
+ // be cheaper to watch and says nothing here: an append to a file leaves the
124
+ // directory holding it as it was.
125
+ //
126
+ // A cursor the sweep drops outside the listing hands its offset over, so a
127
+ // session that sat idle past the window resumes where the deck stopped reading.
128
+ // A path that cannot be statted leaves; a path the listing shows is the
129
+ // listing's.
130
+ const codexOlderRollouts = new Map();
131
+ let codexOlderTurn = null;
132
+ let codexOlderWalkedAt = null;
133
+ // About three seconds of stats per thousand old rollouts on a slow disk, spread
134
+ // over ticks: a resumed session in a history of N rollouts is found within
135
+ // ceil(N / 128) ticks, which is ~12s for a thousand and ~2 minutes for ten.
136
+ const CODEX_OLDER_STATS_PER_TICK = 128;
137
+
138
+ /** The next old rollout due a look, round-robin; null when there are none. The
139
+ * Map's own iterator is the turn: it survives deletions and ends after the
140
+ * newest key, and a fresh one starts the round again. */
141
+ function nextOlderRollout() {
142
+ for (let pass = 0; pass < 2; pass++) {
143
+ codexOlderTurn ??= codexOlderRollouts.keys();
144
+ const next = codexOlderTurn.next();
145
+ if (!next.done) return next.value;
146
+ codexOlderTurn = null;
147
+ }
148
+ return null;
149
+ }
150
+
103
151
  // The scan in flight, if any, as its promise. A tick that lands while one is
104
152
  // still reading joins it rather than starting a second over the same cursors,
105
153
  // and scanCodexNow can wait it out.
@@ -109,17 +157,21 @@ let codexWorkspace = "";
109
157
 
110
158
  // List rollout files from the newest 2 day-directories. New sessions always
111
159
  // land in today's dir, so this captures live activity without scanning years
112
- // of history every tick.
113
- async function listRecentCodexRollouts() {
160
+ // of history every tick. With `all`, the rest of the tree is listed too, as
161
+ // `older` — the walk codexOlderRollouts is filled from.
162
+ async function listRecentCodexRollouts(all = false) {
114
163
  const out = [];
164
+ const older = [];
115
165
  let dayDirs = 0;
116
166
  await walkRolloutDays((dir, files) => {
117
- for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
167
+ const into = dayDirs < 2 ? out : older;
168
+ for (const f of files) if (f.endsWith(".jsonl")) into.push(join(dir, f));
118
169
  // Two day-directories deep is the whole point of this listing: it runs every
119
- // tick, and anything older than that is a session no process will append to.
120
- if (++dayDirs >= 2) return STOP;
170
+ // tick. Anything older is still watched, by codexOlderRollouts, at a
171
+ // bounded number of stats a tick rather than a readdir of every day.
172
+ if (++dayDirs >= 2 && !all) return STOP;
121
173
  });
122
- return out;
174
+ return { files: out, older };
123
175
  }
124
176
 
125
177
  /** Which of the two ways a rollout can fail to say whose it is has been printed
@@ -359,18 +411,70 @@ function emitCodexLines(state, consume, persist) {
359
411
  }
360
412
 
361
413
  /**
362
- * Rollout files fall out of the newest-2-days listing and never come back,
363
- * but their tail cursors used to live as long as the process did. Expire by
364
- * "not seen for a while" rather than "absent from this listing": a single
365
- * unreadable directory mid-scan would otherwise drop a live file's cursor,
366
- * and re-adding it at offset 0 replays that entire rollout as fresh events.
414
+ * Rollout files fall out of the newest-2-days listing, but their tail cursors
415
+ * used to live as long as the process did. Expire by "not seen for a while"
416
+ * rather than "absent from this listing": a single unreadable directory
417
+ * mid-scan would otherwise drop a live file's cursor, and re-adding it at
418
+ * offset 0 replays that entire rollout as fresh events. "Seen" is listed, or
419
+ * its mtime moved (#1730).
420
+ *
421
+ * What a swept cursor had read is not forgotten with it: its offset goes to
422
+ * codexOlderRollouts, so a rollout written to again — a `codex resume` of it,
423
+ * or a session that sat idle past the window — is read from where this deck
424
+ * stopped, joined late. The listing takes the entry back if the path is listed.
367
425
  */
368
426
  function sweepCodexCursors(now) {
369
427
  for (const [p, s] of codexFileState) {
370
- if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
428
+ if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) {
429
+ codexFileState.delete(p);
430
+ codexOlderRollouts.set(p, s.offset);
431
+ }
371
432
  }
372
433
  }
373
434
 
435
+ /**
436
+ * Take the old rollouts a whole-tree walk found into codexOlderRollouts, each
437
+ * without a size until its first look. A path already held keeps the size it
438
+ * has: that is the one fact about it the walk cannot supply. Nothing is removed
439
+ * here — a walk that could not read a directory answers as though it were
440
+ * empty — so a path leaves only when it cannot be statted.
441
+ */
442
+ function catalogOlderRollouts(older) {
443
+ for (const p of older) {
444
+ if (!codexOlderRollouts.has(p) && !codexFileState.has(p)) codexOlderRollouts.set(p, null);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Look at up to CODEX_OLDER_STATS_PER_TICK old rollouts, and return a cursor
450
+ * for each that has grown since its last look, opened joined late from the
451
+ * size it had then. See codexOlderRollouts.
452
+ */
453
+ async function openGrownOlderRollouts(now) {
454
+ const opened = [];
455
+ const due = Math.min(CODEX_OLDER_STATS_PER_TICK, codexOlderRollouts.size);
456
+ for (let i = 0; i < due; i++) {
457
+ const path = nextOlderRollout();
458
+ if (path == null) break;
459
+ const was = codexOlderRollouts.get(path);
460
+ let st;
461
+ try { st = await stat(path); } catch { codexOlderRollouts.delete(path); continue; }
462
+ if (was == null || st.size <= was) { codexOlderRollouts.set(path, st.size); continue; }
463
+ const header = await readCodexHeader(path);
464
+ if (!header || !header.sid) continue; // the size it grew from is kept; next round
465
+ codexOlderRollouts.delete(path);
466
+ const skip = !codexCwdInWorkspace(header.cwd, codexWorkspace);
467
+ // Never `sawBeginning`, whatever this process read of the file before: the
468
+ // lines before `was` are ones this deck has either drawn already or joined
469
+ // too late for, and either way a SessionStart now would be a false one.
470
+ const state = { offset: skip ? st.size : was, sid: header.sid, cwd: header.cwd, skip, sawBeginning: false, rootOpened: false, seenAt: now, mtimeMs: st.mtimeMs };
471
+ codexFileState.set(path, state);
472
+ rememberCodexPath(path);
473
+ opened.push({ path, st, state });
474
+ }
475
+ return opened;
476
+ }
477
+
374
478
  function codexScanOnce(firstRun) {
375
479
  codexScan ??= scanRollouts(firstRun).finally(() => { codexScan = null; });
376
480
  return codexScan;
@@ -399,8 +503,20 @@ async function scanRollouts(firstRun) {
399
503
  // listing for the answer to a question nothing is asking.
400
504
  let decksRead = null;
401
505
  const liveDecks = () => (decksRead ??= readLiveDecks());
402
- const files = await listRecentCodexRollouts();
506
+ // The whole tree at boot and once per CODEX_STATE_TTL_MS after, for
507
+ // codexOlderRollouts; the newest two day directories on every other tick.
508
+ // `null` rather than 0 for "never", so a clock that starts near the epoch,
509
+ // or a faked one, cannot make boot look recent.
510
+ const walkAll = codexOlderWalkedAt == null || Math.abs(now - codexOlderWalkedAt) >= CODEX_STATE_TTL_MS;
511
+ const { files, older } = await listRecentCodexRollouts(walkAll);
512
+ if (walkAll) { codexOlderWalkedAt = now; catalogOlderRollouts(older); }
513
+ const listed = new Set(files);
514
+ // Every rollout this tick has a reason to read, with its stat and cursor:
515
+ // the listed ones, then the cursors outside the listing, then the old
516
+ // rollouts found grown. One pass reads them all below.
517
+ const due = [];
403
518
  for (const path of files) {
519
+ codexOlderRollouts.delete(path); // listed, so the listing's
404
520
  let st;
405
521
  try { st = await stat(path); } catch { continue; }
406
522
  let state = codexFileState.get(path);
@@ -410,7 +526,23 @@ async function scanRollouts(firstRun) {
410
526
  state = await openCodexCursor(path, st, now, firstRun);
411
527
  if (!state) continue;
412
528
  }
529
+ state.mtimeMs = st.mtimeMs;
530
+ due.push({ path, st, state });
531
+ }
532
+ // A cursor whose file has left the listing is still read, and kept for as
533
+ // long as its mtime keeps moving (#1730). One that cannot be statted is left
534
+ // for the sweep, as a listed file that cannot be is.
535
+ for (const [path, state] of codexFileState) {
536
+ if (listed.has(path)) continue;
537
+ let st;
538
+ try { st = await stat(path); } catch { continue; }
539
+ if (st.mtimeMs !== state.mtimeMs) state.seenAt = now;
540
+ state.mtimeMs = st.mtimeMs;
541
+ due.push({ path, st, state });
542
+ }
543
+ due.push(...await openGrownOlderRollouts(now));
413
544
 
545
+ for (const { path, st, state } of due) {
414
546
  if (state.skip) { state.offset = st.size; continue; }
415
547
  // A cursor sitting PAST the end of the file is a different thing from
416
548
  // "nothing new", and the two used to collapse into one `continue` — where
@@ -480,7 +612,7 @@ export function startCodexWatcher(workspace) {
480
612
  // starts — and rollout tailing is the only Codex capture path there is, so
481
613
  // bailing out here meant a fresh install had to restart the deck before any
482
614
  // Codex agent ever showed up, while the banner claimed to be watching.
483
- // listRecentCodexRollouts returns [] while the directory is missing, so the
615
+ // listRecentCodexRollouts lists nothing while the directory is missing, so the
484
616
  // poll below is a cheap no-op that doubles as the existence re-check. A
485
617
  // filesystem watch is no help: fs.watch on a missing path throws, and
486
618
  // watching the parent recursively is macOS/Windows-only.
@@ -76,10 +76,23 @@ function summarise(stdout) {
76
76
  * says as much about its own guard: the next interval is at most fifteen
77
77
  * seconds away and the work is idempotent by design. Here it is not even
78
78
  * skipped, only queued.
79
+ *
80
+ * AND QUEUED IS A WAIT (#1797). The chain is first-in first-out with no
81
+ * timeout, so a tick can stand behind a sixty-second `cswap add` — and the
82
+ * switch runTick checked before joining the queue may have been turned off by
83
+ * the time the lock is its. Both of runTick's questions are asked again as the
84
+ * first thing the lock runs, before `cswapBin()` and before anything is
85
+ * spawned, and a tick that finds either answer changed comes back as the same
86
+ * skip runTick would have recorded. The external-engine reading is the shared
87
+ * ten-second one, so a tick that did not wait pays nothing for it here.
79
88
  */
80
89
  async function runAutoTick() {
81
- const r = await withStoreLock(async () =>
82
- run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS }));
90
+ const r = await withStoreLock(async () => {
91
+ if (!_enabled) return { skipped: "disabled" };
92
+ if (await externalAutoRunning()) return { skipped: "external-engine" };
93
+ return run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
94
+ });
95
+ if (r.skipped) return { event: "skipped", reason: r.skipped };
83
96
  // A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
84
97
  // 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
85
98
  // is false for a tick that emitted its `{"event":"poll"}` line and then
@@ -8,7 +8,7 @@
8
8
  // engine before every tick, and drops both readings when it writes a setting;
9
9
  // it re-exports all five names, which is where the suite reaches them.
10
10
  import { run } from "./exec.mjs";
11
- import { cswapBin } from "./cswap-install.mjs";
11
+ import { cswapBin, cswapRefused } from "./cswap-install.mjs";
12
12
 
13
13
  // ── one reading at a time ──────────────────────────────────────────────────
14
14
 
@@ -34,9 +34,11 @@ import { cswapBin } from "./cswap-install.mjs";
34
34
  *
35
35
  * Two callers reach these without an attacker anywhere: AccountsPanel polls the
36
36
  * route every 15s per open tab, and runTick asks externalAutoRunning() again
37
- * before every tick. And it is a GET, so it passes isTrustedRead for any local
37
+ * before every tick. And it is a GET, so it passed isTrustedRead for any local
38
38
  * client that sends neither Origin nor Sec-Fetch-Site — curl, a shell script, a
39
- * sandboxed agent.
39
+ * sandboxed agent. It is a guarded read now (GUARDED_READS in
40
+ * request-gates.mjs), which refuses those callers unless they hold the token;
41
+ * the guard below still bounds the ones that pass.
40
42
  *
41
43
  * The fix is #544's, at the route that sweep did not reach: a minimum gap plus
42
44
  * one shared in-flight promise per reader. There is no MAX_OUTSTANDING beside it
@@ -165,7 +167,11 @@ export function readCswapConfig() {
165
167
  }
166
168
 
167
169
  async function readCswapConfigNow() {
168
- const r = await run(await cswapBin(), ["config"]);
170
+ const bin = await cswapBin();
171
+ // Not a copy the deck refused (#1799): the panel's settings are no reason to
172
+ // run it, and null is what a tool that cannot be asked already answers.
173
+ if (cswapRefused()) return null;
174
+ const r = await run(bin, ["config"]);
169
175
  if (!r.ok) return null;
170
176
  const out = {};
171
177
  for (const line of r.stdout.split("\n")) {
@@ -159,12 +159,12 @@ export async function autoStatus() {
159
159
  * goes through one mutex.
160
160
  *
161
161
  * And the missing invalidation is what made the press look like a no-op. The
162
- * panel polls every 15s and each poll stamps `_lastReadAt`, so that stamp is
163
- * never more than 15s old. The press then reloads with ?refresh=1, and a
164
- * forced read needs `now - _lastReadAt >= FORCE_POLL_MS` (60s) — a quantity
165
- * that can never be reached while the panel is open. The read was refused
166
- * every time and `heldReading` handed back the pre-press roster: the chip did
167
- * not move, the button still said "hold out", and nothing had failed.
162
+ * press reloads with ?refresh=1 milliseconds after the read before it, and a
163
+ * forced read needs `now - _lastReadAt` past its floor — then FORCE_POLL_MS
164
+ * (60s), which the panel's 15s poll never let it reach; the cache's 5s since
165
+ * #1798, which the reload after a press still cannot. The read was refused
166
+ * and `heldReading` handed back the pre-press roster: the chip did not move,
167
+ * the button still said "hold out", and nothing had failed.
168
168
  * invalidateClaudeAccountsCache sets `_lastReadAt = 0`, which is what makes
169
169
  * the following forced read real work — the invalidator's own docblock says
170
170
  * so, and calls a floor that answers ?refresh=1 with the stale roster "the