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.
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 +22 -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-CyAFTvAY.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  175. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  176. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,496 @@
1
+ // The Codex half of capture: the rollouts directory tailed for the events
2
+ // themselves. A session's rollout found by id, for the usage its hook payloads
3
+ // never carry, is codex-enrichment.mjs.
4
+ //
5
+ // These lived in src/server/index.mjs, between the session enrichment and the
6
+ // ring's reader. They reach the pipeline through event-sink.mjs, as the
7
+ // enrichment does, and take the log election, the live decks, the canonical
8
+ // cwd and the chunked reader from the modules that own them — nothing here
9
+ // reads the ring or the SSE clients. index.mjs starts the watcher. The bodies
10
+ // are unchanged.
11
+ import { stat } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { PRODUCT } from "./brand.mjs";
14
+ import { STOP, sidFromRolloutName, walkRolloutDays } from "./codex-dir.mjs";
15
+ // What one rollout line means as a hook payload, and the shape of the header
16
+ // record that says whose rollout it is — see codex-translate.mjs. The watcher
17
+ // below still decides which lines are read and where each one goes.
18
+ import { codexObjToPayload, codexSessionModel, sessionMeta } from "./codex-translate.mjs";
19
+ import { codexCwdInWorkspace, writesCodexLog } from "./log-election.mjs";
20
+ // The one spelling of a rollout's cwd — see canonical-path.mjs.
21
+ import { canonicalCwd } from "./canonical-path.mjs";
22
+ import { eventLogPath } from "./event-log.mjs";
23
+ // Every deck registered right now that proved it is the deck its record
24
+ // describes — what the log election counts. See live-decks.mjs.
25
+ import { readLiveDecks } from "./live-decks.mjs";
26
+ // The bounded chunk reads the Claude transcripts use — see jsonl-chunks.mjs.
27
+ import { readAppendedLines, readByteRange } from "./jsonl-chunks.mjs";
28
+ import { maybeResolveCodexMemory } from "./session-enrichment.mjs";
29
+ // event-pipeline.mjs's pushEvent, reached without importing it — see
30
+ // event-sink.mjs.
31
+ import { pushEvent } from "./event-sink.mjs";
32
+
33
+ // ─── Codex rollout watcher ────────────────────────────────────────────────
34
+ // Codex CLI hooks never fire on Windows — the elevated/unelevated sandbox
35
+ // refuses to spawn the hook command (exit 1, child never runs). So instead of
36
+ // relying on hooks, we tail the rollout JSONL files Codex writes to
37
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl and reconstruct the
38
+ // agent-dag event stream from them. Each rollout line is one append-only JSON
39
+ // object {timestamp, type, payload}; we map the relevant ones to the same
40
+ // synthetic hook payloads the reducer already understands:
41
+ // session_meta → SessionStart, but ONLY for a rollout
42
+ // this watcher read from byte 0 (#684).
43
+ // See ensureCodexRoot.
44
+ // event_msg/user_message → UserPromptSubmit (Codex ≤ 0.144)
45
+ // event_msg/item_completed/UserMessage → UserPromptSubmit (Codex ≥ 0.147)
46
+ // response_item/function_call → PreToolUse
47
+ // response_item/function_call_output → PostToolUse / PostToolUseFailure,
48
+ // decided by the outcome line Codex
49
+ // prepends to the output (codexCallFailed)
50
+ // event_msg/token_count → UsageObserved, and riding on it the
51
+ // session's live context occupancy and
52
+ // the CLI's own window (#399)
53
+ // event_msg/task_started (+window) → ModelObserved (context window)
54
+ // event_msg/task_complete → Stop (the turn finished)
55
+ // event_msg/turn_aborted → Stop (the turn was interrupted)
56
+ // turn_context / response_item.model → model snapshot (ModelObserved on change)
57
+ // turn_context.approval_policy → session snapshot, spread onto every
58
+ // payload below (#398); no event of its own
59
+ // There is deliberately no SessionEnd here: Codex writes no session-close
60
+ // record, so the end of a SESSION is still inferred by sweepStaleSessions.
61
+ // Nor is there anything that maps to `Notification`, and no synthetic one is
62
+ // invented: Codex writes no approval record to a rollout at all, so the deck
63
+ // cannot see a Codex session blocked on a human and does not pretend to — see
64
+ // codex-approval.ts for the evidence and for what is said instead of guessing.
65
+ // Events are emitted with source "codex" so pushEvent skips the Claude-only
66
+ // transcript enrichment (which needs transcript_path / hook events) but still
67
+ // broadcasts them exactly like a hook event, and persists them when this deck is
68
+ // the one elected to log this rollout — see writesCodexLog. This path is
69
+ // entirely additive — the Claude hook flow is untouched.
70
+ // path -> { offset, sid, cwd, skip, sawBeginning, rootOpened, seenAt }
71
+ const codexFileState = new Map();
72
+ // How long a rollout's tail cursor is kept after it stops showing up in the
73
+ // listing. The listing covers two day-directories, so anything missing from it
74
+ // is at least a day old and will never be appended to again.
75
+ const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
76
+ // Every rollout path this PROCESS has ever held a cursor for, which the TTL
77
+ // sweep deliberately does not clear — that is the whole of its job.
78
+ //
79
+ // A cursor dropped and then re-created is indistinguishable, to the code that
80
+ // creates it, from a rollout nobody has ever read: both are "no entry in
81
+ // codexFileState". The difference matters enormously, because a new file is
82
+ // opened at byte 0 and told it has its own beginning, so a re-discovery
83
+ // replayed the entire rollout as fresh events AND minted a `SessionStart` for a
84
+ // session this deck joined late. This set is what lets the second case answer
85
+ // "no, we have seen this one" and park at the end instead (#981).
86
+ //
87
+ // Capped, in insertion order, for the reason codexFileState is swept at all: a
88
+ // long-lived deck on a busy machine would otherwise accumulate a path per
89
+ // session for as long as it runs. A path is far cheaper than a cursor, so the
90
+ // cap is generous — but it is a cap, and it is the same LRU shape
91
+ // sessionTouchedAt uses.
92
+ const codexSeenEver = new Set();
93
+ const MAX_CODEX_SEEN_EVER = 512;
94
+ function rememberCodexPath(path) {
95
+ // Delete-then-add so the Set's own insertion order IS the LRU order, and
96
+ // eviction is one key read rather than a scan.
97
+ codexSeenEver.delete(path);
98
+ codexSeenEver.add(path);
99
+ while (codexSeenEver.size > MAX_CODEX_SEEN_EVER) {
100
+ codexSeenEver.delete(codexSeenEver.values().next().value);
101
+ }
102
+ }
103
+ // The scan in flight, if any, as its promise. A tick that lands while one is
104
+ // still reading joins it rather than starting a second over the same cursors,
105
+ // and scanCodexNow can wait it out.
106
+ let codexScan = null;
107
+ let codexWatchTimer = null;
108
+ let codexWorkspace = "";
109
+
110
+ // List rollout files from the newest 2 day-directories. New sessions always
111
+ // land in today's dir, so this captures live activity without scanning years
112
+ // of history every tick.
113
+ async function listRecentCodexRollouts() {
114
+ const out = [];
115
+ let dayDirs = 0;
116
+ await walkRolloutDays((dir, files) => {
117
+ for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
118
+ // Two day-directories deep is the whole point of this listing: it runs every
119
+ // tick, and anything older than that is a session no process will append to.
120
+ if (++dayDirs >= 2) return STOP;
121
+ });
122
+ return out;
123
+ }
124
+
125
+ /** Which of the two ways a rollout can fail to say whose it is has been printed
126
+ * already. Once per kind for the life of the process, not once per file: the
127
+ * scan retries a rollout it cannot place on every tick, and a change to the
128
+ * format would hit every rollout at once, so per file would be the same
129
+ * sentence once for each of them. */
130
+ const codexHeaderWarned = new Set();
131
+ function warnUnplacedRollout(kind, path, why) {
132
+ if (codexHeaderWarned.has(kind)) return;
133
+ codexHeaderWarned.add(kind);
134
+ console.warn(`${PRODUCT}: cannot tell which Codex session ${path} belongs to: ${why}. If Codex has changed its rollout format, its sessions will not be drawn until the deck reads the new one.`);
135
+ }
136
+
137
+ // Read the first complete JSON line of a rollout (the session_meta header)
138
+ // to learn sid + cwd before we start streaming. The header line can be large
139
+ // (base_instructions text runs tens of KB), so we read in growing chunks until
140
+ // we hit the first newline rather than guessing a fixed window.
141
+ async function readCodexHeader(path) {
142
+ try {
143
+ const size = (await stat(path)).size;
144
+ if (size === 0) return null;
145
+ const CHUNK = 65536;
146
+ let upto = Math.min(CHUNK, size);
147
+ let text = "";
148
+ for (;;) {
149
+ text = await readByteRange(path, 0, upto);
150
+ const nl = text.indexOf("\n");
151
+ if (nl >= 0) {
152
+ const obj = JSON.parse(text.slice(0, nl));
153
+ const meta = sessionMeta(obj);
154
+ if (meta) {
155
+ // THE ID, WITH THE FILE NAME BEHIND IT (#996). Every other field this
156
+ // file takes off a rollout goes through a type guard, and the event
157
+ // names are read in both spellings because OpenAI has renamed a record
158
+ // once already; this one went straight into `sid`. A renamed or
159
+ // dropped `payload.id` made every header answer undefined, and the
160
+ // scan `continue`s on that — so every rollout was retried on every
161
+ // tick, its first 64KB re-read each time, not one Codex session was
162
+ // drawn, and nothing said why. The file name carries the same id.
163
+ const id = meta.id;
164
+ const sid = typeof id === "string" && id !== "" ? id : sidFromRolloutName(path);
165
+ if (!sid) warnUnplacedRollout("no-id", path, "its session_meta has no id, and its file name carries none");
166
+ // Canonicalised here and nowhere else: everything downstream — the
167
+ // workspace test below, the log election, the cwd on every event this
168
+ // rollout produces — reads state.cwd, and this is the one place it is
169
+ // read off disk. See canonicalCwd.
170
+ return { sid, cwd: await canonicalCwd(meta.cwd) };
171
+ }
172
+ // The other rename the scan would otherwise retry in silence: a first
173
+ // line that is whole but is not a session_meta will never become one.
174
+ // Said, not guessed around — which record a renamed header would be is
175
+ // not something to infer from one line. Only for a file named like a
176
+ // rollout, so some other `.jsonl` left in the tree is not reported as a
177
+ // format change.
178
+ if (obj && typeof obj === "object" && sidFromRolloutName(path)) {
179
+ warnUnplacedRollout("no-header", path, `its first line is a ${JSON.stringify(String(obj.type ?? "record with no type"))}, not a session_meta`);
180
+ }
181
+ return null;
182
+ }
183
+ if (upto >= size) return null; // no newline in the whole file yet
184
+ upto = Math.min(upto + CHUNK, size); // grow and retry
185
+ if (upto > 4 * 1024 * 1024) return null; // 4MB sanity cap on a single line
186
+ }
187
+ } catch {}
188
+ return null;
189
+ }
190
+
191
+ // `persist` is false when another deck tailing this same rollout was elected to
192
+ // write it to the log they share. The event is still buffered and broadcast —
193
+ // every deck watching the session draws it — it is only the second copy on disk
194
+ // that is dropped. See writesCodexLog in log-election.mjs.
195
+ function emitCodexEvent(payload, persist) {
196
+ pushEvent(payload, "codex", { persist });
197
+ }
198
+
199
+ /**
200
+ * Open this rollout's session on the canvas, once, lazily — only when it
201
+ * actually produces an event. Emitting eagerly for every file on disk at
202
+ * startup would fill the canvas with empty roots for sessions nobody will ever
203
+ * append to again.
204
+ *
205
+ * WHETHER A `SessionStart` IS EMITTED AT ALL IS THE WHOLE OF #684.
206
+ *
207
+ * This used to mint one unconditionally, and for a rollout the deck joined
208
+ * partway through that event is a false statement. #683 made the falsehood
209
+ * visible rather than merely wrong: a root created by anything OTHER than a
210
+ * `SessionStart` is marked `synthetic` in the reducer — "the deck joined this
211
+ * session after it had already begun, so the start time, the prompt history and
212
+ * the early tool calls on this card are incomplete rather than empty" — and a
213
+ * Codex session that carries a minted `SessionStart` clears that marker and
214
+ * asserts a beginning nobody watched. The Claude side never had the problem: a
215
+ * deck that starts mid-session simply never receives a `SessionStart` hook for
216
+ * it, which is exactly the case the marker was built for.
217
+ *
218
+ * `sawBeginning` is the one fact that separates the two, and it is not a
219
+ * filesystem question. It is set where the tail cursor is: TRUE when this
220
+ * watcher opened the rollout at byte 0 and therefore holds every line the
221
+ * session ever wrote, FALSE when openCodexCursor, on scanRollouts'
222
+ * `firstRun`, skipped a pre-existing file's history by seeking to its current
223
+ * size. That is the same fact the event states, read off the only thing that
224
+ * actually knows it, and it is the same on Linux, macOS and Windows because no
225
+ * platform API is consulted.
226
+ *
227
+ * WHY NOT `fs.watch`, AND WHY NOT `birthtime` — both measured rather than
228
+ * assumed, on Node 22.14 / darwin, plus the documented behaviour elsewhere:
229
+ *
230
+ * • `fs.watch` on a path that does not exist throws ENOENT, and
231
+ * ~/.codex/sessions/YYYY/MM/DD does not exist until the day's first
232
+ * session — the same reason startCodexWatcher polls instead of watching.
233
+ * • a NON-recursive watch on ~/.codex/sessions reports only `rename:2026`
234
+ * when a rollout appears three levels below it; the file's creation is
235
+ * invisible to it.
236
+ * • `recursive: true` works here and on Windows, and on Linux only from a
237
+ * Node 20.x release — while this package declares `"node": ">=18"`, so on
238
+ * the OS Codex users most often run servers on it may simply not be there.
239
+ * • even where it works the event does not mean "created": writing the file
240
+ * and then rewriting it produced `rename:rollout.jsonl` BOTH times, and
241
+ * Node documents the event type as not guaranteed and `filename` as
242
+ * possibly null.
243
+ * • `stat().birthtimeMs` is real on APFS, but Node documents it as falling
244
+ * back to ctime or to the Unix epoch on filesystems that do not carry it —
245
+ * so on some Linux hosts every rollout would look newborn.
246
+ *
247
+ * WHAT ANOTHER DECK SEES. Nothing new: the fix REMOVES a line from
248
+ * events.jsonl, it does not add one or change a shape. A joined-late Codex
249
+ * session now reaches the shared log looking exactly like a joined-late Claude
250
+ * one — a root conjured by its first real event — which every deck, including
251
+ * one older than #683 that has no marker to light, has always been able to
252
+ * replay. The events that create the root instead (`UserPromptSubmit`,
253
+ * `PreToolUse`, `ModelObserved`, …) each carry `provider: "codex"`, `cwd` and
254
+ * `approval_policy` from `base` in codexObjToPayload, so nothing an older deck
255
+ * read off the `SessionStart` is lost with it.
256
+ *
257
+ * `rootOpened` still flips in both cases, and deliberately: it gates the
258
+ * per-batch AGENTS.md resolution in scanRollouts, which asks "is this session
259
+ * being drawn", not "did we announce it".
260
+ */
261
+ function ensureCodexRoot(state, persist) {
262
+ if (state.rootOpened) return;
263
+ state.rootOpened = true;
264
+ if (!state.sawBeginning) return;
265
+ emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" }, persist);
266
+ }
267
+
268
+ /**
269
+ * A cursor for a rollout this watcher holds none for yet, recorded in
270
+ * codexFileState, and returned when this tick should go on to read from it.
271
+ * Null when it should not: the header cannot be read yet, so the file is
272
+ * retried next tick; the rollout runs outside this deck's workspace, so a
273
+ * skipping cursor is parked at its end; or this is the first scan, and the
274
+ * file's history is skipped.
275
+ */
276
+ async function openCodexCursor(path, st, now, firstRun) {
277
+ // New file — read the header for sid + cwd, then decide whether to
278
+ // capture it. Skip files outside our workspace.
279
+ const header = await readCodexHeader(path);
280
+ if (!header || !header.sid) return null; // not ready yet — retry next tick
281
+ if (!codexCwdInWorkspace(header.cwd, codexWorkspace)) {
282
+ codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, sawBeginning: false, rootOpened: false, seenAt: now });
283
+ return null;
284
+ }
285
+ // Opened at byte 0, so every line this session ever wrote is about to
286
+ // be read: this watcher HAS its beginning, and ensureCodexRoot may say
287
+ // so. The `firstRun` branch below is the one case that takes it away.
288
+ //
289
+ // UNLESS THIS PROCESS HAS SEEN THE PATH BEFORE, in which case there is
290
+ // no beginning to claim and nothing to replay: the cursor was dropped
291
+ // by the TTL sweep and this is a RE-discovery. Parking at the current
292
+ // size costs whatever was appended while the tree was unreachable,
293
+ // which is the same trade `firstRun` already makes below — against
294
+ // re-emitting every prompt, every PreToolUse/PostToolUse and every
295
+ // UsageObserved in the file to every tab, and re-appending the lot to
296
+ // the shared events.jsonl if this deck is the elected writer (#981).
297
+ const fresh = !codexSeenEver.has(path);
298
+ const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now };
299
+ codexFileState.set(path, state);
300
+ rememberCodexPath(path);
301
+ if (firstRun) {
302
+ // On startup, skip a pre-existing session's history entirely — no
303
+ // replay. Only future appends (a live session that keeps going) will
304
+ // lazily open the root via ensureCodexRoot.
305
+ //
306
+ // And it opens WITHOUT a `SessionStart` (#684). Seeking to the
307
+ // current size is precisely the admission that this deck did not
308
+ // watch the session begin, so it must not go on to emit the event
309
+ // that says it did — that is the input #683's joined-late marker is
310
+ // entitled to trust. A deck RESTARTING over a session that is still
311
+ // running lands here too, and correctly: the new process holds none
312
+ // of the old one's history either. If the log it replays at boot
313
+ // already contains a `SessionStart` this deck minted honestly in an
314
+ // earlier life, the root is rebuilt unmarked from that line and stays
315
+ // unmarked, because nothing here emits a second one to disturb it.
316
+ //
317
+ // NOT because the reducer would refuse to listen. It used to say so
318
+ // here — "the reducer only honours `synthetic` on the call that
319
+ // CREATES the node" — and that is false: `applySessionStart`, in
320
+ // src/web/session-lifecycle.ts, clears `root.synthetic` on EVERY
321
+ // `SessionStart`, deliberately, so that one arriving after the event
322
+ // that created the root retracts a marker the deck no longer
323
+ // deserves to be wearing (#677). Which makes the marker exactly as
324
+ // honest as the emitter above it, and is why the rule this branch
325
+ // states — do not emit a `SessionStart` for a beginning this process
326
+ // did not watch — is the whole guarantee rather than a belt beside a
327
+ // brace (#981).
328
+ state.offset = st.size;
329
+ state.sawBeginning = false;
330
+ return null;
331
+ }
332
+ return state;
333
+ }
334
+
335
+ /**
336
+ * Send on each complete line of one batch appended to a rollout, as the hook
337
+ * payload it translates to, opening the session's root before its first event.
338
+ * A line that changes the session's model is preceded by a ModelObserved.
339
+ * `persist` is the batch's one verdict — see emitCodexEvent.
340
+ */
341
+ function emitCodexLines(state, consume, persist) {
342
+ for (const line of consume.split("\n")) {
343
+ if (!line) continue;
344
+ let obj;
345
+ try { obj = JSON.parse(line); } catch { continue; }
346
+ const prevModel = codexSessionModel.get(state.sid);
347
+ const payload = codexObjToPayload(obj, state.sid, state.cwd);
348
+ // If the model changed (turn_context/response_item), surface it.
349
+ const nowModel = codexSessionModel.get(state.sid);
350
+ if (nowModel && nowModel !== prevModel) {
351
+ ensureCodexRoot(state, persist);
352
+ emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel }, persist);
353
+ }
354
+ if (payload) {
355
+ ensureCodexRoot(state, persist);
356
+ emitCodexEvent(payload, persist);
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Rollout files fall out of the newest-2-days listing and never come back,
363
+ * but their tail cursors used to live as long as the process did. Expire by
364
+ * "not seen for a while" rather than "absent from this listing": a single
365
+ * unreadable directory mid-scan would otherwise drop a live file's cursor,
366
+ * and re-adding it at offset 0 replays that entire rollout as fresh events.
367
+ */
368
+ function sweepCodexCursors(now) {
369
+ for (const [p, s] of codexFileState) {
370
+ if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
371
+ }
372
+ }
373
+
374
+ function codexScanOnce(firstRun) {
375
+ codexScan ??= scanRollouts(firstRun).finally(() => { codexScan = null; });
376
+ return codexScan;
377
+ }
378
+
379
+ /**
380
+ * One scan that begins after this call, awaited — what the poll does every
381
+ * 1500ms, on demand.
382
+ *
383
+ * A scan already reading when this is called may have listed the tree before
384
+ * whatever the caller just changed, so it is waited out first and not counted.
385
+ * The suite waits on this rather than on the poll's clock: a case that needs
386
+ * "the watcher has looked since I wrote" used to sleep two polls for it, and a
387
+ * case that needs "and nothing more came" two polls more (#994).
388
+ */
389
+ export async function scanCodexNow() {
390
+ if (codexScan) await codexScan;
391
+ await codexScanOnce(false);
392
+ }
393
+
394
+ async function scanRollouts(firstRun) {
395
+ try {
396
+ const now = Date.now();
397
+ // Read at most once per scan, and only from the first rollout that actually
398
+ // has new bytes: most ticks read nothing and must not pay a directory
399
+ // listing for the answer to a question nothing is asking.
400
+ let decksRead = null;
401
+ const liveDecks = () => (decksRead ??= readLiveDecks());
402
+ const files = await listRecentCodexRollouts();
403
+ for (const path of files) {
404
+ let st;
405
+ try { st = await stat(path); } catch { continue; }
406
+ let state = codexFileState.get(path);
407
+ if (state) state.seenAt = now;
408
+
409
+ if (!state) {
410
+ state = await openCodexCursor(path, st, now, firstRun);
411
+ if (!state) continue;
412
+ }
413
+
414
+ if (state.skip) { state.offset = st.size; continue; }
415
+ // A cursor sitting PAST the end of the file is a different thing from
416
+ // "nothing new", and the two used to collapse into one `continue` — where
417
+ // the Claude scanner beside this one starts the file over (scanTranscript).
418
+ // Not reachable today: rollouts are append-only and uniquely named, and
419
+ // readAppendedLines cannot advance a cursor past `size`. It is resynced
420
+ // FORWARD rather than back to zero because re-reading a rollout from the
421
+ // beginning is precisely the full replay #981 is about.
422
+ if (st.size < state.offset) state.offset = st.size;
423
+ if (st.size <= state.offset) continue;
424
+
425
+ // Same bounded reader the Claude transcripts use. A rollout is not a
426
+ // caller-chosen path, so this is not the #674 exposure — but the
427
+ // allocation was the size of the appended bytes here too, and one chunk
428
+ // per tick is more than any real rollout appends in a tick.
429
+ const { text: consume, advanced } = await readAppendedLines(path, state, st.size);
430
+ if (advanced === 0) continue; // no complete line yet
431
+
432
+ // Decided per file rather than per line: which decks are up, and which of
433
+ // them tail this rollout, cannot change inside one batch of appended
434
+ // lines, and the answer must be the same for every event in it — a root
435
+ // written by one deck and its tool calls by another is worse than either.
436
+ const persist = !eventLogPath()
437
+ || writesCodexLog({ decks: await liveDecks(), pid: process.pid, cwd: state.cwd });
438
+
439
+ emitCodexLines(state, consume, persist);
440
+
441
+ // Once per batch of appended lines rather than once per line — the scan
442
+ // throttles itself per session, but the cheapest call is the one that is
443
+ // never made, and a batch can be hundreds of lines. Gated on the root
444
+ // existing so a session the deck has decided not to draw does not cost a
445
+ // directory walk, and repeated rather than done once at root creation so
446
+ // an AGENTS.md written after the session started is still found (#399).
447
+ //
448
+ // Carries the same `persist` verdict as every emit in the loop above, and
449
+ // for the same reason: this is one more event about this batch of appended
450
+ // lines, and a batch whose roots and tool calls went to one deck's log
451
+ // while its memory list went to every deck's is the split the election
452
+ // exists to prevent (#447).
453
+ if (state.rootOpened) maybeResolveCodexMemory(state.sid, state.cwd, persist);
454
+ }
455
+
456
+ // AN EMPTY LISTING IS NOT EVIDENCE THAT ANYTHING WENT AWAY, and skipping
457
+ // the sweep on those ticks is the other half of sweepCodexCursors'
458
+ // argument. The "not seen for a while" clock was meant to ride out a
459
+ // directory that is unreadable for a moment, and it did — but it ran on
460
+ // ticks where `seenAt` had been refreshed for NOBODY, because
461
+ // walkRolloutDays swallows its readdir error at every level and answers
462
+ // `[]` rather than throwing. Ten minutes of an unreachable $CODEX_HOME — a
463
+ // network or removable volume, an encrypted home not yet unlocked, a
464
+ // permission change — and every cursor was gone, which is the whole window
465
+ // the clock was sized to survive (#981).
466
+ //
467
+ // Nothing is lost by waiting: an empty listing means no file can be read
468
+ // this tick anyway, and a tree that really is empty stays empty, so the
469
+ // first tick that lists anything at all sweeps what is genuinely stale.
470
+ if (files.length) sweepCodexCursors(now);
471
+ } catch {
472
+ /* swallow — watcher must never crash the server */
473
+ }
474
+ }
475
+
476
+ export function startCodexWatcher(workspace) {
477
+ codexWorkspace = workspace ?? "";
478
+ // Deliberately not gated on CODEX_SESSIONS_DIR existing. `codex login`
479
+ // creates ~/.codex, but sessions/ only appears when the first session
480
+ // starts — and rollout tailing is the only Codex capture path there is, so
481
+ // bailing out here meant a fresh install had to restart the deck before any
482
+ // Codex agent ever showed up, while the banner claimed to be watching.
483
+ // listRecentCodexRollouts returns [] while the directory is missing, so the
484
+ // poll below is a cheap no-op that doubles as the existence re-check. A
485
+ // filesystem watch is no help: fs.watch on a missing path throws, and
486
+ // watching the parent recursively is macOS/Windows-only.
487
+ //
488
+ // Initial catalog: park a cursor at the end of every rollout already on disk,
489
+ // skipping its history, then poll for new lines. The `true` is what tells
490
+ // codexScanOnce that these files pre-date this deck, and so that the roots
491
+ // they eventually open must arrive without a `SessionStart` (#684).
492
+ codexScanOnce(true).catch(() => {});
493
+ codexWatchTimer = setInterval(() => { codexScanOnce(false).catch(() => {}); }, 1500);
494
+ if (codexWatchTimer.unref) codexWatchTimer.unref();
495
+ return codexWatchTimer;
496
+ }