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,298 @@
1
+ // This deck's entry in the discovery dir: the record every hook reads to find a
2
+ // deck to post to, written so no reader lands inside it, and kept on disk for as
3
+ // long as the deck runs.
4
+ //
5
+ // These lived in src/server/installer.mjs, after the hook installer, and share
6
+ // nothing with it but the directory and the atomic write. The hooks are
7
+ // installed once per boot; the record is written at boot, re-asserted every five
8
+ // seconds and removed at shutdown, by bin/deck.js. So they moved to a module of
9
+ // their own, and the directory's definition came with them. installer.mjs
10
+ // imports the directory and re-exports every name, so bin/deck.js and the CLI
11
+ // still import them from there. The code is unchanged.
12
+ import { readFile, mkdir, unlink, chmod, utimes } from "node:fs/promises";
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { claudeConfigDir } from "./claude-dir.mjs";
16
+ // Under the name installer.mjs gives it, which is the one the bodies below use.
17
+ import { CODEX_HOME as CODEX_DIR } from "./codex-dir.mjs";
18
+ import { canonicalLogPath } from "./log-election.mjs";
19
+ import { stripBom, writeFileAtomic } from "./atomic-write.mjs";
20
+
21
+ // Resolved through claude-dir.mjs, the module that owns the rule, exactly as
22
+ // installer.mjs resolves it: the record has to land where the hooks it is for
23
+ // look, and they follow CLAUDE_CONFIG_DIR.
24
+ const CLAUDE_DIR = claudeConfigDir();
25
+
26
+ // Single shared discovery dir — both providers' hook scripts post here so one
27
+ // running agent-dag server can match either ecosystem's events. It follows the
28
+ // Claude config dir, so hook/hook.js has to resolve that dir the same way: it
29
+ // reads what this writes, and a disagreement means the hooks find no server.
30
+ const AGENT_DAG_DIR = join(CLAUDE_DIR, "agent-dag");
31
+
32
+ /**
33
+ * The events log as the record spells it: an absolute path, or null when this
34
+ * deck writes none. Shared by the writer and by ensureDiscovery's comparison,
35
+ * so a file this process wrote can never read back as somebody else's.
36
+ */
37
+ function persistField(persist) {
38
+ return typeof persist === "string" && persist !== "" ? persist : null;
39
+ }
40
+
41
+ /**
42
+ * The Codex tree as the record spells it: the canonical path of the CODEX_HOME
43
+ * this process reads, or null for a --no-codex deck, which reads none. Shared by
44
+ * the writer and by ensureDiscovery's comparison for the reason persistField
45
+ * is, and exported for a start's own shape in bin/deck.js — so a start asking
46
+ * "does the running deck read my tree" spells the tree exactly the way the
47
+ * record it compares against does. Worked out on every call rather than once at
48
+ * load: it is one realpath on a five-second heartbeat, and a tree that did not
49
+ * exist when the deck started — the first `codex login` creates it — is then
50
+ * named the way it is named once it does.
51
+ */
52
+ export function codexHomeField(codex) {
53
+ return codex !== false ? canonicalLogPath(CODEX_DIR) : null;
54
+ }
55
+
56
+ // Every deck's token lives in this directory, and writeFileAtomic's temp file is
57
+ // created beside its target with whatever the umask allows — 0644 on most
58
+ // machines — so for the moment before the rename the token would sit in a
59
+ // world-readable file. 0700 on the directory closes that window from the outside:
60
+ // another user cannot traverse into it whatever the mode of a file inside says.
61
+ // The dir usually predates this code, so the mode is re-asserted rather than only
62
+ // set at creation, where it would be masked by the umask anyway. Windows ignores
63
+ // both — NTFS ACLs inherit from the per-user profile directory.
64
+ async function ensureDiscoveryDir() {
65
+ if (!existsSync(AGENT_DAG_DIR)) await mkdir(AGENT_DAG_DIR, { recursive: true, mode: 0o700 });
66
+ await chmod(AGENT_DAG_DIR, 0o700).catch(() => {});
67
+ }
68
+
69
+ /**
70
+ * Register this deck, in one step no reader can land inside.
71
+ *
72
+ * The record used to go down with a plain writeFile, which truncates the target
73
+ * and then fills it, so the file existed and was empty for a moment on every
74
+ * rewrite. Everything that reads this directory parses each record whole —
75
+ * electWriters in hook/hook.js, readLiveDecks in live-decks.mjs and
76
+ * sweepStaleDiscovery in index.mjs — and a record that fails to parse is a deck
77
+ * missing from that cycle: the event it should have logged is either logged by
78
+ * nobody or logged twice by the decks that remain, which is the exact failure
79
+ * the single-writer election exists to prevent, reached through the file the
80
+ * election reads. A rename is atomic on Linux, macOS and Windows alike, so a
81
+ * reader now sees the previous record or the new one and never half of either.
82
+ *
83
+ * The token is the deck's proof of identity, so the file holding it is the
84
+ * deck's key material: readable and writable by its owner, nobody else. The mode
85
+ * is pinned after the write because writeFileAtomic can only carry over a mode
86
+ * the target already had — a first registration, or one left by an earlier run
87
+ * under a recycled pid, would otherwise keep whatever the umask handed it.
88
+ */
89
+ export async function writeDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
90
+ await ensureDiscoveryDir();
91
+ const file = discoveryPath();
92
+ const data = {
93
+ pid: process.pid,
94
+ port,
95
+ workspace: workspace ?? "",
96
+ // Without this the hooks refuse to post: a file naming a port it cannot
97
+ // authenticate is exactly the stale-file case they now decline to trust.
98
+ token: token ?? "",
99
+ // Absolute path of the events log this deck appends to, or null under
100
+ // --no-persist. The hook reads it to elect a single writer per file:
101
+ // several decks receive the same event by design, and without this they
102
+ // each appended their own copy to the one log they share. See
103
+ // electWriters in hook/hook.js.
104
+ persist: persistField(persist),
105
+ // Is this deck tailing Codex's rollout files? Those events never pass
106
+ // through a hook, so the decks elect a writer for them among themselves —
107
+ // and a deck running --no-codex must be left out of that election rather
108
+ // than win it and record a rollout it is not even reading. See
109
+ // writesCodexLog in src/server/log-election.mjs.
110
+ codex: codex !== false,
111
+ // And WHOSE rollouts. `codex: true` says this deck tails them and never said
112
+ // from where, while CODEX_HOME moves the whole tree — #375 was five modules
113
+ // disagreeing about where it points. Two decks sharing one events.jsonl can
114
+ // therefore be reading two different trees, and the election grouped them
115
+ // anyway, because an unscoped deck's workspace contains every cwd and this
116
+ // was the one field that could have told them apart. The lower-port deck won
117
+ // a rollout it had never opened, the deck reading it stood down, and the
118
+ // shared log recorded nothing of that session (#982).
119
+ //
120
+ // Canonical rather than as spelled, for the reason canonicalLogPath gives
121
+ // the log path above (#793): a symlinked ~/.codex, an 8.3-shortened
122
+ // USERPROFILE or /tmp against /private/tmp would otherwise put two decks
123
+ // reading one tree into two groups and elect them both — the duplicate the
124
+ // election exists to end. Only this published copy is canonicalised.
125
+ // codex-dir.mjs goes on reading and writing through the spelling the user
126
+ // chose, which it has to for a symlinked home (see codexHome() there).
127
+ codexHome: codexHomeField(codex),
128
+ // Does this deck run Browser Watch? The watch elects a single writer among
129
+ // the decks on a machine, and it elected on port alone — so an older ccdeck
130
+ // that predates the feature won the election by having the lower port and
131
+ // then wrote nothing, while the deck that HAS the watch stood down. Measured
132
+ // on this machine: a v1.46 deck from an npx cache held 4317, answered the
133
+ // watch route with the SPA's index.html, and Browser Watch silently
134
+ // recorded nothing for as long as both were up. No error, no log line — the
135
+ // panel showed findings on screen and the disk stayed empty.
136
+ //
137
+ // Same shape as `codex` above, and for the same reason: a deck that is not
138
+ // doing the work must be left out of the election rather than win it. An
139
+ // older deck has no such field, so it is excluded by construction.
140
+ watch: true,
141
+ // Is the Claude side of this deck switched on? Same shape as `codex` above
142
+ // and there for a second reader: a bare `ccdeck` that finds this deck
143
+ // running attaches to it instead of starting a rival, and it may only do
144
+ // that when the deck on the port is the deck it would itself have built. A
145
+ // `--no-claude` deck has no hooks, no accounts panel and no switcher, and
146
+ // until this field existed it was indistinguishable from one that has all
147
+ // three. An older deck has no such field, so it never passes for one and is
148
+ // replaced instead — see secondStart.
149
+ claude: claude !== false,
150
+ // What this deck IS, so a launcher that attaches can say whether the deck
151
+ // it found is the version the user just asked for. Never a decision: a
152
+ // rival deck on a random port is worse than an older deck said out loud.
153
+ version: typeof version === "string" ? version : "",
154
+ // The supervisor above this worker, or null when nothing is supervising.
155
+ //
156
+ // Only `ccdeck --stop` reads it, and only on the path where the polite
157
+ // request failed. A worker killed on its own leaves its supervisor alive,
158
+ // and a supervisor that puts crashed workers back would answer that kill by
159
+ // starting the deck again — so the ladder has to end the parent first and
160
+ // the child second, which it cannot do without being told who the parent is.
161
+ parent: Number.isInteger(parent) ? parent : null,
162
+ startedAt: new Date().toISOString(),
163
+ };
164
+ await writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
165
+ await chmod(file, 0o600).catch(() => {});
166
+ return file;
167
+ }
168
+
169
+ export async function removeDiscovery(file) {
170
+ try { await unlink(file); } catch {}
171
+ }
172
+
173
+ /** Where this process registers itself. One file per deck, named by pid. */
174
+ export function discoveryPath() {
175
+ return join(AGENT_DAG_DIR, `${process.pid}.json`);
176
+ }
177
+
178
+ /**
179
+ * Make sure this deck's discovery file is on disk and says what it should.
180
+ *
181
+ * Registration was a single write at boot, so anything that took the file away
182
+ * afterwards — a sweep on another machine's clock, a half-finished restart, a
183
+ * user tidying the directory — left a deck that was listening, serving and
184
+ * completely invisible: hook.js enumerates this directory and nothing else, so
185
+ * the deck received zero events while looking perfectly healthy. Re-asserting
186
+ * is cheap (one small read), so the deck checks rather than assumes.
187
+ *
188
+ * A file this process wrote is left alone, mode and contents included — only its
189
+ * mtime moves, stamped on every check; see the note where it is. Anything else —
190
+ * no file, unreadable, another pid, a stale port, token, events log, Codex
191
+ * setting or Codex tree, Claude setting, or a version left by the deck this
192
+ * process replaced — is replaced. Every field another deck decides by is compared, the
193
+ * log path included: leave one out and a record missing it would pass as ours
194
+ * forever, which for the log path means no deck can tell which of them share a
195
+ * file and they all write their own copy of every event again. The Codex tree
196
+ * is compared strictly for the same reason: a record from before the field
197
+ * existed has none, and `undefined` must read as "rewrite", never as a
198
+ * --no-codex deck's null.
199
+ */
200
+ export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
201
+ const file = discoveryPath();
202
+ try {
203
+ const d = JSON.parse(stripBom(await readFile(file, "utf8")));
204
+ if (d
205
+ && d.pid === process.pid
206
+ && d.port === port
207
+ && (d.workspace ?? "") === (workspace ?? "")
208
+ && (d.token ?? "") === (token ?? "")
209
+ && (d.persist ?? null) === persistField(persist)
210
+ && d.codex === (codex !== false)
211
+ && d.codexHome === codexHomeField(codex)
212
+ && d.claude === (claude !== false)
213
+ && (d.version ?? "") === (typeof version === "string" ? version : "")
214
+ && (d.parent ?? null) === (Number.isInteger(parent) ? parent : null)) {
215
+ // STAMPED, though nothing in it changed. The mtime is how a reader tells a
216
+ // record some deck is still keeping from one whose deck is gone while its
217
+ // pid lives on under another process: hook.js unlinks a record whose port
218
+ // lets both challenge deadlines pass only once this stamp is a minute old
219
+ // (#1069), which a deck running this check every five seconds never lets
220
+ // happen. Best-effort — a stamp that cannot be written leaves the record
221
+ // exactly as every deck before this one left it.
222
+ const now = new Date();
223
+ await utimes(file, now, now).catch(() => {});
224
+ return { file, rewritten: false };
225
+ }
226
+ } catch { /* missing, unreadable or corrupt — rewritten below */ }
227
+ await writeDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
228
+ return { file, rewritten: true };
229
+ }
230
+
231
+ /**
232
+ * Keep this deck registered for as long as it runs, and tell the caller when
233
+ * that stops being true.
234
+ *
235
+ * `onState` hears the first outcome, every change of health, and every
236
+ * re-registration after the first — never a steady state. A deck that cannot
237
+ * write the file has to say so: silently listening while no hook can find it
238
+ * is the failure this exists to end, not a state worth hiding.
239
+ *
240
+ * The interval is unref'd, so it never keeps a finished process alive, and
241
+ * `stop()` must be called before the file is removed on shutdown — otherwise
242
+ * the next tick would put it straight back.
243
+ *
244
+ * `stop()` ANSWERS WITH THE CHECK ALREADY IN FLIGHT, and shutdown has to await
245
+ * it. Clearing the interval stops the next tick; it does nothing about the one
246
+ * that started a moment ago and is currently inside writeFileAtomic. That tick
247
+ * finishes after the unlink and re-creates the file — exactly the "leave the
248
+ * file behind for the hooks to find once nothing is listening" that stopping
249
+ * first is supposed to prevent, reached by the one route stopping first does
250
+ * not cover. The window is a rename and an fsync on POSIX; on Windows it is
251
+ * that plus renameWithRetry's ladder, up to 200ms of sleeping while a scanner
252
+ * holds the target — the platform the retry was written for is the platform
253
+ * where the race is twenty times wider.
254
+ *
255
+ * `run()` never rejects (every failure is a state), so awaiting this cannot
256
+ * throw and cannot outlast one bounded check.
257
+ */
258
+ export function keepDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null, intervalMs = 5000, onState = null } = {}) {
259
+ // null until the first outcome, which therefore always differs and is always
260
+ // reported — the caller learns where it stands before anything else happens.
261
+ let healthy = null;
262
+
263
+ const run = async () => {
264
+ let state;
265
+ try {
266
+ const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
267
+ state = { ok: true, rewritten, file: discoveryPath(), error: null };
268
+ } catch (err) {
269
+ state = { ok: false, rewritten: false, file: discoveryPath(), error: err };
270
+ }
271
+ const worthSaying = healthy !== state.ok || state.rewritten;
272
+ healthy = state.ok;
273
+ if (worthSaying && onState) { try { onState(state); } catch { /* not our problem */ } }
274
+ return state;
275
+ };
276
+
277
+ // One check at a time. Registration is a write and a write is now a rename,
278
+ // which costs an fsync — long enough that a tick can land inside the boot-time
279
+ // check bin/deck.js runs by hand. That second check reads a file the first has
280
+ // not renamed into place yet, concludes the deck is unregistered and writes it
281
+ // again, and the two writes race over one record: the deck reports itself
282
+ // unregistered on a machine where nothing whatsoever is wrong. A caller that
283
+ // asks mid-check gets the answer the check already in flight is fetching.
284
+ let inFlight = null;
285
+ const check = () => (inFlight ??= run().finally(() => { inFlight = null; }));
286
+
287
+ const timer = setInterval(() => { check(); }, intervalMs);
288
+ timer.unref?.();
289
+
290
+ const stop = () => {
291
+ clearInterval(timer);
292
+ return inFlight ?? Promise.resolve(null);
293
+ };
294
+
295
+ return { file: discoveryPath(), check, stop };
296
+ }
297
+
298
+ export { AGENT_DAG_DIR };
@@ -0,0 +1,328 @@
1
+ // The events.jsonl this deck keeps: where it is, whether this deck is the one
2
+ // that writes a given session to it, who else shares it, when it rolls over,
3
+ // and whether it is being written at all.
4
+ //
5
+ // These lived in src/server/index.mjs — the path and the election near the top,
6
+ // the rotation after them, and the writability probe beside the Restart gate
7
+ // that reads it. Every one of them is a question about the same file and none
8
+ // of them touches the ring, the SSE fan-out or the scanners, so they moved
9
+ // together, with `persistPath` itself. That variable is assigned in one place,
10
+ // openEventLog, and read everywhere else through eventLogPath(); the bodies
11
+ // that moved read it directly, as they always did.
12
+ import { mkdir, open, stat, unlink } from "node:fs/promises";
13
+ import { resolve, dirname as pdirname } from "node:path";
14
+ import { PRODUCT } from "./brand.mjs";
15
+ import { appendFailureStats, appendsLanded, flushAppends } from "./log-writer.mjs";
16
+ import { electWriters, foldsCase } from "./log-election.mjs";
17
+ // Every deck registered right now that proved it is the deck its record
18
+ // describes — the group logSharing counts. See live-decks.mjs.
19
+ import { readLiveDecks } from "./live-decks.mjs";
20
+
21
+ let persistPath = null; // absolute path to events.jsonl, or null
22
+
23
+ /** The log this deck was told to keep, resolved, or null when it keeps none.
24
+ * Read through here by everything but the code that owns the file;
25
+ * openEventLog is the one writer. */
26
+ function eventLogPath() {
27
+ return persistPath;
28
+ }
29
+
30
+ // ─── Which deck records which session ─────────────────────────────────────
31
+ // The hook posts an event to every deck whose workspace matches, and by
32
+ // default all of them append to one events.jsonl — so each event landed in
33
+ // that file once per running deck, and every later replay of it ingested each
34
+ // tool call that many times. The hook now elects one writer per log file and
35
+ // marks the request to the rest `?persist=0`. That flag covers the hook event
36
+ // itself; this map carries it to the ModelObserved/UsageObserved/
37
+ // ContextObserved events a deck derives from the same session's transcript,
38
+ // which every deck reads for itself and would otherwise duplicate exactly the
39
+ // same way. A session is recorded unless we have been told someone else owns
40
+ // it, so a lone deck — and a deck the hook is too old to flag — still writes
41
+ // everything.
42
+ //
43
+ // THIS COVERS THE CLAUDE SIDE ONLY, and that is worth stating because reading it
44
+ // as the general answer is what produced #447. The only thing that ever fills
45
+ // the set is noteLogWriter, called from handleEventIngest — a hook POST. Codex
46
+ // events are read off the rollout files inside this process and never reach an
47
+ // HTTP handler, and Codex hooks are not installed any more, so a Codex session
48
+ // id cannot get in here and writesLogFor answers "mine" for every one of them on
49
+ // every deck. The Codex half is decided by writesCodexLog in the watcher's scan
50
+ // loop instead, and every event derived from a rollout — including the memory
51
+ // scan's ContextObserved — has to carry that verdict explicitly.
52
+ const foreignSessions = new Set(); // session ids another deck is logging
53
+ const MAX_FOREIGN_SESSIONS = 512; // bound: insertion order = oldest first
54
+
55
+ function noteLogWriter(payload, mine) {
56
+ const sid = payload && typeof payload === "object" ? payload.session_id : null;
57
+ if (typeof sid !== "string" || sid === "") return;
58
+ // Delete either way: on re-add it moves the id back to the young end, so the
59
+ // eviction below drops sessions that stopped being posted, not busy ones.
60
+ foreignSessions.delete(sid);
61
+ if (mine) return;
62
+ foreignSessions.add(sid);
63
+ if (foreignSessions.size > MAX_FOREIGN_SESSIONS) {
64
+ foreignSessions.delete(foreignSessions.values().next().value);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Is this deck the one that writes this payload's session to the log? Every
70
+ * event goes through here on its way to disk, including the ones this deck
71
+ * derives from a transcript on its own — that is the point of remembering the
72
+ * session rather than only honouring the flag on the event that carried it.
73
+ */
74
+ export function writesLogFor(payload) {
75
+ const sid = payload && typeof payload === "object" ? payload.session_id : null;
76
+ return typeof sid !== "string" || sid === "" || !foreignSessions.has(sid);
77
+ }
78
+
79
+ /**
80
+ * Who else is holding the log this deck would empty, and whose it is to empty.
81
+ *
82
+ * `events.jsonl` is one file several decks share — that is the whole reason the
83
+ * election above exists — and `POST /api/clear` truncated it from whichever deck
84
+ * happened to be asked. So Clear on a deck scoped to one tree deleted the
85
+ * machine-wide deck's weeks of history, and told nobody: the other deck goes on
86
+ * serving what is still in its ring, so the damage only shows up the next time
87
+ * it boots and replays a file that is now empty (#698). Measured on macOS 15 /
88
+ * Node 22.14: 1407 bytes and five lines before, 134 bytes after — and the 134
89
+ * were the clearing deck's own `__clear` marker, appended to a log it writes
90
+ * nothing else to.
91
+ *
92
+ * The rule this establishes is the one the rest of the file already runs on: a
93
+ * deck may empty the log it WRITES. Ownership is electWriters — the same
94
+ * election, over the same discovery records, that decides which deck appends a
95
+ * line — so the process that truncates the file is the process that fills it,
96
+ * which is also the only way the two operations are ordered on any platform. A
97
+ * deck that is not the elected writer clears its own canvas and leaves the file
98
+ * to the deck that owns it; the confirmation says so before the user presses
99
+ * anything, and says how many decks share the file when the answer is "yours to
100
+ * empty". Nothing here decides the copy — `GET /api/clear` hands these facts to
101
+ * the dialog, which is the half that keeps the user from destroying history they
102
+ * were never told about.
103
+ *
104
+ * Deliberately NOT a scope test. Whether this deck was started with
105
+ * `--workspace` has nothing to do with who owns a file: two machine-wide decks
106
+ * share one log exactly as a scoped one shares it with a machine-wide one, and
107
+ * the harm is the same in both directions.
108
+ *
109
+ * Fail-safe matches writesCodexLog's, for the same reason: with no discovery
110
+ * record of our own — the window before the first heartbeat writes it, or a deck
111
+ * that cannot write one at all — nobody can elect us and we keep what a lone
112
+ * deck does. The COUNT is still every deck sharing the file, so even inside that
113
+ * window the confirmation warns rather than guessing quietly.
114
+ */
115
+ export async function logSharing() {
116
+ if (!persistPath) return { path: null, decks: 1, mine: true, owner: null };
117
+ const fold = s => (foldsCase() ? s.toLowerCase() : s);
118
+ const here = fold(persistPath);
119
+ const sameLog = d => typeof d.persist === "string" && d.persist !== "" && fold(d.persist) === here;
120
+
121
+ let live = [];
122
+ try { live = await readLiveDecks(); } catch { /* unreadable dir — treated as "alone" below */ }
123
+ const group = live.filter(sameLog);
124
+ const self = group.find(d => d.pid === process.pid) ?? null;
125
+ if (!self) return { path: persistPath, decks: Math.max(group.length + 1, 1), mine: true, owner: null };
126
+
127
+ const writers = electWriters(group);
128
+ const mine = writers.has(self);
129
+ // One log path, so the election normally returns exactly one deck. It can
130
+ // return more only when two decks spell the same file differently on a
131
+ // case-sensitive platform, which `sameLog` folded together and electWriters
132
+ // did not — the lowest port of them is the one to name, and `mine` above is
133
+ // the election's own answer either way.
134
+ const owner = mine
135
+ ? self
136
+ : [...writers].sort((a, b) => a.port - b.port || a.pid - b.pid)[0] ?? null;
137
+ return { path: persistPath, decks: group.length, mine, owner };
138
+ }
139
+
140
+ // ─── Persistence rotation ─────────────────────────────────────────────────
141
+ // 24/7 dev servers used to grow events.jsonl unbounded — saw it hit GBs
142
+ // across weeks. We rotate when the file passes ROTATE_AT_BYTES, archiving
143
+ // the previous file to .1 and starting fresh. Last-event-id replay still
144
+ // covers the in-memory ring buffer, which is bounded by MAX_BUFFER events AND
145
+ // by MAX_BUFFER_CHARS — so how far back a replay reaches depends on how large
146
+ // the traffic has been, not on the count alone.
147
+ const ROTATE_AT_BYTES = 50 * 1024 * 1024;
148
+ let lastRotateCheckAt = 0;
149
+ let rotateInProgress = false;
150
+ /** Bytes handed to appendLogLine since the last time we looked at the file.
151
+ *
152
+ * THE 30-SECOND CLOCK MADE THE THRESHOLD ADVISORY. The stat was throttled to
153
+ * once per 30s and this function has one caller — the push path — so the
154
+ * overshoot was exactly `30s x the ingest byte rate`, which is unbounded in
155
+ * throughput rather than merely loose. Measured, on a log doing 10.5 MB/s:
156
+ *
157
+ * t=6s log=51393249 <- 50 MB crossed
158
+ * t=30s log=306262048
159
+ * t=31s log=0 log1=316750478 <- rotation fires, 302 MB, 25s late
160
+ *
161
+ * and at higher rates 1,199 MB and 2,579 MB with ZERO rotations — 51x the
162
+ * cap, 604 MB on disk across the two generations for a documented 50 MB.
163
+ *
164
+ * Counting what we hand the appender costs one addition per event and removes
165
+ * the dependency on throughput: the check now happens when enough has been
166
+ * written to be worth a stat, whatever the clock says. The clock stays as a
167
+ * FLOOR for the idle case, where nothing is being written and a periodic look
168
+ * is the only way to notice a file that grew by some other route.
169
+ *
170
+ * Deliberately a lower bound on the real file: it counts bytes queued, not
171
+ * bytes landed, and it is reset on every look rather than on a successful
172
+ * rotation — so a stat that finds the file still under the threshold has
173
+ * already paid for itself and does not need to re-count what it just saw. */
174
+ let bytesSinceRotateCheck = 0;
175
+ /** Enough written to be worth a stat, whatever the clock says. A fifth of the
176
+ * threshold, so the worst overshoot is bounded at ~20% rather than by rate. */
177
+ const ROTATE_CHECK_EVERY_BYTES = Math.floor(ROTATE_AT_BYTES / 5);
178
+ /** And a floor for the idle case, where nothing is being written and a
179
+ * periodic look is the only way to notice a file that grew by some other
180
+ * route — another deck appending to a log they share, say. */
181
+ const ROTATE_CHECK_EVERY_MS = 30_000;
182
+ /**
183
+ * Whether it is worth asking the filesystem how big the log is.
184
+ *
185
+ * Pure, and exported, for the reason mayReadAccounts and maySelfPoll are: this
186
+ * is the rule, and a rule whose only observable failure is a file quietly
187
+ * reaching gigabytes belongs somewhere a test can point at it. Rotation had no
188
+ * test of any kind.
189
+ */
190
+ export function rotateCheckDue({ now, lastCheckAt, bytesSince }) {
191
+ return now - lastCheckAt >= ROTATE_CHECK_EVERY_MS
192
+ || bytesSince >= ROTATE_CHECK_EVERY_BYTES;
193
+ }
194
+
195
+ async function maybeRotatePersistFile(wroteBytes = 0) {
196
+ if (!persistPath) return;
197
+ bytesSinceRotateCheck += wroteBytes;
198
+ const now = Date.now();
199
+ if (!rotateCheckDue({ now, lastCheckAt: lastRotateCheckAt, bytesSince: bytesSinceRotateCheck })) return;
200
+ lastRotateCheckAt = now;
201
+ bytesSinceRotateCheck = 0;
202
+ if (rotateInProgress) return;
203
+ rotateInProgress = true;
204
+ try {
205
+ const s = await stat(persistPath).catch(() => null);
206
+ if (!s || s.size < ROTATE_AT_BYTES) return;
207
+ // ONLY THE DECK THAT WRITES THIS LOG MAY MOVE IT, the same gate `/api/clear`
208
+ // already keeps and for the same reason. Two decks appending to one log is
209
+ // explicitly permitted as the fail-safe — log-election.mjs says "a line
210
+ // written twice is recoverable" — so both cross 50 MB and both rotate. If
211
+ // B's stat lands before A's rename, B's `unlink` deletes the archive A has
212
+ // just made and B's `rename` moves the new, near-empty live file into its
213
+ // place: one generation of history destroyed by a deck that was only
214
+ // housekeeping. The append path was made multi-writer safe; this one was
215
+ // not. Ownership is electWriters, so nothing new gets to disagree with it.
216
+ const sharing = await logSharing();
217
+ if (!sharing.mine) return;
218
+ // Roll events.jsonl → events.jsonl.1 (replacing any previous .1).
219
+ const oldPath = persistPath + ".1";
220
+ // LET THE QUEUE DRAIN FIRST. Appends are ordered behind a promise chain
221
+ // inside log-writer.mjs and a descriptor opened before the rename completes
222
+ // into the RENAMED inode, so the line being appended at the moment of a
223
+ // rotation lands in the archive rather than the live log — measured, a
224
+ // 24 MB line in events.jsonl.1 and a 65-byte live file. See flushAppends.
225
+ await flushAppends(persistPath);
226
+ try { await unlink(oldPath); } catch {}
227
+ const { rename } = await import("node:fs/promises");
228
+ await rename(persistPath, oldPath);
229
+ console.log(`${PRODUCT}: rotated ${persistPath} (${(s.size / 1024 / 1024).toFixed(0)}MB → ${oldPath})`);
230
+ } catch (err) {
231
+ console.error(`${PRODUCT}: persist rotation failed:`, err && err.message ? err.message : err);
232
+ } finally {
233
+ rotateInProgress = false;
234
+ }
235
+ }
236
+
237
+ // A log that cannot be written is no log wearing a different hat. The Restart
238
+ // gate — `_canRestart` in index.mjs — was a test of CONFIGURATION alone,
239
+ // `_onRestart != null && persist != null`, so a deck whose `--history` named a
240
+ // read-only volume, a full one, or a removable drive that was later unmounted
241
+ // answered `canRestart: true` while not one event reached disk. Measured: 10 of
242
+ // 10 posts acknowledged, no file on disk, `canRestart: true`. The press then
243
+ // lands on replayLog's `if (!existsSync(filePath)) return 0` and takes the
244
+ // whole session history with it — which is precisely the loss the flag exists
245
+ // to prevent.
246
+ let _persistWritable = false;
247
+ // How many lines had landed on `persistPath` when the probe answered. Read by
248
+ // logWritableNow and nothing else; see appendsLanded.
249
+ let _landedAtProbe = 0;
250
+
251
+ /**
252
+ * Is the log this deck was told to keep being written, on the newest evidence?
253
+ *
254
+ * `_persistWritable` answered this alone, and it is the boot probe's answer:
255
+ * nothing after the boot could change it. The gate it fed had a closing edge —
256
+ * a failing append opens an episode, and an episode shut the gate — and no
257
+ * opening one. MEASURED on a sandboxed deck (#1130), a log whose parent was a
258
+ * file at boot and a directory a moment later, which is what a mount that
259
+ * comes up late or a permission that is fixed leaves behind: a line on disk,
260
+ * the failure count at 0, and `canRestart` false for the life of the process.
261
+ *
262
+ * The appender is the evidence, and it already produces it: every line either
263
+ * lands or fails, and log-writer counts both. So the answer is whichever of the
264
+ * two sources spoke LAST. An open failure episode means the newest append
265
+ * failed, whatever came before it. Otherwise a line landed since the probe
266
+ * means the newest evidence is a write that worked, whatever the probe said;
267
+ * and with neither, the probe is still the newest thing anyone knows.
268
+ *
269
+ * No syscall is added anywhere for it. The probe stays the one at boot, the
270
+ * episode is the one #1062 keeps, and what an event pays is one counter
271
+ * increment in the landing handler (append-failures.mjs), beside the episode
272
+ * lookup that handler already makes.
273
+ */
274
+ function logWritableNow() {
275
+ if (!persistPath) return false;
276
+ if (appendFailureStats(persistPath).failing > 0) return false;
277
+ return _persistWritable || appendsLanded(persistPath) > _landedAtProbe;
278
+ }
279
+
280
+ /**
281
+ * Is the log this deck was told to keep actually writable?
282
+ *
283
+ * One `open(path, "a")` and one close — the same pair every append pays, asked
284
+ * once at boot so the answer is known before the first event rather than after
285
+ * the first silent loss. It CREATES the file, which is what an append would do
286
+ * anyway and what makes the probe honest about the directory as well as the
287
+ * file: EROFS, EACCES and ENOENT all surface here.
288
+ */
289
+ async function probeLogWritable(path) {
290
+ let handle = null;
291
+ try {
292
+ handle = await open(path, "a");
293
+ return true;
294
+ } catch (err) {
295
+ console.error(`${PRODUCT}: the event log ${path} is not writable (${err && err.message ? err.message : err}) — Restart is disabled until an event reaches it, because it would replay a log this deck is not filling`);
296
+ return false;
297
+ } finally {
298
+ await handle?.close().catch(() => {});
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Point the deck at the log it was told to keep, and probe it once.
304
+ *
305
+ * Lifted out of startServer, which calls it once per boot, before the replay.
306
+ * A boot with no log leaves `persistPath` as it was and only resets the probe,
307
+ * which is what startServer always did: the path is assigned when there is one
308
+ * and never cleared.
309
+ */
310
+ async function openEventLog(persist) {
311
+ _persistWritable = false;
312
+ if (!persist) return;
313
+ persistPath = resolve(persist);
314
+ try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
315
+ // Asked before the first event, so a log that cannot be written is a fact
316
+ // the deck states rather than one the user discovers by pressing Restart.
317
+ // The mkdir above is inside a bare try/catch, so its failure is already
318
+ // swallowed once by the time we get here — see _persistWritable.
319
+ _persistWritable = await probeLogWritable(persistPath);
320
+ // Taken the moment the probe answers, so that a line landing from here on
321
+ // is evidence newer than it. See logWritableNow.
322
+ _landedAtProbe = appendsLanded(persistPath);
323
+ }
324
+
325
+ // What index.mjs calls besides the three exported above. Listed rather than
326
+ // marked at each declaration, so the declarations read as they did where they
327
+ // came from.
328
+ export { eventLogPath, logWritableNow, maybeRotatePersistFile, noteLogWriter, openEventLog };