agent-dag 3.22.1 → 3.22.3

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 (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,912 +0,0 @@
1
- // Idempotent hook installer. One provider installs, two uninstall:
2
- // - "claude" → $CLAUDE_CONFIG_DIR/settings.json (Claude Code, ~/.claude by default)
3
- // - "codex" → $CODEX_HOME/hooks.json (uninstall only — see PROVIDERS)
4
- // Hooks post to the discovery dir at <claude config dir>/agent-dag/, and Codex
5
- // sessions reach the same server through the rollout watcher instead, so one
6
- // running server still sees both CLIs. Re-runs are safe; entries are tagged
7
- // with __agent-dag and de-duped.
8
- import { readFile, mkdir, unlink, rename, open, stat, chmod, realpath, readlink } from "node:fs/promises";
9
- import { existsSync } from "node:fs";
10
- import { join, resolve, dirname } from "node:path";
11
- import { setTimeout as delay } from "node:timers/promises";
12
- import { fileURLToPath } from "node:url";
13
- import { claudeConfigDir } from "./claude-dir.mjs";
14
- import { CODEX_HOME } from "./codex-dir.mjs";
15
- import { shellQuoteArg } from "./exec.mjs";
16
- import { PRODUCT } from "./brand.mjs";
17
-
18
- const __dirname = dirname(fileURLToPath(import.meta.url));
19
- const PKG_ROOT = resolve(__dirname, "..", "..");
20
-
21
- // Honours CLAUDE_CONFIG_DIR, exactly as CODEX_DIR honours CODEX_HOME below.
22
- // Without it the hooks land in a settings.json Claude Code never opens.
23
- const CLAUDE_DIR = claudeConfigDir();
24
- // Both directories now come from the module that owns the rule rather than from
25
- // a copy of it here — claude-dir.mjs and codex-dir.mjs. The local name stays
26
- // because CODEX_DIR is what the rest of this file and its tests call it, and it
27
- // says what the value is FOR here: the directory hooks.json is taken out of.
28
- const CODEX_DIR = CODEX_HOME;
29
-
30
- // Single shared discovery dir — both providers' hook scripts post here so one
31
- // running agent-dag server can match either ecosystem's events. It follows the
32
- // Claude config dir, so hook/hook.js has to resolve that dir the same way: it
33
- // reads what this writes, and a disagreement means the hooks find no server.
34
- const AGENT_DAG_DIR = join(CLAUDE_DIR, "agent-dag");
35
-
36
- const CLAUDE_EVENTS = [
37
- "SessionStart",
38
- "UserPromptSubmit",
39
- "PreToolUse",
40
- "PostToolUse",
41
- "PostToolUseFailure",
42
- "SubagentStart",
43
- "SubagentStop",
44
- "Stop",
45
- "SessionEnd",
46
- "Notification",
47
- ];
48
-
49
- const PROVIDERS = {
50
- claude: {
51
- settingsPath: join(CLAUDE_DIR, "settings.json"),
52
- hookInstallDir: join(CLAUDE_DIR, "agent-dag"),
53
- events: CLAUDE_EVENTS,
54
- ensureDir: CLAUDE_DIR,
55
- },
56
- // Uninstall-only. Codex hooks do not fire reliably on Windows, so the deck
57
- // stopped installing them and reads Codex's rollout files instead — nothing
58
- // calls installHooks with this provider any more. The entry stays because a
59
- // machine that ran an older deck still has our forwarders in hooks.json, and
60
- // uninstallHooks needs the path to take them back out. It reads nothing else:
61
- // it walks the events already in the file rather than a list of our own.
62
- codex: {
63
- settingsPath: join(CODEX_DIR, "hooks.json"),
64
- },
65
- };
66
-
67
- const MARK_KEY = "__agent-dag";
68
- // Legacy marks from earlier names — purged on every install/uninstall so
69
- // duplicate forwarders don't pile up when the project gets renamed.
70
- const LEGACY_MARKS = ["__ccgraph", "__agent-flow"];
71
- const LEGACY_DIRS = ["ccgraph", "agent-flow", "agent-dag"];
72
-
73
- /**
74
- * The `command` string Claude Code stores for our forwarder, and runs THROUGH A
75
- * SHELL on every tool call.
76
- *
77
- * The settings.json hook format is a string, not an argv, so this is one of the
78
- * two places in the codebase that has to build a shell command line by hand —
79
- * see shellQuoteArg, which is where the escaping rules and their one Windows
80
- * residual are written down.
81
- *
82
- * It used to wrap both paths in double quotes, which on POSIX escapes nothing:
83
- * `$(…)`, a backtick and `\` are all still live inside them. Both paths come
84
- * from outside — `installedHookPath` is built from $CLAUDE_CONFIG_DIR (resolved,
85
- * never validated) or homedir(), and `node` is process.execPath — so a config
86
- * dir called `/tmp/a$(id)b` was shell code, written into the user's own settings
87
- * file and executed on every hook fire for as long as it stayed there. The
88
- * quieter half of the same bug cost nothing but the feature: an ordinary `$` in
89
- * a path expanded to nothing, the hook pointed at a file that was not there, and
90
- * hooks stopped firing with no error to explain it.
91
- *
92
- * `provider` is a key of PROVIDERS — "claude" or "codex", never anything a
93
- * caller chose — and is quoted anyway, because that is not a property worth
94
- * re-deriving at every reading.
95
- *
96
- * Exported, with the node path AND the platform injectable, so the escaping can
97
- * be checked against a path the test names rather than against whatever ran the
98
- * suite — and against the rule of a platform that suite is not running on. The
99
- * two rules are genuinely different (POSIX single quotes, cmd.exe doubled
100
- * double quotes), so without the second parameter the only assertion a test can
101
- * make is the one its own OS happens to produce, which is how this went five
102
- * releases with the Windows half of it never once executed.
103
- */
104
- export function hookCommand(installedHookPath, provider, node = process.execPath,
105
- platform = process.platform) {
106
- const q = (s) => shellQuoteArg(s, platform);
107
- return `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
108
- }
109
-
110
- function isOurEntry(g) {
111
- if (!g || typeof g !== "object") return false;
112
- if (g[MARK_KEY] === true) return true;
113
- for (const k of LEGACY_MARKS) if (g[k] === true) return true;
114
- const cmds = Array.isArray(g.hooks) ? g.hooks : [];
115
- for (const h of cmds) {
116
- const c = typeof h?.command === "string" ? h.command : "";
117
- for (const dir of LEGACY_DIRS) {
118
- if (c.includes(`.claude/${dir}/hook.js`) || c.includes(`.claude\\${dir}\\hook.js`)) return true;
119
- if (c.includes(`.codex/${dir}/hook.js`) || c.includes(`.codex\\${dir}\\hook.js`)) return true;
120
- }
121
- }
122
- return false;
123
- }
124
-
125
- async function ensureDir(p) {
126
- if (!existsSync(p)) await mkdir(p, { recursive: true });
127
- }
128
-
129
- // Notepad and PowerShell's Set-Content write UTF-8 with a byte-order mark, and
130
- // JSON.parse throws on it when the file is read as utf8. A BOM is not damage —
131
- // the JSON behind it is fine — so it never gets to look like a corrupt file.
132
- function stripBom(text) {
133
- return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
134
- }
135
-
136
- function unreadableSettings(p, why) {
137
- const err = new Error(
138
- `${p} could not be read as JSON (${why}). Refusing to overwrite it — ` +
139
- `fix the file or move it aside, then run ${PRODUCT} again.`,
140
- );
141
- err.code = "SETTINGS_UNREADABLE";
142
- err.settingsPath = p;
143
- // The bare reason, without the path and without the remedy sentence, so a
144
- // caller that wants to phrase its own advice — `--uninstall` does; "run
145
- // ccdeck again" is the wrong instruction there — does not have to take this
146
- // message apart with a regex to get at the only part it cannot re-derive.
147
- err.why = why;
148
- return err;
149
- }
150
-
151
- /**
152
- * Read settings we are about to rewrite. Only ENOENT means "nothing there yet";
153
- * every other failure is a file whose contents we cannot reproduce — a stray
154
- * comma, a half-written file from another process, a permission error — and
155
- * writing our hooks over it would destroy every setting the user has. So the
156
- * install refuses instead, loudly, and leaves the file exactly as it found it.
157
- */
158
- async function readSettingsForWrite(p) {
159
- let raw;
160
- try {
161
- raw = await readFile(p, "utf8");
162
- } catch (err) {
163
- if (err?.code === "ENOENT") return { settings: {}, raw: null };
164
- throw unreadableSettings(p, err?.message ?? String(err));
165
- }
166
- let parsed;
167
- try {
168
- parsed = JSON.parse(stripBom(raw));
169
- } catch (err) {
170
- throw unreadableSettings(p, err?.message ?? String(err));
171
- }
172
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
173
- throw unreadableSettings(p, "top level is not a JSON object");
174
- }
175
- return { settings: parsed, raw };
176
- }
177
-
178
- // Rename over an open file is the one thing Windows does differently here.
179
- // libuv's rename is one MoveFileExW with MOVEFILE_REPLACE_EXISTING and nothing
180
- // else — no retry, no MOVEFILE_COPY_ALLOWED — and MoveFileEx honours share
181
- // modes: it fails outright while ANY other handle is open on the source or the
182
- // target without FILE_SHARE_DELETE. A virus scanner or the search indexer opens
183
- // files the instant they are written, so the target is briefly untouchable on a
184
- // perfectly healthy machine. Node has declined to paper over this (nodejs/node
185
- // #29481, closed wontfix: "not something that's really under Node's or libuv's
186
- // control"), and libuv reverted its own four-attempt ladder for the same
187
- // reason. So the policy lives here, where the stakes are known.
188
- //
189
- // The ladder is 10 attempts over ~1.4s, and the second number is the one that
190
- // matters. It used to be 5 over 200ms, which is comfortably enough for the
191
- // indexer and not enough for a scanner: the argument on libuv#2098 for
192
- // reverting their retry was in part that an AV hold can outlast 2s, and 200ms
193
- // of patience on a hold like that is the same as none.
194
- //
195
- // What that thinness cost is not an install. codex-auth.mjs stages a REFRESH
196
- // TOKEN through this call, the old one is spent server-side by the time it runs,
197
- // and a rename that gives up too early destroys the only copy of the new
198
- // credential — the deck reports refresh_rejected and the user has to run
199
- // `codex login` again. Trading a second of latency against that is not close.
200
- // The comparison points: steno retries a rename 10 times at 100ms, npm's
201
- // bin-links 5 times at 500ms exponential, and write-file-atomic does not retry
202
- // at all (npm/write-file-atomic#227).
203
- //
204
- // POSIX never hits this path, and a genuinely permanent permission error costs
205
- // that second and a half once, on a path that was already failing.
206
- const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
207
-
208
- async function renameWithRetry(from, to, attempts = 10) {
209
- for (let attempt = 1; ; attempt++) {
210
- try {
211
- await rename(from, to);
212
- return;
213
- } catch (err) {
214
- if (attempt >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
215
- // Linear: 30, 60, 90 … 270ms, ~1.4s across the nine waits. Linear rather
216
- // than exponential because the holds this exists for are short and
217
- // frequent, and a doubling ladder spends its whole budget on the last
218
- // two waits.
219
- await delay(30 * attempt);
220
- }
221
- }
222
- }
223
-
224
- // Counts writes, not processes. The pid alone gave every call in one deck the
225
- // same temp path, and there is more than one writer in a deck now: the sound
226
- // toggle rewrites settings.json from a request handler, so two clients toggling
227
- // within the same few milliseconds both opened that one path with O_TRUNC and
228
- // both wrote their own JSON at offset zero. What got renamed over settings.json
229
- // was the shorter payload with the tail of the longer one still behind it —
230
- // unparseable, and readSettingsForWrite turns unparseable into a permanent
231
- // SETTINGS_UNREADABLE refusal for every later toggle and install. The loser then
232
- // found its temp file already renamed away and threw ENOENT on top of it.
233
- let tmpSeq = 0;
234
-
235
- /**
236
- * Create the temp file for one write, never sharing it with another writer.
237
- *
238
- * "wx" is the point of it: a name that already exists is an error here rather
239
- * than a silently truncated file two writers are both filling in. The only way
240
- * to meet a taken name is a deck killed between the write and the rename whose
241
- * pid the OS has since handed out again — that writer is gone by definition, so
242
- * the leftover is deleted and the next counter tried. Which is also the whole
243
- * story on litter: the names come from a small space, this pid crossed with the
244
- * first few counters, because a deck writes these files a handful of times per
245
- * run. Later runs walk the same names and sweep what they find instead of
246
- * piling fresh ones beside it. Digits and hyphens only — a legal filename
247
- * everywhere, Windows included.
248
- *
249
- * `mode` is the mode the file is created with, which matters when the bytes are
250
- * secret: codex-auth stages a rotated Codex refresh token here, and a temp file
251
- * that starts at the umask default is readable by every other account on the
252
- * box for as long as the write takes, whatever chmod follows it. O_EXCL is what
253
- * makes the mode binding — a create honours it only when it is the call that
254
- * makes the file, so adopting a leftover would keep the leftover's permissions.
255
- */
256
- async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
257
- for (let attempt = 1; ; attempt++) {
258
- const tmp = `${target}.agent-dag-${process.pid}-${tmpSeq++}.tmp`;
259
- try {
260
- return { tmp, handle: await open(tmp, "wx", mode) };
261
- } catch (err) {
262
- if (attempt >= attempts || err?.code !== "EEXIST") throw err;
263
- await unlink(tmp).catch(() => {});
264
- }
265
- }
266
- }
267
-
268
- // A chain longer than this is a loop, or something no real setup has: stow and
269
- // chezmoi produce one hop, an encrypted volume two. The number is a bound on the
270
- // walk below, not a promise about how deep a legitimate link goes.
271
- const MAX_LINK_HOPS = 8;
272
-
273
- /**
274
- * The file a name is really asking for — the one the links under it end at.
275
- *
276
- * A rename replaces the DIRECTORY ENTRY it is handed. Handed a symlink, it
277
- * deletes the link and leaves an ordinary file where it was, and
278
- * `~/.claude/settings.json` is a symlink on a great many machines: into a
279
- * dotfiles repo, a stow or chezmoi target, an encrypted volume. The content
280
- * survives — we write back what we read — so nothing looks wrong and nothing
281
- * says anything. The repo copy keeps what it said before, never goes dirty, and
282
- * from then on the user's edits there reach nobody while every launch rewrites
283
- * the detached file and widens the gap. This is a file the deck did not create
284
- * and does not own; quietly cutting it loose from the thing that manages it is
285
- * worse than failing to write it at all.
286
- *
287
- * persistAuth in codex-auth.mjs has resolved for exactly this reason since it
288
- * was written, on the file that is LESS often linked. This is the same rule at
289
- * the helper every settings writer in the deck goes through — and the same
290
- * function, so there is one of it rather than two that can drift.
291
- *
292
- * Resolving also decides which filesystem the temp file is staged on, and it has
293
- * to be the target's. A rename is atomic within one filesystem and fails with
294
- * EXDEV across two, so staging beside the LINK — a link into a dotfiles repo on
295
- * a separate volume — is a write that cannot land at all.
296
- *
297
- * A DANGLING link is the case realpath alone cannot answer: the target not
298
- * created yet, the encrypted volume not mounted. Answering it with the raw path
299
- * is the bug again, because that is precisely when the link gets replaced — so
300
- * the walk falls back to readlink, which reads a link without needing its target
301
- * to exist, and follows it the way opening the name for writing would.
302
- * `printf x > link` creates the target; it does not replace the link. If the
303
- * target's directory is gone the write then fails, which is the honest answer:
304
- * the bytes did not reach the file the user's setup points at.
305
- *
306
- * The ordinary case — a plain file, no link anywhere — is one realpath and the
307
- * first return.
308
- */
309
- async function resolveWriteTarget(raw) {
310
- let at = raw;
311
- for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
312
- // Resolves every link on the path at once, when all of them lead somewhere.
313
- const real = await realpath(at).catch(() => null);
314
- if (real !== null) return real;
315
- const to = await readlink(at).catch(() => null);
316
- // Not a link, so nothing exists at this name yet and this name is the
317
- // answer: a first install, or the far end of a chain we have just followed.
318
- if (to === null) return at;
319
- at = resolve(dirname(at), to);
320
- }
321
- // Only a cycle gets here. Say so rather than pick a link out of it and
322
- // destroy that one — the OS answers a write through such a name the same way.
323
- const err = new Error(`too many symbolic links resolving ${raw}`);
324
- err.code = "ELOOP";
325
- throw err;
326
- }
327
-
328
- /**
329
- * Replace a file in a single step readers cannot land inside.
330
- *
331
- * The temp file is created beside the target rather than in $TMPDIR, because
332
- * rename is only atomic within one filesystem and the two are routinely on
333
- * different ones. It is fsync'd before the rename so that a crash or power loss
334
- * just after a successful install cannot leave the new directory entry pointing
335
- * at blocks that were never flushed — the classic file-of-zero-bytes.
336
- *
337
- * "Beside the target" means beside the file the name resolves to, not beside the
338
- * name: see resolveWriteTarget, which is what keeps a symlinked settings.json a
339
- * symlink.
340
- */
341
- async function writeFileAtomic(rawTarget, text) {
342
- const target = await resolveWriteTarget(rawTarget);
343
- const { tmp, handle } = await createTemp(target);
344
- try {
345
- try {
346
- await handle.writeFile(text, "utf8");
347
- await handle.sync();
348
- } finally {
349
- await handle.close();
350
- }
351
- // A rename creates a fresh directory entry, so the old file's mode does not
352
- // come with it. Carry it over — a settings.json the user chmod'ed to 600 has
353
- // to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
354
- const mode = await stat(target).then(s => s.mode, () => null);
355
- if (mode !== null) await chmod(tmp, mode).catch(() => {});
356
- // A READ-ONLY TARGET IS A DEAD END ON WINDOWS, and only there. libuv's
357
- // rename is one MoveFileExW(MOVEFILE_REPLACE_EXISTING), which refuses to
358
- // replace a destination carrying FILE_ATTRIBUTE_READONLY; POSIX rename(2)
359
- // over a 0444 file succeeds, because only the parent directory's write bit
360
- // decides. A settings.json picks that attribute up from a OneDrive restore,
361
- // a copy off a network share, or read-only media — and EACCES is in the
362
- // retry ladder, so the whole ~1.4s was spent before throwing, on every
363
- // boot, forever. Every settings writer goes through here, so hooks never
364
- // installed and the sound-hook retirement could never repair a stale entry
365
- // either.
366
- //
367
- // chmod on Windows toggles exactly that attribute and nothing else, which
368
- // is why this is safe to do unconditionally there: the mode carried above
369
- // is re-applied to the new file after the rename, so a file the user marked
370
- // read-only stays read-only.
371
- // ONLY WHEN THE TARGET IS ACTUALLY READ-ONLY. Two extra syscalls on the
372
- // path between the temp write and the rename are not free on Windows:
373
- // discovery-live.test.ts hammers writeDiscovery while a reader holds the
374
- // destination open, and the wider window turned a rename the retry ladder
375
- // used to win into an EPERM it gave up on. The attribute is what this
376
- // clears, so a file that does not carry it has nothing to clear.
377
- const readOnly = process.platform === "win32" && mode !== null && (mode & 0o200) === 0;
378
- if (readOnly) await chmod(target, 0o666).catch(() => {});
379
- await renameWithRetry(tmp, target);
380
- if (readOnly) await chmod(target, mode).catch(() => {});
381
- } catch (err) {
382
- // Cleanup covers the write and the fsync as well as the rename: a full disk
383
- // used to leave the half-written temp file sitting beside the target.
384
- await unlink(tmp).catch(() => {});
385
- throw err;
386
- }
387
- }
388
-
389
- /**
390
- * Install one of the packaged hook scripts without ever exposing a partial one.
391
- *
392
- * copyFile truncates the destination and then fills it, and the destination here
393
- * is a script every live Claude Code session runs on each tool call. Starting the
394
- * deck while sessions are open — the normal way this is used — puts a hook
395
- * invocation inside that window sooner or later, and what it executes is an
396
- * empty or half-written program: a dropped event at best, a SyntaxError in the
397
- * user's session at worst. Renaming a finished copy over the name closes it, so
398
- * a session opens either the old script or the new one and both are whole.
399
- *
400
- * Re-installs are the common case and almost always produce the same bytes, so
401
- * identical content skips the write and the file is not replaced at all.
402
- */
403
- async function installScript(src, dst) {
404
- const text = await readFile(src, "utf8");
405
- const current = await readFile(dst, "utf8").catch(() => null);
406
- if (current === text) return false;
407
- await writeFileAtomic(dst, text);
408
- return true;
409
- }
410
-
411
- async function installHookScript(installDir) {
412
- await ensureDir(installDir);
413
- const src = join(PKG_ROOT, "hook", "hook.js");
414
- const dst = join(installDir, "hook.js");
415
- await installScript(src, dst);
416
- return dst;
417
- }
418
-
419
- function buildHookEntry(command) {
420
- return {
421
- [MARK_KEY]: true,
422
- hooks: [{ type: "command", command, timeout: 2 }],
423
- };
424
- }
425
-
426
- function dedupeOurEntries(group) {
427
- if (!Array.isArray(group)) return [];
428
- return group.filter(g => !isOurEntry(g));
429
- }
430
-
431
- /** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
432
- export async function installHooks({ provider = "claude", beforeWrite = null } = {}) {
433
- const cfg = PROVIDERS[provider];
434
- if (!cfg) throw new Error(`unknown provider: ${provider}`);
435
- // An uninstall-only provider has no event list. Saying so beats the
436
- // TypeError that installing an undefined list would otherwise raise several
437
- // frames deep, after the hook script had already been written to disk.
438
- if (!cfg.events) throw new Error(`provider ${provider} is uninstall-only: hooks are not installed for it`);
439
-
440
- // Read before writing anything, so a settings file we cannot parse aborts
441
- // the install without leaving half of it behind.
442
- const { settings: current, raw: before } = await readSettingsForWrite(cfg.settingsPath);
443
-
444
- const hookPath = await installHookScript(cfg.hookInstallDir);
445
- const command = hookCommand(hookPath, provider);
446
- await ensureDir(cfg.ensureDir);
447
- // Discovery dir is shared across providers — always make sure it exists.
448
- await ensureDir(AGENT_DAG_DIR);
449
-
450
- current.hooks = current.hooks ?? {};
451
-
452
- for (const evt of cfg.events) {
453
- const cleaned = dedupeOurEntries(current.hooks[evt]);
454
- cleaned.push(buildHookEntry(command));
455
- current.hooks[evt] = cleaned;
456
- }
457
-
458
- // Retiring the finish-sound hook rides in here, on this read and this write,
459
- // and this is the seam it needs rather than a convenient one. #704 moved the
460
- // sound into the browser and deleted the script the old `Stop` entry ran, so
461
- // an install that upgrades a machine which HAS that entry leaves a hook
462
- // pointing at a file that is no longer in the package — an error at the end of
463
- // every turn, on a machine that was working before the upgrade. It therefore
464
- // has to happen without the user asking for it, and a normal boot is the only
465
- // moment that qualifies.
466
- //
467
- // Riding along buys the two properties it would otherwise have to invent.
468
- // There is ONE write of settings.json on the boot that retires, compared
469
- // against the exact bytes read a few lines up — so a second deck doing the
470
- // same work at the same time writes the same payload, and every later boot
471
- // finds nothing to do and changes nothing. And the mutate-then-let-the-caller-
472
- // write split is what keeps the script deletion after the write: until the new
473
- // file has landed, a live Claude Code session's next turn still runs the old
474
- // command.
475
- //
476
- // Imported here rather than at the top of the file because retire-sound-hook.mjs
477
- // imports this module — writeFileAtomic and readSettingsForWrite live here —
478
- // and a static import would close that into a cycle. Claude only: the entry
479
- // was one line in Claude Code's settings.json and there was never a Codex one.
480
- //
481
- // The equality test is not ceremony. Retirement DELETES two files — the parked
482
- // hooks and the installed script — at absolute paths it resolved for itself,
483
- // from claudeConfigDir() and os.homedir(), at its own import. This function
484
- // writes `cfg.settingsPath`. In the product those are the same settings.json
485
- // and the paths belong together. When they are not the same file, the two
486
- // modules are looking at different homes, and acting on that difference means
487
- // deleting files belonging to a machine this install is not writing to. That
488
- // is not hypothetical: it happened to the author's own ~/.agents-deck while
489
- // this very change was being written, from a test whose environment teardown
490
- // ran a describe too early. Disagreement is a reason to do nothing.
491
- let retire = { pending: false, changed: false, removed: 0, restored: 0 };
492
- let completeSoundHookRetirement = null;
493
- if (provider === "claude") {
494
- const retirement = await import("./retire-sound-hook.mjs");
495
- if (retirement.SETTINGS_PATH === cfg.settingsPath) {
496
- completeSoundHookRetirement = retirement.completeSoundHookRetirement;
497
- retire = await retirement.retireSoundHookIn(current);
498
- }
499
- }
500
-
501
- // Every launch reinstalls, and on all but the first the entries are already
502
- // there and identical. Writing anyway is pure downside: it is one more chance
503
- // to be interrupted mid-write, and one more window in which a change Claude
504
- // Code made to the file between our read and our write gets discarded. So
505
- // compare against the exact bytes we read and, when they match, do nothing.
506
- const next = JSON.stringify(current, null, 2) + "\n";
507
- const changed = next !== before;
508
- if (changed) {
509
- // COMPARE AGAINST THE FILE, NOT AGAINST THE SNAPSHOT, at the last moment.
510
- //
511
- // Everything above was computed from bytes read at the top of this
512
- // function, and two decks booting together — the ordinary case on a machine
513
- // where one was already running — interleave inside that window. The one
514
- // that loses is unrecoverable rather than merely stale: deck A restores the
515
- // user's own sound hooks from the parked file and deletes the park, and
516
- // deck B then writes a settings object computed before that restore, with
517
- // an empty park behind it. The user's hook is gone from settings.json and
518
- // from the only other copy of it.
519
- //
520
- // So the file is re-read immediately before the write and, if another
521
- // writer has touched it, this pass declines. Declining is safe by
522
- // construction: every boot reinstalls, so the next one recomputes against
523
- // the new bytes and converges — and the entries this function adds are
524
- // identical on both decks, which is why the loser has nothing of its own to
525
- // lose.
526
- // The seam the suite needs, and the only way to test this deterministically:
527
- // the window between the read at the top and the write below is filled with
528
- // real fs work, so a test that raced it by wall clock would pass or fail by
529
- // how fast the machine is. Production passes nothing.
530
- if (beforeWrite) await beforeWrite();
531
- // A READ THE GUARD COULD NOT PERFORM IS NOT PROOF NOTHING CHANGED (#788).
532
- // This used to be `.catch(() => ({ raw: before }))`, which substituted the
533
- // snapshot and so answered "unchanged" for every failed re-read. ENOENT is
534
- // the one case that substitution would be right for, and it does not reach
535
- // here at all — readSettingsForWrite returns `{ raw: null }` for it without
536
- // throwing. What does reach here is EACCES/EBUSY on a file written
537
- // microseconds ago, which is the condition this module's own header names:
538
- // "a virus scanner or the search indexer opens files the instant they are
539
- // written, so the target is briefly untouchable on a perfectly healthy
540
- // machine". Precisely when another deck has just written it.
541
- //
542
- // So an unreadable re-read declines, like a changed one. Declining is safe
543
- // for the reason above: every boot reinstalls and the next pass converges.
544
- // Being wrong the other way is not — it is the lost update this guard
545
- // exists to prevent, with the user's own sound hook gone from settings.json
546
- // and from the park that was its only other copy.
547
- let onDisk;
548
- let unreadable = false;
549
- try { ({ raw: onDisk } = await readSettingsForWrite(cfg.settingsPath)); }
550
- catch { unreadable = true; }
551
- if (unreadable || onDisk !== before) {
552
- return {
553
- settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider,
554
- changed: false, raced: true, retire: { ...retire, pending: false },
555
- };
556
- }
557
- await writeFileAtomic(cfg.settingsPath, next);
558
- }
559
- // After the write, never before it: the notify script an older deck installed
560
- // is what a live session's cached command still names until the new entry is
561
- // on disk, and deleting it early turns a stale sound into a missing module.
562
- if (retire.pending) await completeSoundHookRetirement(retire, current);
563
- return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed, retire };
564
- }
565
-
566
- /**
567
- * Take our forwarders back out of one provider's settings file.
568
- *
569
- * Returns `{ok: true, changed}` when the file was read — `changed` says whether
570
- * anything of ours was in it — and `{ok: false, reason: "settings_unreadable"}`
571
- * when it was not. Callers must look at `ok` FIRST: `changed: false` on a
572
- * refusal is the literal truth about the disk and a lie about the question
573
- * being asked, because the hooks are still in there.
574
- *
575
- * That conflation is what this used to ship. The read was readJsonSafe, which
576
- * turned every parse and IO failure into `null`, so a settings.json with one
577
- * stray comma — the exact file readSettingsForWrite was written to protect —
578
- * came back indistinguishable from a clean machine with none of our hooks in
579
- * it. `--uninstall` printed "no Claude hooks to remove" and exited 0 while all
580
- * ten `__agent-dag` entries sat in the file, spawning node on every tool call
581
- * of every session, for a deck the user had been told was gone. The other half
582
- * of the same command already knew better: the sound-hook half read through
583
- * readSettingsForWrite and said so out loud, so one command gave two opposite
584
- * verdicts about one file and the load-bearing one was the one that lied.
585
- *
586
- * So the read is the same read the install does, and for the same reason. A
587
- * file we cannot parse is a file whose contents we cannot reproduce, and this
588
- * function rewrites the whole thing — every permission, env var, model pin and
589
- * hand-written hook in it. Refusing leaves it byte for byte as it was found and
590
- * hands the user something they can act on; guessing would either destroy it or
591
- * quietly do nothing. Only ENOENT is genuinely empty, and readSettingsForWrite
592
- * already answers that with `{}`, which falls through to `changed: false`.
593
- */
594
- export async function uninstallHooks({ provider = "claude", beforeWrite = null } = {}) {
595
- const cfg = PROVIDERS[provider];
596
- if (!cfg) throw new Error(`unknown provider: ${provider}`);
597
- let current;
598
- let before;
599
- try {
600
- ({ settings: current, raw: before } = await readSettingsForWrite(cfg.settingsPath));
601
- } catch (err) {
602
- if (err?.code !== "SETTINGS_UNREADABLE") throw err;
603
- // Same shape retireSoundHook answers with, so bin/deck.js reports both
604
- // halves of `--uninstall` the same way instead of one of them inventing a
605
- // second vocabulary for the identical condition on the identical file.
606
- return {
607
- ok: false,
608
- reason: "settings_unreadable",
609
- changed: false,
610
- provider,
611
- settingsPath: cfg.settingsPath,
612
- why: err.why ?? err.message,
613
- message: err.message,
614
- };
615
- }
616
- if (!current?.hooks) return { ok: true, changed: false, provider, settingsPath: cfg.settingsPath };
617
- let changed = false;
618
- for (const evt of Object.keys(current.hooks)) {
619
- const cleaned = dedupeOurEntries(current.hooks[evt]);
620
- if (cleaned.length !== (current.hooks[evt]?.length ?? 0)) changed = true;
621
- if (cleaned.length === 0) delete current.hooks[evt];
622
- else current.hooks[evt] = cleaned;
623
- }
624
- if (changed) {
625
- // THE SAME GUARD ITS SIBLING HAS, for the same file (#788). `installHooks`
626
- // grew a compare-against-the-file check at the last moment and this
627
- // function — which rewrites the same settings.json from a snapshot read
628
- // just as long ago — never got one.
629
- //
630
- // The race is `ccdeck --uninstall` against a deck that is starting.
631
- // Uninstall reads settings.json with the user's own hooks still parked. In
632
- // the window before its write — resolveWriteTarget, temp create, write,
633
- // fsync, stat, rename — the booting deck restores those hooks and deletes
634
- // the park. Uninstall then writes its stale object over the restore,
635
- // bin/deck.js re-reads the clobbered file, readParked hits ENOENT, and it
636
- // reports `restored: 0`. The hooks are gone from both copies and the CLI
637
- // exits 0 saying the uninstall succeeded.
638
- //
639
- // Declining here is not as cheap as declining an install — nothing retries
640
- // an uninstall — so it says so in the result rather than answering `ok`
641
- // with `changed: false`, which would read as "there was nothing to remove".
642
- // The seam the suite needs, and `installHooks` states the argument for it:
643
- // the window this guard covers is filled with real fs work, so a test that
644
- // raced it by wall clock would pass or fail by how fast the machine is.
645
- // Production passes nothing.
646
- if (beforeWrite) await beforeWrite();
647
- let onDisk;
648
- let unreadable = false;
649
- try { ({ raw: onDisk } = await readSettingsForWrite(cfg.settingsPath)); }
650
- catch { unreadable = true; }
651
- if (unreadable || onDisk !== before) {
652
- return {
653
- ok: false, reason: "raced", changed: false, provider,
654
- settingsPath: cfg.settingsPath,
655
- why: "another writer changed settings.json while the uninstall was running",
656
- message: `${cfg.settingsPath} changed while uninstalling — nothing was removed. Run --uninstall again.`,
657
- };
658
- }
659
- await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
660
- }
661
- return { ok: true, changed, provider, settingsPath: cfg.settingsPath };
662
- }
663
-
664
- /** True when ~/.codex/ exists — the CLI's default answer to whether the Codex
665
- * rollout watcher is worth starting, and whether there are hooks to remove. */
666
- export function hasCodexInstalled() {
667
- return existsSync(CODEX_DIR);
668
- }
669
-
670
- /**
671
- * The events log as the record spells it: an absolute path, or null when this
672
- * deck writes none. Shared by the writer and by ensureDiscovery's comparison,
673
- * so a file this process wrote can never read back as somebody else's.
674
- */
675
- function persistField(persist) {
676
- return typeof persist === "string" && persist !== "" ? persist : null;
677
- }
678
-
679
- // Every deck's token lives in this directory, and writeFileAtomic's temp file is
680
- // created beside its target with whatever the umask allows — 0644 on most
681
- // machines — so for the moment before the rename the token would sit in a
682
- // world-readable file. 0700 on the directory closes that window from the outside:
683
- // another user cannot traverse into it whatever the mode of a file inside says.
684
- // The dir usually predates this code, so the mode is re-asserted rather than only
685
- // set at creation, where it would be masked by the umask anyway. Windows ignores
686
- // both — NTFS ACLs inherit from the per-user profile directory.
687
- async function ensureDiscoveryDir() {
688
- if (!existsSync(AGENT_DAG_DIR)) await mkdir(AGENT_DAG_DIR, { recursive: true, mode: 0o700 });
689
- await chmod(AGENT_DAG_DIR, 0o700).catch(() => {});
690
- }
691
-
692
- /**
693
- * Register this deck, in one step no reader can land inside.
694
- *
695
- * The record used to go down with a plain writeFile, which truncates the target
696
- * and then fills it, so the file existed and was empty for a moment on every
697
- * rewrite. Everything that reads this directory parses each record whole —
698
- * electWriters in hook/hook.js, readLiveDecks and sweepStaleDiscovery in
699
- * index.mjs — and a record that fails to parse is a deck missing from that
700
- * cycle: the event it should have logged is either logged by nobody or logged
701
- * twice by the decks that remain, which is the exact failure the single-writer
702
- * election exists to prevent, reached through the file the election reads. A
703
- * rename is atomic on Linux, macOS and Windows alike, so a reader now sees the
704
- * previous record or the new one and never half of either.
705
- *
706
- * The token is the deck's proof of identity, so the file holding it is the
707
- * deck's key material: readable and writable by its owner, nobody else. The mode
708
- * is pinned after the write because writeFileAtomic can only carry over a mode
709
- * the target already had — a first registration, or one left by an earlier run
710
- * under a recycled pid, would otherwise keep whatever the umask handed it.
711
- */
712
- export async function writeDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
713
- await ensureDiscoveryDir();
714
- const file = discoveryPath();
715
- const data = {
716
- pid: process.pid,
717
- port,
718
- workspace: workspace ?? "",
719
- // Without this the hooks refuse to post: a file naming a port it cannot
720
- // authenticate is exactly the stale-file case they now decline to trust.
721
- token: token ?? "",
722
- // Absolute path of the events log this deck appends to, or null under
723
- // --no-persist. The hook reads it to elect a single writer per file:
724
- // several decks receive the same event by design, and without this they
725
- // each appended their own copy to the one log they share. See
726
- // electWriters in hook/hook.js.
727
- persist: persistField(persist),
728
- // Is this deck tailing Codex's rollout files? Those events never pass
729
- // through a hook, so the decks elect a writer for them among themselves —
730
- // and a deck running --no-codex must be left out of that election rather
731
- // than win it and record a rollout it is not even reading. See
732
- // writesCodexLog in src/server/log-writer.mjs.
733
- codex: codex !== false,
734
- // Does this deck run Browser Watch? The watch elects a single writer among
735
- // the decks on a machine, and it elected on port alone — so an older ccdeck
736
- // that predates the feature won the election by having the lower port and
737
- // then wrote nothing, while the deck that HAS the watch stood down. Measured
738
- // on this machine: a v1.46 deck from an npx cache held 4317, answered the
739
- // watch route with the SPA's index.html, and Browser Watch silently
740
- // recorded nothing for as long as both were up. No error, no log line — the
741
- // panel showed findings on screen and the disk stayed empty.
742
- //
743
- // Same shape as `codex` above, and for the same reason: a deck that is not
744
- // doing the work must be left out of the election rather than win it. An
745
- // older deck has no such field, so it is excluded by construction.
746
- watch: true,
747
- // Is the Claude side of this deck switched on? Same shape as `codex` above
748
- // and there for a second reader: a bare `ccdeck` that finds this deck
749
- // running attaches to it instead of starting a rival, and it may only do
750
- // that when the deck on the port is the deck it would itself have built. A
751
- // `--no-claude` deck has no hooks, no accounts panel and no switcher, and
752
- // until this field existed it was indistinguishable from one that has all
753
- // three. An older deck has no such field, so it is excluded by
754
- // construction — see runningDeck.
755
- claude: claude !== false,
756
- // What this deck IS, so a launcher that attaches can say whether the deck
757
- // it found is the version the user just asked for. Never a decision: a
758
- // rival deck on a random port is worse than an older deck said out loud.
759
- version: typeof version === "string" ? version : "",
760
- // The supervisor above this worker, or null when nothing is supervising.
761
- //
762
- // Only `ccdeck --stop` reads it, and only on the path where the polite
763
- // request failed. A worker killed on its own leaves its supervisor alive,
764
- // and a supervisor that puts crashed workers back would answer that kill by
765
- // starting the deck again — so the ladder has to end the parent first and
766
- // the child second, which it cannot do without being told who the parent is.
767
- parent: Number.isInteger(parent) ? parent : null,
768
- startedAt: new Date().toISOString(),
769
- };
770
- await writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
771
- await chmod(file, 0o600).catch(() => {});
772
- return file;
773
- }
774
-
775
- export async function removeDiscovery(file) {
776
- try { await unlink(file); } catch {}
777
- }
778
-
779
- /** Where this process registers itself. One file per deck, named by pid. */
780
- export function discoveryPath() {
781
- return join(AGENT_DAG_DIR, `${process.pid}.json`);
782
- }
783
-
784
- /**
785
- * Make sure this deck's discovery file is on disk and says what it should.
786
- *
787
- * Registration was a single write at boot, so anything that took the file away
788
- * afterwards — a sweep on another machine's clock, a half-finished restart, a
789
- * user tidying the directory — left a deck that was listening, serving and
790
- * completely invisible: hook.js enumerates this directory and nothing else, so
791
- * the deck received zero events while looking perfectly healthy. Re-asserting
792
- * is cheap (one small read), so the deck checks rather than assumes.
793
- *
794
- * A file this process wrote is left alone, mode included. Anything else — no
795
- * file, unreadable, another pid, a stale port, token, events log, Codex or
796
- * Claude setting, or a version left by the deck this process replaced — is
797
- * replaced. Every field another deck decides by is compared, the
798
- * log path included: leave one out and a record missing it would pass as ours
799
- * forever, which for the log path means no deck can tell which of them share a
800
- * file and they all write their own copy of every event again.
801
- */
802
- export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
803
- const file = discoveryPath();
804
- try {
805
- const d = JSON.parse(stripBom(await readFile(file, "utf8")));
806
- if (d
807
- && d.pid === process.pid
808
- && d.port === port
809
- && (d.workspace ?? "") === (workspace ?? "")
810
- && (d.token ?? "") === (token ?? "")
811
- && (d.persist ?? null) === persistField(persist)
812
- && d.codex === (codex !== false)
813
- && d.claude === (claude !== false)
814
- && (d.version ?? "") === (typeof version === "string" ? version : "")
815
- && (d.parent ?? null) === (Number.isInteger(parent) ? parent : null)) {
816
- return { file, rewritten: false };
817
- }
818
- } catch { /* missing, unreadable or corrupt — rewritten below */ }
819
- await writeDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
820
- return { file, rewritten: true };
821
- }
822
-
823
- /**
824
- * Keep this deck registered for as long as it runs, and tell the caller when
825
- * that stops being true.
826
- *
827
- * `onState` hears the first outcome, every change of health, and every
828
- * re-registration after the first — never a steady state. A deck that cannot
829
- * write the file has to say so: silently listening while no hook can find it
830
- * is the failure this exists to end, not a state worth hiding.
831
- *
832
- * The interval is unref'd, so it never keeps a finished process alive, and
833
- * `stop()` must be called before the file is removed on shutdown — otherwise
834
- * the next tick would put it straight back.
835
- *
836
- * `stop()` ANSWERS WITH THE CHECK ALREADY IN FLIGHT, and shutdown has to await
837
- * it. Clearing the interval stops the next tick; it does nothing about the one
838
- * that started a moment ago and is currently inside writeFileAtomic. That tick
839
- * finishes after the unlink and re-creates the file — exactly the "leave the
840
- * file behind for the hooks to find once nothing is listening" that stopping
841
- * first is supposed to prevent, reached by the one route stopping first does
842
- * not cover. The window is a rename and an fsync on POSIX; on Windows it is
843
- * that plus renameWithRetry's ladder, up to 200ms of sleeping while a scanner
844
- * holds the target — the platform the retry was written for is the platform
845
- * where the race is twenty times wider.
846
- *
847
- * `run()` never rejects (every failure is a state), so awaiting this cannot
848
- * throw and cannot outlast one bounded check.
849
- */
850
- export function keepDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null, intervalMs = 5000, onState = null } = {}) {
851
- // null until the first outcome, which therefore always differs and is always
852
- // reported — the caller learns where it stands before anything else happens.
853
- let healthy = null;
854
-
855
- const run = async () => {
856
- let state;
857
- try {
858
- const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
859
- state = { ok: true, rewritten, file: discoveryPath(), error: null };
860
- } catch (err) {
861
- state = { ok: false, rewritten: false, file: discoveryPath(), error: err };
862
- }
863
- const worthSaying = healthy !== state.ok || state.rewritten;
864
- healthy = state.ok;
865
- if (worthSaying && onState) { try { onState(state); } catch { /* not our problem */ } }
866
- return state;
867
- };
868
-
869
- // One check at a time. Registration is a write and a write is now a rename,
870
- // which costs an fsync — long enough that a tick can land inside the boot-time
871
- // check bin/deck.js runs by hand. That second check reads a file the first has
872
- // not renamed into place yet, concludes the deck is unregistered and writes it
873
- // again, and the two writes race over one record: the deck reports itself
874
- // unregistered on a machine where nothing whatsoever is wrong. A caller that
875
- // asks mid-check gets the answer the check already in flight is fetching.
876
- let inFlight = null;
877
- const check = () => (inFlight ??= run().finally(() => { inFlight = null; }));
878
-
879
- const timer = setInterval(() => { check(); }, intervalMs);
880
- timer.unref?.();
881
-
882
- const stop = () => {
883
- clearInterval(timer);
884
- return inFlight ?? Promise.resolve(null);
885
- };
886
-
887
- return { file: discoveryPath(), check, stop };
888
- }
889
-
890
- // CLAUDE_EVENTS used to ride along here (#383). It is the events list of the
891
- // `claude` entry in PROVIDERS and has never had a reader outside this file; it
892
- // was easy to miss because the three directories beside it ARE imported and
893
- // because the long justification below belongs to the SECOND export, not this
894
- // one. Which events the deck asks Claude Code for is answered by installHooks
895
- // writing settings.json, which is what the tests read.
896
- export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
897
- // Exported for the other modules that rewrite settings.json — the sound toggle
898
- // today. Every one of them needs the same two guarantees: a file we cannot
899
- // parse is never treated as an empty one, and the replacement is a single
900
- // rename rather than a truncate a reader can land inside. installScript carries
901
- // the same guarantee to the hook scripts themselves, which are the files live
902
- // sessions are actually executing, and renameWithRetry goes out on its own for
903
- // the files writeFileAtomic cannot write — the fetched uv binary in
904
- // uv-bootstrap.mjs — which still need the Windows retry. createTemp goes out for
905
- // the same reason one step lower: codex-auth.mjs needs the collision-free temp
906
- // name but not writeFileAtomic's mode handling, which carries over the target's
907
- // mode and so would leave a brand-new auth.json at whatever the umask allows.
908
- // resolveWriteTarget goes with them, because auth.json is linked into a dotfiles
909
- // repo for the same reasons settings.json is, and "never rename onto a link" is
910
- // one rule: codex-auth.mjs called a realpath of its own before this existed, and
911
- // two spellings of a rule are two things that can drift.
912
- export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget };