ccdeck 3.26.4 → 3.26.5

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.
@@ -44,7 +44,7 @@
44
44
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
45
45
  })();
46
46
  </script>
47
- <script type="module" crossorigin src="/assets/index-CPHonXg4.js"></script>
47
+ <script type="module" crossorigin src="/assets/index-D4fPrztm.js"></script>
48
48
  <link rel="stylesheet" crossorigin href="/assets/index-DlaHgzye.css">
49
49
  </head>
50
50
  <body>
package/hook/hook.js CHANGED
@@ -357,13 +357,15 @@ function sameProof(got, want) {
357
357
  //
358
358
  // They are now separated by a barrier: every target is challenged, then the
359
359
  // election is decided, then the payload goes out (#695). The worst case is
360
- // unchanged — the challenges run in parallel, so it is still one 400ms deadline
361
- // followed by one 1000ms deadline. What the barrier does cost is that an honest
362
- // deck's POST waits for the slowest challenge in the set, which only matters
363
- // when some OTHER record's port accepts a connection and then says nothing. A
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.
360
+ // unchanged — the challenges run in parallel, so it is still one challenge's
361
+ // two 400ms attempts followed by one 1000ms deadline, and each 400ms bounds the
362
+ // whole answer rather than a silence in it (see prove). What the barrier does
363
+ // cost is that an honest deck's POST waits for the slowest challenge in the
364
+ // set, which only matters when some OTHER record's port accepts a connection
365
+ // and then does not answer in time. A ghost port with nothing behind it refuses
366
+ // instantly and delays no one; one with something slow or silent behind it
367
+ // costs both attempts once, and its record is then forgotten if no deck has
368
+ // been keeping it — see forgetIfAbandoned.
367
369
  const CHALLENGE_TIMEOUT_MS = 400;
368
370
  const POST_TIMEOUT_MS = 1000;
369
371
 
@@ -423,6 +425,8 @@ const CAP_MS = 1900;
423
425
  */
424
426
  function prove(d, cb, attempt = 0) {
425
427
  let settled = false;
428
+ // Armed once the request is out, and cleared by whichever verdict comes first.
429
+ let deadline = null;
426
430
  // A DEADLINE IS NOT AN ANSWER, and the difference is worth one retry.
427
431
  //
428
432
  // A wrong proof, a refused connection and a 404 are all verdicts: that port
@@ -444,7 +448,12 @@ function prove(d, cb, attempt = 0) {
444
448
  settled = true; // this attempt is over; the next owns `cb`
445
449
  prove(d, cb, attempt + 1);
446
450
  };
447
- const finish = (ok, why = null) => { if (settled) return; settled = true; cb(ok, why); };
451
+ const finish = (ok, why = null) => {
452
+ if (settled) return;
453
+ settled = true;
454
+ clearTimeout(deadline);
455
+ cb(ok, why);
456
+ };
448
457
 
449
458
  if (!requiresProof(d)) return finish(true);
450
459
 
@@ -456,7 +465,6 @@ function prove(d, cb, attempt = 0) {
456
465
  port: d.port,
457
466
  path: `/api/hook-challenge?nonce=${nonce}`,
458
467
  method: "GET",
459
- timeout: CHALLENGE_TIMEOUT_MS,
460
468
  }, res => {
461
469
  if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
462
470
  let answer = "";
@@ -476,12 +484,26 @@ function prove(d, cb, attempt = 0) {
476
484
  finish(sameProof(proof, want));
477
485
  });
478
486
  });
479
- // `destroy()` on a timeout makes 'error' fire with ECONNRESET, so the two
480
- // handlers have to agree on which of them is speaking: `timedOut` is what
481
- // tells a deadline apart from a refusal.
482
- let timedOut = false;
483
- req.on("error", () => { if (timedOut) retryOnTimeout(); else finish(false); });
484
- req.on("timeout", () => { timedOut = true; req.destroy(); });
487
+ req.on("error", () => finish(false));
488
+ // THE DEADLINE IS ON THE WHOLE ANSWER, not on the silences in it (#1172).
489
+ //
490
+ // This was http.request's `timeout`, which is an IDLE timeout: it fires only
491
+ // after that long with no bytes either way. A port that answers one byte
492
+ // every 150ms is never idle and never reaches the 4096-byte cap above, so its
493
+ // prove() never settled — and proveTargets waits for every target before
494
+ // anything is posted. Measured with an honest deck beside such a port: the
495
+ // hook ran to CAP_MS at 1938ms and the deck saw its challenge and no event,
496
+ // on every tool call for as long as the record stood, and the record was
497
+ // never forgotten either, because the "deadline" verdict forgetIfAbandoned
498
+ // needs never came.
499
+ //
500
+ // The idle timeout had a second hole, and a port that sends its status line
501
+ // and then goes quiet fell into it: once a response has begun, destroying the
502
+ // request raises no 'error' on it, so the verdict that used to ride on that
503
+ // event never arrived either. So the timer gives the verdict itself, and
504
+ // gives it first: the destroy's own 'error', if it raises one, finds this
505
+ // attempt already over.
506
+ deadline = setTimeout(() => { retryOnTimeout(); req.destroy(); }, CHALLENGE_TIMEOUT_MS);
485
507
  req.end();
486
508
  }
487
509
 
@@ -686,11 +708,30 @@ function post(d, body, persists, done) {
686
708
  finish(res.statusCode >= 200 && res.statusCode < 300);
687
709
  });
688
710
  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(); });
711
+ // `destroy()` on a timeout makes 'error' fire with ECONNRESET — the status
712
+ // line has not arrived, or it would have settled this already — so `timedOut`
713
+ // is what tells this process giving up apart from the writer hanging up.
714
+ //
715
+ // AND THE BODY'S STATE IS READ WHEN THE DEADLINE FIRES, NOT WHEN THE ERROR
716
+ // ARRIVES (#1172). `timedOut && sent` was evaluated in the 'error' handler,
717
+ // and by the time that runs the answer is always yes: destroying a request
718
+ // that has been end()ed finalises its writable, which emits 'finish' — so
719
+ // `sent` flips to true on the way out of req.destroy(), one tick before the
720
+ // 'error' it causes. Measured on Node 22 against a listener that takes the
721
+ // headers and then stops reading, with 256MB still queued:
722
+ //
723
+ // TIMEOUT sent=false writableLength=268435608
724
+ // FINISH writableFinished=true pending=0
725
+ // ERROR ECONNRESET sent=true <- verdict "deadline"
726
+ //
727
+ // which made every deadline a "deadline" and left the paragraph above
728
+ // describing a branch that could not be taken: a writer wedged mid-read kept
729
+ // a log it had not received, and the deck behind it — which could have
730
+ // written the line — was never asked. The snapshot is the rule that paragraph
731
+ // states, taken at the only moment it is still true.
732
+ let sentByDeadline = false;
733
+ req.on("error", () => finish(false, timedOut && sentByDeadline ? "deadline" : null));
734
+ req.on("timeout", () => { timedOut = true; sentByDeadline = sent; req.destroy(); });
694
735
  req.write(body);
695
736
  req.end();
696
737
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.26.4",
3
+ "version": "3.26.5",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,8 @@
47
47
  "3.26.0",
48
48
  "3.26.1",
49
49
  "3.26.3",
50
- "3.26.4"
50
+ "3.26.4",
51
+ "3.26.5"
51
52
  ],
52
53
  "3.25.0": [
53
54
  {
@@ -403,15 +403,37 @@ async function writeFileAtomic(rawTarget, text) {
403
403
  * identical content skips the write and the file is not replaced at all.
404
404
  */
405
405
  async function installScript(src, dst) {
406
- const text = await readFile(src, "utf8");
406
+ return installText(await readFile(src, "utf8"), dst);
407
+ }
408
+
409
+ async function installText(text, dst) {
407
410
  const current = await readFile(dst, "utf8").catch(() => null);
408
411
  if (current === text) return false;
409
412
  await writeFileAtomic(dst, text);
410
413
  return true;
411
414
  }
412
415
 
416
+ /**
417
+ * What makes the installed hook.js a CommonJS file wherever the config dir is.
418
+ *
419
+ * Node decides whether a `.js` file is CommonJS or an ES module from the nearest
420
+ * package.json above it, and hook.js is CommonJS. With none in agent-dag/, that
421
+ * nearest one is whatever the user has further up: a `"type": "module"` in
422
+ * ~/package.json, or in the repo a relocated CLAUDE_CONFIG_DIR lives in, loads
423
+ * the hook as an ES module, and it dies on its first `require` before main()
424
+ * has installed a single handler. Exit 1 and `require is not defined in ES
425
+ * module scope` on every tool call, and no deck receives anything (#1172). One
426
+ * file here answers the question before Node looks any further.
427
+ *
428
+ * Written before the hook, so a first install never has a hook.js without it,
429
+ * and through the same atomic write, because a half-written package.json is a
430
+ * load error of its own for every hook that starts while it is being written.
431
+ */
432
+ const HOOK_PACKAGE_JSON = '{ "type": "commonjs" }\n';
433
+
413
434
  async function installHookScript(installDir) {
414
435
  await ensureDir(installDir);
436
+ await installText(HOOK_PACKAGE_JSON, join(installDir, "package.json"));
415
437
  const src = join(PKG_ROOT, "hook", "hook.js");
416
438
  const dst = join(installDir, "hook.js");
417
439
  await installScript(src, dst);
@@ -91,8 +91,17 @@ const EVENT = "Stop";
91
91
  // writes this any more; retirement reads it once and deletes it.
92
92
  const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
93
93
 
94
+ // `Array.isArray`, not `?? []`, and that is a crash fix rather than a style
95
+ // choice. A group whose `hooks` is not an array — hand-edited settings.json,
96
+ // something else's schema, a half-finished edit — made this `.map` throw
97
+ // `TypeError: … .map is not a function`, out of `retireSoundHookIn`, out of
98
+ // `installHooks`, on the unconditional install every boot runs: the deck would
99
+ // not start at all until the user found and fixed the entry, with nothing but a
100
+ // stack to say which one. installer.mjs's own `isOurEntry` has always read the
101
+ // same field with the same guard, and its answer for a group it cannot read is
102
+ // to leave it exactly where it is.
94
103
  const commandsOf = (entry) =>
95
- (entry?.hooks ?? []).map(h => (typeof h?.command === "string" ? h.command : ""));
104
+ (Array.isArray(entry?.hooks) ? entry.hooks : []).map(h => (typeof h?.command === "string" ? h.command : ""));
96
105
 
97
106
  /**
98
107
  * THE TAIL, NOT THE WHOLE PATH — and this is a data-loss fix, not a tidy-up.