agent-dag 1.42.0 → 1.44.1

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.
@@ -609,7 +609,7 @@ function maybeResolveUsage(payload) {
609
609
  // The emit is what is actually kept rare, and it is gated on a CHANGE: with 685
610
610
  // records carrying 2 distinct values, a per-pass emit would be ~683 events
611
611
  // saying nothing. Sessions that never get named emit nothing at all.
612
- const nameBySession = new Map(); // sid -> `${agentName}${aiTitle}`
612
+ const nameBySession = new Map(); // sid -> `${agentName}\0${aiTitle}`
613
613
  const lastNameReadAt = new Map(); // sid -> ms timestamp
614
614
  const pendingNameReads = new Set(); // sid currently being read
615
615
 
@@ -637,7 +637,7 @@ function maybeResolveSessionName(payload) {
637
637
  readSessionNamingFromTranscript(tp)
638
638
  .then(naming => {
639
639
  if (!naming) return;
640
- const sig = `${naming.agentName ?? ""}${naming.aiTitle ?? ""}`;
640
+ const sig = `${naming.agentName ?? ""}\u0000${naming.aiTitle ?? ""}`;
641
641
  if (nameBySession.get(sid) === sig) return;
642
642
  nameBySession.set(sid, sig);
643
643
  pushEvent({
@@ -1701,6 +1701,18 @@ const MAX_TRACKED_SESSIONS = 256;
1701
1701
 
1702
1702
  function forgetSession(sid) {
1703
1703
  modelBySession.delete(sid);
1704
+ // The two the session-naming work added (#520/#522) and did not list here.
1705
+ // Both are keyed by session id and nothing else ever removed an entry, which
1706
+ // is the exact leak the comment above says this mechanism exists to end —
1707
+ // every sibling cache is capped at MAX_TRACKED_SESSIONS and these two were
1708
+ // not. The functional half is worse than the leak: nameBySession gates the
1709
+ // SessionNamed emit on "has this changed", so a live session evicted past the
1710
+ // cap and then heard from again re-emits its model (modelBySession was
1711
+ // cleared) and never re-emits its name. A tab that connects after the event
1712
+ // ring has rolled past the original SessionNamed shows that session unnamed
1713
+ // for the rest of its life.
1714
+ nameBySession.delete(sid);
1715
+ lastNameReadAt.delete(sid);
1704
1716
  modelLastReadAt.delete(sid);
1705
1717
  lastUsageReadAt.delete(sid);
1706
1718
  lastContextReadAt.delete(sid);
@@ -1737,14 +1749,77 @@ function touchSession(sid) {
1737
1749
  // buffer replays whatever it missed. Dropping individual events instead would
1738
1750
  // leave a hole the resume path cannot even see, the client's last id having
1739
1751
  // moved past it.
1740
- const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024;
1752
+ //
1753
+ // The ceiling has to clear the largest SINGLE frame the deck can emit, because
1754
+ // one write() of such a frame puts the whole of it in the queue with nothing
1755
+ // having had the chance to drain any of it — a client reading at full speed
1756
+ // looks, for that instant, exactly like a frozen tab. #588 was exactly that
1757
+ // failure: queuedBytes below doubled every reading, so the real ceiling was
1758
+ // 4 MiB and one 4 MiB tool response hung up on every subscribed tab at once.
1759
+ //
1760
+ // So the number is checked against what `POST /api/event` admits rather than
1761
+ // left to feel. handleEventIngest caps a body at 5,000,000 CHARACTERS. The
1762
+ // event is re-serialized before it goes out, and re-serializing a value that
1763
+ // came from JSON.parse of an N-character document cannot exceed N characters —
1764
+ // every escape the output needs was already paid for in the input, and \uXXXX
1765
+ // input comes back shorter — so one frame is at most 5,000,000 characters, plus
1766
+ // this deck's envelope, measured at 127, plus the id/event/data framing. 8 MiB
1767
+ // clears that by a little over 1.6x, and is the number this constant has always
1768
+ // named; what #588 changed is that it now means it.
1769
+ //
1770
+ // Characters, not bytes, which is the one misleading thing left in the name.
1771
+ // writeSse and writeResume write STRINGS, and a Writable with decodeStrings
1772
+ // false — which both an OutgoingMessage and the net.Socket under it are — adds
1773
+ // `chunk.length`, i.e. UTF-16 units, to its queue. Measured on Node 22.14: a
1774
+ // 4,800,000-character Read of CJK text is 14,400,184 bytes on the wire and
1775
+ // `res.writableLength` reports 4,800,311. That is why comparing this against a
1776
+ // character-denominated ingest limit is the right comparison and comparing it
1777
+ // against a byte count would not be — and it is worth stating plainly, because
1778
+ // assuming a unit for writableLength instead of measuring it is the whole
1779
+ // shape of the bug this comment exists to explain. The memory behind a full
1780
+ // buffer is larger than the number says, up to two bytes per unit while it is
1781
+ // held as a string; that is not what the cap is for, which is noticing a client
1782
+ // that has stopped reading at all.
1783
+ //
1784
+ // Exported, with queuedBytes, so the arithmetic can be asserted directly. #588
1785
+ // survived because it could only be observed through a live socket, where the
1786
+ // existing tests' tolerances were wider than the error.
1787
+ export const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024;
1741
1788
 
1742
- /** Bytes queued for a client: what the response has not handed to the socket
1743
- * yet, plus what the socket has not handed to the kernel. */
1744
- function queuedBytes(res) {
1789
+ /**
1790
+ * What this response has accepted and not yet handed to the kernel, in the
1791
+ * units writableLength reports it in — see MAX_CLIENT_BUFFER_BYTES above, which
1792
+ * is the number this is compared against.
1793
+ *
1794
+ * NOT the sum of the two writableLengths, which is what #588 was: Node's
1795
+ * `OutgoingMessage.writableLength` getter is `outputSize + this[kChunkedLength]
1796
+ * + (socket ? socket.writableLength : 0)`, so the socket's queue is already
1797
+ * inside it. For an SSE response, whose outputSize is zero from the moment the
1798
+ * headers flush, the two readings are the same number exactly — measured on
1799
+ * Node 22.14 against a paused reader, `res.writableLength=4194615
1800
+ * socket.writableLength=4194615` — so adding them reported exactly twice the
1801
+ * real backlog and made an 8 MiB constant behave as a 4 MiB one.
1802
+ *
1803
+ * Why max and not simply `res.writableLength`, which is today's whole answer.
1804
+ * That composition is a Node implementation detail and it has moved before, so
1805
+ * the expression is chosen to survive it moving again. Read the two as an
1806
+ * overlapping pair and take the larger:
1807
+ * - composed as it is today, `own` already contains `sock`, so `own >= sock`
1808
+ * and max is `own` — the exact total;
1809
+ * - were the getter to stop including the socket term, `own` for a flushed
1810
+ * SSE response is zero and max is `sock` — again the exact total;
1811
+ * - with the socket detached (`res.socket` null, which happens between the
1812
+ * response ending and the handle being released) max is `own`, the only
1813
+ * reading there is.
1814
+ * Every case is right, and the failure mode if some future composition makes
1815
+ * both terms non-zero and disjoint is under-counting by at most 2x — a client
1816
+ * held a little longer than intended, which is the harmless direction. Summing
1817
+ * fails the other way, and dropping readers that are not behind is the bug.
1818
+ */
1819
+ export function queuedBytes(res) {
1745
1820
  const own = typeof res.writableLength === "number" ? res.writableLength : 0;
1746
1821
  const sock = res.socket && typeof res.socket.writableLength === "number" ? res.socket.writableLength : 0;
1747
- return own + sock;
1822
+ return Math.max(own, sock);
1748
1823
  }
1749
1824
 
1750
1825
  /** Hang up on a client we have decided not to keep. `delete` on a response
@@ -1775,6 +1850,14 @@ function writeSse(res, frame) {
1775
1850
  // not accept a byte in any budget, so the only thing a long one buys it is a
1776
1851
  // few more seconds of holding its own buffer. The environment override exists
1777
1852
  // so the tests can pin the drop without sitting through the real budget.
1853
+ //
1854
+ // It is a budget per awaited frame, and what that frame waits on is everything
1855
+ // queued ahead of it draining — a full MAX_CLIENT_BUFFER_BYTES, by
1856
+ // construction, since that is what the loop fills to before it stops. So this
1857
+ // states a minimum rate a resuming client has to manage, and #588 doubled that
1858
+ // flush in practice without touching this line: the cap it is sized against was
1859
+ // really 4 MiB and is now the 8 MiB it always said. Still generous — measured
1860
+ // on loopback a full cap flushes in about a quarter of a second.
1778
1861
  const REPLAY_DRAIN_MS = Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS) > 0
1779
1862
  ? Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS)
1780
1863
  : 30_000;
@@ -1797,10 +1880,12 @@ const REPLAY_DRAIN_MS = Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS) > 0
1797
1880
  * up on a client that takes nothing at all for REPLAY_DRAIN_MS.
1798
1881
  *
1799
1882
  * The wait is on write()'s completion callback rather than on a 'drain' event:
1800
- * 'drain' only follows a write that was answered false, and a frame can push
1801
- * queuedBytes past the cap while still being answered true, the socket's own
1802
- * pending bytes being one of the two terms in that sum. The callback fires
1803
- * once this chunk —
1883
+ * 'drain' fires only after a write that was answered false, so waiting on it
1884
+ * means depending on an answer this call may never have seen. The cap sits far
1885
+ * above the stream's own 16 KiB high-water mark, so by the time queuedBytes is
1886
+ * at the cap the `false` that a 'drain' would eventually answer belongs to some
1887
+ * frame long since written, and its drain may already have come and gone. The
1888
+ * callback fires once this chunk —
1804
1889
  * and therefore everything queued ahead of it — has reached the OS, which is
1805
1890
  * exactly the condition being waited for. It also fires, with an error we do
1806
1891
  * not need to read, if the response is destroyed underneath us, so this cannot
@@ -3086,16 +3171,102 @@ export function sendInternalError(res, err, log = console.error) {
3086
3171
  else res.end();
3087
3172
  }
3088
3173
 
3174
+ // One attempt, leaving the server with no more listeners on it than it started
3175
+ // with. `server.listen(port, host, cb)` registers `cb` for a 'listening' event
3176
+ // that a failed bind never emits, so the previous shape left one behind per
3177
+ // attempt — eleven candidates printed Node's "MaxListenersExceededWarning:
3178
+ // 11 listening listeners added to [Server]" into the middle of a boot that was
3179
+ // already going wrong. Both sides are removed by whichever fires first.
3089
3180
  async function tryListen(server, port, host) {
3090
3181
  return new Promise((res, rej) => {
3091
- server.once("error", rej);
3092
- server.listen(port, host, () => {
3093
- server.removeListener("error", rej);
3094
- res();
3095
- });
3182
+ const onListening = () => { server.removeListener("error", onError); res(); };
3183
+ const onError = (err) => { server.removeListener("listening", onListening); rej(err); };
3184
+ server.once("error", onError);
3185
+ server.once("listening", onListening);
3186
+ server.listen(port, host);
3096
3187
  });
3097
3188
  }
3098
3189
 
3190
+ /**
3191
+ * Whether another PORT could fix this failed `listen`.
3192
+ *
3193
+ * startServer builds ten random fallback candidates and they exist for exactly
3194
+ * one situation: "this port is unavailable". Until #552 only EADDRINUSE reached
3195
+ * them, which is the POSIX spelling of that answer and not the only one.
3196
+ *
3197
+ * WINDOWS. `winnat` hands out contiguous TCP blocks to Hyper-V, WSL2 and Docker
3198
+ * Desktop, and a bind INSIDE one of those reserved exclusion ranges — the ones
3199
+ * `netsh interface ipv4 show excludedportrange protocol=tcp` prints — is refused
3200
+ * with WSAEACCES, which libuv reports as EACCES. Any machine with containers or
3201
+ * WSL on it can therefore have 4317 blocked without a single socket being open
3202
+ * on it. The loop rethrew on the first candidate, bin/deck.js printed
3203
+ * `server failed: listen EACCES: permission denied 127.0.0.1:4317`, and the deck
3204
+ * exited 1 with the fallback range untouched.
3205
+ *
3206
+ * On POSIX the same code is what a bind below 1024 gets without privilege.
3207
+ * Retrying is right there too: 4317 and the whole fallback range are above 1024,
3208
+ * so a random candidate is a port the user can actually have — and the deck
3209
+ * coming up on 4322 beats it refusing to come up at all, which is already how
3210
+ * EADDRINUSE on a privileged port behaves today.
3211
+ *
3212
+ * The other direction is the half that keeps this honest. EADDRNOTAVAIL,
3213
+ * ENOTFOUND, EAI_AGAIN and EAFNOSUPPORT are about the HOST, not the port: the
3214
+ * address does not exist on this machine or does not resolve, and no candidate
3215
+ * can help. Walking eleven of them only delays the one sentence that would have
3216
+ * explained it, so anything not named here stops the loop.
3217
+ */
3218
+ export const portRetryable = (err) =>
3219
+ Boolean(err) && (err.code === "EADDRINUSE" || err.code === "EACCES");
3220
+
3221
+ /**
3222
+ * The sentence that goes beside a listen errno, or "" when the errno says it
3223
+ * all.
3224
+ *
3225
+ * Pure, and the platform is a parameter, for the reason every Windows answer in
3226
+ * this repo is written that way: the branch that matters most is the one the
3227
+ * author cannot run. A raw `listen EACCES: permission denied 127.0.0.1:4317` is
3228
+ * true and useless — the thing the user needs is the name of the command that
3229
+ * lists the ranges their machine has reserved.
3230
+ */
3231
+ export function listenHint(code, { host = "", port = 0, platform = process.platform } = {}) {
3232
+ if (code === "EACCES") {
3233
+ if (platform === "win32") {
3234
+ return "a reserved port range is the usual cause on Windows: Hyper-V, WSL2 and Docker Desktop have winnat hold contiguous TCP blocks. Run `netsh interface ipv4 show excludedportrange protocol=tcp` to see them, then pass --port with a number outside every range";
3235
+ }
3236
+ if (port > 0 && port < 1024) return "ports below 1024 need root — pass --port with a number above 1024";
3237
+ return "the OS refused the bind; a sandbox or a security policy is the usual cause";
3238
+ }
3239
+ if (code === "EADDRNOTAVAIL") {
3240
+ return `no interface on this machine holds ${host || "that address"}, so no other port can help — pass --host with an address it does hold, or 127.0.0.1 for local only`;
3241
+ }
3242
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
3243
+ return `${host || "that host"} does not resolve, so no other port can help — pass --host with an address rather than a name`;
3244
+ }
3245
+ return "";
3246
+ }
3247
+
3248
+ /**
3249
+ * The error startServer throws, with the hint already in it.
3250
+ *
3251
+ * `exhausted` is the difference between "every candidate was refused" and "this
3252
+ * one was refused for a reason more candidates cannot fix". The exhausted
3253
+ * message keeps its opening words — bin/deck.js prints them and
3254
+ * boot-listen-before-report.test.ts reads them — and now names the LAST errno
3255
+ * rather than asserting EADDRINUSE, which was a lie the moment EACCES could
3256
+ * reach the end of the loop.
3257
+ */
3258
+ export function listenFailure(err, { host = "", port = 0, platform = process.platform, exhausted = false } = {}) {
3259
+ const code = err?.code;
3260
+ const head = exhausted
3261
+ ? `all ports tried — none available (last: ${code ?? "unknown"} on ${host}:${port})`
3262
+ : String(err?.message ?? err ?? "listen failed");
3263
+ const hint = listenHint(code, { host, port, platform });
3264
+ const out = new Error(hint ? `${head} — ${hint}` : head);
3265
+ if (code !== undefined) out.code = code;
3266
+ if (err) out.cause = err;
3267
+ return out;
3268
+ }
3269
+
3099
3270
  // Set from startServer's options. The server cannot restart itself — the
3100
3271
  // process lifecycle belongs to the supervisor in bin/agent-dag.js, which is the
3101
3272
  // only thing that can bring a replacement up on the same port without racing
@@ -3217,6 +3388,31 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
3217
3388
  if (req.method === "POST" && url.pathname === "/api/clear") {
3218
3389
  events.length = 0;
3219
3390
  if (persistPath) truncate(persistPath, 0).catch(() => {});
3391
+ // Drop the caches that gate an emit on "has this changed", because the
3392
+ // client is about to forget what they are comparing against: __clear makes
3393
+ // the reducer return a fresh state, so every session's name and every
3394
+ // subagent's model label go with it. maybeResolveSessionName then computes
3395
+ // the same signature, takes its early return, and emits nothing — so the
3396
+ // card falls back to cwd/prompt for the rest of that session while the
3397
+ // server is sitting on the name.
3398
+ //
3399
+ // The root model survives without help because pushEvent stamps
3400
+ // `raw.model` on every payload; there is no equivalent stamp for the name
3401
+ // or for a subagent's model, which is why those two are listed and the
3402
+ // rest of the per-session state is not.
3403
+ //
3404
+ // The rule, for the next cache that gates an emit: anything answering
3405
+ // "has this changed" has to appear in BOTH places that mean the client no
3406
+ // longer has it — here, and in forgetSession.
3407
+ nameBySession.clear();
3408
+ modelBySession.clear();
3409
+ // The read stamps go with them. Clearing only the signatures would leave
3410
+ // the next hook event inside MODEL_READ_THROTTLE_MS, so the transcript
3411
+ // would not be re-read at all and the name would stay missing until the
3412
+ // throttle expired — a clear followed by a keystroke is exactly when a
3413
+ // user is watching.
3414
+ lastNameReadAt.clear();
3415
+ modelLastReadAt.clear();
3220
3416
  pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal");
3221
3417
  return send(res, 200, { ok: true });
3222
3418
  }
@@ -3229,6 +3425,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
3229
3425
  const candidates = [port];
3230
3426
  for (let i = 0; i < 10; i++) candidates.push(randomPort(portRange[0], portRange[1]));
3231
3427
 
3428
+ // The errno the exhaustion message ends up naming, and the port it happened
3429
+ // on. Kept because the last candidate's reason is the only one still worth
3430
+ // saying by then — the nine before it were random ports nobody asked for.
3431
+ let lastErr = null;
3432
+ let lastPort = port;
3232
3433
  for (const candidate of candidates) {
3233
3434
  try {
3234
3435
  await tryListen(server, candidate, host);
@@ -3241,11 +3442,17 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
3241
3442
  cswapAutoModule().then(m => m.initCswapAuto()).catch(() => {});
3242
3443
  return server;
3243
3444
  } catch (err) {
3244
- if (err && err.code === "EADDRINUSE") continue;
3245
- throw err;
3445
+ lastErr = err;
3446
+ lastPort = candidate;
3447
+ // "This port is unavailable" — which Windows spells EACCES for a port
3448
+ // inside a reserved exclusion range. See portRetryable.
3449
+ if (portRetryable(err)) continue;
3450
+ // Anything else is about the host or the socket, and the next candidate
3451
+ // would fail identically. Say why instead of trying ten more times.
3452
+ throw listenFailure(err, { host, port: candidate });
3246
3453
  }
3247
3454
  }
3248
- throw Object.assign(new Error(`all ports tried — none available`), { code: "EADDRINUSE" });
3455
+ throw listenFailure(lastErr, { host, port: lastPort, exhausted: true });
3249
3456
  }
3250
3457
 
3251
3458
  // Allow running this file directly for dev (`npm run dev:server`). The port
@@ -93,11 +93,17 @@ const LEGACY_DIRS = ["ccgraph", "agent-flow", "agent-dag"];
93
93
  * caller chose — and is quoted anyway, because that is not a property worth
94
94
  * re-deriving at every reading.
95
95
  *
96
- * Exported, with the node path injectable, so the escaping can be checked
97
- * against a path the test names rather than against whatever ran the suite.
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.
98
103
  */
99
- export function hookCommand(installedHookPath, provider, node = process.execPath) {
100
- const q = (s) => shellQuoteArg(s);
104
+ export function hookCommand(installedHookPath, provider, node = process.execPath,
105
+ platform = process.platform) {
106
+ const q = (s) => shellQuoteArg(s, platform);
101
107
  return `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
102
108
  }
103
109
 
@@ -127,10 +133,6 @@ function stripBom(text) {
127
133
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
128
134
  }
129
135
 
130
- async function readJsonSafe(p) {
131
- try { return JSON.parse(stripBom(await readFile(p, "utf8"))); } catch { return null; }
132
- }
133
-
134
136
  function unreadableSettings(p, why) {
135
137
  const err = new Error(
136
138
  `${p} could not be read as JSON (${why}). Refusing to overwrite it — ` +
@@ -138,6 +140,11 @@ function unreadableSettings(p, why) {
138
140
  );
139
141
  err.code = "SETTINGS_UNREADABLE";
140
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;
141
148
  return err;
142
149
  }
143
150
 
@@ -168,23 +175,48 @@ async function readSettingsForWrite(p) {
168
175
  return { settings: parsed, raw };
169
176
  }
170
177
 
171
- // Rename over an open file is the one thing Windows does differently here. The
172
- // call itself is atomic (MoveFileEx with MOVEFILE_REPLACE_EXISTING), but it
173
- // fails outright while another process holds the target open — and a virus
174
- // scanner or the search indexer opens files the instant they are written, for a
175
- // few milliseconds at a time. Retrying a sharing violation a handful of times
176
- // turns that into a successful install. POSIX never hits this path, and a real
177
- // permission error just costs an extra fraction of a second before it surfaces.
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.
178
206
  const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
179
207
 
180
- async function renameWithRetry(from, to, attempts = 5) {
208
+ async function renameWithRetry(from, to, attempts = 10) {
181
209
  for (let attempt = 1; ; attempt++) {
182
210
  try {
183
211
  await rename(from, to);
184
212
  return;
185
213
  } catch (err) {
186
214
  if (attempt >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
187
- await delay(20 * attempt);
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);
188
220
  }
189
221
  }
190
222
  }
@@ -334,6 +366,29 @@ export async function installHooks({ provider = "claude" } = {}) {
334
366
  current.hooks[evt] = cleaned;
335
367
  }
336
368
 
369
+ // The finish sound is the deck's second installed script, and until this line
370
+ // it was the only one nothing ever re-installed. dedupeOurEntries does not
371
+ // touch it — isOurEntry knows `__agent-dag` and the entry is marked
372
+ // `__agent-dag-sound` — so the loop above carried a stale entry straight
373
+ // through, and nothing anywhere looked at the file that entry names. See
374
+ // reassertSoundHook: it re-asserts the script only where our Stop entry is
375
+ // already present, so a user who turned the sound off does not get it back,
376
+ // and it mutates `current` rather than writing, so the comparison below is
377
+ // still what decides whether settings.json is touched at all.
378
+ //
379
+ // Imported here rather than at the top of the file because sound-hook.mjs
380
+ // imports this module — installScript, writeFileAtomic and readSettingsForWrite
381
+ // all live here — and a static import would close that into a cycle. Claude
382
+ // only: the sound entry is one line in Claude Code's settings.json and there
383
+ // is no Codex equivalent.
384
+ let sound = { present: false };
385
+ let sweepLegacySoundScript = null;
386
+ if (provider === "claude") {
387
+ const soundHook = await import("./sound-hook.mjs");
388
+ sweepLegacySoundScript = soundHook.sweepLegacySoundScript;
389
+ sound = await soundHook.reassertSoundHook(current);
390
+ }
391
+
337
392
  // Every launch reinstalls, and on all but the first the entries are already
338
393
  // there and identical. Writing anyway is pure downside: it is one more chance
339
394
  // to be interrupted mid-write, and one more window in which a change Claude
@@ -342,14 +397,63 @@ export async function installHooks({ provider = "claude" } = {}) {
342
397
  const next = JSON.stringify(current, null, 2) + "\n";
343
398
  const changed = next !== before;
344
399
  if (changed) await writeFileAtomic(cfg.settingsPath, next);
345
- return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed };
400
+ // After the write, never before it: the `notify.js` an older deck installed is
401
+ // what a live session's cached command still names until the new entry is on
402
+ // disk, and deleting it early turns a stale sound into a missing module.
403
+ if (sound.present) await sweepLegacySoundScript();
404
+ return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed, sound };
346
405
  }
347
406
 
407
+ /**
408
+ * Take our forwarders back out of one provider's settings file.
409
+ *
410
+ * Returns `{ok: true, changed}` when the file was read — `changed` says whether
411
+ * anything of ours was in it — and `{ok: false, reason: "settings_unreadable"}`
412
+ * when it was not. Callers must look at `ok` FIRST: `changed: false` on a
413
+ * refusal is the literal truth about the disk and a lie about the question
414
+ * being asked, because the hooks are still in there.
415
+ *
416
+ * That conflation is what this used to ship. The read was readJsonSafe, which
417
+ * turned every parse and IO failure into `null`, so a settings.json with one
418
+ * stray comma — the exact file readSettingsForWrite was written to protect —
419
+ * came back indistinguishable from a clean machine with none of our hooks in
420
+ * it. `--uninstall` printed "no Claude hooks to remove" and exited 0 while all
421
+ * ten `__agent-dag` entries sat in the file, spawning node on every tool call
422
+ * of every session, for a deck the user had been told was gone. The other half
423
+ * of the same command already knew better: uninstallSoundHook reads through
424
+ * readSettingsForWrite and says so out loud, so one command gave two opposite
425
+ * verdicts about one file and the load-bearing one was the one that lied.
426
+ *
427
+ * So the read is the same read the install does, and for the same reason. A
428
+ * file we cannot parse is a file whose contents we cannot reproduce, and this
429
+ * function rewrites the whole thing — every permission, env var, model pin and
430
+ * hand-written hook in it. Refusing leaves it byte for byte as it was found and
431
+ * hands the user something they can act on; guessing would either destroy it or
432
+ * quietly do nothing. Only ENOENT is genuinely empty, and readSettingsForWrite
433
+ * already answers that with `{}`, which falls through to `changed: false`.
434
+ */
348
435
  export async function uninstallHooks({ provider = "claude" } = {}) {
349
436
  const cfg = PROVIDERS[provider];
350
437
  if (!cfg) throw new Error(`unknown provider: ${provider}`);
351
- const current = await readJsonSafe(cfg.settingsPath);
352
- if (!current?.hooks) return { changed: false, provider };
438
+ let current;
439
+ try {
440
+ ({ settings: current } = await readSettingsForWrite(cfg.settingsPath));
441
+ } catch (err) {
442
+ if (err?.code !== "SETTINGS_UNREADABLE") throw err;
443
+ // Same shape uninstallSoundHook answers with, so bin/deck.js reports both
444
+ // halves of `--uninstall` the same way instead of one of them inventing a
445
+ // second vocabulary for the identical condition on the identical file.
446
+ return {
447
+ ok: false,
448
+ reason: "settings_unreadable",
449
+ changed: false,
450
+ provider,
451
+ settingsPath: cfg.settingsPath,
452
+ why: err.why ?? err.message,
453
+ message: err.message,
454
+ };
455
+ }
456
+ if (!current?.hooks) return { ok: true, changed: false, provider, settingsPath: cfg.settingsPath };
353
457
  let changed = false;
354
458
  for (const evt of Object.keys(current.hooks)) {
355
459
  const cleaned = dedupeOurEntries(current.hooks[evt]);
@@ -358,7 +462,7 @@ export async function uninstallHooks({ provider = "claude" } = {}) {
358
462
  else current.hooks[evt] = cleaned;
359
463
  }
360
464
  if (changed) await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
361
- return { changed, provider, settingsPath: cfg.settingsPath };
465
+ return { ok: true, changed, provider, settingsPath: cfg.settingsPath };
362
466
  }
363
467
 
364
468
  /** True when ~/.codex/ exists — the CLI's default answer to whether the Codex
@@ -494,6 +598,20 @@ export async function ensureDiscovery({ port, workspace, token, persist = null,
494
598
  * The interval is unref'd, so it never keeps a finished process alive, and
495
599
  * `stop()` must be called before the file is removed on shutdown — otherwise
496
600
  * the next tick would put it straight back.
601
+ *
602
+ * `stop()` ANSWERS WITH THE CHECK ALREADY IN FLIGHT, and shutdown has to await
603
+ * it. Clearing the interval stops the next tick; it does nothing about the one
604
+ * that started a moment ago and is currently inside writeFileAtomic. That tick
605
+ * finishes after the unlink and re-creates the file — exactly the "leave the
606
+ * file behind for the hooks to find once nothing is listening" that stopping
607
+ * first is supposed to prevent, reached by the one route stopping first does
608
+ * not cover. The window is a rename and an fsync on POSIX; on Windows it is
609
+ * that plus renameWithRetry's ladder, up to 200ms of sleeping while a scanner
610
+ * holds the target — the platform the retry was written for is the platform
611
+ * where the race is twenty times wider.
612
+ *
613
+ * `run()` never rejects (every failure is a state), so awaiting this cannot
614
+ * throw and cannot outlast one bounded check.
497
615
  */
498
616
  export function keepDiscovery({ port, workspace, token, persist = null, codex = true, intervalMs = 5000, onState = null } = {}) {
499
617
  // null until the first outcome, which therefore always differs and is always
@@ -527,7 +645,12 @@ export function keepDiscovery({ port, workspace, token, persist = null, codex =
527
645
  const timer = setInterval(() => { check(); }, intervalMs);
528
646
  timer.unref?.();
529
647
 
530
- return { file: discoveryPath(), check, stop: () => clearInterval(timer) };
648
+ const stop = () => {
649
+ clearInterval(timer);
650
+ return inFlight ?? Promise.resolve(null);
651
+ };
652
+
653
+ return { file: discoveryPath(), check, stop };
531
654
  }
532
655
 
533
656
  // CLAUDE_EVENTS used to ride along here (#383). It is the events list of the
@@ -1,11 +1,11 @@
1
1
  // Which of the three published commands the user typed — and nothing else.
2
2
  //
3
- // The deck is on npm three times over: `agents-deck` (what this repo publishes),
4
- // `agent-dag` (the same tarball under the name it shipped as first) and
5
- // `ccdeck` (a stub that depends on agents-deck and spawns its bin). Every
6
- // surface a human reads says ccdeck now, and 95% of the downloads are still on
7
- // the other two, so nothing closes that split on its own. This file is the
8
- // evidence behind saying so — once in the terminal, once in the browser.
3
+ // The deck is on npm three times over — `ccdeck`, `agents-deck` and `agent-dag`
4
+ // — and since #340 all three are literally the same tarball, published with
5
+ // `name` set to each in turn. Every surface a human reads says ccdeck now, and
6
+ // most of the downloads are still on the other two, so nothing closes that split
7
+ // on its own. This file is the evidence behind saying so — once in the terminal,
8
+ // once in the browser.
9
9
  //
10
10
  // Two rules make that safe, and they are the whole of the file.
11
11
  //
@@ -15,13 +15,15 @@
15
15
  // dead on the one machine where the deck was the thing that would have
16
16
  // explained why.
17
17
  //
18
- // And it is never asked of the PACKAGE. `ccdeck/package.json` depends on
19
- // `agents-deck`, and `ccdeck/bin/ccdeck.js` spawns
20
- // `../../agents-deck/bin/agent-dag.js` — so "is this build agents-deck?" is
21
- // true inside every ccdeck run, and a notice keyed on it would scold exactly
22
- // the people who did what we asked. The question is which name the USER TYPED.
23
- // That is a different question, with a different answer, and on one platform
24
- // with no answer at all:
18
+ // And it is never asked of the PACKAGE. That was unarguable while ccdeck was a
19
+ // stub — it depended on `agents-deck` and spawned its bin, so "is this build
20
+ // agents-deck?" was true inside every ccdeck run, and a notice keyed on it would
21
+ // have scolded exactly the people who did what we asked. #340 removed the stub
22
+ // and the rule outlived it, because the three names are now ONE tarball: asking
23
+ // the package what it is gets you whichever name the publish step happened to
24
+ // set last, which is not evidence about the user at all. The question is which
25
+ // name the USER TYPED. That is a different question, with a different answer,
26
+ // and on one platform with no answer at all:
25
27
  //
26
28
  // npx, everywhere — npm writes the literal spec into
27
29
  // `_npx/<hash>/package.json` as `_npx.packages`, which npxRestartSpec
@@ -37,7 +39,7 @@
37
39
  //
38
40
  // a global install on Windows — nowhere. npm writes <name>.cmd, <name>.ps1
39
41
  // and an extensionless sh shim, each of which runs
40
- // `node "…\node_modules\agents-deck\bin\agent-dag.js" %*` (npm's cmd-shim).
42
+ // `node "…\node_modules\<pkg>\bin\agent-dag.js" %*` (npm's cmd-shim).
41
43
  // The typed name is the shim's FILENAME and never becomes an argument, and
42
44
  // there is no other carrier — npm_config_user_agent and friends are not set
43
45
  // for a direct bin invocation. So Windows answers null.