ccdeck 3.32.2 → 3.33.1
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 +14 -12
- package/bin/agent-dag.js +6 -0
- package/bin/cli/help.js +2 -0
- package/bin/cli/one-shot.js +32 -13
- package/bin/cli/second-start.js +1 -0
- package/bin/cli/startup.js +34 -0
- package/bin/cli/uninstall.js +47 -2
- package/bin/deck.js +12 -1
- package/dist/web/assets/BrowserWatchModal-BTNOQdp5.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CIDpgHTW.js +1 -0
- package/dist/web/assets/index-BIANlQ9A.css +1 -0
- package/dist/web/assets/index-D7ddxIpC.js +299 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +36 -1
- package/src/server/account-projects.mjs +11 -2
- package/src/server/args.mjs +12 -2
- package/src/server/auto-update.mjs +14 -1
- package/src/server/best-of-nostalgia.mjs +2 -1
- package/src/server/block-notify.mjs +114 -11
- package/src/server/browser-history.mjs +81 -4
- package/src/server/browser-presence.mjs +98 -2
- package/src/server/browser-react.mjs +39 -4
- package/src/server/browser-watch-seen.mjs +35 -1
- package/src/server/browser-watch.mjs +105 -18
- package/src/server/cafe-music-bgm.mjs +2 -1
- package/src/server/ccusage-install.mjs +8 -4
- package/src/server/ccusage.mjs +3 -1
- package/src/server/claude-accounts.mjs +38 -16
- package/src/server/codex-dir.mjs +8 -6
- package/src/server/codex-quota.mjs +4 -2
- package/src/server/codex-usage.mjs +93 -38
- package/src/server/codex-watch.mjs +150 -18
- package/src/server/cswap-auto-loop.mjs +15 -2
- package/src/server/cswap-auto-readers.mjs +10 -4
- package/src/server/cswap-auto.mjs +6 -6
- package/src/server/cswap-install.mjs +118 -3
- package/src/server/deck-prefs.mjs +4 -0
- package/src/server/event-pipeline.mjs +3 -0
- package/src/server/event-ring.mjs +2 -2
- package/src/server/good-life-radio.mjs +2 -1
- package/src/server/index.mjs +3 -2
- package/src/server/installer.mjs +62 -12
- package/src/server/lan-beacon.mjs +6 -4
- package/src/server/lan-call.mjs +4 -0
- package/src/server/lan-dials.mjs +10 -1
- package/src/server/lan-engine.mjs +221 -44
- package/src/server/lan-requests.mjs +88 -5
- package/src/server/lan-round-record.mjs +21 -4
- package/src/server/lan-socket.mjs +105 -15
- package/src/server/lan-sync.mjs +18 -4
- package/src/server/lifecycle.mjs +21 -2
- package/src/server/live-radio-mix.mjs +2 -1
- package/src/server/log-replay.mjs +55 -24
- package/src/server/login-service.mjs +38 -3
- package/src/server/metrics-run.mjs +5 -3
- package/src/server/network-sampler.mjs +25 -2
- package/src/server/npm-upgrade.mjs +8 -1
- package/src/server/npx.mjs +32 -5
- package/src/server/process-list.mjs +103 -13
- package/src/server/purge-key.mjs +52 -24
- package/src/server/request-gates.mjs +47 -14
- package/src/server/retire-sound-hook.mjs +56 -9
- package/src/server/ring-bounds.mjs +10 -0
- package/src/server/running-deck.mjs +72 -2
- package/src/server/session-enrichment.mjs +40 -8
- package/src/server/stop-deck.mjs +13 -4
- package/src/server/system-metrics.mjs +2 -2
- package/src/server/transcript-scan.mjs +27 -6
- package/dist/web/assets/BrowserWatchModal-bx_k4o-J.js +0 -1
- package/dist/web/assets/UsageHistoryModal-Do8S0UPG.js +0 -1
- package/dist/web/assets/index-DKn7nc15.css +0 -1
- package/dist/web/assets/index-hGO2DTdi.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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
// longer drops active-but-old
|
|
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
|
-
|
|
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
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
|
|
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
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|