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.
- package/README.md +10 -6
- package/bin/agent-dag.js +63 -9
- package/bin/deck.js +46 -11
- 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 +114 -2
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +108 -3
- 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 +145 -22
- 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 +158 -28
- package/src/server/supervisor.mjs +36 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +48 -11
- package/dist/web/assets/index-CHFnwsds.css +0 -1
- package/dist/web/assets/index-DLihciEi.js +0 -78
- package/hook/notify.js +0 -60
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
|
@@ -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
|
|
97
|
-
* against a path the test names rather than against whatever ran the
|
|
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
|
-
|
|
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.
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
+
// Rename over an open file is the one thing Windows does differently here.
|
|
179
|
+
// libuv's rename is one MoveFileExW with MOVEFILE_REPLACE_EXISTING and nothing
|
|
180
|
+
// else — no retry, no MOVEFILE_COPY_ALLOWED — and MoveFileEx honours share
|
|
181
|
+
// modes: it fails outright while ANY other handle is open on the source or the
|
|
182
|
+
// target without FILE_SHARE_DELETE. A virus scanner or the search indexer opens
|
|
183
|
+
// files the instant they are written, so the target is briefly untouchable on a
|
|
184
|
+
// perfectly healthy machine. Node has declined to paper over this (nodejs/node
|
|
185
|
+
// #29481, closed wontfix: "not something that's really under Node's or libuv's
|
|
186
|
+
// control"), and libuv reverted its own four-attempt ladder for the same
|
|
187
|
+
// reason. So the policy lives here, where the stakes are known.
|
|
188
|
+
//
|
|
189
|
+
// The ladder is 10 attempts over ~1.4s, and the second number is the one that
|
|
190
|
+
// matters. It used to be 5 over 200ms, which is comfortably enough for the
|
|
191
|
+
// indexer and not enough for a scanner: the argument on libuv#2098 for
|
|
192
|
+
// reverting their retry was in part that an AV hold can outlast 2s, and 200ms
|
|
193
|
+
// of patience on a hold like that is the same as none.
|
|
194
|
+
//
|
|
195
|
+
// What that thinness cost is not an install. codex-auth.mjs stages a REFRESH
|
|
196
|
+
// TOKEN through this call, the old one is spent server-side by the time it runs,
|
|
197
|
+
// and a rename that gives up too early destroys the only copy of the new
|
|
198
|
+
// credential — the deck reports refresh_rejected and the user has to run
|
|
199
|
+
// `codex login` again. Trading a second of latency against that is not close.
|
|
200
|
+
// The comparison points: steno retries a rename 10 times at 100ms, npm's
|
|
201
|
+
// bin-links 5 times at 500ms exponential, and write-file-atomic does not retry
|
|
202
|
+
// at all (npm/write-file-atomic#227).
|
|
203
|
+
//
|
|
204
|
+
// POSIX never hits this path, and a genuinely permanent permission error costs
|
|
205
|
+
// that second and a half once, on a path that was already failing.
|
|
178
206
|
const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
179
207
|
|
|
180
|
-
async function renameWithRetry(from, to, attempts =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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.
|
|
19
|
-
// `agents-deck
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// the
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
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.
|