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
@@ -6,16 +6,19 @@
6
6
  // running server still sees both CLIs. Re-runs are safe; entries are tagged
7
7
  // with __agent-dag and de-duped.
8
8
  import { hookRuntime } from "./app-host.mjs";
9
- import { readFile, mkdir, unlink, rename, open, stat, chmod, realpath, readlink, utimes } from "node:fs/promises";
9
+ import { readFile, mkdir } from "node:fs/promises";
10
10
  import { existsSync } from "node:fs";
11
11
  import { join, resolve, dirname } from "node:path";
12
- import { setTimeout as delay } from "node:timers/promises";
13
12
  import { fileURLToPath } from "node:url";
14
13
  import { claudeConfigDir } from "./claude-dir.mjs";
15
14
  import { CODEX_HOME } from "./codex-dir.mjs";
16
- import { canonicalLogPath } from "./log-writer.mjs";
17
15
  import { shellQuoteArg } from "./exec.mjs";
18
- import { PRODUCT } from "./brand.mjs";
16
+ // The read-before-rewrite and the atomic replace every settings writer goes
17
+ // through — see atomic-write.mjs.
18
+ import { readSettingsForWrite, writeFileAtomic } from "./atomic-write.mjs";
19
+ // The discovery dir every deck registers in and every hook reads — see
20
+ // discovery.mjs, which keeps this deck's record there.
21
+ import { AGENT_DAG_DIR } from "./discovery.mjs";
19
22
 
20
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
24
  const PKG_ROOT = resolve(__dirname, "..", "..");
@@ -29,12 +32,6 @@ const CLAUDE_DIR = claudeConfigDir();
29
32
  // says what the value is FOR here: the directory hooks.json is taken out of.
30
33
  const CODEX_DIR = CODEX_HOME;
31
34
 
32
- // Single shared discovery dir — both providers' hook scripts post here so one
33
- // running agent-dag server can match either ecosystem's events. It follows the
34
- // Claude config dir, so hook/hook.js has to resolve that dir the same way: it
35
- // reads what this writes, and a disagreement means the hooks find no server.
36
- const AGENT_DAG_DIR = join(CLAUDE_DIR, "agent-dag");
37
-
38
35
  const CLAUDE_EVENTS = [
39
36
  "SessionStart",
40
37
  "UserPromptSubmit",
@@ -128,266 +125,6 @@ async function ensureDir(p) {
128
125
  if (!existsSync(p)) await mkdir(p, { recursive: true });
129
126
  }
130
127
 
131
- // Notepad and PowerShell's Set-Content write UTF-8 with a byte-order mark, and
132
- // JSON.parse throws on it when the file is read as utf8. A BOM is not damage —
133
- // the JSON behind it is fine — so it never gets to look like a corrupt file.
134
- function stripBom(text) {
135
- return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
136
- }
137
-
138
- function unreadableSettings(p, why) {
139
- const err = new Error(
140
- `${p} could not be read as JSON (${why}). Refusing to overwrite it — ` +
141
- `fix the file or move it aside, then run ${PRODUCT} again.`,
142
- );
143
- err.code = "SETTINGS_UNREADABLE";
144
- err.settingsPath = p;
145
- // The bare reason, without the path and without the remedy sentence, so a
146
- // caller that wants to phrase its own advice — `--uninstall` does; "run
147
- // ccdeck again" is the wrong instruction there — does not have to take this
148
- // message apart with a regex to get at the only part it cannot re-derive.
149
- err.why = why;
150
- return err;
151
- }
152
-
153
- /**
154
- * Read settings we are about to rewrite. Only ENOENT means "nothing there yet";
155
- * every other failure is a file whose contents we cannot reproduce — a stray
156
- * comma, a half-written file from another process, a permission error — and
157
- * writing our hooks over it would destroy every setting the user has. So the
158
- * install refuses instead, loudly, and leaves the file exactly as it found it.
159
- */
160
- async function readSettingsForWrite(p) {
161
- let raw;
162
- try {
163
- raw = await readFile(p, "utf8");
164
- } catch (err) {
165
- if (err?.code === "ENOENT") return { settings: {}, raw: null };
166
- throw unreadableSettings(p, err?.message ?? String(err));
167
- }
168
- let parsed;
169
- try {
170
- parsed = JSON.parse(stripBom(raw));
171
- } catch (err) {
172
- throw unreadableSettings(p, err?.message ?? String(err));
173
- }
174
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
175
- throw unreadableSettings(p, "top level is not a JSON object");
176
- }
177
- return { settings: parsed, raw };
178
- }
179
-
180
- // Rename over an open file is the one thing Windows does differently here.
181
- // libuv's rename is one MoveFileExW with MOVEFILE_REPLACE_EXISTING and nothing
182
- // else — no retry, no MOVEFILE_COPY_ALLOWED — and MoveFileEx honours share
183
- // modes: it fails outright while ANY other handle is open on the source or the
184
- // target without FILE_SHARE_DELETE. A virus scanner or the search indexer opens
185
- // files the instant they are written, so the target is briefly untouchable on a
186
- // perfectly healthy machine. Node has declined to paper over this (nodejs/node
187
- // #29481, closed wontfix: "not something that's really under Node's or libuv's
188
- // control"), and libuv reverted its own four-attempt ladder for the same
189
- // reason. So the policy lives here, where the stakes are known.
190
- //
191
- // The ladder is 10 attempts over ~1.4s, and the second number is the one that
192
- // matters. It used to be 5 over 200ms, which is comfortably enough for the
193
- // indexer and not enough for a scanner: the argument on libuv#2098 for
194
- // reverting their retry was in part that an AV hold can outlast 2s, and 200ms
195
- // of patience on a hold like that is the same as none.
196
- //
197
- // What that thinness cost is not an install. codex-auth.mjs stages a REFRESH
198
- // TOKEN through this call, the old one is spent server-side by the time it runs,
199
- // and a rename that gives up too early destroys the only copy of the new
200
- // credential — the deck reports refresh_rejected and the user has to run
201
- // `codex login` again. Trading a second of latency against that is not close.
202
- // The comparison points: steno retries a rename 10 times at 100ms, npm's
203
- // bin-links 5 times at 500ms exponential, and write-file-atomic does not retry
204
- // at all (npm/write-file-atomic#227).
205
- //
206
- // POSIX never hits this path, and a genuinely permanent permission error costs
207
- // that second and a half once, on a path that was already failing.
208
- const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
209
-
210
- async function renameWithRetry(from, to, attempts = 10) {
211
- for (let attempt = 1; ; attempt++) {
212
- try {
213
- await rename(from, to);
214
- return;
215
- } catch (err) {
216
- if (attempt >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
217
- // Linear: 30, 60, 90 … 270ms, ~1.4s across the nine waits. Linear rather
218
- // than exponential because the holds this exists for are short and
219
- // frequent, and a doubling ladder spends its whole budget on the last
220
- // two waits.
221
- await delay(30 * attempt);
222
- }
223
- }
224
- }
225
-
226
- // Counts writes, not processes. The pid alone gave every call in one deck the
227
- // same temp path, and there is more than one writer in a deck now: the sound
228
- // toggle rewrites settings.json from a request handler, so two clients toggling
229
- // within the same few milliseconds both opened that one path with O_TRUNC and
230
- // both wrote their own JSON at offset zero. What got renamed over settings.json
231
- // was the shorter payload with the tail of the longer one still behind it —
232
- // unparseable, and readSettingsForWrite turns unparseable into a permanent
233
- // SETTINGS_UNREADABLE refusal for every later toggle and install. The loser then
234
- // found its temp file already renamed away and threw ENOENT on top of it.
235
- let tmpSeq = 0;
236
-
237
- /**
238
- * Create the temp file for one write, never sharing it with another writer.
239
- *
240
- * "wx" is the point of it: a name that already exists is an error here rather
241
- * than a silently truncated file two writers are both filling in. The only way
242
- * to meet a taken name is a deck killed between the write and the rename whose
243
- * pid the OS has since handed out again — that writer is gone by definition, so
244
- * the leftover is deleted and the next counter tried. Which is also the whole
245
- * story on litter: the names come from a small space, this pid crossed with the
246
- * first few counters, because a deck writes these files a handful of times per
247
- * run. Later runs walk the same names and sweep what they find instead of
248
- * piling fresh ones beside it. Digits and hyphens only — a legal filename
249
- * everywhere, Windows included.
250
- *
251
- * `mode` is the mode the file is created with, which matters when the bytes are
252
- * secret: codex-auth stages a rotated Codex refresh token here, and a temp file
253
- * that starts at the umask default is readable by every other account on the
254
- * box for as long as the write takes, whatever chmod follows it. O_EXCL is what
255
- * makes the mode binding — a create honours it only when it is the call that
256
- * makes the file, so adopting a leftover would keep the leftover's permissions.
257
- */
258
- async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
259
- for (let attempt = 1; ; attempt++) {
260
- const tmp = `${target}.agent-dag-${process.pid}-${tmpSeq++}.tmp`;
261
- try {
262
- return { tmp, handle: await open(tmp, "wx", mode) };
263
- } catch (err) {
264
- if (attempt >= attempts || err?.code !== "EEXIST") throw err;
265
- await unlink(tmp).catch(() => {});
266
- }
267
- }
268
- }
269
-
270
- // A chain longer than this is a loop, or something no real setup has: stow and
271
- // chezmoi produce one hop, an encrypted volume two. The number is a bound on the
272
- // walk below, not a promise about how deep a legitimate link goes.
273
- const MAX_LINK_HOPS = 8;
274
-
275
- /**
276
- * The file a name is really asking for — the one the links under it end at.
277
- *
278
- * A rename replaces the DIRECTORY ENTRY it is handed. Handed a symlink, it
279
- * deletes the link and leaves an ordinary file where it was, and
280
- * `~/.claude/settings.json` is a symlink on a great many machines: into a
281
- * dotfiles repo, a stow or chezmoi target, an encrypted volume. The content
282
- * survives — we write back what we read — so nothing looks wrong and nothing
283
- * says anything. The repo copy keeps what it said before, never goes dirty, and
284
- * from then on the user's edits there reach nobody while every launch rewrites
285
- * the detached file and widens the gap. This is a file the deck did not create
286
- * and does not own; quietly cutting it loose from the thing that manages it is
287
- * worse than failing to write it at all.
288
- *
289
- * persistAuth in codex-auth.mjs has resolved for exactly this reason since it
290
- * was written, on the file that is LESS often linked. This is the same rule at
291
- * the helper every settings writer in the deck goes through — and the same
292
- * function, so there is one of it rather than two that can drift.
293
- *
294
- * Resolving also decides which filesystem the temp file is staged on, and it has
295
- * to be the target's. A rename is atomic within one filesystem and fails with
296
- * EXDEV across two, so staging beside the LINK — a link into a dotfiles repo on
297
- * a separate volume — is a write that cannot land at all.
298
- *
299
- * A DANGLING link is the case realpath alone cannot answer: the target not
300
- * created yet, the encrypted volume not mounted. Answering it with the raw path
301
- * is the bug again, because that is precisely when the link gets replaced — so
302
- * the walk falls back to readlink, which reads a link without needing its target
303
- * to exist, and follows it the way opening the name for writing would.
304
- * `printf x > link` creates the target; it does not replace the link. If the
305
- * target's directory is gone the write then fails, which is the honest answer:
306
- * the bytes did not reach the file the user's setup points at.
307
- *
308
- * The ordinary case — a plain file, no link anywhere — is one realpath and the
309
- * first return.
310
- */
311
- async function resolveWriteTarget(raw) {
312
- let at = raw;
313
- for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
314
- // Resolves every link on the path at once, when all of them lead somewhere.
315
- const real = await realpath(at).catch(() => null);
316
- if (real !== null) return real;
317
- const to = await readlink(at).catch(() => null);
318
- // Not a link, so nothing exists at this name yet and this name is the
319
- // answer: a first install, or the far end of a chain we have just followed.
320
- if (to === null) return at;
321
- at = resolve(dirname(at), to);
322
- }
323
- // Only a cycle gets here. Say so rather than pick a link out of it and
324
- // destroy that one — the OS answers a write through such a name the same way.
325
- const err = new Error(`too many symbolic links resolving ${raw}`);
326
- err.code = "ELOOP";
327
- throw err;
328
- }
329
-
330
- /**
331
- * Replace a file in a single step readers cannot land inside.
332
- *
333
- * The temp file is created beside the target rather than in $TMPDIR, because
334
- * rename is only atomic within one filesystem and the two are routinely on
335
- * different ones. It is fsync'd before the rename so that a crash or power loss
336
- * just after a successful install cannot leave the new directory entry pointing
337
- * at blocks that were never flushed — the classic file-of-zero-bytes.
338
- *
339
- * "Beside the target" means beside the file the name resolves to, not beside the
340
- * name: see resolveWriteTarget, which is what keeps a symlinked settings.json a
341
- * symlink.
342
- */
343
- async function writeFileAtomic(rawTarget, text) {
344
- const target = await resolveWriteTarget(rawTarget);
345
- const { tmp, handle } = await createTemp(target);
346
- try {
347
- try {
348
- await handle.writeFile(text, "utf8");
349
- await handle.sync();
350
- } finally {
351
- await handle.close();
352
- }
353
- // A rename creates a fresh directory entry, so the old file's mode does not
354
- // come with it. Carry it over — a settings.json the user chmod'ed to 600 has
355
- // to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
356
- const mode = await stat(target).then(s => s.mode, () => null);
357
- if (mode !== null) await chmod(tmp, mode).catch(() => {});
358
- // A READ-ONLY TARGET IS A DEAD END ON WINDOWS, and only there. libuv's
359
- // rename is one MoveFileExW(MOVEFILE_REPLACE_EXISTING), which refuses to
360
- // replace a destination carrying FILE_ATTRIBUTE_READONLY; POSIX rename(2)
361
- // over a 0444 file succeeds, because only the parent directory's write bit
362
- // decides. A settings.json picks that attribute up from a OneDrive restore,
363
- // a copy off a network share, or read-only media — and EACCES is in the
364
- // retry ladder, so the whole ~1.4s was spent before throwing, on every
365
- // boot, forever. Every settings writer goes through here, so hooks never
366
- // installed and the sound-hook retirement could never repair a stale entry
367
- // either.
368
- //
369
- // chmod on Windows toggles exactly that attribute and nothing else, which
370
- // is why this is safe to do unconditionally there: the mode carried above
371
- // is re-applied to the new file after the rename, so a file the user marked
372
- // read-only stays read-only.
373
- // ONLY WHEN THE TARGET IS ACTUALLY READ-ONLY. Two extra syscalls on the
374
- // path between the temp write and the rename are not free on Windows:
375
- // discovery-live.test.ts hammers writeDiscovery while a reader holds the
376
- // destination open, and the wider window turned a rename the retry ladder
377
- // used to win into an EPERM it gave up on. The attribute is what this
378
- // clears, so a file that does not carry it has nothing to clear.
379
- const readOnly = process.platform === "win32" && mode !== null && (mode & 0o200) === 0;
380
- if (readOnly) await chmod(target, 0o666).catch(() => {});
381
- await renameWithRetry(tmp, target);
382
- if (readOnly) await chmod(target, mode).catch(() => {});
383
- } catch (err) {
384
- // Cleanup covers the write and the fsync as well as the rename: a full disk
385
- // used to leave the half-written temp file sitting beside the target.
386
- await unlink(tmp).catch(() => {});
387
- throw err;
388
- }
389
- }
390
-
391
128
  /**
392
129
  * Install one of the packaged hook scripts without ever exposing a partial one.
393
130
  *
@@ -473,6 +210,67 @@ function dedupeOurEntries(group) {
473
210
  return group.filter(g => !isOurEntry(g));
474
211
  }
475
212
 
213
+ /**
214
+ * Whether a settings file still holds exactly the bytes a pass read at its
215
+ * start: false when another writer has changed it since, and false when it
216
+ * cannot be read back at all. installHooks and uninstallHooks both ask it at
217
+ * the last moment before they write, and both decline on false.
218
+ *
219
+ * A READ THE GUARD COULD NOT PERFORM IS NOT PROOF NOTHING CHANGED (#788).
220
+ * This used to be `.catch(() => ({ raw: before }))`, which substituted the
221
+ * snapshot and so answered "unchanged" for every failed re-read. ENOENT is
222
+ * the one case that substitution would be right for, and it does not reach
223
+ * here at all — readSettingsForWrite returns `{ raw: null }` for it without
224
+ * throwing. What does reach here is EACCES/EBUSY on a file written
225
+ * microseconds ago, which is the condition atomic-write.mjs's rename retry
226
+ * names:
227
+ * "a virus scanner or the search indexer opens files the instant they are
228
+ * written, so the target is briefly untouchable on a perfectly healthy
229
+ * machine". Precisely when another deck has just written it.
230
+ */
231
+ async function unchangedSince(settingsPath, before) {
232
+ let onDisk;
233
+ let unreadable = false;
234
+ try { ({ raw: onDisk } = await readSettingsForWrite(settingsPath)); }
235
+ catch { unreadable = true; }
236
+ return !(unreadable || onDisk !== before);
237
+ }
238
+
239
+ /**
240
+ * Take the retired finish-sound hook out of `current`, the settings object
241
+ * installHooks is about to write, and hand back what is left to do once that
242
+ * write has landed. installHooks says why it rides along on that write.
243
+ *
244
+ * Imported here rather than at the top of the file because retire-sound-hook.mjs
245
+ * imports this module — it takes writeFileAtomic and readSettingsForWrite from
246
+ * here — and a static import would close that into a cycle. Claude only: the
247
+ * entry was one line in Claude Code's settings.json and there was never a Codex
248
+ * one.
249
+ *
250
+ * The equality test is not ceremony. Retirement DELETES two files — the parked
251
+ * hooks and the installed script — at absolute paths it resolved for itself,
252
+ * from claudeConfigDir() and os.homedir(), at its own import. installHooks
253
+ * writes `cfg.settingsPath`. In the product those are the same settings.json
254
+ * and the paths belong together. When they are not the same file, the two
255
+ * modules are looking at different homes, and acting on that difference means
256
+ * deleting files belonging to a machine this install is not writing to. That
257
+ * is not hypothetical: it happened to the author's own ~/.agents-deck while
258
+ * this very change was being written, from a test whose environment teardown
259
+ * ran a describe too early. Disagreement is a reason to do nothing.
260
+ */
261
+ async function soundHookRetirement(provider, cfg, current) {
262
+ let retire = { pending: false, changed: false, removed: 0, restored: 0 };
263
+ let completeSoundHookRetirement = null;
264
+ if (provider === "claude") {
265
+ const retirement = await import("./retire-sound-hook.mjs");
266
+ if (retirement.SETTINGS_PATH === cfg.settingsPath) {
267
+ completeSoundHookRetirement = retirement.completeSoundHookRetirement;
268
+ retire = await retirement.retireSoundHookIn(current);
269
+ }
270
+ }
271
+ return { retire, completeSoundHookRetirement };
272
+ }
273
+
476
274
  /** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
477
275
  export async function installHooks({ provider = "claude", beforeWrite = null } = {}) {
478
276
  const cfg = PROVIDERS[provider];
@@ -519,31 +317,7 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
519
317
  // write split is what keeps the script deletion after the write: until the new
520
318
  // file has landed, a live Claude Code session's next turn still runs the old
521
319
  // command.
522
- //
523
- // Imported here rather than at the top of the file because retire-sound-hook.mjs
524
- // imports this module — writeFileAtomic and readSettingsForWrite live here —
525
- // and a static import would close that into a cycle. Claude only: the entry
526
- // was one line in Claude Code's settings.json and there was never a Codex one.
527
- //
528
- // The equality test is not ceremony. Retirement DELETES two files — the parked
529
- // hooks and the installed script — at absolute paths it resolved for itself,
530
- // from claudeConfigDir() and os.homedir(), at its own import. This function
531
- // writes `cfg.settingsPath`. In the product those are the same settings.json
532
- // and the paths belong together. When they are not the same file, the two
533
- // modules are looking at different homes, and acting on that difference means
534
- // deleting files belonging to a machine this install is not writing to. That
535
- // is not hypothetical: it happened to the author's own ~/.agents-deck while
536
- // this very change was being written, from a test whose environment teardown
537
- // ran a describe too early. Disagreement is a reason to do nothing.
538
- let retire = { pending: false, changed: false, removed: 0, restored: 0 };
539
- let completeSoundHookRetirement = null;
540
- if (provider === "claude") {
541
- const retirement = await import("./retire-sound-hook.mjs");
542
- if (retirement.SETTINGS_PATH === cfg.settingsPath) {
543
- completeSoundHookRetirement = retirement.completeSoundHookRetirement;
544
- retire = await retirement.retireSoundHookIn(current);
545
- }
546
- }
320
+ const { retire, completeSoundHookRetirement } = await soundHookRetirement(provider, cfg, current);
547
321
 
548
322
  // Every launch reinstalls, and on all but the first the entries are already
549
323
  // there and identical. Writing anyway is pure downside: it is one more chance
@@ -575,27 +349,12 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
575
349
  // real fs work, so a test that raced it by wall clock would pass or fail by
576
350
  // how fast the machine is. Production passes nothing.
577
351
  if (beforeWrite) await beforeWrite();
578
- // A READ THE GUARD COULD NOT PERFORM IS NOT PROOF NOTHING CHANGED (#788).
579
- // This used to be `.catch(() => ({ raw: before }))`, which substituted the
580
- // snapshot and so answered "unchanged" for every failed re-read. ENOENT is
581
- // the one case that substitution would be right for, and it does not reach
582
- // here at all — readSettingsForWrite returns `{ raw: null }` for it without
583
- // throwing. What does reach here is EACCES/EBUSY on a file written
584
- // microseconds ago, which is the condition this module's own header names:
585
- // "a virus scanner or the search indexer opens files the instant they are
586
- // written, so the target is briefly untouchable on a perfectly healthy
587
- // machine". Precisely when another deck has just written it.
588
- //
589
352
  // So an unreadable re-read declines, like a changed one. Declining is safe
590
353
  // for the reason above: every boot reinstalls and the next pass converges.
591
354
  // Being wrong the other way is not — it is the lost update this guard
592
355
  // exists to prevent, with the user's own sound hook gone from settings.json
593
356
  // and from the park that was its only other copy.
594
- let onDisk;
595
- let unreadable = false;
596
- try { ({ raw: onDisk } = await readSettingsForWrite(cfg.settingsPath)); }
597
- catch { unreadable = true; }
598
- if (unreadable || onDisk !== before) {
357
+ if (!(await unchangedSince(cfg.settingsPath, before))) {
599
358
  return {
600
359
  settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider,
601
360
  changed: false, raced: true, retire: { ...retire, pending: false },
@@ -691,11 +450,7 @@ export async function uninstallHooks({ provider = "claude", beforeWrite = null }
691
450
  // raced it by wall clock would pass or fail by how fast the machine is.
692
451
  // Production passes nothing.
693
452
  if (beforeWrite) await beforeWrite();
694
- let onDisk;
695
- let unreadable = false;
696
- try { ({ raw: onDisk } = await readSettingsForWrite(cfg.settingsPath)); }
697
- catch { unreadable = true; }
698
- if (unreadable || onDisk !== before) {
453
+ if (!(await unchangedSince(cfg.settingsPath, before))) {
699
454
  return {
700
455
  ok: false, reason: "raced", changed: false, provider,
701
456
  settingsPath: cfg.settingsPath,
@@ -767,299 +522,15 @@ export async function leftoverCodexHooks() {
767
522
  return count > 0 ? { settingsPath, count } : null;
768
523
  }
769
524
 
770
- /**
771
- * The events log as the record spells it: an absolute path, or null when this
772
- * deck writes none. Shared by the writer and by ensureDiscovery's comparison,
773
- * so a file this process wrote can never read back as somebody else's.
774
- */
775
- function persistField(persist) {
776
- return typeof persist === "string" && persist !== "" ? persist : null;
777
- }
778
-
779
- /**
780
- * The Codex tree as the record spells it: the canonical path of the CODEX_HOME
781
- * this process reads, or null for a --no-codex deck, which reads none. Shared by
782
- * the writer and by ensureDiscovery's comparison for the reason persistField
783
- * is, and exported for a start's own shape in bin/deck.js — so a start asking
784
- * "does the running deck read my tree" spells the tree exactly the way the
785
- * record it compares against does. Worked out on every call rather than once at
786
- * load: it is one realpath on a five-second heartbeat, and a tree that did not
787
- * exist when the deck started — the first `codex login` creates it — is then
788
- * named the way it is named once it does.
789
- */
790
- export function codexHomeField(codex) {
791
- return codex !== false ? canonicalLogPath(CODEX_DIR) : null;
792
- }
793
-
794
- // Every deck's token lives in this directory, and writeFileAtomic's temp file is
795
- // created beside its target with whatever the umask allows — 0644 on most
796
- // machines — so for the moment before the rename the token would sit in a
797
- // world-readable file. 0700 on the directory closes that window from the outside:
798
- // another user cannot traverse into it whatever the mode of a file inside says.
799
- // The dir usually predates this code, so the mode is re-asserted rather than only
800
- // set at creation, where it would be masked by the umask anyway. Windows ignores
801
- // both — NTFS ACLs inherit from the per-user profile directory.
802
- async function ensureDiscoveryDir() {
803
- if (!existsSync(AGENT_DAG_DIR)) await mkdir(AGENT_DAG_DIR, { recursive: true, mode: 0o700 });
804
- await chmod(AGENT_DAG_DIR, 0o700).catch(() => {});
805
- }
806
-
807
- /**
808
- * Register this deck, in one step no reader can land inside.
809
- *
810
- * The record used to go down with a plain writeFile, which truncates the target
811
- * and then fills it, so the file existed and was empty for a moment on every
812
- * rewrite. Everything that reads this directory parses each record whole —
813
- * electWriters in hook/hook.js, readLiveDecks and sweepStaleDiscovery in
814
- * index.mjs — and a record that fails to parse is a deck missing from that
815
- * cycle: the event it should have logged is either logged by nobody or logged
816
- * twice by the decks that remain, which is the exact failure the single-writer
817
- * election exists to prevent, reached through the file the election reads. A
818
- * rename is atomic on Linux, macOS and Windows alike, so a reader now sees the
819
- * previous record or the new one and never half of either.
820
- *
821
- * The token is the deck's proof of identity, so the file holding it is the
822
- * deck's key material: readable and writable by its owner, nobody else. The mode
823
- * is pinned after the write because writeFileAtomic can only carry over a mode
824
- * the target already had — a first registration, or one left by an earlier run
825
- * under a recycled pid, would otherwise keep whatever the umask handed it.
826
- */
827
- export async function writeDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
828
- await ensureDiscoveryDir();
829
- const file = discoveryPath();
830
- const data = {
831
- pid: process.pid,
832
- port,
833
- workspace: workspace ?? "",
834
- // Without this the hooks refuse to post: a file naming a port it cannot
835
- // authenticate is exactly the stale-file case they now decline to trust.
836
- token: token ?? "",
837
- // Absolute path of the events log this deck appends to, or null under
838
- // --no-persist. The hook reads it to elect a single writer per file:
839
- // several decks receive the same event by design, and without this they
840
- // each appended their own copy to the one log they share. See
841
- // electWriters in hook/hook.js.
842
- persist: persistField(persist),
843
- // Is this deck tailing Codex's rollout files? Those events never pass
844
- // through a hook, so the decks elect a writer for them among themselves —
845
- // and a deck running --no-codex must be left out of that election rather
846
- // than win it and record a rollout it is not even reading. See
847
- // writesCodexLog in src/server/log-writer.mjs.
848
- codex: codex !== false,
849
- // And WHOSE rollouts. `codex: true` says this deck tails them and never said
850
- // from where, while CODEX_HOME moves the whole tree — #375 was five modules
851
- // disagreeing about where it points. Two decks sharing one events.jsonl can
852
- // therefore be reading two different trees, and the election grouped them
853
- // anyway, because an unscoped deck's workspace contains every cwd and this
854
- // was the one field that could have told them apart. The lower-port deck won
855
- // a rollout it had never opened, the deck reading it stood down, and the
856
- // shared log recorded nothing of that session (#982).
857
- //
858
- // Canonical rather than as spelled, for the reason canonicalLogPath gives
859
- // the log path above (#793): a symlinked ~/.codex, an 8.3-shortened
860
- // USERPROFILE or /tmp against /private/tmp would otherwise put two decks
861
- // reading one tree into two groups and elect them both — the duplicate the
862
- // election exists to end. Only this published copy is canonicalised.
863
- // codex-dir.mjs goes on reading and writing through the spelling the user
864
- // chose, which it has to for a symlinked home (see codexHome() there).
865
- codexHome: codexHomeField(codex),
866
- // Does this deck run Browser Watch? The watch elects a single writer among
867
- // the decks on a machine, and it elected on port alone — so an older ccdeck
868
- // that predates the feature won the election by having the lower port and
869
- // then wrote nothing, while the deck that HAS the watch stood down. Measured
870
- // on this machine: a v1.46 deck from an npx cache held 4317, answered the
871
- // watch route with the SPA's index.html, and Browser Watch silently
872
- // recorded nothing for as long as both were up. No error, no log line — the
873
- // panel showed findings on screen and the disk stayed empty.
874
- //
875
- // Same shape as `codex` above, and for the same reason: a deck that is not
876
- // doing the work must be left out of the election rather than win it. An
877
- // older deck has no such field, so it is excluded by construction.
878
- watch: true,
879
- // Is the Claude side of this deck switched on? Same shape as `codex` above
880
- // and there for a second reader: a bare `ccdeck` that finds this deck
881
- // running attaches to it instead of starting a rival, and it may only do
882
- // that when the deck on the port is the deck it would itself have built. A
883
- // `--no-claude` deck has no hooks, no accounts panel and no switcher, and
884
- // until this field existed it was indistinguishable from one that has all
885
- // three. An older deck has no such field, so it never passes for one and is
886
- // replaced instead — see secondStart.
887
- claude: claude !== false,
888
- // What this deck IS, so a launcher that attaches can say whether the deck
889
- // it found is the version the user just asked for. Never a decision: a
890
- // rival deck on a random port is worse than an older deck said out loud.
891
- version: typeof version === "string" ? version : "",
892
- // The supervisor above this worker, or null when nothing is supervising.
893
- //
894
- // Only `ccdeck --stop` reads it, and only on the path where the polite
895
- // request failed. A worker killed on its own leaves its supervisor alive,
896
- // and a supervisor that puts crashed workers back would answer that kill by
897
- // starting the deck again — so the ladder has to end the parent first and
898
- // the child second, which it cannot do without being told who the parent is.
899
- parent: Number.isInteger(parent) ? parent : null,
900
- startedAt: new Date().toISOString(),
901
- };
902
- await writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
903
- await chmod(file, 0o600).catch(() => {});
904
- return file;
905
- }
906
-
907
- export async function removeDiscovery(file) {
908
- try { await unlink(file); } catch {}
909
- }
910
-
911
- /** Where this process registers itself. One file per deck, named by pid. */
912
- export function discoveryPath() {
913
- return join(AGENT_DAG_DIR, `${process.pid}.json`);
914
- }
915
-
916
- /**
917
- * Make sure this deck's discovery file is on disk and says what it should.
918
- *
919
- * Registration was a single write at boot, so anything that took the file away
920
- * afterwards — a sweep on another machine's clock, a half-finished restart, a
921
- * user tidying the directory — left a deck that was listening, serving and
922
- * completely invisible: hook.js enumerates this directory and nothing else, so
923
- * the deck received zero events while looking perfectly healthy. Re-asserting
924
- * is cheap (one small read), so the deck checks rather than assumes.
925
- *
926
- * A file this process wrote is left alone, mode and contents included — only its
927
- * mtime moves, stamped on every check; see the note where it is. Anything else —
928
- * no file, unreadable, another pid, a stale port, token, events log, Codex
929
- * setting or Codex tree, Claude setting, or a version left by the deck this
930
- * process replaced — is replaced. Every field another deck decides by is compared, the
931
- * log path included: leave one out and a record missing it would pass as ours
932
- * forever, which for the log path means no deck can tell which of them share a
933
- * file and they all write their own copy of every event again. The Codex tree
934
- * is compared strictly for the same reason: a record from before the field
935
- * existed has none, and `undefined` must read as "rewrite", never as a
936
- * --no-codex deck's null.
937
- */
938
- export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
939
- const file = discoveryPath();
940
- try {
941
- const d = JSON.parse(stripBom(await readFile(file, "utf8")));
942
- if (d
943
- && d.pid === process.pid
944
- && d.port === port
945
- && (d.workspace ?? "") === (workspace ?? "")
946
- && (d.token ?? "") === (token ?? "")
947
- && (d.persist ?? null) === persistField(persist)
948
- && d.codex === (codex !== false)
949
- && d.codexHome === codexHomeField(codex)
950
- && d.claude === (claude !== false)
951
- && (d.version ?? "") === (typeof version === "string" ? version : "")
952
- && (d.parent ?? null) === (Number.isInteger(parent) ? parent : null)) {
953
- // STAMPED, though nothing in it changed. The mtime is how a reader tells a
954
- // record some deck is still keeping from one whose deck is gone while its
955
- // pid lives on under another process: hook.js unlinks a record whose port
956
- // lets both challenge deadlines pass only once this stamp is a minute old
957
- // (#1069), which a deck running this check every five seconds never lets
958
- // happen. Best-effort — a stamp that cannot be written leaves the record
959
- // exactly as every deck before this one left it.
960
- const now = new Date();
961
- await utimes(file, now, now).catch(() => {});
962
- return { file, rewritten: false };
963
- }
964
- } catch { /* missing, unreadable or corrupt — rewritten below */ }
965
- await writeDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
966
- return { file, rewritten: true };
967
- }
968
-
969
- /**
970
- * Keep this deck registered for as long as it runs, and tell the caller when
971
- * that stops being true.
972
- *
973
- * `onState` hears the first outcome, every change of health, and every
974
- * re-registration after the first — never a steady state. A deck that cannot
975
- * write the file has to say so: silently listening while no hook can find it
976
- * is the failure this exists to end, not a state worth hiding.
977
- *
978
- * The interval is unref'd, so it never keeps a finished process alive, and
979
- * `stop()` must be called before the file is removed on shutdown — otherwise
980
- * the next tick would put it straight back.
981
- *
982
- * `stop()` ANSWERS WITH THE CHECK ALREADY IN FLIGHT, and shutdown has to await
983
- * it. Clearing the interval stops the next tick; it does nothing about the one
984
- * that started a moment ago and is currently inside writeFileAtomic. That tick
985
- * finishes after the unlink and re-creates the file — exactly the "leave the
986
- * file behind for the hooks to find once nothing is listening" that stopping
987
- * first is supposed to prevent, reached by the one route stopping first does
988
- * not cover. The window is a rename and an fsync on POSIX; on Windows it is
989
- * that plus renameWithRetry's ladder, up to 200ms of sleeping while a scanner
990
- * holds the target — the platform the retry was written for is the platform
991
- * where the race is twenty times wider.
992
- *
993
- * `run()` never rejects (every failure is a state), so awaiting this cannot
994
- * throw and cannot outlast one bounded check.
995
- */
996
- export function keepDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null, intervalMs = 5000, onState = null } = {}) {
997
- // null until the first outcome, which therefore always differs and is always
998
- // reported — the caller learns where it stands before anything else happens.
999
- let healthy = null;
1000
-
1001
- const run = async () => {
1002
- let state;
1003
- try {
1004
- const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
1005
- state = { ok: true, rewritten, file: discoveryPath(), error: null };
1006
- } catch (err) {
1007
- state = { ok: false, rewritten: false, file: discoveryPath(), error: err };
1008
- }
1009
- const worthSaying = healthy !== state.ok || state.rewritten;
1010
- healthy = state.ok;
1011
- if (worthSaying && onState) { try { onState(state); } catch { /* not our problem */ } }
1012
- return state;
1013
- };
1014
-
1015
- // One check at a time. Registration is a write and a write is now a rename,
1016
- // which costs an fsync — long enough that a tick can land inside the boot-time
1017
- // check bin/deck.js runs by hand. That second check reads a file the first has
1018
- // not renamed into place yet, concludes the deck is unregistered and writes it
1019
- // again, and the two writes race over one record: the deck reports itself
1020
- // unregistered on a machine where nothing whatsoever is wrong. A caller that
1021
- // asks mid-check gets the answer the check already in flight is fetching.
1022
- let inFlight = null;
1023
- const check = () => (inFlight ??= run().finally(() => { inFlight = null; }));
1024
-
1025
- const timer = setInterval(() => { check(); }, intervalMs);
1026
- timer.unref?.();
1027
-
1028
- const stop = () => {
1029
- clearInterval(timer);
1030
- return inFlight ?? Promise.resolve(null);
1031
- };
1032
-
1033
- return { file: discoveryPath(), check, stop };
1034
- }
1035
-
1036
525
  // CLAUDE_EVENTS used to ride along here (#383). It is the events list of the
1037
526
  // `claude` entry in PROVIDERS and has never had a reader outside this file; it
1038
527
  // was easy to miss because the three directories beside it ARE imported and
1039
- // because the long justification below belongs to the SECOND export, not this
1040
- // one. Which events the deck asks Claude Code for is answered by installHooks
1041
- // writing settings.json, which is what the tests read.
528
+ // because the long justification that sat below it belonged to the SECOND
529
+ // export, not this one (it is in atomic-write.mjs now, with that export). Which
530
+ // events the deck asks Claude Code for is answered by installHooks writing
531
+ // settings.json, which is what the tests read.
1042
532
  export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
1043
- // Exported for the other modules that rewrite settings.json — the sound toggle
1044
- // today. Every one of them needs the same two guarantees: a file we cannot
1045
- // parse is never treated as an empty one, and the replacement is a single
1046
- // rename rather than a truncate a reader can land inside. installScript carries
1047
- // the same guarantee to the hook scripts themselves, which are the files live
1048
- // sessions are actually executing, and renameWithRetry goes out on its own for
1049
- // the files writeFileAtomic cannot write — the fetched uv binary in
1050
- // uv-bootstrap.mjs — which still need the Windows retry. createTemp goes out for
1051
- // the same reason one step lower: codex-auth.mjs needs the collision-free temp
1052
- // name but not writeFileAtomic's mode handling, which carries over the target's
1053
- // mode and so would leave a brand-new auth.json at whatever the umask allows.
1054
- // resolveWriteTarget goes with them, because auth.json is linked into a dotfiles
1055
- // repo for the same reasons settings.json is, and "never rename onto a link" is
1056
- // one rule: codex-auth.mjs called a realpath of its own before this existed, and
1057
- // two spellings of a rule are two things that can drift.
1058
- // `stripBom` goes out on the same argument at the smallest scale. Two stores
1059
- // now move an unparseable file aside instead of writing defaults over it —
1060
- // deck-prefs.mjs since #1002 and browser-watch-store.mjs since #1003 — and all
1061
- // three readers have to mean the same thing by "a BOM is not damage". If they
1062
- // do not, a prefs.json or a state.json somebody opened in Notepad gets
1063
- // quarantined for a mark settings.json has ignored since this function was
1064
- // written.
1065
- export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget, stripBom };
533
+ // This deck's discovery record, which moved to discovery.mjs. Exported from this
534
+ // file before they moved, and still: bin/deck.js and the CLI import them from
535
+ // here.
536
+ export { codexHomeField, discoveryPath, ensureDiscovery, keepDiscovery, removeDiscovery, writeDiscovery } from "./discovery.mjs";