ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  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-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -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 +40 -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 +7 -3
  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 +2 -2
  67. package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,32 @@
1
+ // The load average, and the one rule for when it is a reading at all.
2
+ //
3
+ // Two things in system-metrics.mjs ask for it: the `load:1m` series the sampler
4
+ // records on every CPU tick, and the `loadavg` that /api/system answers. Each
5
+ // used to carry its own copy of the platform test and its own copy of the
6
+ // comment defending it, the second pointing at the first. #1028 was a bug in
7
+ // BOTH copies at once — the same extra clause on each — so this file is the rule
8
+ // spelled once, with both callers asking it.
9
+ import os from "node:os";
10
+
11
+ /**
12
+ * The one-, five- and fifteen-minute load averages, to two places, or null on a
13
+ * platform that publishes none.
14
+ *
15
+ * `os.loadavg()` reads a kernel value — no syscall worth the name — which is why
16
+ * the sampler can afford it on every CPU tick. Windows returns [0,0,0], which is
17
+ * not a reading and is not reported as one.
18
+ *
19
+ * THE PLATFORM TEST IS THE WHOLE TEST. It used to be joined by
20
+ * `load.some(n => n > 0)`, which reads as belt and braces and is not: Node
21
+ * documents `os.loadavg()` as always [0,0,0] on Windows, so the platform test
22
+ * alone already excludes every non-reading — and the extra clause could only
23
+ * ever reject a reading that was REAL. `/proc/loadavg` on a genuinely quiet
24
+ * Linux box says `0.00 0.00 0.00`, so "Queued work" recorded no points at all
25
+ * for every quiet minute, and after the machine woke the chart read as though
26
+ * the deck had been switched off through them. And MachinePanel gates the whole
27
+ * section on `{loadavg && …}`, so an idle Linux box had the section disappear
28
+ * from under it (#1028).
29
+ */
30
+ export function loadReading(platform = process.platform, load = os.loadavg()) {
31
+ return platform !== "win32" ? load.map(n => Math.round(n * 100) / 100) : null;
32
+ }
@@ -0,0 +1,235 @@
1
+ // Which of the running decks writes an event to the log they share?
2
+ //
3
+ // The hook already answers that for the events it delivers: it groups the decks
4
+ // it is about to post to by the log file each one names in its discovery record,
5
+ // elects one per file, and marks the request to every other one `?persist=0`.
6
+ // See electWriters in hook/hook.js.
7
+ //
8
+ // The Codex rollout watcher never goes through the hook — it builds its events
9
+ // inside the server by tailing ~/.codex/sessions/**/rollout-*.jsonl — so nothing
10
+ // suppressed the copies on that path: every deck tailing the same rollout
11
+ // appended its own line to the one events.jsonl they all default to, so each
12
+ // Codex tool call, prompt and session start landed there once per running deck.
13
+ // That is the duplication the hook election was added to end, still open on the
14
+ // path that is the only Codex capture there is on Windows, where Codex hooks
15
+ // never fire at all.
16
+ //
17
+ // So the server runs the same election, over the same discovery records, with
18
+ // the same tie-break. The rule is repeated here rather than imported from
19
+ // hook/hook.js because that file is copied out of the package and run standalone
20
+ // by the host CLI, with no path back to the module it came from — the same
21
+ // reason it re-derives the Claude config dir inline. The two copies are pinned
22
+ // equal by a test, as challengeProof's pair already is.
23
+ //
24
+ // These lived at the top of log-writer.mjs, above the appender they decide
25
+ // for. They are pure rules over discovery records and paths and the appender
26
+ // never called one, so they moved on their own: event-log.mjs asks the
27
+ // election who owns the log, the rollout watcher and the boot replay ask it
28
+ // whose rollout a cwd is, and discovery.mjs and the launcher spell the log's
29
+ // path through it. The bodies are unchanged.
30
+ import { realpathSync } from "node:fs";
31
+ import { basename, dirname, join, resolve, win32, posix } from "node:path";
32
+
33
+ /**
34
+ * The one spelling of an events log, so two decks pointed at one file land in
35
+ * one group (#793).
36
+ *
37
+ * `resolve` alone was what shipped, and it settles relative-vs-absolute and
38
+ * nothing else. The election below then only case-folds — so two spellings of
39
+ * one file read as two files, which is the exact thing `bin/deck.js`'s comment
40
+ * over this value says must not happen. On Windows it needs no odd user action:
41
+ * `claudeConfigDir()` derives from `homedir()`, and a shell whose `USERPROFILE`
42
+ * is 8.3-shortened yields a different default string than one with the long
43
+ * form. `subst` and mapped drives and junctions do it too, and on macOS so does
44
+ * `/tmp` against `/private/tmp`.
45
+ *
46
+ * What it cost was not merely a duplicate group. BOTH decks were then elected,
47
+ * so every hook event was appended twice — the duplicate-tools-after-restart
48
+ * symptom the election exists to end — and `logSharing()` compares the same
49
+ * string, so both answered `mine: true` and `POST /api/clear` truncated a file
50
+ * this deck does not own. That is the #698 history loss the ownership gate was
51
+ * added to prevent.
52
+ *
53
+ * THE DIRECTORY IS CANONICALISED EVEN WHEN THE FILE IS NOT THERE, which is the
54
+ * half a plain realpath misses. On a first run, or against a `--history` naming
55
+ * a file the deck will create, `realpath` throws ENOENT — and falling back to
56
+ * the resolved string would leave the two spellings different for exactly the
57
+ * run that creates the file. The parent exists (or is about to be created under
58
+ * one canonical name), so it is canonicalised and the basename rejoined.
59
+ *
60
+ * `canonicalWorkspace` in canonical-path.mjs is the same rule for the other
61
+ * path this deck publishes, with three comments naming 8.3 expansion as its
62
+ * reason. This is that rule reaching the value two lines away from it.
63
+ */
64
+ export function canonicalLogPath(raw) {
65
+ if (typeof raw !== "string" || raw.trim() === "") return "";
66
+ const abs = resolve(raw);
67
+ try { return realpathSync.native(abs); } catch { /* not created yet */ }
68
+ try { return join(realpathSync.native(dirname(abs)), basename(abs)); } catch { return abs; }
69
+ }
70
+
71
+ /**
72
+ * Does this platform's filesystem treat two spellings that differ only in case
73
+ * as the same file? The platform is a parameter so both answers can be checked
74
+ * from either kind of machine.
75
+ *
76
+ * Windows always does, and macOS does by default (APFS and HFS+ are formatted
77
+ * case-insensitive unless the user deliberately chose otherwise). Linux does
78
+ * not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
79
+ * /srv/A/events.jsonl are two real files, each of which needs a writer.
80
+ */
81
+ export const foldsCase = (platform = process.platform) =>
82
+ platform === "win32" || platform === "darwin";
83
+
84
+ /**
85
+ * Of these decks, which ones write to disk? Returns the subset that should;
86
+ * every other one is expected to draw the event and keep no record of it.
87
+ *
88
+ * Decks are grouped by the log file each one names in its discovery record and
89
+ * one deck per group is elected. Grouping by the file rather than counting decks
90
+ * is what keeps the overrides honest: a deck run with `--history` sits alone in
91
+ * its own group and always writes, a deck run with `--no-persist` reports no
92
+ * file and can never be elected to write for one that does, and a deck too old
93
+ * to report either keeps the behaviour it had before this rule existed. Within a
94
+ * group the lowest port wins — a fixed rule, so the same deck holds the file for
95
+ * as long as it is up and the next one inherits it as soon as that deck is gone.
96
+ *
97
+ * Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
98
+ * for the same decks over the same records, and a disagreement between them
99
+ * means one log line written twice or none at all.
100
+ */
101
+ export function electWriters(decks, platform = process.platform) {
102
+ const byLog = new Map();
103
+ for (const d of decks) {
104
+ const log = typeof d.persist === "string" ? d.persist : "";
105
+ // Two namespaces, so a deck with no log to share — and a deck too old to
106
+ // report one — is alone in its group and cannot collide with a real path.
107
+ const key = log
108
+ ? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
109
+ : `deck:${d.pid}:${d.port}`;
110
+ const held = byLog.get(key);
111
+ // Ports are unique among live decks; pid only breaks a tie a stale
112
+ // discovery file could invent, so the answer stays deterministic.
113
+ if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
114
+ byLog.set(key, d);
115
+ }
116
+ }
117
+ return new Set(byLog.values());
118
+ }
119
+
120
+ /**
121
+ * Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
122
+ * empty workspace is unscoped and captures every session; a rollout that never
123
+ * said where it runs is inside no workspace, so only an unscoped deck draws it.
124
+ *
125
+ * This answers two questions with one function — whether THIS deck tails a
126
+ * rollout, and whether another deck tails it too — and that is only sound while
127
+ * the rule below is the rule every deck actually runs. Model another deck's
128
+ * capture with anything else and the election covers the wrong set: a deck that
129
+ * writes without being elected, or an elected deck that never opened the file.
130
+ *
131
+ * It is also the rule hook/hook.js runs for the sessions it delivers, under the
132
+ * name capturesSession — that script is copied out of the package and run
133
+ * standalone, so the two are written twice and pinned equal by a test walking
134
+ * one table of paths through both. They were not equal: case was folded here on
135
+ * every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
136
+ * from /srv/Proj and Claude sessions from neither. Those are two real
137
+ * directories there, and the hook's own comment says what folding them together
138
+ * costs — a deck handed the events of a tree it was not scoped to. So the fold
139
+ * is per-platform on both sides now, and `--workspace` means one thing.
140
+ *
141
+ * (The narrow window that opens: two decks on Linux whose workspaces differ only
142
+ * in case, one of them old enough to still fold, both containing one rollout's
143
+ * cwd. Each models the other as tailing the file; one of them is wrong, and the
144
+ * cost is a single log line written twice.)
145
+ *
146
+ * The platform is a parameter, following the hook's cwdInWorkspace and
147
+ * spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
148
+ * POSIX machine.
149
+ */
150
+ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
151
+ if (!workspace || typeof workspace !== "string") return true;
152
+ if (!cwd || typeof cwd !== "string") return false;
153
+ const p = platform === "win32" ? win32 : posix;
154
+ const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
155
+ const a = fold(p.resolve(cwd));
156
+ const b = fold(p.resolve(workspace));
157
+ if (a === b) return true;
158
+ // A root ("C:\", "/") already ends in the separator; appending a second one
159
+ // would match nothing.
160
+ return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
161
+ }
162
+
163
+ /**
164
+ * Do two discovery records name the same Codex tree? Each argument is a
165
+ * record's `codexHome`: the canonical path of the CODEX_HOME that deck tails.
166
+ *
167
+ * Anything that is not a non-empty string answers yes. That is a record written
168
+ * before the field existed — a machine halfway through an upgrade is the
169
+ * ordinary way to meet one — and guessing "a different tree" there would elect
170
+ * a second writer for one log on every machine with an older deck still up.
171
+ * Guessing "the same tree" is what every deck did before the field existed,
172
+ * which is the fail-safe writesCodexLog below already takes for a record too old
173
+ * to carry `codex` at all.
174
+ *
175
+ * Case is folded where the filesystem folds it, exactly as electWriters folds
176
+ * the log path: on Windows and macOS two spellings that differ only in case are
177
+ * one directory with one reader, and on Linux they are two directories, each
178
+ * read by its own deck. The other ways to spell one directory — a symlinked
179
+ * ~/.codex, an 8.3-shortened USERPROFILE, /tmp against /private/tmp — are
180
+ * settled before the value is published (writeDiscovery in discovery.mjs runs
181
+ * it through canonicalLogPath), so two records compared here already agree on
182
+ * everything but case.
183
+ */
184
+ export function sameCodexTree(a, b, platform = process.platform) {
185
+ if (typeof a !== "string" || a === "" || typeof b !== "string" || b === "") return true;
186
+ return foldsCase(platform) ? a.toLowerCase() === b.toLowerCase() : a === b;
187
+ }
188
+
189
+ /**
190
+ * Does this deck append a rollout's events to its log, or is another deck doing
191
+ * it? `decks` is every deck registered right now, `pid` identifies this one
192
+ * among them, and `cwd` is the workspace the rollout is running in.
193
+ *
194
+ * The group is every deck that tails this same rollout: each deck decides for
195
+ * itself, so all of them whose workspace contains the cwd read the file and all
196
+ * of them would write it. The hook builds the same group the same way for the
197
+ * events it delivers — it used to narrow them to the longest workspace match
198
+ * first, which is the asymmetry the predicate above describes the end of. A deck
199
+ * started with `--no-codex` tails nothing and is left out; electing it would
200
+ * mean the rollout's events reach no log at all. A deck too old to say either
201
+ * way is assumed to be tailing, which is what it was doing before this field
202
+ * existed.
203
+ *
204
+ * A deck reading a different Codex tree is left out for the same reason (#982).
205
+ * CODEX_HOME moves the whole tree, so two decks on one machine can share one
206
+ * events.jsonl while reading two different sets of rollouts — and for an
207
+ * unscoped deck the workspace test above answers yes to every cwd, so it tells
208
+ * them apart not at all. The record's `codex: true` said a deck tails rollouts
209
+ * and never said whose. So the lower-port deck won the line for a rollout its
210
+ * own tree does not hold and appended nothing, the deck that was reading it
211
+ * stood down, and the shared log recorded none of the session while the canvas
212
+ * drew all of it: #695's symptom, from a deck that is alive, answers its
213
+ * challenge, and is simply looking somewhere else. The same deck launched with
214
+ * a stale CODEX_HOME is the duller version of it.
215
+ */
216
+ export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
217
+ const live = Array.isArray(decks) ? decks : [];
218
+ const self = live.find(d => d && d.pid === pid) ?? null;
219
+ // No record of our own on disk — the window before the first heartbeat writes
220
+ // it, or a deck that cannot write one at all. Nobody can elect us and we
221
+ // cannot see who else is here, so keep what this deck did before the election
222
+ // existed and write. A line written twice is recoverable; a deck that quietly
223
+ // stops recording anything is not.
224
+ if (!self || typeof self.persist !== "string" || self.persist === "") return true;
225
+
226
+ const group = [self];
227
+ for (const d of live) {
228
+ if (!d || d.pid === self.pid) continue;
229
+ if (d.codex === false) continue;
230
+ if (!sameCodexTree(self.codexHome, d.codexHome, platform)) continue;
231
+ if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
232
+ group.push(d);
233
+ }
234
+ return electWriters(group, platform).has(self);
235
+ }
@@ -0,0 +1,374 @@
1
+ // The boot replay: what a starting deck reads back out of its log, and which of
2
+ // those events belong on this deck's canvas.
3
+ //
4
+ // These lived in src/server/index.mjs, after pushEvent. They push through
5
+ // event-sink.mjs as every other emitter outside index.mjs does, size their
6
+ // read against the ring's own two bounds (ring-bounds.mjs), and judge scope by
7
+ // the same predicate the Codex watcher runs (log-election.mjs) — nothing here
8
+ // reads the ring. startServer calls replayLog once, with the log openEventLog
9
+ // resolved. The bodies are unchanged.
10
+ import { existsSync } from "node:fs";
11
+ import { PRODUCT } from "./brand.mjs";
12
+ import { codexCwdInWorkspace } from "./log-election.mjs";
13
+ import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
14
+ import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, payloadChars } from "./ring-bounds.mjs";
15
+ // event-pipeline.mjs's pushEvent, reached without importing it — see
16
+ // event-sink.mjs.
17
+ import { pushEvent } from "./event-sink.mjs";
18
+
19
+ /**
20
+ * Which of the log's events belong on THIS deck's canvas — the boot replay's
21
+ * half of `--workspace`, and the half that did not exist (#696).
22
+ *
23
+ * `--workspace` filtered the two LIVE capture paths and nothing else. The log is
24
+ * the machine-wide `<claude config dir>/agent-dag/events.jsonl` that every deck
25
+ * on the box shares by default, and `replayLog` pushed all of it, so a deck
26
+ * started with `--workspace ~/proj` came up with every session on the machine
27
+ * already drawn — including the ones it had just printed it would not capture,
28
+ * contradicting its own `workspace` row, README.md and the empty-state sentence
29
+ * in src/web/scope.ts that exists precisely so the canvas stops asserting things
30
+ * that are not true (#404).
31
+ *
32
+ * THE RULE IS NOT A NEW ONE. Per event it is `codexCwdInWorkspace`, the same
33
+ * predicate the Codex watcher runs and the twin of `capturesSession` in
34
+ * hook/hook.js — the two are pinned equal by a test walking one table of paths
35
+ * through both, so `--workspace` means one thing on every path a payload can
36
+ * reach the ring by, including this one. Nothing about case folding, separators
37
+ * or the sibling-prefix trap (`/srv/projX` is not inside `/srv/proj`) is decided
38
+ * here; it is decided there, once, per platform.
39
+ *
40
+ * WHAT THE MAP IS FOR. Not every line carries a cwd. The synthetic enrichment
41
+ * events the transcript scanners emit — `ModelObserved`, `UsageObserved`,
42
+ * `SessionNamed`, `ContextObserved` — carry `session_id` and nothing else, and
43
+ * they are persisted like any other event. Judging those by the live rule alone
44
+ * (no cwd, so inside no workspace) would keep an in-scope session on the canvas
45
+ * while stripping its model, its token columns and its name until the next live
46
+ * event arrived. So the answer for a session is learned from the events that DO
47
+ * say where they run and carried forward to the ones that do not.
48
+ *
49
+ * That is a reconstruction of the live behaviour rather than a second notion of
50
+ * scope: live, a deck only ever emits `ModelObserved` for a session whose hook
51
+ * event it already accepted, so "follows its session" is what those events
52
+ * already do — the map only re-derives it from a file. It costs one entry per
53
+ * distinct session id in the log (a few thousand at the very most, against a log
54
+ * measured in tens of megabytes) and one lookup per line.
55
+ *
56
+ * TWO CASES DECIDED EXPLICITLY:
57
+ *
58
+ * * `__clear` — the control marker `/api/clear` pushes, with `cwd: ""`. It is
59
+ * not a session event; it is the instruction that makes the reducer forget
60
+ * everything before it. It has not been written to the log since #698, but
61
+ * logs the decks before that wrote still carry it, and dropping it on a
62
+ * scoped deck would replay the state a user had explicitly cleared, so it
63
+ * is always admitted.
64
+ * * a payload with no cwd and no session the map has seen — refused on a
65
+ * scoped deck, which is exactly what `capturesSession` decides live for a
66
+ * session that never said where it runs.
67
+ *
68
+ * An unscoped deck (`workspace === ""`, the default) admits everything, and
69
+ * takes the cheapest possible path to saying so.
70
+ *
71
+ * @param {string} workspace this deck's canonical workspace; "" for machine-wide
72
+ * @returns {(payload: unknown) => boolean} called once per replayed envelope, in
73
+ * log order — it remembers, so the order matters and it is not reusable across
74
+ * two replays.
75
+ */
76
+ export function replayScope(workspace, platform = process.platform, providers = null) {
77
+ // WHICH CLIS THIS DECK IS WATCHING, and the half of `--no-codex` that did not
78
+ // exist (#1004). Live capture is gated on the provider — `if (codex)
79
+ // startCodexWatcher(...)`, and the Claude hook is only installed when
80
+ // `deckProviders().claude` — and the boot replay was not, so a deck started
81
+ // with `--no-codex` printed
82
+ //
83
+ // Codex sessions skipped — no ~/.codex/, or --no-codex
84
+ //
85
+ // and then filled its canvas with Codex sessions out of the machine-wide
86
+ // events.jsonl, frozen mid-turn, which no live path could ever close because
87
+ // the watcher that would have is the one that was skipped. Measured on a log
88
+ // of two Claude lines and two Codex lines replayed with `codex: false`:
89
+ //
90
+ // in the ring: [ 'claude:claude-1:SessionStart', 'claude:claude-1:PreToolUse',
91
+ // 'codex:codex-1:SessionStart', 'codex:codex-1:PreToolUse' ]
92
+ //
93
+ // The mirror case is worse: a `--no-claude` deck replays Claude sessions while
94
+ // `providers.claude` is false, so App.tsx hides the accounts panel and the
95
+ // sound controls — sessions on screen the deck has deliberately taken the
96
+ // controls for away.
97
+ //
98
+ // It costs one comparison per line. `provider: "codex"` is stamped on every
99
+ // payload the Codex watcher emits, its cwd-less enrichment included, and the
100
+ // Claude side is the complement — which is what types.ts already says
101
+ // `provider` means ("defaults to claude for replay events written before
102
+ // multi-provider support"). So this needs no per-session memory of its own and
103
+ // leaves `orderDependent` alone.
104
+ const claudeOn = providers?.claude !== false;
105
+ const codexOn = providers?.codex !== false;
106
+ const scoped = !!workspace && typeof workspace === "string";
107
+ if (!scoped && claudeOn && codexOn) {
108
+ const all = () => true;
109
+ // Every caller gets the same answer for the same payload, forever. That is
110
+ // what lets replayLog read the log from its end — see `orderDependent` on
111
+ // the scoped predicate below for the half that cannot.
112
+ all.orderDependent = false;
113
+ return all;
114
+ }
115
+ const bySession = new Map();
116
+ /**
117
+ * THE ANSWER DEPENDS ON WHAT CAME BEFORE, and #742 is why that is now stated
118
+ * out loud rather than left as an implementation detail.
119
+ *
120
+ * The synthetic enrichment events — ModelObserved, UsageObserved,
121
+ * SessionNamed, ContextObserved — carry a session_id and no cwd, so the only
122
+ * thing that can decide them is a cwd-bearing event for the same session,
123
+ * and that event is EARLIER in the log. Fed the log backwards, this predicate
124
+ * meets the enrichment first, has nothing in `bySession`, and drops it: the
125
+ * session lands on the canvas with no model and no tokens.
126
+ *
127
+ * So the flag is not advice. replayLog reads it, and reads the file forwards
128
+ * whenever it is set. Making a scoped replay cheap needs an index of where a
129
+ * workspace's lines are, which is a different change from this one.
130
+ */
131
+ admits.orderDependent = scoped;
132
+ return admits;
133
+
134
+ function admits(payload) {
135
+ if (!payload || typeof payload !== "object") return false;
136
+ if (payload.hook_event_name === "__clear") return true;
137
+ // The provider gate, ahead of the workspace one and exempting `__clear` for
138
+ // the same reason it does: the marker carries no provider, and a deck that
139
+ // dropped it would replay a canvas the user had explicitly cleared.
140
+ if (!(payload.provider === "codex" ? codexOn : claudeOn)) return false;
141
+ if (!scoped) return true;
142
+ const sid = typeof payload.session_id === "string" ? payload.session_id : null;
143
+ const cwd = typeof payload.cwd === "string" && payload.cwd !== "" ? payload.cwd : null;
144
+ if (cwd) {
145
+ const inside = codexCwdInWorkspace(cwd, workspace, platform);
146
+ if (sid) bySession.set(sid, inside);
147
+ return inside;
148
+ }
149
+ if (sid && bySession.has(sid)) return bySession.get(sid);
150
+ return false;
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Can this log fill the ring on its own?
156
+ *
157
+ * Read from the END and bounded by the ring, so the answer costs at most one
158
+ * ring's worth of parsing however large the file is — and on a full log it
159
+ * stops within the first few hundred lines. Deliberately UNSCOPED: the number
160
+ * it returns is an upper bound on what any workspace predicate will admit, so
161
+ * `false` is a certainty that the archive is needed while `true` is only the
162
+ * absence of evidence that it is. See the order-dependent branch of replayLog
163
+ * for why that asymmetry is the right way round.
164
+ */
165
+ async function fillsRing(filePath, maxEvents, maxChars) {
166
+ let n = 0;
167
+ let chars = 0;
168
+ for await (const line of linesFromEnd(filePath)) {
169
+ if (!line) continue;
170
+ let evt;
171
+ try { evt = JSON.parse(line); } catch { continue; }
172
+ if (!evt || typeof evt !== "object" || !evt.payload) continue;
173
+ n++;
174
+ chars += ENVELOPE_CHARS + payloadChars(evt.payload);
175
+ if (n >= maxEvents || chars >= maxChars) return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ /**
181
+ * Read the log back into the ring buffer at boot.
182
+ *
183
+ * A line that will not parse is skipped rather than thrown on, and that is
184
+ * deliberate — it is what lets a log damaged by the pre-#446 writer still
185
+ * replay. Every line before the damage and every line after it is whole, so
186
+ * stopping at the first bad one would throw away the rest of the session for a
187
+ * fault that costs one event. Newline framing is what makes the recovery
188
+ * possible: a torn write leaves the reader resynchronised at the very next
189
+ * `\n`, with no length prefix to have been lost along with the bytes.
190
+ *
191
+ * What changed is that the skip is no longer silent. Before, a deck whose log
192
+ * had been shredded replayed "mostly" and said nothing anywhere: the largest
193
+ * tool responses and whatever ordinary event was spliced into them were gone,
194
+ * with no counter and no line on the terminal. The count below is the only
195
+ * signal that a log carries damage from a writer that has since been fixed, and
196
+ * the byte total is what tells the user whether it was one truncated tail from
197
+ * a kill -9 or a megabyte of shredded tool output.
198
+ *
199
+ * Deliberately one line, and deliberately without the path in it: this prints
200
+ * onto a terminal the deck is about to paint over (see oneLine in term.mjs for
201
+ * what a multi-line message does there), and the path was already printed at
202
+ * boot by the caller.
203
+ *
204
+ * `workspace` is what makes the replay agree with the two live capture paths —
205
+ * see replayScope. An out-of-scope line is NOT counted into `skipped` and is not
206
+ * warned about: `skipped` means "this log has bytes in it no reader can parse",
207
+ * which is damage and is worth a line on the terminal, while a scoped deck
208
+ * declining a session it was told not to capture is the flag doing its job. They
209
+ * are two different things and only the first is ever printed.
210
+ *
211
+ * WHAT THE FILTER COSTS AT BOOT, honestly: nothing is saved on the read of any
212
+ * line this reaches, because the cwd being judged is inside the JSON. What it
213
+ * saves is everything after the parse: no redaction pass, no envelope, no ring
214
+ * insert, no character accounting and no eviction pressure for a line this deck
215
+ * should never have held. The ring is bounded by MAX_BUFFER events AND
216
+ * MAX_BUFFER_CHARS, so on a busy machine the out-of-scope traffic was not
217
+ * merely extra — it was evicting the in-scope sessions the user started the
218
+ * deck to watch.
219
+ *
220
+ * READ BACKWARDS, AND ONLY AS FAR AS THE RING (#742). This used to stream the
221
+ * whole file from the front, and the whole file is where the boot's time went:
222
+ * 12,079 lines and 31 MB on the machine it was measured on, 690ms of
223
+ * JSON.parse, growing with every session until rotation cuts it at 50 MB — to
224
+ * fill a ring that holds two thousand events. Five sixths of that parse was
225
+ * feeding the eviction loop, on the critical path of a boot, every time.
226
+ *
227
+ * So the lines arrive newest-first and the loop stops the moment the ring is
228
+ * full, which makes the cost a property of MAX_BUFFER rather than of how long
229
+ * the user has been running the deck. Nothing is lost by it: what a forward
230
+ * replay left in the ring was always the NEWEST admitted events that fit, and
231
+ * that is exactly the set this collects. A young log — too few events to fill
232
+ * the ring — is read to its start, and costs what it always did.
233
+ *
234
+ * The order of the pushes is still oldest-first. `seq` is assigned by pushEvent
235
+ * in the order it is called, and a ring numbered backwards would hand every
236
+ * resuming client a Last-Event-ID that means the opposite of what it says.
237
+ *
238
+ * A SCOPED DECK STILL READS FORWARDS, and that is not an oversight. Its
239
+ * predicate decides the cwd-less enrichment events — ModelObserved,
240
+ * UsageObserved, SessionNamed, ContextObserved — from the cwd-bearing event
241
+ * earlier in the log, so backwards it meets the answer after the question and
242
+ * drops them: the session arrives on the canvas with no model and no tokens.
243
+ * `replayScope` says which kind of predicate it handed over rather than this
244
+ * inferring it from the workspace string, so the two cannot drift apart. Making
245
+ * that case cheap needs an index of where a workspace's lines are, which is a
246
+ * different change from this one.
247
+ */
248
+ /* Exported for the suite, with both ceilings as parameters. The byte budget is
249
+ * 128 MiB, so a test that wanted to reach it honestly would have to write 128
250
+ * MiB — which is why the count bound was the only one anything pinned, and why
251
+ * the byte bound was the one that broke. Production passes neither argument. */
252
+ export async function replayLog(filePath, workspace = "", {
253
+ maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS, providers = null,
254
+ } = {}) {
255
+ // THE ARCHIVE IS PART OF THE HISTORY, and for two years nothing read it.
256
+ // `maybeRotatePersistFile` renames events.jsonl to events.jsonl.1 at 50 MB;
257
+ // a grep for `.1` across src/ and bin/ finds the write, the copy deck-home's
258
+ // migration makes, and two comments. replayLog is called once, with
259
+ // `persistPath` alone. So the boot immediately after a rotation replays a
260
+ // file holding a handful of lines while 50 MB of history sits beside it
261
+ // unread — and the next rotation's `unlink` deletes it outright. Measured:
262
+ // one rotation, then `replayLog(events.jsonl)` returning 1.
263
+ //
264
+ // The reader below already stops the moment EITHER of the ring's bounds is
265
+ // reached, so on a log that can fill the ring on its own this costs nothing
266
+ // at all: the archive is never opened. It is read only when the live log
267
+ // cannot fill the ring, which is exactly the window a rotation opens.
268
+ //
269
+ // A Clear opens the same window and means the opposite by it. It empties the
270
+ // live log, which can then fill nothing, so reading on into the archive put
271
+ // back precisely what the user had cleared (#1130) — which is why a Clear
272
+ // removes the archive as well as emptying the live log, rather than this
273
+ // learning to tell the two kinds of empty apart. See handleClear.
274
+ const archivePath = filePath + ".1";
275
+ const liveThere = existsSync(filePath);
276
+ const archiveThere = existsSync(archivePath);
277
+ if (!liveThere && !archiveThere) return 0;
278
+ let skipped = 0;
279
+ let skippedBytes = 0;
280
+ // `providers` is the second scope this deck has, and it goes the same way the
281
+ // first one does — into the predicate, so the replay rule stays pinned equal
282
+ // to the live rule rather than being a second notion of it. See replayScope.
283
+ const admits = replayScope(workspace, process.platform, providers);
284
+ const replay = (evt) =>
285
+ pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
286
+ const parse = (line) => {
287
+ try {
288
+ return JSON.parse(line);
289
+ } catch {
290
+ skipped++;
291
+ skippedBytes += Buffer.byteLength(line, "utf8");
292
+ return null;
293
+ }
294
+ };
295
+ const usable = (evt) => evt && typeof evt === "object" && evt.payload;
296
+
297
+ let count = 0;
298
+ if (admits.orderDependent) {
299
+ // Oldest first, so the archive comes BEFORE the live log — `admits` is
300
+ // stateful and answers from what it has already seen, which is the whole
301
+ // reason this branch reads forwards at all.
302
+ //
303
+ // Whether the archive is worth reading is decided by a probe rather than by
304
+ // this branch's own reader, because this branch has no stopping rule: it
305
+ // pushes everything and lets the ring evict. The probe reads the LIVE log
306
+ // backwards, unscoped, and is bounded by the ring, so it costs at most one
307
+ // ring's worth of parsing from the end of the file and nothing more. An
308
+ // unscoped count is an upper bound on what scoping will admit, so "this
309
+ // cannot fill the ring" is certain when the probe says so — and when it
310
+ // says the opposite a scoped deck may still under-fill, which is precisely
311
+ // what it does today. Nothing regresses; the case a rotation creates, where
312
+ // the live log holds a handful of lines, is the one that is fixed.
313
+ const files = [];
314
+ if (archiveThere && !(liveThere && await fillsRing(filePath, maxEvents, maxChars))) files.push(archivePath);
315
+ if (liveThere) files.push(filePath);
316
+ for (const file of files) {
317
+ for await (const line of linesFromStart(file)) {
318
+ if (!line) continue;
319
+ const evt = parse(line);
320
+ if (!usable(evt) || !admits(evt.payload)) continue;
321
+ replay(evt);
322
+ count++;
323
+ }
324
+ }
325
+ } else {
326
+ // Newest first, so this is filled back to front and then walked in reverse
327
+ // to push. Bounded by BOTH of the ring's limits, which is what makes the
328
+ // memory here a property of the ring rather than of the file.
329
+ //
330
+ // The count alone was not enough, and the comment that used to say it was
331
+ // predates #625. Eviction is the only thing that applies MAX_BUFFER_CHARS,
332
+ // and eviction happens inside pushEvent — which does not run until this
333
+ // array is already full. Measured on a 187 MB log of 40 events of 4.9M
334
+ // characters each, every one of them under the ingest cap: RSS went from
335
+ // 215 MB to a peak of 505 MB, and the ring that survived held 27 events and
336
+ // 126 MiB. About 290 MB staged for a ring capped at 128.
337
+ //
338
+ // Rotation at 50 MB normally keeps logs well under this, but rotation is
339
+ // best-effort and its failure is only logged, and the rotation's own note
340
+ // in event-log.mjs records logs reaching gigabytes.
341
+ const newestFirst = [];
342
+ let stagedChars = 0;
343
+ // Newest generation first. The second pass runs only if the first stopped
344
+ // because it ran out of FILE rather than because it reached a bound, which
345
+ // is the "cannot fill the ring" test stated exactly and for free — and it
346
+ // is also why a full live log never opens the archive at all.
347
+ for (const file of liveThere ? [filePath, archivePath] : [archivePath]) {
348
+ if (!existsSync(file)) continue;
349
+ if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
350
+ for await (const line of linesFromEnd(file)) {
351
+ if (!line) continue;
352
+ const evt = parse(line);
353
+ if (!usable(evt) || !admits(evt.payload)) continue;
354
+ newestFirst.push(evt);
355
+ stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
356
+ // Everything older than this would be evicted by the events already held,
357
+ // so reading further is work whose only result is throwing it away.
358
+ // Either limit reaching its ceiling means exactly that.
359
+ if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
360
+ }
361
+ }
362
+ for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
363
+ count = newestFirst.length;
364
+ }
365
+ if (skipped > 0) {
366
+ // "in the part of the log it read", because that is now a part rather than
367
+ // the whole: a damaged line older than the ring is never reached, and
368
+ // claiming to have counted every unreadable line in the file would be a
369
+ // number this no longer has.
370
+ const kb = (skippedBytes / 1024).toFixed(0);
371
+ console.warn(`${PRODUCT}: skipped ${skipped} unreadable line(s) (${kb}KB) while replaying the event log`);
372
+ }
373
+ return count;
374
+ }