ccdeck 3.22.15 → 3.23.0
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 +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +244 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -184
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +335 -47
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +472 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BYqkKWPC.js +0 -274
- package/dist/web/assets/index-s_dF8YYA.css +0 -1
package/hook/hook.js
CHANGED
|
@@ -69,6 +69,53 @@ function normPath(p) {
|
|
|
69
69
|
return r;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The same rule, off the main thread — and this is the one main() runs.
|
|
74
|
+
*
|
|
75
|
+
* A SYNCHRONOUS fs CALL CANNOT BE BOUNDED BY A TIMER ON THE SAME THREAD. While
|
|
76
|
+
* fs.realpathSync.native is in the kernel this thread runs nothing, and the
|
|
77
|
+
* `setTimeout` that is supposed to end this process is on that thread. So a
|
|
78
|
+
* path that does not answer does not cost CAP_MS, it costs whatever the path
|
|
79
|
+
* costs. Measured with one registry entry the filesystem never answers for, and
|
|
80
|
+
* a healthy deck registered beside it (#1018):
|
|
81
|
+
*
|
|
82
|
+
* STILL ALIVE after 12008ms — cap never fired
|
|
83
|
+
* deck saw: [] — never even challenged
|
|
84
|
+
*
|
|
85
|
+
* The realistic trigger is not exotic and is not the deck's fault: `$HOME` on
|
|
86
|
+
* NFS/autofs/SMB, a CLAUDE_CONFIG_DIR on a network share, or — the widest
|
|
87
|
+
* surface of the three — the SESSION'S OWN cwd on a network or FUSE mount,
|
|
88
|
+
* which is a directory this process is handed rather than one it chose.
|
|
89
|
+
* fs.realpath.native goes to libuv's threadpool instead, so the event loop
|
|
90
|
+
* keeps its turn: the deadline fires, the decks that DID answer are challenged
|
|
91
|
+
* and posted to, and the same run now reads
|
|
92
|
+
*
|
|
93
|
+
* deck saw: challenge@431ms, POST /api/event@435ms
|
|
94
|
+
*
|
|
95
|
+
* WHAT IS LEFT, said plainly rather than left to be discovered. Node's exit
|
|
96
|
+
* joins libuv's threadpool, so while a request is still executing in one of
|
|
97
|
+
* those threads `process.exit(0)` does not complete either — measured: the
|
|
98
|
+
* timer fires on time, calls exit, and the process is still there. Ending the
|
|
99
|
+
* process by signal instead would trade a slow hook for a hook that looks like
|
|
100
|
+
* it failed, which is the worse of the two in the user's own session. So the
|
|
101
|
+
* residue is: the event is delivered on time, and a filesystem that never
|
|
102
|
+
* answers at all is ended by the host CLI's own timeout — which is why that
|
|
103
|
+
* timeout has to be a value this process can actually finish under, and is now
|
|
104
|
+
* 3s in installer.mjs rather than 2s. A filesystem call that comes back late
|
|
105
|
+
* rather than never — the ordinary stalled mount — costs nothing now.
|
|
106
|
+
*
|
|
107
|
+
* `cb` is called exactly once and never with an error: a path that does not
|
|
108
|
+
* resolve keeps its resolved form, which is exactly what the sync one's empty
|
|
109
|
+
* catch does with it. The sync normPath stays — it is the statement of the rule
|
|
110
|
+
* that workspace-one-meaning.test.ts walks one table of paths through against
|
|
111
|
+
* the server's canonicalCwd, and a predicate is easier to pin than a callback.
|
|
112
|
+
*/
|
|
113
|
+
function normPathAsync(p, cb) {
|
|
114
|
+
const r = path.resolve(p);
|
|
115
|
+
try { fs.realpath.native(r, (err, out) => cb(err ? r : out)); }
|
|
116
|
+
catch { cb(r); }
|
|
117
|
+
}
|
|
118
|
+
|
|
72
119
|
/**
|
|
73
120
|
* Does this platform's filesystem treat two spellings that differ only in case
|
|
74
121
|
* as the same directory? Exported for tests: the platform is a parameter so
|
|
@@ -195,22 +242,42 @@ function isAlive(pid) {
|
|
|
195
242
|
function electWriters(decks, platform = process.platform) {
|
|
196
243
|
const byLog = new Map();
|
|
197
244
|
for (const d of decks) {
|
|
198
|
-
const
|
|
199
|
-
// Two namespaces, so a deck with no log to share — and a deck too old to
|
|
200
|
-
// report one — is alone in its group and cannot collide with a real path.
|
|
201
|
-
const key = log
|
|
202
|
-
? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
|
|
203
|
-
: `deck:${d.pid}:${d.port}`;
|
|
245
|
+
const key = logGroup(d, platform);
|
|
204
246
|
const held = byLog.get(key);
|
|
205
|
-
|
|
206
|
-
// discovery file could invent, so the answer stays deterministic.
|
|
207
|
-
if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
|
|
208
|
-
byLog.set(key, d);
|
|
209
|
-
}
|
|
247
|
+
if (!held || beforeInLine(d, held) < 0) byLog.set(key, d);
|
|
210
248
|
}
|
|
211
249
|
return new Set(byLog.values());
|
|
212
250
|
}
|
|
213
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Which log a deck is competing for — the group electWriters decides within.
|
|
254
|
+
*
|
|
255
|
+
* Two namespaces, so a deck with no log to share — and a deck too old to report
|
|
256
|
+
* one — is alone in its group and cannot collide with a real path.
|
|
257
|
+
*
|
|
258
|
+
* Named rather than inlined because the hand-on in main() has to ask the same
|
|
259
|
+
* question: when the elected writer cannot take the event, the decks entitled
|
|
260
|
+
* to take it from it are exactly the ones this answers the same for.
|
|
261
|
+
*/
|
|
262
|
+
function logGroup(d, platform = process.platform) {
|
|
263
|
+
const log = typeof d.persist === "string" ? d.persist : "";
|
|
264
|
+
return log
|
|
265
|
+
? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
|
|
266
|
+
: `deck:${d.pid}:${d.port}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The order a log's decks stand in to write it. Lowest port first; ports are
|
|
271
|
+
* unique among live decks, so pid only breaks a tie a stale discovery file
|
|
272
|
+
* could invent and the answer stays deterministic.
|
|
273
|
+
*
|
|
274
|
+
* A comparator rather than a "beats" predicate because main() sorts a whole
|
|
275
|
+
* group with it — the second in line matters now, not only the first.
|
|
276
|
+
*/
|
|
277
|
+
function beforeInLine(a, b) {
|
|
278
|
+
return a.port - b.port || a.pid - b.pid;
|
|
279
|
+
}
|
|
280
|
+
|
|
214
281
|
/**
|
|
215
282
|
* The answer a deck must give to be handed a session payload.
|
|
216
283
|
*
|
|
@@ -283,7 +350,7 @@ function sameProof(got, want) {
|
|
|
283
350
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
284
351
|
}
|
|
285
352
|
|
|
286
|
-
// Two round trips happen per target, and main()'s hard cap is
|
|
353
|
+
// Two round trips happen per target, and main()'s hard cap is CAP_MS, so the
|
|
287
354
|
// pair has to fit inside it with room to spare. The challenge is a bodyless GET
|
|
288
355
|
// to a loopback port — sub-millisecond when a deck is there, and instant
|
|
289
356
|
// ECONNREFUSED when nothing is.
|
|
@@ -294,14 +361,63 @@ function sameProof(got, want) {
|
|
|
294
361
|
// followed by one 1000ms deadline. What the barrier does cost is that an honest
|
|
295
362
|
// deck's POST waits for the slowest challenge in the set, which only matters
|
|
296
363
|
// when some OTHER record's port accepts a connection and then says nothing. A
|
|
297
|
-
// ghost port with nothing behind it refuses instantly and delays no one
|
|
364
|
+
// ghost port with nothing behind it refuses instantly and delays no one; one
|
|
365
|
+
// with something silent behind it costs both deadlines once, and its record is
|
|
366
|
+
// then forgotten if no deck has been keeping it — see forgetIfAbandoned.
|
|
298
367
|
const CHALLENGE_TIMEOUT_MS = 400;
|
|
299
368
|
const POST_TIMEOUT_MS = 1000;
|
|
300
369
|
|
|
370
|
+
// The third phase that can stall, and the one that had no deadline at all:
|
|
371
|
+
// reading the registry and canonicalising the paths in it. See discoverTargets
|
|
372
|
+
// for what it is bounding and why one unanswering path must not cost the event
|
|
373
|
+
// for every other deck on the machine.
|
|
374
|
+
const DISCOVERY_TIMEOUT_MS = 400;
|
|
375
|
+
|
|
376
|
+
// How long the canonical spelling of the session's cwd is worth waiting for
|
|
377
|
+
// before the merely-resolved one is used instead. Half the phase, so a cwd that
|
|
378
|
+
// never answers still leaves the registry scan a turn — see discoverTargets.
|
|
379
|
+
const CWD_TIMEOUT_MS = 200;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The whole of this process's life, MEASURED FROM WHEN NODE STARTED rather than
|
|
383
|
+
* from when main() ran — see the deadline main() sets.
|
|
384
|
+
*
|
|
385
|
+
* 1900ms is not the sum of the phase deadlines, and it is not meant to be. Four
|
|
386
|
+
* phases now carry one — discovery 400, the challenge's two attempts 400 + 400,
|
|
387
|
+
* the POST 1000 — and they only stack to 2200 if all three surfaces stall on
|
|
388
|
+
* the same run: an unanswering filesystem, a port that accepts and says
|
|
389
|
+
* nothing, and a deck that takes the body and never replies. Each deadline
|
|
390
|
+
* bounds its own pathology; this one is the backstop over all of them, and it
|
|
391
|
+
* is what a reader should believe rather than the addition.
|
|
392
|
+
*
|
|
393
|
+
* What it is NOT is the whole cost of a hook run, and that was the bug it hid.
|
|
394
|
+
* Claude Code executes the command through `sh -c`, so a fork/exec, Node's own
|
|
395
|
+
* startup and the read of a stdin payload that is routinely megabytes — a Read
|
|
396
|
+
* or a Grep result arrives whole in `tool_response` — all happened BEFORE the
|
|
397
|
+
* old timer was armed, and were charged to the host's clock anyway.
|
|
398
|
+
*
|
|
399
|
+
* Measured through the exact installed command shape, one ghost record stalling
|
|
400
|
+
* the challenge and a deck that answers the handshake and then never answers
|
|
401
|
+
* the POST (#1018):
|
|
402
|
+
*
|
|
403
|
+
* idle, 16 cores, 134-byte payload: 1.847s 1.837s 1.836s
|
|
404
|
+
* 48 busy workers: 1.978s 2.010s 2.190s <- past timeout: 2
|
|
405
|
+
* 48 busy workers + 2MB payload: 2.160s 2.126s 2.165s <- past timeout: 2
|
|
406
|
+
*
|
|
407
|
+
* The machine the retry in prove() exists for — "the full test suite, 335 files
|
|
408
|
+
* in parallel" — is exactly the machine whose startup is slow enough to push the
|
|
409
|
+
* total past the declared timeout, and being killed there costs the deck a
|
|
410
|
+
* truncated body and the user the full timeout on that turn, because PreToolUse
|
|
411
|
+
* blocks the tool call until every matching hook returns.
|
|
412
|
+
*/
|
|
413
|
+
const CAP_MS = 1900;
|
|
414
|
+
|
|
301
415
|
/**
|
|
302
416
|
* Ask the listener to prove it is the deck that wrote `d`. `cb` is called
|
|
303
417
|
* exactly once with true or false — a refused connection, a silent port and a
|
|
304
|
-
* wrong answer are all just "not the deck this record describes"
|
|
418
|
+
* wrong answer are all just "not the deck this record describes" — and, for a
|
|
419
|
+
* port that let BOTH deadlines pass, with "deadline" as a second argument: the
|
|
420
|
+
* one verdict that costs 800ms, and the one proveTargets acts on.
|
|
305
421
|
*
|
|
306
422
|
* A deck that advertised no token cannot be asked and passes: see requiresProof.
|
|
307
423
|
*/
|
|
@@ -317,17 +433,18 @@ function prove(d, cb, attempt = 0) {
|
|
|
317
433
|
// miss that window, and the event is then dropped with nothing on screen to
|
|
318
434
|
// say so. A big build or a machine running several agents is the same shape.
|
|
319
435
|
//
|
|
320
|
-
// One retry, only on the deadline
|
|
321
|
-
// the
|
|
322
|
-
//
|
|
323
|
-
//
|
|
436
|
+
// One retry, only on the deadline: 400 + 400 for the challenge and 1000 for
|
|
437
|
+
// the POST, inside the CAP_MS this process gives itself, which is inside the
|
|
438
|
+
// timeout the installed hook entry declares — see CAP_MS for what that
|
|
439
|
+
// relation is and why the second attempt is the expensive half of it when the
|
|
440
|
+
// port answering is not a deck at all.
|
|
324
441
|
const retryOnTimeout = () => {
|
|
325
442
|
if (settled) return;
|
|
326
|
-
if (attempt >= 1) return finish(false);
|
|
443
|
+
if (attempt >= 1) return finish(false, "deadline");
|
|
327
444
|
settled = true; // this attempt is over; the next owns `cb`
|
|
328
445
|
prove(d, cb, attempt + 1);
|
|
329
446
|
};
|
|
330
|
-
const finish = ok => { if (settled) return; settled = true; cb(ok); };
|
|
447
|
+
const finish = (ok, why = null) => { if (settled) return; settled = true; cb(ok, why); };
|
|
331
448
|
|
|
332
449
|
if (!requiresProof(d)) return finish(true);
|
|
333
450
|
|
|
@@ -391,33 +508,170 @@ function prove(d, cb, attempt = 0) {
|
|
|
391
508
|
* ordering, and it is the same reordering src/server/index.mjs makes in
|
|
392
509
|
* readLiveDecks for the Codex rollouts no hook ever sees.
|
|
393
510
|
*
|
|
394
|
-
*
|
|
395
|
-
* is
|
|
396
|
-
*
|
|
511
|
+
* A FAILED CHALLENGE ON ITS OWN IS STILL NOT PROOF THE DECK IS GONE. A dead pid
|
|
512
|
+
* is, and is swept above; a failed challenge is not — a deck restarting under
|
|
513
|
+
* its supervisor refuses connections for a moment while its record still
|
|
397
514
|
* stands, and a merely busy one can miss the 400ms deadline. Deleting another
|
|
398
|
-
* deck's registration on that evidence trades a bug that loses log lines
|
|
399
|
-
* that loses a whole deck's events
|
|
400
|
-
*
|
|
401
|
-
*
|
|
515
|
+
* deck's registration on that evidence alone trades a bug that loses log lines
|
|
516
|
+
* for one that loses a whole deck's events.
|
|
517
|
+
*
|
|
518
|
+
* WHAT THAT COST WHEN SOMETHING WAS LISTENING (#1069). This paragraph used to
|
|
519
|
+
* end "a ghost that survives on disk costs one instant ECONNREFUSED per hook
|
|
520
|
+
* run", which is true only while NOTHING is listening on the port the record
|
|
521
|
+
* names. Measured through the installed command shape, one healthy deck plus
|
|
522
|
+
* one record whose pid is alive and names a port that accepts a connection and
|
|
523
|
+
* then says nothing:
|
|
524
|
+
*
|
|
525
|
+
* healthy deck alone 36ms 38ms 39ms
|
|
526
|
+
* + ghost whose port refuses 36ms 45ms 37ms
|
|
527
|
+
* + ghost whose port accepts and is silent 849ms 848ms 836ms
|
|
528
|
+
*
|
|
529
|
+
* The retry in prove() turns one 400ms deadline into two, and the barrier makes
|
|
530
|
+
* every honest deck's POST wait for the slowest challenge in the set — so that
|
|
531
|
+
* was the price of EVERY event, for good, because nothing else ever removed the
|
|
532
|
+
* record once the OS had recycled the dead deck's pid onto something
|
|
533
|
+
* long-lived. And 4317, the deck's own default, is also the standard OTLP
|
|
534
|
+
* collector port, so something silent being there is not far-fetched.
|
|
535
|
+
*
|
|
536
|
+
* So one more piece of evidence is asked for, and only on the verdict that
|
|
537
|
+
* costs anything. A running deck keeps its record: keepDiscovery re-asserts it
|
|
538
|
+
* every five seconds, and ensureDiscovery stamps the file's mtime each time even
|
|
539
|
+
* when nothing in it has changed. A record that let both deadlines pass AND has
|
|
540
|
+
* not been stamped for ABANDONED_AFTER_MS belongs to no running deck, whatever
|
|
541
|
+
* its pid says, and is unlinked — see forgetIfAbandoned. The two cases above
|
|
542
|
+
* stay protected: a restarting deck REFUSES, which is a different verdict and
|
|
543
|
+
* never gets here, and a busy deck is still stamping its record every five
|
|
544
|
+
* seconds, so however late its answer, its record is fresh. The table's last row
|
|
545
|
+
* is now paid once, on the first event after the deck died, instead of on every
|
|
546
|
+
* one after it.
|
|
402
547
|
*/
|
|
403
548
|
function proveTargets(targets, cb) {
|
|
404
549
|
const ok = new Array(targets.length).fill(false);
|
|
405
550
|
let pending = targets.length;
|
|
406
551
|
const settle = () => { if (--pending <= 0) cb(targets.filter((_, i) => ok[i])); };
|
|
407
|
-
targets.forEach((d, i) => prove(d, answered => {
|
|
552
|
+
targets.forEach((d, i) => prove(d, (answered, why) => {
|
|
553
|
+
ok[i] = answered;
|
|
554
|
+
// Inside the barrier rather than after it, so the unlink has landed before
|
|
555
|
+
// main() can exit on the last POST. It is a stat and at most an unlink, on
|
|
556
|
+
// the one path that has already spent 800ms waiting.
|
|
557
|
+
if (why === "deadline") return forgetIfAbandoned(d, settle);
|
|
558
|
+
settle();
|
|
559
|
+
}));
|
|
408
560
|
}
|
|
409
561
|
|
|
410
562
|
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
563
|
+
* How long a record may go unstamped before a silent port is taken to mean that
|
|
564
|
+
* no deck is keeping it. Twelve of keepDiscovery's five-second intervals: a deck
|
|
565
|
+
* whose event loop has not run a timer for a minute is not answering anybody,
|
|
566
|
+
* and if it does come back, its next check writes the record again.
|
|
567
|
+
*/
|
|
568
|
+
const ABANDONED_AFTER_MS = 60_000;
|
|
569
|
+
|
|
570
|
+
/** The file each target was read from, for forgetIfAbandoned. A WeakMap rather
|
|
571
|
+
* than a field on the record, because the record is the deck's own JSON and
|
|
572
|
+
* every field on it is a field some reader decides by. */
|
|
573
|
+
const RECORD_FILE = new WeakMap();
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Unlink `d`'s record if nothing has stamped it for ABANDONED_AFTER_MS. `done`
|
|
577
|
+
* runs exactly once, whatever the filesystem says: a record that cannot be
|
|
578
|
+
* statted, or that another hook run has already unlinked, is left as it is.
|
|
579
|
+
*
|
|
580
|
+
* A deck judged abandoned wrongly — frozen for a minute, then back — is not
|
|
581
|
+
* lost: keepDiscovery finds its file missing on the next check and writes it
|
|
582
|
+
* again, which is the case that function was written for.
|
|
583
|
+
*/
|
|
584
|
+
function forgetIfAbandoned(d, done) {
|
|
585
|
+
const file = RECORD_FILE.get(d);
|
|
586
|
+
if (!file) return done();
|
|
587
|
+
fs.stat(file, (err, st) => {
|
|
588
|
+
if (err || Date.now() - st.mtimeMs < ABANDONED_AFTER_MS) return done();
|
|
589
|
+
fs.unlink(file, () => done());
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Hand this deck the payload. `done` runs exactly once, with whether the deck
|
|
595
|
+
* TOOK it — which is a different question from whether this target is finished,
|
|
596
|
+
* and answering only the second one is #1019 — and, for the one ending that
|
|
597
|
+
* answers neither yes nor no, with "deadline" as a second argument: see the
|
|
598
|
+
* last paragraph.
|
|
414
599
|
*
|
|
415
600
|
* `persists` is this deck's answer from electWriters: true for the one deck that
|
|
416
601
|
* logs the event, false for every other one it is also drawn on.
|
|
602
|
+
*
|
|
603
|
+
* THE STATUS LINE WAS NEVER READ. A 200, a 500 and a connection reset all ran
|
|
604
|
+
* the same callback, so the hook counted every POST as delivered and the caller
|
|
605
|
+
* had nothing to branch on. With the elected writer 500ing and one healthy deck
|
|
606
|
+
* beside it, measured against a real deck and a real events.jsonl:
|
|
607
|
+
*
|
|
608
|
+
* hook exit=0 wall=576ms 4450 (elected) POST /api/event -> 500
|
|
609
|
+
* 4460 (healthy) POST /api/event?persist=0
|
|
610
|
+
* drew on its canvas: ["tool-1019"]
|
|
611
|
+
* events.jsonl lines: 0
|
|
612
|
+
*
|
|
613
|
+
* Same count for a writer that hangs up mid-body (wall=580ms) and for one that
|
|
614
|
+
* takes the body and never answers (wall=1686ms). That is #695's symptom
|
|
615
|
+
* through a different door: every deck draws the event, all the others were
|
|
616
|
+
* told `?persist=0`, and the log silently stops growing.
|
|
617
|
+
*
|
|
618
|
+
* The status line settles this, whichever status it carries, rather than the
|
|
619
|
+
* last byte of the response. The route answers 2xx only once the event is in
|
|
620
|
+
* the ring and queued for the log, so that status IS the receipt and nothing
|
|
621
|
+
* after it can withdraw one — while a socket that dies while the answer is
|
|
622
|
+
* still arriving would otherwise read as a refusal and make main() hand the log
|
|
623
|
+
* to a second deck that then writes the line a second time. Any other status
|
|
624
|
+
* has refused already, and a body that stalls behind it must not run into the
|
|
625
|
+
* deadline below and come out as something softer than a refusal.
|
|
626
|
+
*
|
|
627
|
+
* A DEADLINE AFTER THE BODY WENT OUT IS NOT A REFUSAL (#1133), and reading it as
|
|
628
|
+
* one wrote the line twice. POST_TIMEOUT_MS is an idle timeout: when it fires,
|
|
629
|
+
* it is this process that gave up, while the writer still holds the connection
|
|
630
|
+
* with the whole event in its receive buffer. A busy deck — the load the retry
|
|
631
|
+
* in prove() exists for — reads it when it catches up, appends the line and
|
|
632
|
+
* answers into a socket nobody is listening on any more; by then main() had
|
|
633
|
+
* already asked the next deck in line to append the same line. Measured with a
|
|
634
|
+
* real deck behind a proxy that wins the election, forwards the handshake and
|
|
635
|
+
* the POST at once, and holds back only the writer's 200:
|
|
636
|
+
*
|
|
637
|
+
* writer's 200 held 1300ms hook exit=0 lines for the event: 2
|
|
638
|
+
* writer's 200 held 0ms hook exit=0 lines for the event: 1
|
|
639
|
+
*
|
|
640
|
+
* where the hook from before #1087 wrote one line in both. A duplicated line
|
|
641
|
+
* does not go away: the replay draws it twice after every restart for as long
|
|
642
|
+
* as the log is kept. So the endings are sorted by WHO ENDED THE EXCHANGE:
|
|
643
|
+
*
|
|
644
|
+
* • a status line — 2xx took the event, anything else refused it;
|
|
645
|
+
* • an error this process did not cause — the connection refused, reset or
|
|
646
|
+
* closed with no answer on it — is the writer ending the exchange without
|
|
647
|
+
* claiming the event, and is `false`. A deck restarting under its
|
|
648
|
+
* supervisor refuses, and a deck that tears the socket down stopped short
|
|
649
|
+
* of the ingest: handleEventIngest queues the line and answers in one
|
|
650
|
+
* synchronous turn, so it cannot have done one without the other short of
|
|
651
|
+
* dying between two statements;
|
|
652
|
+
* • this process's own deadline is `false` only while the body is not all
|
|
653
|
+
* out — 'finish' is the kernel taking the last byte, and a writer that
|
|
654
|
+
* stopped reading before that cannot complete the request, let alone log
|
|
655
|
+
* it — and "deadline" once it is, because that writer may still append.
|
|
656
|
+
*
|
|
657
|
+
* "deadline" is the one ending main() does not hand on. It makes a slow writer
|
|
658
|
+
* one line again, and it makes a writer that took the body and then never
|
|
659
|
+
* answers at all the lost line it was before #1087. That is the trade, taken
|
|
660
|
+
* on purpose: from here the two are the same observation until the process has
|
|
661
|
+
* to end, a deck that answered its challenge within CHALLENGE_TIMEOUT_MS and
|
|
662
|
+
* then stalls for good inside the next second is far rarer than one that is
|
|
663
|
+
* merely slow, and a missing line costs one event where a duplicated one is
|
|
664
|
+
* replayed forever. It is #1019's third measured case, and #1019's refusal and
|
|
665
|
+
* hang-up cases still hand on.
|
|
417
666
|
*/
|
|
418
667
|
function post(d, body, persists, done) {
|
|
419
668
|
let settled = false;
|
|
420
|
-
const finish = () => { if (settled) return; settled = true; done(); };
|
|
669
|
+
const finish = (ok, why = null) => { if (settled) return; settled = true; done(ok, why); };
|
|
670
|
+
// Whether the kernel has taken the whole body, and whether it was this
|
|
671
|
+
// process's deadline rather than the writer that ended the exchange — the
|
|
672
|
+
// two facts the last paragraph above sorts an error by.
|
|
673
|
+
let sent = false;
|
|
674
|
+
let timedOut = false;
|
|
421
675
|
const req = http.request({
|
|
422
676
|
hostname: "127.0.0.1",
|
|
423
677
|
port: d.port,
|
|
@@ -427,19 +681,228 @@ function post(d, body, persists, done) {
|
|
|
427
681
|
method: "POST",
|
|
428
682
|
headers: { "Content-Type": "application/json" },
|
|
429
683
|
timeout: POST_TIMEOUT_MS,
|
|
430
|
-
}, res => {
|
|
431
|
-
|
|
432
|
-
|
|
684
|
+
}, res => {
|
|
685
|
+
res.resume();
|
|
686
|
+
finish(res.statusCode >= 200 && res.statusCode < 300);
|
|
687
|
+
});
|
|
688
|
+
req.on("finish", () => { sent = true; });
|
|
689
|
+
// `destroy()` on a timeout makes 'error' fire with ECONNRESET, as it does in
|
|
690
|
+
// prove(), so `timedOut` is what tells this process giving up apart from the
|
|
691
|
+
// writer hanging up.
|
|
692
|
+
req.on("error", () => finish(false, timedOut && sent ? "deadline" : null));
|
|
693
|
+
req.on("timeout", () => { timedOut = true; req.destroy(); });
|
|
433
694
|
req.write(body);
|
|
434
695
|
req.end();
|
|
435
696
|
}
|
|
436
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Read one discovery record and decide whether the deck it describes captures
|
|
700
|
+
* this session. `done` runs exactly once; a target that qualifies is pushed onto
|
|
701
|
+
* `found` before it does.
|
|
702
|
+
*
|
|
703
|
+
* Nothing here is synchronous any more, and that is the point: see
|
|
704
|
+
* normPathAsync. The record lives in <claude config dir>/agent-dag/, which is
|
|
705
|
+
* under $HOME by default, and the workspace it names is any directory on the
|
|
706
|
+
* machine — a stalled mount under either used to stop this whole process dead.
|
|
707
|
+
*/
|
|
708
|
+
function readRecord(file, resolvedCwd, found, done) {
|
|
709
|
+
const full = path.join(DIR, file);
|
|
710
|
+
fs.readFile(full, "utf8", (err, text) => {
|
|
711
|
+
if (err) return done();
|
|
712
|
+
let d;
|
|
713
|
+
try { d = JSON.parse(text); } catch { return done(); }
|
|
714
|
+
// THE SERVER'S GUARD, VERBATIM PLUS THE PORT RANGE. This read
|
|
715
|
+
// `typeof d.workspace !== "string" || !d.pid || !d.port`, which has two
|
|
716
|
+
// holes and both are reachable from one hand-edited file:
|
|
717
|
+
//
|
|
718
|
+
// • `d.workspace` is a property access, and it sat OUTSIDE the try —
|
|
719
|
+
// so a record of `null` threw before the guard could refuse it;
|
|
720
|
+
// • `!d.port` admits any truthy non-port. `"http"`, `-1` and `{}` all
|
|
721
|
+
// passed, reached http.request({ port }) and threw SYNCHRONOUSLY
|
|
722
|
+
// inside the forEach below, before a single socket was opened.
|
|
723
|
+
//
|
|
724
|
+
// Either way: exit 1, a Node stack trace on stderr, and — measured — ZERO
|
|
725
|
+
// POSTs to a healthy deck registered alongside. Claude Code surfaces a
|
|
726
|
+
// non-zero exit as `<hook> hook error` with the first stderr line, on
|
|
727
|
+
// every tool call, and nothing removes the record: pid 1 is init, so
|
|
728
|
+
// isAlive is true forever and the unlink below never fires.
|
|
729
|
+
//
|
|
730
|
+
// index.mjs:2156 reads these same files and always refused them. Two
|
|
731
|
+
// readers of one directory disagreeing is the bug; this is the stronger
|
|
732
|
+
// half, which is the one that belongs in the process that cannot afford
|
|
733
|
+
// to throw.
|
|
734
|
+
//
|
|
735
|
+
// IT IS INSIDE THE fs CALLBACK NOW, and that is where it has to be rather
|
|
736
|
+
// than merely where it ended up. The reads are asynchronous (#1018), so the
|
|
737
|
+
// throw this refuses would no longer come out of a loop in main() — it
|
|
738
|
+
// would come out of a callback, where the only thing left to catch it is
|
|
739
|
+
// the process-level handler main() installs. Refusing the record here is
|
|
740
|
+
// what keeps that handler a last resort instead of the mechanism.
|
|
741
|
+
// hook-read-only.test.ts runs the real script against each of these shapes,
|
|
742
|
+
// so the guard is proved on the path it actually sits on.
|
|
743
|
+
if (!d || typeof d.pid !== "number"
|
|
744
|
+
|| !Number.isInteger(d.port) || d.port < 1 || d.port > 65535
|
|
745
|
+
|| typeof d.workspace !== "string") return done();
|
|
746
|
+
// A missing token is not a reason to drop the file here — prove() decides
|
|
747
|
+
// what a target has to prove, and a deck older than the handshake can
|
|
748
|
+
// prove nothing. See requiresProof.
|
|
749
|
+
|
|
750
|
+
if (!isAlive(d.pid)) return fs.unlink(full, () => done());
|
|
751
|
+
|
|
752
|
+
// Where this record lives, for the one later verdict that may forget it —
|
|
753
|
+
// see forgetIfAbandoned.
|
|
754
|
+
RECORD_FILE.set(d, full);
|
|
755
|
+
|
|
756
|
+
// "" is machine-wide and must never reach normPath: resolving it would
|
|
757
|
+
// produce this hook's own cwd — the agent's — and scope a deck that asked
|
|
758
|
+
// for no scope at all. Any other spelling is canonicalized here, which is
|
|
759
|
+
// now a second pass over a path bin/deck.js already canonicalized before
|
|
760
|
+
// publishing it — kept because a deck old enough to have published a
|
|
761
|
+
// relative one is still entitled to its events.
|
|
762
|
+
if (d.workspace === "") {
|
|
763
|
+
if (capturesSession(resolvedCwd, "")) found.push(d);
|
|
764
|
+
return done();
|
|
765
|
+
}
|
|
766
|
+
normPathAsync(d.workspace, ws => {
|
|
767
|
+
if (capturesSession(resolvedCwd, ws)) found.push(d);
|
|
768
|
+
done();
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Every deck whose workspace contains this cwd, and nothing else decides it.
|
|
775
|
+
*
|
|
776
|
+
* This used to sort the matches by how long each deck's workspace path was
|
|
777
|
+
* and deliver only to the longest — so a deck scoped to /Users/x/proj TOOK
|
|
778
|
+
* that tree's sessions away from a machine-wide deck, which then sat there
|
|
779
|
+
* showing nothing while `--all` promised it captured every session on this
|
|
780
|
+
* machine. Nothing documented that, and the server's own Codex capture never
|
|
781
|
+
* did it: each deck tails the rollout files itself and evaluates its own
|
|
782
|
+
* workspace, so a Codex session inside a scoped tree appeared on both decks
|
|
783
|
+
* while the Claude session beside it appeared on one. One flag, one path,
|
|
784
|
+
* two answers.
|
|
785
|
+
*
|
|
786
|
+
* The fan-out is the documented meaning and the one kept: `--workspace` says
|
|
787
|
+
* which sessions a deck captures, not which sessions it takes from the decks
|
|
788
|
+
* around it. It is also what electWriters assumes — several decks drawing one
|
|
789
|
+
* event is the case it exists to keep from being written to one log several
|
|
790
|
+
* times.
|
|
791
|
+
*
|
|
792
|
+
* AND IT IS A PHASE WITH A DEADLINE, like the challenge and the POST. It is
|
|
793
|
+
* three filesystem calls deep — canonicalise the cwd, list the directory, read
|
|
794
|
+
* and canonicalise each record — and every one of them is a path this process
|
|
795
|
+
* was handed rather than one it chose. One that does not answer used to take
|
|
796
|
+
* the event away from every OTHER deck on the machine as well, because the
|
|
797
|
+
* whole scan had to finish before anything was challenged. Now the deadline
|
|
798
|
+
* hands back whatever answered in time and the rest of the run proceeds: a
|
|
799
|
+
* healthy deck registered beside a stalled record still gets its event.
|
|
800
|
+
*
|
|
801
|
+
* The cwd gets a deadline INSIDE that one, and it does not abort the phase, it
|
|
802
|
+
* downgrades: a session's cwd is the one path here this process did not choose,
|
|
803
|
+
* and if the mount it is on will not canonicalise it, `path.resolve` of it is
|
|
804
|
+
* still an answer. It is the same answer normPath gives for a path that does
|
|
805
|
+
* not resolve at all, it is what a machine-wide deck needs (which is the
|
|
806
|
+
* default), and it is right for a scoped deck too wherever nothing in the path
|
|
807
|
+
* is a symlink, a junction or an 8.3 short name. Giving up on the whole phase
|
|
808
|
+
* instead would hand every deck on the machine nothing.
|
|
809
|
+
*
|
|
810
|
+
* `cb` runs exactly once, with a snapshot — a read that lands after the
|
|
811
|
+
* deadline may still push, and must not change the set already being acted on.
|
|
812
|
+
*/
|
|
813
|
+
function discoverTargets(cwd, cb) {
|
|
814
|
+
const found = [];
|
|
815
|
+
// The spelling capture was actually decided on, handed back so the payload
|
|
816
|
+
// can carry the same one — see main(). It starts as the merely-resolved form
|
|
817
|
+
// because that is what scan() falls back to, so there is no window in which
|
|
818
|
+
// this names a spelling nothing was compared against.
|
|
819
|
+
let usedCwd = path.resolve(cwd);
|
|
820
|
+
let settled = false;
|
|
821
|
+
const finish = () => {
|
|
822
|
+
if (settled) return;
|
|
823
|
+
settled = true;
|
|
824
|
+
clearTimeout(timer);
|
|
825
|
+
cb(found.slice(), usedCwd);
|
|
826
|
+
};
|
|
827
|
+
const timer = setTimeout(finish, DISCOVERY_TIMEOUT_MS);
|
|
828
|
+
|
|
829
|
+
let scanned = false;
|
|
830
|
+
// Assigned below, and read from a callback normPathAsync may run
|
|
831
|
+
// synchronously — so it is declared first rather than closed over as a
|
|
832
|
+
// `const` that would still be in its dead zone.
|
|
833
|
+
let cwdTimer = null;
|
|
834
|
+
const scan = resolvedCwd => {
|
|
835
|
+
if (scanned || settled) return;
|
|
836
|
+
scanned = true;
|
|
837
|
+
usedCwd = resolvedCwd;
|
|
838
|
+
clearTimeout(cwdTimer);
|
|
839
|
+
fs.readdir(DIR, (err, names) => {
|
|
840
|
+
if (settled) return;
|
|
841
|
+
if (err) return finish();
|
|
842
|
+
// `${pid}.json`, which is what writeDiscovery names them — not every
|
|
843
|
+
// `.json` in the directory. DIR is ~/.claude/agent-dag/, and that is not
|
|
844
|
+
// a registry: it is the deck's old home. prefs.json lived there and
|
|
845
|
+
// deck-home.mjs's migration leaves the original where it is, so on every
|
|
846
|
+
// upgraded machine this was reading and parsing the deck's 0600
|
|
847
|
+
// private-key file on every tool call. It was never leaked — no top-level
|
|
848
|
+
// `workspace`, so the guard dropped it — but it is the one file in there
|
|
849
|
+
// guaranteed to have a shape readRecord does not expect.
|
|
850
|
+
const files = names.filter(f => /^\d+\.json$/.test(f));
|
|
851
|
+
if (!files.length) return finish();
|
|
852
|
+
let pending = files.length;
|
|
853
|
+
const one = () => { if (--pending <= 0) finish(); };
|
|
854
|
+
for (const file of files) readRecord(file, resolvedCwd, found, one);
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
cwdTimer = setTimeout(() => scan(path.resolve(cwd)), CWD_TIMEOUT_MS);
|
|
858
|
+
normPathAsync(cwd, scan);
|
|
859
|
+
}
|
|
860
|
+
|
|
437
861
|
function main() {
|
|
438
|
-
// Hard cap so a stuck server can never wedge the host CLI
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
862
|
+
// Hard cap so a stuck server can never wedge the host CLI: this process ends
|
|
863
|
+
// itself rather than being killed. CAP_MS says what the budget is; this line
|
|
864
|
+
// is about WHEN THE CLOCK STARTED.
|
|
865
|
+
//
|
|
866
|
+
// It starts when Node did, not when main() ran. The old timer was armed here
|
|
867
|
+
// and counted only what came after, so the `sh -c` fork/exec and the
|
|
868
|
+
// interpreter startup that Claude Code is already timing were spent outside
|
|
869
|
+
// the budget and charged to it anyway — which is how a 1900ms cap declared
|
|
870
|
+
// under a `timeout: 2` came out at 2.19s on a loaded box (#1018).
|
|
871
|
+
// process.uptime() is how far into that budget we already are.
|
|
872
|
+
//
|
|
873
|
+
// The floor is deliberate. A startup slow enough to eat the whole budget is a
|
|
874
|
+
// machine under real load, which is exactly when a deck is worth posting to;
|
|
875
|
+
// exiting before opening a loopback socket would drop the event to save
|
|
876
|
+
// nothing. installer.mjs declares the timeout from the other side and has to
|
|
877
|
+
// stay strictly above CAP_MS plus that startup — hook-budget.test.ts pins the
|
|
878
|
+
// two numbers against each other so they cannot drift apart again.
|
|
879
|
+
//
|
|
880
|
+
// AND THIS LINE FIRING IS NOT THE SAME AS THIS PROCESS ENDING. Node's exit
|
|
881
|
+
// joins libuv's threadpool, so `process.exit(0)` does not complete while a
|
|
882
|
+
// filesystem request is still executing in one of those threads — measured,
|
|
883
|
+
// the timer runs on time, calls exit, and the process is still there. Moving
|
|
884
|
+
// the reads off the main thread is what lets the timer run at all and what
|
|
885
|
+
// gets the event delivered; it is NOT on its own a guarantee that this
|
|
886
|
+
// process ends itself. normPathAsync says the whole of it, including why
|
|
887
|
+
// ending by signal instead would be the worse trade. Read that before
|
|
888
|
+
// concluding a timer plus async fs closes this.
|
|
889
|
+
setTimeout(() => process.exit(0), Math.max(200, CAP_MS - Math.round(process.uptime() * 1000)));
|
|
890
|
+
|
|
891
|
+
// AND NOTHING THIS PROCESS DOES MAY REACH THE HOST CLI'S TRANSCRIPT.
|
|
892
|
+
//
|
|
893
|
+
// The cap above covers a stuck server. This covers the other way out: a
|
|
894
|
+
// throw. Claude Code surfaces a non-zero exit as `<hook> hook error` plus the
|
|
895
|
+
// first stderr line, so one uncaught TypeError puts a Node stack trace in
|
|
896
|
+
// front of the user on every tool call — which is what a malformed discovery
|
|
897
|
+
// record used to do, forever, because nothing removes such a record.
|
|
898
|
+
//
|
|
899
|
+
// Deliberately last-resort and deliberately silent. Every path in this file
|
|
900
|
+
// is written to end at exit 0 on its own; this is here because
|
|
901
|
+
// hook-read-only.test.ts proves that property by grepping for `process.exit`
|
|
902
|
+
// literals, and a grep cannot see a throw. It covers the async callbacks too,
|
|
903
|
+
// which a try/catch around the stdin handler would not.
|
|
904
|
+
process.on("uncaughtException", () => process.exit(0));
|
|
905
|
+
process.on("unhandledRejection", () => process.exit(0));
|
|
443
906
|
|
|
444
907
|
// The deck reads the Claude quota by running `claude --print /usage`, which is
|
|
445
908
|
// a full Claude Code invocation and therefore fires these hooks. Reporting it
|
|
@@ -462,73 +925,142 @@ function main() {
|
|
|
462
925
|
if (parsed && typeof parsed === "object" && !parsed.provider) {
|
|
463
926
|
parsed.provider = PROVIDER;
|
|
464
927
|
}
|
|
465
|
-
const taggedInput = JSON.stringify(parsed);
|
|
466
928
|
|
|
467
|
-
|
|
929
|
+
// Discover, prove, elect, post — four phases, each with a deadline of its
|
|
930
|
+
// own, and none of them able to stop the timer above from ending this
|
|
931
|
+
// process. See discoverTargets for why the first one is a phase at all.
|
|
932
|
+
discoverTargets(cwd, (targets, resolvedCwd) => {
|
|
933
|
+
if (!targets.length) return process.exit(0);
|
|
468
934
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
935
|
+
// POST THE CWD WE DECIDED ON, not the one we were handed.
|
|
936
|
+
//
|
|
937
|
+
// This used to serialise `parsed` before normPath ran, so the payload —
|
|
938
|
+
// and therefore events.jsonl — carried the RAW cwd while capture was
|
|
939
|
+
// decided on the canonical one. The two spellings only coincide where
|
|
940
|
+
// nothing in the path is a symlink, a junction, a subst drive or an 8.3
|
|
941
|
+
// short name, which is why it held on the machine it was written on.
|
|
942
|
+
//
|
|
943
|
+
// Everywhere else it made a `--workspace` deck capture all day and replay
|
|
944
|
+
// nothing: `bin/deck.js:548` canonicalises the flag once, and replayScope
|
|
945
|
+
// compares the logged cwd against it with a pure string predicate. On
|
|
946
|
+
// macOS, `--workspace /tmp/proj` captures (the hook resolves both sides to
|
|
947
|
+
// /private/tmp/proj) and then comes back empty after a restart. Silently —
|
|
948
|
+
// replayLog does not count or warn about an out-of-scope line.
|
|
949
|
+
//
|
|
950
|
+
// The Codex watcher already stores the canonical form (index.mjs:2203,
|
|
951
|
+
// `cwd: await canonicalCwd(...)`, the same resolve+realpath this is), which
|
|
952
|
+
// is why the two providers disagreed on one board. One spelling in the log
|
|
953
|
+
// is the whole fix.
|
|
954
|
+
//
|
|
955
|
+
// THE SPELLING COMES BACK FROM discoverTargets now rather than being
|
|
956
|
+
// computed here, because canonicalising a cwd is a filesystem call and
|
|
957
|
+
// those belong inside that phase's deadline (#1018). What it hands back is
|
|
958
|
+
// whatever capture was actually decided on: the canonical spelling when
|
|
959
|
+
// the mount answered for it, and `path.resolve(cwd)` when it did not.
|
|
960
|
+
// That is this rule, not an exception to it — the log has to carry the
|
|
961
|
+
// spelling the match was made against, whichever one that turned out to
|
|
962
|
+
// be. A deck that captured on the resolved spelling and replayed on the
|
|
963
|
+
// canonical one is the same empty board this comment was written about.
|
|
964
|
+
//
|
|
965
|
+
// It also sits after the early return above, so a machine with no deck
|
|
966
|
+
// listening no longer serialises a payload nobody is going to read: a
|
|
967
|
+
// Read or a Grep result arrives whole in `tool_response` and is routinely
|
|
968
|
+
// megabytes.
|
|
969
|
+
if (parsed && typeof parsed === "object") parsed.cwd = resolvedCwd;
|
|
970
|
+
const taggedInput = JSON.stringify(parsed);
|
|
474
971
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
// machine. Nothing documented that, and the server's own Codex capture never
|
|
482
|
-
// did it: each deck tails the rollout files itself and evaluates its own
|
|
483
|
-
// workspace, so a Codex session inside a scoped tree appeared on both decks
|
|
484
|
-
// while the Claude session beside it appeared on one. One flag, one path,
|
|
485
|
-
// two answers.
|
|
486
|
-
//
|
|
487
|
-
// The fan-out is the documented meaning and the one kept: `--workspace` says
|
|
488
|
-
// which sessions a deck captures, not which sessions it takes from the decks
|
|
489
|
-
// around it. It is also what electWriters below already assumes — several
|
|
490
|
-
// decks drawing one event is the case it exists to keep from being written
|
|
491
|
-
// to one log several times.
|
|
492
|
-
const targets = [];
|
|
493
|
-
for (const file of files) {
|
|
494
|
-
let d;
|
|
495
|
-
try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
|
|
496
|
-
if (typeof d.workspace !== "string" || !d.pid || !d.port) continue;
|
|
497
|
-
// A missing token is not a reason to drop the file here — prove() decides
|
|
498
|
-
// what a target has to prove, and a deck older than the handshake can
|
|
499
|
-
// prove nothing. See requiresProof.
|
|
500
|
-
|
|
501
|
-
if (!isAlive(d.pid)) {
|
|
502
|
-
try { fs.unlinkSync(path.join(DIR, file)); } catch {}
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// "" is machine-wide and must never reach normPath: resolving it would
|
|
507
|
-
// produce this hook's own cwd — the agent's — and scope a deck that asked
|
|
508
|
-
// for no scope at all. Any other spelling is canonicalized here, which is
|
|
509
|
-
// now a second pass over a path bin/deck.js already canonicalized before
|
|
510
|
-
// publishing it — kept because a deck old enough to have published a
|
|
511
|
-
// relative one is still entitled to its events.
|
|
512
|
-
const ws = d.workspace === "" ? "" : normPath(d.workspace);
|
|
513
|
-
if (capturesSession(resolvedCwd, ws)) targets.push(d);
|
|
514
|
-
}
|
|
972
|
+
// See proveTargets for what the other order cost. A record whose pid is
|
|
973
|
+
// merely alive has established nothing: it may be a deck that died and had
|
|
974
|
+
// its pid recycled, and electing one of those to write the log meant
|
|
975
|
+
// nobody wrote it (#695).
|
|
976
|
+
proveTargets(targets, proven => {
|
|
977
|
+
if (!proven.length) return process.exit(0);
|
|
515
978
|
|
|
516
|
-
|
|
979
|
+
// One deck per events log records this event; the others only draw it.
|
|
980
|
+
const writers = electWriters(proven);
|
|
517
981
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
// nothing: it may be a deck that died and had its pid recycled, and electing
|
|
521
|
-
// one of those to write the log meant nobody wrote it (#695).
|
|
522
|
-
proveTargets(targets, proven => {
|
|
523
|
-
if (!proven.length) return process.exit(0);
|
|
982
|
+
let pending = proven.length;
|
|
983
|
+
const done = () => { if (--pending <= 0) process.exit(0); };
|
|
524
984
|
|
|
525
|
-
|
|
526
|
-
|
|
985
|
+
// THE ELECTION HAS A SECOND PLACE NOW (#1019).
|
|
986
|
+
//
|
|
987
|
+
// Answering the handshake established that the port belongs to the deck
|
|
988
|
+
// its record describes. It did not establish that the deck will take the
|
|
989
|
+
// event: it can be restarted by its supervisor between the challenge and
|
|
990
|
+
// the POST, it can fail the ingest and say 500, and an oversized body is
|
|
991
|
+
// refused outright (#1014). Electing a writer that then refuses used to
|
|
992
|
+
// mean nobody wrote the line, because every other deck sharing that log
|
|
993
|
+
// had already been told `?persist=0` — the same silent stop #695 was
|
|
994
|
+
// closed on, reached through the answer rather than through the record.
|
|
995
|
+
//
|
|
996
|
+
// So each log keeps the queue the election would have picked from, in
|
|
997
|
+
// electWriters' own order, and a writer that did not take the event —
|
|
998
|
+
// it answered something other than 2xx, or ended the exchange without
|
|
999
|
+
// answering — hands the log to the next deck in it. A writer that only
|
|
1000
|
+
// let its deadline pass with the whole body in hand is not that, and
|
|
1001
|
+
// keeps the log (#1133): see post(). Only the writers fail over: a deck
|
|
1002
|
+
// that was only drawing the event has nothing to hand on.
|
|
1003
|
+
//
|
|
1004
|
+
// The deck it is handed to has ALREADY been posted this event with
|
|
1005
|
+
// `?persist=0`, so it is asked twice. Both halves of that are settled
|
|
1006
|
+
// where they land: the reducer treats a re-delivered `tool_use_id` as the
|
|
1007
|
+
// call it already has (it names "a hook retry" as one of the three ways
|
|
1008
|
+
// that happens), and the server's `noteLogWriter` reads `persist=1` as
|
|
1009
|
+
// this deck owning the session again, which is what lets it append the
|
|
1010
|
+
// line the first deck refused.
|
|
1011
|
+
//
|
|
1012
|
+
// The fan-out stays parallel. Posting to the writer first and fanning out
|
|
1013
|
+
// only after its 2xx is the ordering that needs no queue, and it costs
|
|
1014
|
+
// every OTHER deck the writer's whole deadline before it is drawn on —
|
|
1015
|
+
// 1000ms of the 1900ms CAP_MS gives this process, on every event, to
|
|
1016
|
+
// insure against a case that is rare. The hand-on is paid for only when a
|
|
1017
|
+
// writer actually fails.
|
|
1018
|
+
//
|
|
1019
|
+
// THE HAND-ON IS STILL THE POST PHASE, and it is bounded the way the
|
|
1020
|
+
// other three are. Each deck it reaches is one more post() carrying its
|
|
1021
|
+
// own POST_TIMEOUT_MS, and nothing in it can hold this process past the
|
|
1022
|
+
// timer main() armed before discovery began: `pending` decides how EARLY
|
|
1023
|
+
// the process may exit, never how late. The worst case that reaches a
|
|
1024
|
+
// hand-on at all — a writer that stops reading before the body is all
|
|
1025
|
+
// out, or refuses a moment before its deadline — is one POST deadline
|
|
1026
|
+
// followed by a loopback round trip to the next deck, which fits inside
|
|
1027
|
+
// CAP_MS when discovery and the challenge answer normally. A writer that
|
|
1028
|
+
// takes the body and never answers no longer reaches one: its deadline
|
|
1029
|
+
// ends its slot instead. When they did not, and the timer ends the process with a
|
|
1030
|
+
// hand-on still in flight, the line is lost exactly as it was lost before
|
|
1031
|
+
// this change and the hook still exits 0. The cap is the backstop over
|
|
1032
|
+
// the hand-on too, not something the hand-on gets to argue with.
|
|
1033
|
+
const inLine = new Map();
|
|
1034
|
+
for (const d of proven) {
|
|
1035
|
+
const key = logGroup(d);
|
|
1036
|
+
if (!inLine.has(key)) inLine.set(key, []);
|
|
1037
|
+
inLine.get(key).push(d);
|
|
1038
|
+
}
|
|
1039
|
+
for (const queue of inLine.values()) queue.sort(beforeInLine);
|
|
527
1040
|
|
|
528
|
-
|
|
529
|
-
|
|
1041
|
+
// `queue[0]` is the deck electWriters picked, so dropping the head is
|
|
1042
|
+
// exactly "the next one down". An empty queue is a log with one deck on
|
|
1043
|
+
// it: there is nobody to hand it to, and nothing here can invent one.
|
|
1044
|
+
//
|
|
1045
|
+
// "deadline" ends the hand-on the way a 2xx does. That writer has the
|
|
1046
|
+
// whole event and may still be about to append it, and asking the next
|
|
1047
|
+
// deck to append it as well is how one event became two lines (#1133).
|
|
1048
|
+
const writeTo = d => post(d, taggedInput, true, (took, why) => {
|
|
1049
|
+
if (took || why === "deadline") return done();
|
|
1050
|
+
const queue = inLine.get(logGroup(d));
|
|
1051
|
+
queue.shift();
|
|
1052
|
+
if (!queue.length) return done();
|
|
1053
|
+
// No done() on this path: the group's slot in `pending` stays open
|
|
1054
|
+
// for as long as the hand-on is still going, so the process does not
|
|
1055
|
+
// exit out from under the deck that is about to be asked.
|
|
1056
|
+
writeTo(queue[0]);
|
|
1057
|
+
});
|
|
530
1058
|
|
|
531
|
-
|
|
1059
|
+
for (const d of proven) {
|
|
1060
|
+
if (writers.has(d)) writeTo(d);
|
|
1061
|
+
else post(d, taggedInput, false, done);
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
532
1064
|
});
|
|
533
1065
|
});
|
|
534
1066
|
}
|