ccdeck 3.29.11 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// POST /api/clear, the deck's one irreversible action: the ring emptied, the
|
|
2
|
+
// log and its archive emptied when this deck is the one writing them, and the
|
|
3
|
+
// `__clear` marker that tells every open page to forget what it drew.
|
|
4
|
+
//
|
|
5
|
+
// And GET /api/clear, the question the dialog asks before the press, which is
|
|
6
|
+
// answered from the same logSharing the press is gated on.
|
|
7
|
+
//
|
|
8
|
+
// Both lived in src/server/index.mjs — the press after the SSE resume, the
|
|
9
|
+
// question in the route table. The press empties the ring through
|
|
10
|
+
// event-ring.mjs, drops the output watch's and the enrichment's emit gates, and
|
|
11
|
+
// sends the marker through pushEvent. Both bodies are unchanged.
|
|
12
|
+
import { PRODUCT } from "./brand.mjs";
|
|
13
|
+
import { send } from "./http-io.mjs";
|
|
14
|
+
// The ring the press empties — see event-ring.mjs.
|
|
15
|
+
import { clearEventBuffer } from "./event-ring.mjs";
|
|
16
|
+
// The one door every event comes through, the marker's included — see
|
|
17
|
+
// event-pipeline.mjs.
|
|
18
|
+
import { pushEvent } from "./event-pipeline.mjs";
|
|
19
|
+
// Which deck owns the log, and so may empty it — see event-log.mjs.
|
|
20
|
+
import { logSharing } from "./event-log.mjs";
|
|
21
|
+
import { emptyLog } from "./log-writer.mjs";
|
|
22
|
+
import { outputWatch } from "./session-tracking.mjs";
|
|
23
|
+
import { clearEnrichmentGates } from "./session-enrichment.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* GET /api/clear — what a POST to this path would do, and to whose log.
|
|
27
|
+
*
|
|
28
|
+
* Asked by the confirmation dialog as it opens, on demand rather than on a
|
|
29
|
+
* timer, for the reason /api/system/processes is: the answer costs a directory
|
|
30
|
+
* read, changes only when a deck starts or stops, and matters at exactly one
|
|
31
|
+
* moment. See logSharing.
|
|
32
|
+
*/
|
|
33
|
+
export function handleClearPreview(res) {
|
|
34
|
+
return logSharing().then(s => send(res, 200, {
|
|
35
|
+
ok: true, path: s.path, decks: s.decks, mine: s.mine,
|
|
36
|
+
owner: s.owner ? { port: s.owner.port } : null,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The deck's one irreversible action: empty the ring, and empty the log — both
|
|
42
|
+
* of its generations, the live file and the archive rotation leaves beside it —
|
|
43
|
+
* but only the log this deck is the one writing.
|
|
44
|
+
*
|
|
45
|
+
* The gate is the whole of #698. `truncate(persistPath, 0)` ran from whichever
|
|
46
|
+
* deck was asked, and `persistPath` is one file several decks share by default,
|
|
47
|
+
* so Clear on a deck scoped to a single tree deleted the machine-wide deck's
|
|
48
|
+
* entire history while its canvas showed no change at all. Ownership is
|
|
49
|
+
* electWriters, the election that already decides which of those decks appends a
|
|
50
|
+
* line, so nothing new gets to disagree with it: the deck that fills the file is
|
|
51
|
+
* the deck that may empty it, and a deck that writes nothing to it cannot
|
|
52
|
+
* destroy it. See logSharing.
|
|
53
|
+
*
|
|
54
|
+
* A deck that does not own the log still clears its own canvas — that is what
|
|
55
|
+
* the user pressed, and the ring is this deck's alone — and says which deck's
|
|
56
|
+
* file it declined to touch, so the answer is a fact the UI can show rather than
|
|
57
|
+
* a silent partial success. The dialog asked `GET /api/clear` before the press
|
|
58
|
+
* and has already said the same thing in words.
|
|
59
|
+
*
|
|
60
|
+
* The `__clear` marker is broadcast and NOT persisted. It never belonged on
|
|
61
|
+
* disk: replaying an empty log and then a marker that empties it produces the
|
|
62
|
+
* same empty state, and appending it was how a deck that writes nothing else to
|
|
63
|
+
* the shared file still left 134 bytes in it — the reproduction's whole
|
|
64
|
+
* remainder. `{ persist: false }` is the same flag the hook sets on the decks it
|
|
65
|
+
* did not elect.
|
|
66
|
+
*/
|
|
67
|
+
export async function handleClear(res) {
|
|
68
|
+
const sharing = await logSharing();
|
|
69
|
+
const mineToEmpty = Boolean(sharing.path && sharing.mine);
|
|
70
|
+
// THE PRESS IS ONE SYNCHRONOUS MOMENT, from the ring being emptied to the
|
|
71
|
+
// marker being pushed, and the log's turn on the append queue is taken
|
|
72
|
+
// inside it. Nothing from here awaits until the marker is out, so no event
|
|
73
|
+
// can be pushed in the middle: every event is on one side of the press, and
|
|
74
|
+
// it is the same side for the board, the ring and the file.
|
|
75
|
+
//
|
|
76
|
+
// * Pushed before it: out of the ring by clearEventBuffer, numbered below
|
|
77
|
+
// the marker so the reducer forgets it, and queued before the truncate —
|
|
78
|
+
// written, then erased.
|
|
79
|
+
// * Pushed after it: numbered above the marker so the board keeps it, and
|
|
80
|
+
// queued AFTER the truncate, so its write cannot begin until the file has
|
|
81
|
+
// been emptied. That is why a Clear cannot take an event posted after it
|
|
82
|
+
// along with it: the chain in log-writer.mjs is the only way this process
|
|
83
|
+
// writes the log, and it starts each step only when the one before it has
|
|
84
|
+
// settled.
|
|
85
|
+
//
|
|
86
|
+
// Both halves were broken in turn. #1005: the truncate consulted the queue in
|
|
87
|
+
// neither direction, so the queue drained PRE-CLEAR lines into the freshly
|
|
88
|
+
// emptied file — 564 bytes and three events of a cleared session, 2.5s after
|
|
89
|
+
// a Clear that answered `{"log":"cleared"}`. Its fix waited for the queue and
|
|
90
|
+
// then truncated beside it, but the wait was for the queue as it stood when
|
|
91
|
+
// the wait began, and the session went on posting through it: MEASURED
|
|
92
|
+
// (#1130), five runs, 4, 5, 3, 3 and 2 events the Clear had taken off the
|
|
93
|
+
// board were in the file a restart replays. See emptyLog.
|
|
94
|
+
//
|
|
95
|
+
// This is #698's residue by a different route, and it survives the ownership
|
|
96
|
+
// gate that fixed #698 precisely because it happens on the deck that DOES own
|
|
97
|
+
// the file.
|
|
98
|
+
//
|
|
99
|
+
// Not `events.length = 0`: the ring is measured by a running total now, and
|
|
100
|
+
// emptying the array without the total leaves a debt that never clears. See
|
|
101
|
+
// clearEventBuffer.
|
|
102
|
+
clearEventBuffer();
|
|
103
|
+
// THE ARCHIVE GOES WITH IT. Since #1062 the replay reads events.jsonl.1
|
|
104
|
+
// whenever the live log cannot fill the ring, and a live log a Clear has just
|
|
105
|
+
// emptied never can — so a Clear on a log that had rotated once came back,
|
|
106
|
+
// whole, at the next boot. MEASURED (#1130): three events in the archive and
|
|
107
|
+
// one in the live log, `/api/clear` answering `log: "cleared"` over a live
|
|
108
|
+
// file of 0 bytes, and replayLog on the same path returning the three
|
|
109
|
+
// archived sessions.
|
|
110
|
+
//
|
|
111
|
+
// Removed, rather than fenced off by a `__clear` line the replay stops at,
|
|
112
|
+
// and the confirmation is why: it tells the user the history is gone and
|
|
113
|
+
// cannot be undone, and a marker would leave a whole rotated generation of it
|
|
114
|
+
// on disk behind a line asking readers not to look — readable by anything
|
|
115
|
+
// else that opens the file. It would also put back on disk the line #698
|
|
116
|
+
// took off it on purpose, and it would need a stopping rule the forwards
|
|
117
|
+
// replay does not have: that branch reads the archive FIRST, so it would push
|
|
118
|
+
// the cleared generation into the ring before it ever met the line saying to
|
|
119
|
+
// discard it. The ownership gate is the truncate's, and it covers the archive
|
|
120
|
+
// exactly: only the deck that writes a log may rotate it (#1062), so only
|
|
121
|
+
// that deck ever made its archive.
|
|
122
|
+
//
|
|
123
|
+
// Nothing else of the log's bookkeeping is reset, deliberately.
|
|
124
|
+
// `failedLines` / `failedChars` count what the DISK refused since this deck
|
|
125
|
+
// started, which a Clear does not change — `/api/health` documents them that
|
|
126
|
+
// way, and zeroing them would hide a volume that is still failing — and an
|
|
127
|
+
// open failure episode ends only on a line that lands, the one evidence that
|
|
128
|
+
// the condition is over, which a Clear is not. The rotation's byte count is
|
|
129
|
+
// a trigger for a `stat`, not a size: over-counting after a truncate costs at
|
|
130
|
+
// most one early look, which finds a small file and resets it. And a
|
|
131
|
+
// rotation running at the same moment needs nothing from here; emptyLog says
|
|
132
|
+
// why the order inside the turn is enough.
|
|
133
|
+
const emptyOutcome = {};
|
|
134
|
+
const emptied = mineToEmpty ? emptyLog(sharing.path, [sharing.path + ".1"], undefined, emptyOutcome) : null;
|
|
135
|
+
// Drop the caches that gate an emit on "has this changed", because the
|
|
136
|
+
// client is about to forget what they are comparing against: __clear makes
|
|
137
|
+
// the reducer return a fresh state, so every session's name and every
|
|
138
|
+
// subagent's model label go with it. maybeResolveSessionName then computes
|
|
139
|
+
// the same signature, takes its early return, and emits nothing — so the
|
|
140
|
+
// card falls back to cwd/prompt for the rest of that session while the
|
|
141
|
+
// server is sitting on the name.
|
|
142
|
+
//
|
|
143
|
+
// The root model survives without help because pushEvent stamps
|
|
144
|
+
// `raw.model` on every payload; there is no equivalent stamp for the name
|
|
145
|
+
// or for a subagent's model, which is why those two are listed and the
|
|
146
|
+
// rest of the per-session state is not.
|
|
147
|
+
//
|
|
148
|
+
// The rule, for the next cache that gates an emit: anything answering
|
|
149
|
+
// "has this changed" has to appear in BOTH places that mean the client no
|
|
150
|
+
// longer has it — here (clearEnrichmentGates), and in forgetSession
|
|
151
|
+
// (forgetEnrichment).
|
|
152
|
+
outputWatch.clear();
|
|
153
|
+
clearEnrichmentGates();
|
|
154
|
+
pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal", { persist: false });
|
|
155
|
+
// The end of the press. Awaited, where the truncate used to be fired and
|
|
156
|
+
// forgotten, so the answer does not go out before the file has actually
|
|
157
|
+
// reached zero — and bounded by emptyLog, so a disk that does not come back
|
|
158
|
+
// in time still gets an answer, with the turn left on the chain in order.
|
|
159
|
+
if (emptied) await emptied;
|
|
160
|
+
// WHAT THE FILE ACTUALLY DID (#1140). This answered "cleared" for any log this
|
|
161
|
+
// deck owns, whatever the truncate had done: emptyLog keeps the queue moving
|
|
162
|
+
// by swallowing its error, and nothing read it back, so on a read-only volume
|
|
163
|
+
// the page was told the history was gone and the next boot replayed all of
|
|
164
|
+
// it. The canvas IS clear either way — the ring was emptied above — so this
|
|
165
|
+
// stays `ok`, and says which half did not happen, here and in the terminal.
|
|
166
|
+
// A turn still on the chain at the deadline has no error yet and keeps the
|
|
167
|
+
// answer it always had.
|
|
168
|
+
//
|
|
169
|
+
// AND THE ARCHIVE, for the same reason: one that survives a Clear is the whole
|
|
170
|
+
// history again at the next boot, because the replay falls back to it once the
|
|
171
|
+
// live log is empty (#1130). Either half failing makes the answer "failed", and
|
|
172
|
+
// each says which it was.
|
|
173
|
+
const truncateFailed = mineToEmpty && emptyOutcome.error ? emptyOutcome.error : null;
|
|
174
|
+
const archiveFailed = mineToEmpty && emptyOutcome.archiveError ? emptyOutcome.archiveError : null;
|
|
175
|
+
const failed = truncateFailed ?? archiveFailed;
|
|
176
|
+
if (truncateFailed) {
|
|
177
|
+
console.error(`${PRODUCT}: Clear could not empty the event log ${sharing.path} (${truncateFailed.code ?? truncateFailed.message}) — the canvas is clear, but that history will come back at the next boot`);
|
|
178
|
+
}
|
|
179
|
+
if (archiveFailed) {
|
|
180
|
+
console.error(`${PRODUCT}: Clear could not remove the event log's archive ${sharing.path}.1 (${archiveFailed.code ?? archiveFailed.message}) — the canvas is clear, but that history will come back at the next boot`);
|
|
181
|
+
}
|
|
182
|
+
return send(res, 200, {
|
|
183
|
+
ok: true,
|
|
184
|
+
log: !sharing.path ? "none" : sharing.mine ? (failed ? "failed" : "cleared") : "kept",
|
|
185
|
+
...(failed ? { error: failed.code ?? "EIO" } : {}),
|
|
186
|
+
path: sharing.path,
|
|
187
|
+
decks: sharing.decks,
|
|
188
|
+
mine: sharing.mine,
|
|
189
|
+
owner: sharing.owner ? { port: sharing.owner.port } : null,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
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, resolveWriteTarget } from "./
|
|
18
|
+
import { createTemp, renameWithRetry, resolveWriteTarget } from "./atomic-write.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 ??
|
|
@@ -150,13 +150,13 @@ async function readAuthFile() {
|
|
|
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
152
|
* replace it with a regular file, quietly detaching the user's setup. That
|
|
153
|
-
* resolution is
|
|
153
|
+
* resolution is atomic-write.mjs's resolveWriteTarget rather than a bare realpath
|
|
154
154
|
* here, because settings.json needed the identical rule (#673) and a rule
|
|
155
155
|
* written twice is a rule that drifts: the shared one also follows a DANGLING
|
|
156
156
|
* link to the file it names, which a realpath cannot answer at all and which is
|
|
157
157
|
* exactly the state a dotfiles repo is in before its first apply.
|
|
158
158
|
*
|
|
159
|
-
* The temp file comes from
|
|
159
|
+
* The temp file comes from atomic-write.mjs's createTemp, which numbers every
|
|
160
160
|
* write and creates it with O_EXCL, rather than from a name built out of the
|
|
161
161
|
* pid alone. A pid names a process, not a write, so that name was one file
|
|
162
162
|
* shared by every write this process makes, and the open that filled it
|
|
@@ -171,7 +171,7 @@ async function readAuthFile() {
|
|
|
171
171
|
* which is also what makes the 0600 binding from the very first byte rather
|
|
172
172
|
* than whatever the file it inherited happened to allow.
|
|
173
173
|
*
|
|
174
|
-
* The rename is
|
|
174
|
+
* The rename is atomic-write.mjs's retrying one because Windows fails it outright
|
|
175
175
|
* with EPERM/EBUSY while another process holds auth.json open, and a virus
|
|
176
176
|
* scanner, the search indexer or the Codex CLI itself does exactly that for a
|
|
177
177
|
* few milliseconds at a time. Everywhere else that costs a re-download; here
|
package/src/server/codex-dir.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Where OpenAI Codex lives on this machine: its home directory, its rollout
|
|
2
|
-
// tree,
|
|
2
|
+
// tree, the walk over that tree and the session id a rollout's file name
|
|
3
|
+
// carries — the Codex-side mirror of claude-dir.mjs.
|
|
3
4
|
//
|
|
4
5
|
// CODEX_HOME relocates ~/.codex wholesale, exactly as CLAUDE_CONFIG_DIR does on
|
|
5
6
|
// the Claude side, and five modules used to answer the question for themselves
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
// right way round rather than merely the majority.
|
|
37
38
|
import { readdir } from "node:fs/promises";
|
|
38
39
|
import { homedir } from "node:os";
|
|
39
|
-
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
40
|
+
import { basename, join, posix as posixPath, win32 as winPath } from "node:path";
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Absolute path of the Codex home directory: $CODEX_HOME or ~/.codex.
|
|
@@ -125,12 +126,13 @@ export const STOP = Symbol("stop-rollout-walk");
|
|
|
125
126
|
* and the names inside it to `onDay(dir, files)`.
|
|
126
127
|
*
|
|
127
128
|
* Three callers walked this tree with the same four nested readdir-and-continue
|
|
128
|
-
* blocks and differed only in the last few lines: find the file carrying a
|
|
129
|
-
* session id (
|
|
130
|
-
* (
|
|
131
|
-
* timestamp falls inside a rolling window
|
|
132
|
-
* part that has to agree — a deck that tails
|
|
133
|
-
* from another is reporting on a session it
|
|
129
|
+
* blocks and differed only in the last few lines: find the file carrying a
|
|
130
|
+
* given session id (now codex-enrichment.mjs), collect everything in the newest
|
|
131
|
+
* two day directories (codex-watch.mjs, for the watcher), and collect
|
|
132
|
+
* everything whose filename timestamp falls inside a rolling window
|
|
133
|
+
* (codex-usage.mjs). The walk is the part that has to agree — a deck that tails
|
|
134
|
+
* one set of files and reports usage from another is reporting on a session it
|
|
135
|
+
* is not showing.
|
|
134
136
|
*
|
|
135
137
|
* EVERY LEVEL SWALLOWS ITS OWN ERROR, which is deliberate rather than lazy. The
|
|
136
138
|
* tree is written by another process while this one reads it: a day directory
|
|
@@ -169,3 +171,45 @@ export async function walkRolloutDays(onDay, { sessionsDir = CODEX_SESSIONS_DIR,
|
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
}
|
|
174
|
+
|
|
175
|
+
// This lived in codex-watch.mjs, beside the header read it is the fallback for.
|
|
176
|
+
// It is the rule for the names of the files in the tree above, which the
|
|
177
|
+
// rollout lookup in codex-enrichment.mjs matches session ids against too, so it
|
|
178
|
+
// lives here, where either can import it without importing the watcher.
|
|
179
|
+
/**
|
|
180
|
+
* The session id a rollout's own file name carries, or null.
|
|
181
|
+
*
|
|
182
|
+
* Codex names every rollout `rollout-<YYYY-MM-DDTHH-MM-SS>-<uuid>.jsonl` —
|
|
183
|
+
* codex-usage.mjs reads the timestamp half as parseRolloutTime — and the uuid
|
|
184
|
+
* is the id `session_meta.payload.id` states. findCodexRolloutPath already leans
|
|
185
|
+
* on that, matching a session id against file names, so the name is the
|
|
186
|
+
* fallback when the header stops saying it (#996). A compressed `.jsonl.zst`
|
|
187
|
+
* is not matched: this reader never opens one.
|
|
188
|
+
*/
|
|
189
|
+
export function sidFromRolloutName(path) {
|
|
190
|
+
const m = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(basename(String(path ?? "")));
|
|
191
|
+
return m ? m[1] : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The id segment of a rollout's file name, or null for a name not built the way
|
|
196
|
+
* Codex builds one.
|
|
197
|
+
*
|
|
198
|
+
* Codex writes `rollout-<YYYY-MM-DDTHH-MM-SS>-<id>.jsonl`: a fixed-width
|
|
199
|
+
* timestamp, then the session's id, then the extension. So the id is exactly
|
|
200
|
+
* what lies between the timestamp's trailing `-` and `.jsonl`, and a session's
|
|
201
|
+
* rollout is the file whose segment EQUALS its id — the test findCodexRolloutPath
|
|
202
|
+
* in codex-enrichment.mjs finds a rollout by (#1653). It used to ask whether the
|
|
203
|
+
* name contained the id anywhere, which a prefix of another session's id, its
|
|
204
|
+
* last group or a piece of the timestamp all pass.
|
|
205
|
+
*
|
|
206
|
+
* Deliberately not sidFromRolloutName above, which answers a different
|
|
207
|
+
* question: what uuid a name ends in, for a watcher that has no other id to go
|
|
208
|
+
* on. This one is handed the id and asks only whether the name is built around
|
|
209
|
+
* it, so it asks nothing of the id's shape — the lookup never has — and for
|
|
210
|
+
* every name Codex writes the two agree.
|
|
211
|
+
*/
|
|
212
|
+
export function rolloutNameId(name) {
|
|
213
|
+
const m = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/.exec(String(name ?? ""));
|
|
214
|
+
return m ? m[1] : null;
|
|
215
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// What the deck learns about a Codex session that its hook payloads never say:
|
|
2
|
+
// the token spend, the model and the context window, read off the session's
|
|
3
|
+
// rollout — found by id under $CODEX_HOME/sessions — and sent on as synthetic
|
|
4
|
+
// events.
|
|
5
|
+
//
|
|
6
|
+
// This lived in src/server/codex-watch.mjs, above the rollout watcher, and
|
|
7
|
+
// shared nothing with it but the tree they both walk and the chunked reader.
|
|
8
|
+
// The watcher tails the rollouts for the events themselves; this reads one for
|
|
9
|
+
// what the events of a hook-delivered Codex session leave out. pushEvent
|
|
10
|
+
// (event-pipeline.mjs) hands every live event to maybeResolveCodex, and
|
|
11
|
+
// session-tracking.mjs forgets a session through forgetCodexSession. The
|
|
12
|
+
// bodies are unchanged.
|
|
13
|
+
import { stat } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { STOP, rolloutNameId, walkRolloutDays } from "./codex-dir.mjs";
|
|
16
|
+
// The per-session model and approval policy the translation remembers, which
|
|
17
|
+
// forgetCodexSession drops with the rest, and the record shapes readCodexRollout
|
|
18
|
+
// reads a rollout's lines by, the ones the translation reads — see
|
|
19
|
+
// codex-translate.mjs.
|
|
20
|
+
import {
|
|
21
|
+
codexSessionApproval, codexSessionModel,
|
|
22
|
+
responseItemModel, sessionMeta, taskStartedWindow, tokenCountInfo,
|
|
23
|
+
} from "./codex-translate.mjs";
|
|
24
|
+
// The bounded chunk reads the Claude transcripts use — see jsonl-chunks.mjs.
|
|
25
|
+
import { readByteRange } from "./jsonl-chunks.mjs";
|
|
26
|
+
// event-pipeline.mjs's pushEvent, reached without importing it — see
|
|
27
|
+
// event-sink.mjs.
|
|
28
|
+
import { pushEvent } from "./event-sink.mjs";
|
|
29
|
+
// One read per session at a time, and none inside the window — see
|
|
30
|
+
// session-read-gate.mjs.
|
|
31
|
+
import { sessionReadGate } from "./session-read-gate.mjs";
|
|
32
|
+
|
|
33
|
+
// ─── Codex transcript enrichment ──────────────────────────────────────────
|
|
34
|
+
// Codex CLI hook payloads carry `session_id` but no transcript path. Sessions
|
|
35
|
+
// are persisted to ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<sid>.jsonl
|
|
36
|
+
// with one JSON object per line: {type, payload}. Token usage shows up in
|
|
37
|
+
// {type:"event_msg", payload:{type:"token_count",
|
|
38
|
+
// info:{total_token_usage:{input_tokens, cached_input_tokens,
|
|
39
|
+
// output_tokens, reasoning_output_tokens,
|
|
40
|
+
// total_tokens}}}}
|
|
41
|
+
// We resolve the rollout path lazily (cache sid→path), then read the tail
|
|
42
|
+
// for usage + model. CODEX_HOME overrides ~/.codex, and codex-dir.mjs owns that
|
|
43
|
+
// rule for every module on the Codex side — this one used to spell it inline,
|
|
44
|
+
// which is how five modules ended up with three spellings of it (#375).
|
|
45
|
+
// sid -> the rollout's path, or, once a walk of the whole tree came back without
|
|
46
|
+
// one, the moment that walk began. See findCodexRolloutPath for why a miss is
|
|
47
|
+
// kept (#992), and why only for CODEX_MISS_TTL_MS (#1134).
|
|
48
|
+
const codexRolloutPathBySid = new Map();
|
|
49
|
+
const CODEX_READ_THROTTLE_MS = 2500;
|
|
50
|
+
const codexUsageReads = sessionReadGate(CODEX_READ_THROTTLE_MS);
|
|
51
|
+
// How many of the newest day directories a lookup reads when it is not owed
|
|
52
|
+
// the whole tree: the bound listRecentCodexRollouts keeps for the watcher.
|
|
53
|
+
const CODEX_RECENT_DAY_DIRS = 2;
|
|
54
|
+
// How long a miss from the whole tree is believed before its id is owed one
|
|
55
|
+
// more walk of it. See findCodexRolloutPath for why ten minutes.
|
|
56
|
+
const CODEX_MISS_TTL_MS = 10 * 60 * 1000;
|
|
57
|
+
// True while one lookup is walking the whole tree. One at a time; see below.
|
|
58
|
+
let codexWholeTreeWalk = false;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where is this session's rollout, if it is anywhere under this tree?
|
|
62
|
+
*
|
|
63
|
+
* WHAT WAS WRONG (#992). A hit was kept and a miss was not, and the walk that
|
|
64
|
+
* answers a miss is the whole tree: one readdir per year, per month and per day
|
|
65
|
+
* under $CODEX_HOME/sessions, which only a hit ends early. So an id with no
|
|
66
|
+
* rollout here paid for that walk again on every pass maybeResolveCodex's
|
|
67
|
+
* 2.5-second throttle let through, for as long as its hooks kept firing. A Codex
|
|
68
|
+
* hook left over from an older install, for a session whose rollout lives
|
|
69
|
+
* under another CODEX_HOME, is enough. The id also arrives on `/api/event`,
|
|
70
|
+
* which takes no credential and throttles per id, so a local process could post
|
|
71
|
+
* a few hundred fresh ids and have every one of them walk every directory at
|
|
72
|
+
* once. Both were measured in codex-unresolved-session-walk-992.test.ts, over a
|
|
73
|
+
* tree holding one year of history. The same unresolvable id, posted twice one
|
|
74
|
+
* throttle window apart, read all 37 of that year's directories both times, and
|
|
75
|
+
* six fresh ids posted together had six walks of it in flight at once.
|
|
76
|
+
*
|
|
77
|
+
* THE FIRST LOOKUP STILL WALKS THE WHOLE TREE, newest first, as it always did.
|
|
78
|
+
* A session resumed from last month appends to last month's rollout, and one
|
|
79
|
+
* that has been running for three days has dropped out of the newest two
|
|
80
|
+
* directories. Bounding the first walk the way the watcher's listing is bounded
|
|
81
|
+
* would find neither, and their usage would never show.
|
|
82
|
+
*
|
|
83
|
+
* A MISS FROM THE WHOLE TREE IS KEPT, as the moment that walk began, and every
|
|
84
|
+
* later lookup for that id reads only the newest two day directories. Those are
|
|
85
|
+
* the only place a rollout written after the walk can land, because Codex names
|
|
86
|
+
* a file for the moment it creates it, and they are the bound
|
|
87
|
+
* listRecentCodexRollouts already trusts for live sessions. So a hook that
|
|
88
|
+
* fires a moment before its rollout is on disk is still answered on a later
|
|
89
|
+
* pass, and an id nothing will ever carry costs about five readdirs a pass
|
|
90
|
+
* instead of the whole history. One caveat: walkRolloutDays swallows every
|
|
91
|
+
* error it meets, so a walk that could not read a directory still counts as
|
|
92
|
+
* having looked there. That is the one way a wrong miss gets kept.
|
|
93
|
+
*
|
|
94
|
+
* BUT NOT FOREVER (#1134). The newest two directories are where a rollout
|
|
95
|
+
* written after the walk lands, and they do not stay the newest two. A session
|
|
96
|
+
* whose first hook fired before its rollout existed, and whose next hook came
|
|
97
|
+
* after two newer day directories had appeared — a `codex resume` two days
|
|
98
|
+
* later — had its rollout in the third-newest directory, behind a miss that
|
|
99
|
+
* only forgetSession or a restart would ever clear. Its usage never showed,
|
|
100
|
+
* where the tree before #1111 found it; codex-kept-miss-expires-1134.test.ts
|
|
101
|
+
* has the measurement.
|
|
102
|
+
*
|
|
103
|
+
* So a kept miss is believed for CODEX_MISS_TTL_MS, and the first lookup after
|
|
104
|
+
* that is owed one more whole walk, on the same one-at-a-time terms as the
|
|
105
|
+
* first. Ten minutes closes the hole outright. A rollout drops out of the newest
|
|
106
|
+
* two directories only once two later calendar days have begun, which is never
|
|
107
|
+
* less than about a day after it was written (23 hours across a DST change), so
|
|
108
|
+
* by then every miss that could be hiding it has expired and the next lookup
|
|
109
|
+
* walks to it. It also keeps #992 fixed: an id nothing carries walks the whole
|
|
110
|
+
* tree once in ten minutes where it used to walk it every 2.5-second throttle
|
|
111
|
+
* window, 240 times as often, and reads its five directories in between. And
|
|
112
|
+
* the caveat above now costs a session ten minutes of usage rather than the
|
|
113
|
+
* rest of the process's life. It is the same ten minutes CODEX_STATE_TTL_MS
|
|
114
|
+
* keeps a rollout's cursor for, the other bound on this side that leans on the
|
|
115
|
+
* two-directory window. A clock that has gone backwards leaves a miss's age
|
|
116
|
+
* unknown, and such a miss is treated as expired: it costs one walk, where
|
|
117
|
+
* believing it would reopen the hole for however far the clock went back.
|
|
118
|
+
*
|
|
119
|
+
* AND ONE WHOLE-TREE WALK AT A TIME. Keeping the miss handles an id that comes
|
|
120
|
+
* back; it does nothing for a caller that sends a new one every time. So a
|
|
121
|
+
* lookup that is owed the whole tree while another lookup is walking it reads
|
|
122
|
+
* the newest two directories instead, keeps nothing, and gets its full walk on
|
|
123
|
+
* a later pass. A burst of fresh ids costs one walk of the history plus a few
|
|
124
|
+
* readdirs each, not one walk each, all at once.
|
|
125
|
+
*
|
|
126
|
+
* The map is bounded as it was: forgetSession evicts it with every other
|
|
127
|
+
* per-session cache, and a number is smaller than the path it stands in for.
|
|
128
|
+
*/
|
|
129
|
+
async function findCodexRolloutPath(sid) {
|
|
130
|
+
const cached = codexRolloutPathBySid.get(sid);
|
|
131
|
+
if (typeof cached === "string") return cached;
|
|
132
|
+
// Owed the whole tree: never looked for yet, or the whole tree's last miss is
|
|
133
|
+
// no longer believed — and nobody else is walking it.
|
|
134
|
+
const startedAt = Date.now();
|
|
135
|
+
const age = typeof cached === "number" ? startedAt - cached : -1;
|
|
136
|
+
const missHolds = age >= 0 && age < CODEX_MISS_TTL_MS;
|
|
137
|
+
const whole = !missHolds && !codexWholeTreeWalk;
|
|
138
|
+
if (whole) codexWholeTreeWalk = true;
|
|
139
|
+
// Walk year → month → day → files, newest first. Codex names the file for the
|
|
140
|
+
// sid (rollout-<timestamp>-<sid>.jsonl) so a directory-scoped match is enough.
|
|
141
|
+
// The walk itself lives in codex-dir.mjs, shared with the watcher's listing
|
|
142
|
+
// in codex-watch.mjs and with codex-usage.mjs, so all three read one tree the
|
|
143
|
+
// same way.
|
|
144
|
+
//
|
|
145
|
+
// The name's WHOLE id segment, never a substring of the name (#1653).
|
|
146
|
+
// `includes` took the first file whose name contained the id anywhere, so an
|
|
147
|
+
// id shorter than a whole one — a prefix of another session's id, its last
|
|
148
|
+
// group, a piece of the timestamp — found that other session's rollout, and
|
|
149
|
+
// the hit below kept it: the short id's card showed another session's usage,
|
|
150
|
+
// model and window from then on. See rolloutNameId in codex-dir.mjs.
|
|
151
|
+
let found = null;
|
|
152
|
+
let dayDirs = 0;
|
|
153
|
+
try {
|
|
154
|
+
await walkRolloutDays((dayDir, files) => {
|
|
155
|
+
const hit = files.find(f => rolloutNameId(f) === sid);
|
|
156
|
+
if (hit) {
|
|
157
|
+
found = join(dayDir, hit);
|
|
158
|
+
return STOP;
|
|
159
|
+
}
|
|
160
|
+
if (!whole && ++dayDirs >= CODEX_RECENT_DAY_DIRS) return STOP;
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
if (whole) codexWholeTreeWalk = false;
|
|
164
|
+
}
|
|
165
|
+
// A hit is kept, as it always was. A miss is kept only when it is the whole
|
|
166
|
+
// tree's answer — a miss in the newest two directories says nothing about the
|
|
167
|
+
// rest of them — and it is kept as the moment that walk began, which is when
|
|
168
|
+
// it read the directories a rollout written since would be in.
|
|
169
|
+
if (found) codexRolloutPathBySid.set(sid, found);
|
|
170
|
+
else if (whole) codexRolloutPathBySid.set(sid, startedAt);
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The head and the tail of a Codex rollout, never the middle.
|
|
176
|
+
*
|
|
177
|
+
* WHAT THIS FIXES (#792). It called itself a tail-read and read the whole file:
|
|
178
|
+
* `Buffer.alloc(s.size)` in one go, then `split` and `JSON.parse` over every
|
|
179
|
+
* line. Measured against this machine's own largest rollout — 44.4 MB — that is
|
|
180
|
+
* 181ms to read and decode, 95ms of SYNCHRONOUS parsing blocking the event
|
|
181
|
+
* loop, and 185 MB of resident memory for the buffer, the string and the split
|
|
182
|
+
* array together. Past about 512 MB `toString` throws outright, the catch
|
|
183
|
+
* answers null, and the deck reports no Codex usage or model for the rest of
|
|
184
|
+
* its life.
|
|
185
|
+
*
|
|
186
|
+
* It is reached from `/api/event`, which is in OPEN_MUTATIONS — no token, no
|
|
187
|
+
* browser identity — so any local process can ask for it, and the per-session
|
|
188
|
+
* throttle is keyed on `sid`, which means distinct sid strings each get their
|
|
189
|
+
* own budget. That note's own rule is the one this violated: the deck's memory
|
|
190
|
+
* "cannot be a function of anything but the two constants named here".
|
|
191
|
+
*
|
|
192
|
+
* WHY HEAD AND TAIL RATHER THAN A CURSOR. The four fields do not live in one
|
|
193
|
+
* place. `session_meta` is the FIRST record — cwd, sometimes the model — while
|
|
194
|
+
* the last `token_count`, the last `task_started` and the newest
|
|
195
|
+
* `response_item` model are all at the END. A forward cursor would have to keep
|
|
196
|
+
* the head's answers across polls, which is a second cache to invalidate; two
|
|
197
|
+
* bounded reads answer the same question with no state at all.
|
|
198
|
+
*
|
|
199
|
+
* A file smaller than both windows is read once, so nothing changes for the
|
|
200
|
+
* ordinary rollout — the median here is well under a megabyte.
|
|
201
|
+
*/
|
|
202
|
+
const CODEX_HEAD_BYTES = 256 * 1024;
|
|
203
|
+
const CODEX_TAIL_BYTES = 2 * 1024 * 1024;
|
|
204
|
+
|
|
205
|
+
// Exported for the suite, which is the only way to drive the head-and-tail
|
|
206
|
+
// branch: building a rollout larger than both windows is cheap, and reaching
|
|
207
|
+
// this function through /api/event would mean a server, a discovery file and a
|
|
208
|
+
// throttle that all have nothing to do with what is being measured.
|
|
209
|
+
export async function readCodexRollout(path) {
|
|
210
|
+
try {
|
|
211
|
+
const s = await stat(path);
|
|
212
|
+
if (s.size === 0) return null;
|
|
213
|
+
let text;
|
|
214
|
+
if (s.size <= CODEX_HEAD_BYTES + CODEX_TAIL_BYTES) {
|
|
215
|
+
text = await readByteRange(path, 0, s.size);
|
|
216
|
+
} else {
|
|
217
|
+
// A newline between them so the two windows cannot splice a half line
|
|
218
|
+
// from the head onto a half line from the tail and hand the parser a
|
|
219
|
+
// record that never existed. The tail's own first line is partial by
|
|
220
|
+
// construction and is dropped the same way: `JSON.parse` refuses it and
|
|
221
|
+
// the loop below skips it.
|
|
222
|
+
const head = await readByteRange(path, 0, CODEX_HEAD_BYTES);
|
|
223
|
+
const tail = await readByteRange(path, s.size - CODEX_TAIL_BYTES, s.size);
|
|
224
|
+
text = `${head}\n${tail}`;
|
|
225
|
+
}
|
|
226
|
+
let lastUsage = null;
|
|
227
|
+
let model = null;
|
|
228
|
+
let contextWindow = null;
|
|
229
|
+
let cwd = null;
|
|
230
|
+
for (const line of text.split("\n")) {
|
|
231
|
+
if (!line) continue;
|
|
232
|
+
let obj;
|
|
233
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
234
|
+
// One record is one of these at most: each asks the record's own type.
|
|
235
|
+
const meta = sessionMeta(obj);
|
|
236
|
+
if (meta) {
|
|
237
|
+
if (typeof meta.cwd === "string") cwd = meta.cwd;
|
|
238
|
+
// session_meta sometimes carries the model in newer Codex versions.
|
|
239
|
+
if (typeof meta.model === "string") model = meta.model;
|
|
240
|
+
}
|
|
241
|
+
const usage = tokenCountInfo(obj)?.total_token_usage;
|
|
242
|
+
if (usage) lastUsage = usage;
|
|
243
|
+
const window = taskStartedWindow(obj);
|
|
244
|
+
if (window !== null) contextWindow = window;
|
|
245
|
+
// Fallback model source — response items carry the model id.
|
|
246
|
+
const itemModel = responseItemModel(obj);
|
|
247
|
+
if (itemModel !== null) model = itemModel;
|
|
248
|
+
}
|
|
249
|
+
if (!lastUsage && !model && !contextWindow) return null;
|
|
250
|
+
return { usage: lastUsage, model, contextWindow, cwd };
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function maybeResolveCodex(payload) {
|
|
257
|
+
if (!payload || typeof payload !== "object") return;
|
|
258
|
+
if (payload.provider !== "codex") return;
|
|
259
|
+
const sid = payload.session_id;
|
|
260
|
+
if (!sid) return;
|
|
261
|
+
codexUsageReads.run(sid, async () => {
|
|
262
|
+
const path = await findCodexRolloutPath(sid);
|
|
263
|
+
if (!path) return;
|
|
264
|
+
const r = await readCodexRollout(path);
|
|
265
|
+
if (!r) return;
|
|
266
|
+
if (r.usage) {
|
|
267
|
+
pushEvent({
|
|
268
|
+
hook_event_name: "UsageObserved",
|
|
269
|
+
session_id: sid,
|
|
270
|
+
usage: r.usage,
|
|
271
|
+
}, "internal");
|
|
272
|
+
}
|
|
273
|
+
if (r.model) {
|
|
274
|
+
pushEvent({
|
|
275
|
+
hook_event_name: "ModelObserved",
|
|
276
|
+
session_id: sid,
|
|
277
|
+
model: r.model,
|
|
278
|
+
}, "internal");
|
|
279
|
+
}
|
|
280
|
+
if (r.contextWindow) {
|
|
281
|
+
// Piggy-back on the model event with the window — reducer reads
|
|
282
|
+
// model_context_window directly off any payload.
|
|
283
|
+
pushEvent({
|
|
284
|
+
hook_event_name: "ModelObserved",
|
|
285
|
+
session_id: sid,
|
|
286
|
+
model: r.model ?? undefined,
|
|
287
|
+
model_context_window: r.contextWindow,
|
|
288
|
+
}, "internal");
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Everything kept about one Codex session by id, dropped: where its rollout is,
|
|
295
|
+
* when its usage was last read, and the model and approval policy the
|
|
296
|
+
* translation remembers. Its one caller is forgetSession.
|
|
297
|
+
*/
|
|
298
|
+
function forgetCodexSession(sid) {
|
|
299
|
+
codexRolloutPathBySid.delete(sid);
|
|
300
|
+
codexUsageReads.forget(sid);
|
|
301
|
+
codexSessionModel.delete(sid);
|
|
302
|
+
codexSessionApproval.delete(sid);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// What event-pipeline.mjs and session-tracking.mjs call besides readCodexRollout
|
|
306
|
+
// above, and findCodexRolloutPath for the suite, which pins the id it matches
|
|
307
|
+
// against file names without a server in front of it. Listed rather than marked
|
|
308
|
+
// at each declaration, so the declarations read as they did where they came
|
|
309
|
+
// from.
|
|
310
|
+
export { findCodexRolloutPath, forgetCodexSession, maybeResolveCodex };
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
// optional, unknown values pass through verbatim, and a `partial` flag tells
|
|
9
9
|
// the UI when something was dropped instead of silently showing less.
|
|
10
10
|
import { readFile } from "node:fs/promises";
|
|
11
|
-
// One clamp for both quota readers: see the note on cooldownFromHeader.
|
|
12
|
-
|
|
11
|
+
// One clamp for both quota readers: see the note on cooldownFromHeader. From
|
|
12
|
+
// quota-oauth.mjs, which owns it, rather than through quota.mjs, which only
|
|
13
|
+
// re-exported it and brings the whole Claude quota chain with it.
|
|
14
|
+
import { cooldownFromHeader } from "./quota-oauth.mjs";
|
|
13
15
|
import { join } from "node:path";
|
|
14
16
|
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
15
17
|
import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
|
|
@@ -337,7 +339,7 @@ async function doFetchCodexQuota() {
|
|
|
337
339
|
// than the ordinary floor before asking again. `retry-after` is honoured when
|
|
338
340
|
// the backend sends one, because it knows better than the constant does.
|
|
339
341
|
const cooldown = (res) => {
|
|
340
|
-
// Clamped, for the reason quota.mjs states at cooldownFromHeader: a `0`
|
|
342
|
+
// Clamped, for the reason quota-oauth.mjs states at cooldownFromHeader: a `0`
|
|
341
343
|
// defeats the cooldown a 429 exists to impose, and a day freezes this
|
|
342
344
|
// poller for the life of the process.
|
|
343
345
|
_rateLimitedUntil = Date.now() + cooldownFromHeader(res?.headers?.get?.("retry-after"), COOLDOWN_MS);
|