agent-dag 1.43.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.
@@ -340,13 +340,40 @@ export const tryNext = (err) =>
340
340
  Boolean(err) && (err.code === "ENOENT" || err.code === "EACCES" ||
341
341
  err.code === "EINVAL" || err.code === "UNKNOWN");
342
342
 
343
- // The three lines cmd.exe prints when it cannot find what it was asked to run,
344
- // anchored to a whole line each. First the two-line pair for a bare name, then
345
- // the one it uses when a directory in an explicit path does not exist.
343
+ // The three lines an ENGLISH cmd.exe prints when it cannot find what it was
344
+ // asked to run, anchored to a whole line each. First the two-line pair for a
345
+ // bare name, then the one it uses when a directory in an explicit path does not
346
+ // exist.
347
+ //
348
+ // These are one signal out of three rather than the whole answer — see
349
+ // looksMissing. Windows ships cmd.exe in every language it ships in, and these
350
+ // sentences are translated with it.
346
351
  const CMD_UNKNOWN = /^'(.+)' is not recognized as an internal or external command,?$/i;
347
352
  const CMD_UNKNOWN_TAIL = /^operable program or batch file\.?$/i;
348
353
  const CMD_NO_PATH = /^the system cannot find the (?:path|file) specified\.?$/i;
349
354
 
355
+ // cmd.exe's own errorlevel for a command token it could not resolve — the one
356
+ // signal here that is not a human sentence, and therefore the only one that is
357
+ // the same on a German install as on an English one. It is the number every CI
358
+ // log in the world prints beside "is not recognized".
359
+ //
360
+ // Not every path reports it: some Windows builds answer a bare `cmd /c missing`
361
+ // with a plain 1 instead, which is why this is a sufficient signal and never a
362
+ // necessary one. A number this large cannot arrive from POSIX at all — a process
363
+ // exit status there is masked to 0-255 before Node ever sees it — so nothing
364
+ // outside cmd.exe can reach it by accident.
365
+ const CMD_NOT_FOUND_EXIT = 9009;
366
+
367
+ /** True when an exit status is cmd.exe saying "no such command", in any locale. */
368
+ export const notFoundExit = (code) => Number(code) === CMD_NOT_FOUND_EXIT;
369
+
370
+ // Every run of text this line wrapped in quotes. cmd.exe quotes the command
371
+ // token it could not find in EVERY locale, and the quote character is the only
372
+ // part of the message that is not a translation: `'…'` in English and French,
373
+ // `"…"` in German, Spanish, Italian, Portuguese, Polish and Russian.
374
+ const quotedRuns = (line) =>
375
+ [...String(line).matchAll(/'([^']+)'|"([^"]+)"/g)].map(m => m[1] ?? m[2]);
376
+
350
377
  // cmd.exe echoes the command token exactly as it was given, so an exact match
351
378
  // is what we expect; the comparison is only case- and quote-insensitive because
352
379
  // Windows paths are.
@@ -409,20 +436,108 @@ const sameCommand = (quoted, name) => {
409
436
  *
410
437
  * Callers that only have the text (failureText) omit it and get the shape rules
411
438
  * alone.
439
+ *
440
+ * ── AND NOT ONLY IN ENGLISH (#552) ──────────────────────────────────────────
441
+ *
442
+ * Everything above was written against three English sentences, and cmd.exe is
443
+ * translated. On a German install with cswap genuinely absent it prints
444
+ *
445
+ * Der Befehl "cswap" ist entweder falsch geschrieben oder konnte nicht
446
+ * gefunden werden.
447
+ *
448
+ * — so this answered false, `run` resolved `{ ok: false, code: 1 }`,
449
+ * claude-accounts.mjs picked `reason: "switch_failed"` over `"no_cswap"` and the
450
+ * panel's install affordance never appeared. failureText then fell through to
451
+ * firstUseful, which puts the LAST line of a localized sentence on screen by
452
+ * itself: #457's symptom reproduced for every non-English locale. It also
453
+ * stopped the candidate loop early, so a `.bat` installed after a missing `.cmd`
454
+ * was never reached.
455
+ *
456
+ * Adding the German sentence, and then the French and the Japanese ones, is not
457
+ * a fix — it is the same defect with a longer list. So two signals that are not
458
+ * sentences carry the answer instead, and the English text is what remains when
459
+ * neither is available:
460
+ *
461
+ * 1. THE EXIT STATUS. `exitCode` 9009 is cmd.exe's own errorlevel for a
462
+ * command token it could not resolve, identical in every language. See
463
+ * CMD_NOT_FOUND_EXIT for why it is not required, and the paragraph below
464
+ * for the two cases where it is not believed either. It is subject to the
465
+ * same "cmd.exe printed nothing else" cap as rule 2, because a status is
466
+ * forwarded as easily as a sentence is.
467
+ *
468
+ * 2. THE SHAPE. cmd.exe quotes the command it could not find, in every locale,
469
+ * and prints that INSTEAD of running anything — so the whole output is at
470
+ * most the two lines of one wrapped sentence. Text of at most two lines
471
+ * whose FIRST line quotes exactly the spelling we launched is cmd.exe's
472
+ * verdict about our command whatever the words around it say.
473
+ *
474
+ * Rule 2 needs `name`, and refuses without it. That is the same principle the
475
+ * paragraphs above argue for and not a limitation bolted on: `Error: "account-9"
476
+ * does not exist` is one line with a quoted token in it, and read as an absence
477
+ * it would send the candidate loop back round to re-run `cswap remove 3`. What
478
+ * makes the rule safe is that the quoted token has to be the exact spelling
479
+ * cmd.exe was handed — `cswap.cmd`, or the absolute path shimPath found — which
480
+ * is a string the tool underneath has no reason to print. The two-line cap is
481
+ * the other half: a Python traceback ending in "The system cannot find the file
482
+ * specified" is four lines and can never qualify.
483
+ *
484
+ * THE SAME TRAP THE ENGLISH RULE ALREADY AVOIDS, NOW FOR THE EXIT STATUS. A
485
+ * `.cmd` shim is itself a batch file, so a shim that EXISTS and whose payload
486
+ * interpreter does not — a scoop or npm-style `cswap.cmd` in front of a python
487
+ * that was uninstalled — has cmd.exe print "is not recognized" about PYTHON and
488
+ * hands the shim's caller that same 9009. Believed on its own, the deck would
489
+ * call the tool absent and re-run the command under the next spelling. So a text
490
+ * that positively names a command OTHER than ours vetoes every rule here,
491
+ * including the status: the check that made #457 safe, applied one level up.
492
+ *
493
+ * What is deliberately still missed: a LOCALIZED "the system cannot find the
494
+ * path specified", which carries no quoted token and no structure to key off.
495
+ * That case only arises when shimPath found a shim that then vanished, and it
496
+ * fails in the safe direction — an honest exit 1 rather than a wrong ENOENT.
412
497
  */
413
- export function looksMissing(text, name = "") {
498
+ export function looksMissing(text, name = "", exitCode = null) {
414
499
  const lines = String(text ?? "").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
500
+ // Whatever cmd.exe named, in whatever language it said the rest — the quoting
501
+ // is the part that is not a translation.
502
+ const named = lines.length ? quotedRuns(lines[0]) : [];
503
+ const namesUs = named.some(q => sameCommand(q, name));
504
+
505
+ // The veto, before anything is believed: a message about somebody else's
506
+ // command is not evidence about ours, and neither is the status that came
507
+ // with it. See the header — a `.cmd` shim in front of a missing interpreter
508
+ // forwards both.
509
+ if (name && named.length > 0 && !namesUs) return false;
510
+
511
+ // cmd.exe says this INSTEAD of running anything, so anything longer than one
512
+ // wrapped sentence came from something that DID run — and that outranks both
513
+ // signals below. A shim can forward its child's 9009 after printing pages of
514
+ // its own; a Python traceback is four lines and one of them quotes the very
515
+ // shim we launched.
516
+ const saidNothingElse = lines.length <= 2;
517
+
518
+ // The signal that is not a sentence. It does not need to READ the text, which
519
+ // is the whole reason it exists: on a non-English install there may be nothing
520
+ // in the text this can read.
521
+ if (saidNothingElse && notFoundExit(exitCode)) return true;
415
522
  if (!lines.length) return false;
523
+
524
+ // The English shapes, whole-line anchored, exactly as before.
525
+ let english = true;
416
526
  for (const line of lines) {
417
- const named = CMD_UNKNOWN.exec(line);
418
- if (named) {
419
- if (name && !sameCommand(named[1], name)) return false;
527
+ const unknown = CMD_UNKNOWN.exec(line);
528
+ if (unknown) {
529
+ if (name && !sameCommand(unknown[1], name)) return false;
420
530
  continue;
421
531
  }
422
532
  if (CMD_UNKNOWN_TAIL.test(line) || CMD_NO_PATH.test(line)) continue;
423
- return false;
533
+ english = false;
534
+ break;
424
535
  }
425
- return true;
536
+ if (english) return true;
537
+
538
+ // Otherwise: cmd.exe in some other language, recognised by its shape and by
539
+ // the one word in it that is ours.
540
+ return Boolean(name) && saidNothingElse && namesUs;
426
541
  }
427
542
 
428
543
  // How much of a hung child's output the deadline keeps. The full buffers belong
@@ -485,7 +600,11 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
485
600
  // be a shell's verdict rather than the tool's own words — spawned
486
601
  // directly, a missing file is a plain ENOENT and anything printed came
487
602
  // from a tool that ran.
488
- const missing = Boolean(err) && tree && looksMissing(`${stderr ?? ""}\n${stdout ?? ""}`, launch);
603
+ // `err.code` is the exit STATUS for a child that ran and failed, which
604
+ // is where cmd.exe's language-independent 9009 arrives; for a spawn
605
+ // failure it is an errno string, and Number() of that is NaN. Either
606
+ // way looksMissing is handed what the attempt actually reported.
607
+ const missing = Boolean(err) && tree && looksMissing(`${stderr ?? ""}\n${stdout ?? ""}`, launch, err.code);
489
608
  if (err && (tryNext(err) || missing) && i + 1 < tries.length) return attempt(i + 1);
490
609
  // The CANDIDATE is what gets remembered, never the resolved path. The
491
610
  // memo is the only entry `candidates` offers afterwards, so recording an
@@ -663,7 +782,7 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
663
782
  // batch candidate, which is the only kind launched through a shell, and
664
783
  // to output that is cmd.exe's message alone — everything below re-runs
665
784
  // the whole command, and these commands remove accounts.
666
- if (code !== 0 && isBatch(raw) && looksMissing(`${stderr}\n${stdout}`, launch)) {
785
+ if (code !== 0 && isBatch(raw) && looksMissing(`${stderr}\n${stdout}`, launch, code)) {
667
786
  if (i + 1 < tries.length) {
668
787
  stdout = ""; stderr = ""; pending = "";
669
788
  child = null;
@@ -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
@@ -133,10 +133,6 @@ function stripBom(text) {
133
133
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
134
134
  }
135
135
 
136
- async function readJsonSafe(p) {
137
- try { return JSON.parse(stripBom(await readFile(p, "utf8"))); } catch { return null; }
138
- }
139
-
140
136
  function unreadableSettings(p, why) {
141
137
  const err = new Error(
142
138
  `${p} could not be read as JSON (${why}). Refusing to overwrite it — ` +
@@ -144,6 +140,11 @@ function unreadableSettings(p, why) {
144
140
  );
145
141
  err.code = "SETTINGS_UNREADABLE";
146
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;
147
148
  return err;
148
149
  }
149
150
 
@@ -365,6 +366,29 @@ export async function installHooks({ provider = "claude" } = {}) {
365
366
  current.hooks[evt] = cleaned;
366
367
  }
367
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
+
368
392
  // Every launch reinstalls, and on all but the first the entries are already
369
393
  // there and identical. Writing anyway is pure downside: it is one more chance
370
394
  // to be interrupted mid-write, and one more window in which a change Claude
@@ -373,14 +397,63 @@ export async function installHooks({ provider = "claude" } = {}) {
373
397
  const next = JSON.stringify(current, null, 2) + "\n";
374
398
  const changed = next !== before;
375
399
  if (changed) await writeFileAtomic(cfg.settingsPath, next);
376
- 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 };
377
405
  }
378
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
+ */
379
435
  export async function uninstallHooks({ provider = "claude" } = {}) {
380
436
  const cfg = PROVIDERS[provider];
381
437
  if (!cfg) throw new Error(`unknown provider: ${provider}`);
382
- const current = await readJsonSafe(cfg.settingsPath);
383
- 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 };
384
457
  let changed = false;
385
458
  for (const evt of Object.keys(current.hooks)) {
386
459
  const cleaned = dedupeOurEntries(current.hooks[evt]);
@@ -389,7 +462,7 @@ export async function uninstallHooks({ provider = "claude" } = {}) {
389
462
  else current.hooks[evt] = cleaned;
390
463
  }
391
464
  if (changed) await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
392
- return { changed, provider, settingsPath: cfg.settingsPath };
465
+ return { ok: true, changed, provider, settingsPath: cfg.settingsPath };
393
466
  }
394
467
 
395
468
  /** True when ~/.codex/ exists — the CLI's default answer to whether the Codex