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.
- package/README.md +10 -6
- package/bin/agent-dag.js +63 -9
- package/bin/deck.js +38 -10
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/notify.mjs +104 -0
- package/package.json +2 -2
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +98 -2
- package/src/server/cswap-admin.mjs +180 -6
- package/src/server/cswap-auto.mjs +209 -10
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +130 -11
- package/src/server/index.mjs +226 -19
- package/src/server/installer.mjs +81 -8
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/self-update.mjs +262 -21
- package/src/server/sound-hook.mjs +152 -24
- package/src/server/supervisor.mjs +36 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +43 -11
- package/dist/web/assets/index-BxAZQc7O.css +0 -1
- package/dist/web/assets/index-DRgZVqF-.js +0 -78
- package/hook/notify.js +0 -60
package/src/server/exec.mjs
CHANGED
|
@@ -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
|
|
344
|
-
// anchored to a whole line each. First the two-line pair for a
|
|
345
|
-
// the one it uses when a directory in an explicit path does not
|
|
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
|
|
418
|
-
if (
|
|
419
|
-
if (name && !sameCommand(
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/server/index.mjs
CHANGED
|
@@ -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}
|
|
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 ?? ""}
|
|
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
|
-
|
|
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
|
-
/**
|
|
1743
|
-
*
|
|
1744
|
-
|
|
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
|
|
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
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
1803
|
-
*
|
|
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.
|
|
3092
|
-
server.
|
|
3093
|
-
|
|
3094
|
-
|
|
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
|
-
|
|
3245
|
-
|
|
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
|
|
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
|
package/src/server/installer.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|