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.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +244 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -184
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +335 -47
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +472 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BYqkKWPC.js +0 -274
  51. 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 log = typeof d.persist === "string" ? d.persist : "";
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
- // Ports are unique among live decks; pid only breaks a tie a stale
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 1500ms, so the
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, and the budget still fits: 400 + 400 for
321
- // the challenge and 1000 for the POST, under the 1900ms cap main() sets —
322
- // which is itself under the two-second timeout the installed hook entry
323
- // carries, so Claude Code never has to kill this process.
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
- * The record is NOT unlinked when a target fails. A dead pid is proof the deck
395
- * is gone and is swept above; a failed challenge is not — a deck restarting
396
- * under its supervisor refuses connections for a moment while its record still
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 for one
399
- * that loses a whole deck's events, and it buys nothing now that the election no
400
- * longer believes the record: a ghost that survives on disk costs one instant
401
- * ECONNREFUSED per hook run and decides nothing.
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 => { ok[i] = answered; settle(); }));
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
- * Hand this deck the payload. `done` runs exactly once, whatever the outcome —
412
- * a delivered event, a refused connection and a socket that errors after the
413
- * response are all just "this target is finished".
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 => { res.resume(); res.on("end", finish); });
431
- req.on("error", finish);
432
- req.on("timeout", () => req.destroy());
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. 1900ms, which is
439
- // the challenge's two attempts (400 + 400) plus the POST's 1000 with a little
440
- // room — and still under the two-second timeout the installed hook entry
441
- // carries, so this process ends itself rather than being killed.
442
- setTimeout(() => process.exit(0), 1900);
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
- const resolvedCwd = normPath(cwd);
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
- let files;
470
- try {
471
- files = fs.readdirSync(DIR).filter(f => f.endsWith(".json"));
472
- } catch { return process.exit(0); }
473
- if (!files.length) return process.exit(0);
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
- // Every deck whose workspace contains this cwd, and nothing else decides it.
476
- //
477
- // This used to sort the matches by how long each deck's workspace path was
478
- // and deliver only to the longest — so a deck scoped to /Users/x/proj TOOK
479
- // that tree's sessions away from a machine-wide deck, which then sat there
480
- // showing nothing while `--all` promised it captured every session on this
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
- if (!targets.length) return process.exit(0);
979
+ // One deck per events log records this event; the others only draw it.
980
+ const writers = electWriters(proven);
517
981
 
518
- // Prove, elect, post — in that order, and see proveTargets for what the
519
- // other order cost. A record whose pid is merely alive has established
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
- // One deck per events log records this event; the others only draw it.
526
- const writers = electWriters(proven);
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
- let pending = proven.length;
529
- const done = () => { if (--pending <= 0) process.exit(0); };
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
- for (const d of proven) post(d, taggedInput, writers.has(d), done);
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
  }