ccdeck 3.29.10 → 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.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +28 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -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 "./installer.mjs";
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 the installer's resolveWriteTarget rather than a bare realpath
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 the installer's createTemp, which numbers every
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 the installer's retrying one because Windows fails it outright
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
@@ -1,5 +1,6 @@
1
1
  // Where OpenAI Codex lives on this machine: its home directory, its rollout
2
- // tree, and the walk over that tree — the Codex-side mirror of claude-dir.mjs.
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 given
129
- * session id (index.mjs), collect everything in the newest two day directories
130
- * (index.mjs again, for the watcher), and collect everything whose filename
131
- * timestamp falls inside a rolling window (codex-usage.mjs). The walk is the
132
- * part that has to agree — a deck that tails one set of files and reports usage
133
- * from another is reporting on a session it is not showing.
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
- import { cooldownFromHeader } from "./quota.mjs";
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);