ccdeck 3.26.3 → 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-DZzBylIp.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.3",
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": {
@@ -46,7 +46,9 @@
46
46
  "3.23.4",
47
47
  "3.26.0",
48
48
  "3.26.1",
49
- "3.26.3"
49
+ "3.26.3",
50
+ "3.26.4",
51
+ "3.26.5"
50
52
  ],
51
53
  "3.25.0": [
52
54
  {
@@ -415,6 +415,48 @@ export async function updatePrefs(mutate, home = deckDataDir(), deps = {}) {
415
415
  return queued(() => save(mutate, home, deps));
416
416
  }
417
417
 
418
+ /**
419
+ * The two `mutate`s the three callers above hand updatePrefs, named so that the
420
+ * callers and prefs-update-1041.test.ts run the same function. The suite used to
421
+ * carry its own copy of each closure and test the copy (#1168), which stays
422
+ * green however the one in index.mjs is edited.
423
+ */
424
+
425
+ /** `lan.manual` with `entry` on the end, or no change when it is there already.
426
+ * What `onDial` and the accept route both write. */
427
+ export function withManualEntry(entry) {
428
+ return prev => {
429
+ const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
430
+ return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
431
+ };
432
+ }
433
+
434
+ /**
435
+ * `lan.shared` with `key` on the end, or no change when it is already there.
436
+ *
437
+ * The tick an account gets when it ARRIVES over the local network (#1188): the
438
+ * group already has that login, so a deck that was just given one is as useful
439
+ * to the next machine as the deck that gave it. Only on arrival — an untick
440
+ * afterwards is the person's answer and nothing re-ticks it, because an account
441
+ * this deck already holds is never added again.
442
+ */
443
+ export function withShared(key) {
444
+ return prev => {
445
+ const shared = Array.isArray(prev?.lan?.shared) ? prev.lan.shared : [];
446
+ return !key || shared.includes(key) ? null : { lan: { shared: [...shared, key] } };
447
+ };
448
+ }
449
+
450
+ /** `lan.aliases` with `fp` called `name`, or without `fp` when the name is
451
+ * empty. The alias route's write; the whole map, rebuilt from the one read. */
452
+ export function withAlias(fp, name) {
453
+ return prev => {
454
+ const next = { ...(prev?.lan?.aliases ?? {}) };
455
+ if (name) next[fp] = name; else delete next[fp];
456
+ return { lan: { aliases: next } };
457
+ };
458
+ }
459
+
418
460
  /** One read-modify-write, behind every other one. Both entry points go through
419
461
  * here so there is a single queue and a single merge. */
420
462
  function queued(job) {
@@ -34,7 +34,7 @@ import { createOutputWatch } from "./output-watch.mjs";
34
34
  import { RECAP_MARK, foldRecapLine } from "./session-recap.mjs";
35
35
  import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
36
36
  import { createPresence } from "./presence.mjs";
37
- import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
37
+ import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, withAlias, withManualEntry, withShared, writePrefs } from "./deck-prefs.mjs";
38
38
  import { createEngine, defaultName } from "./lan-engine.mjs";
39
39
  import { createTailnet, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
40
40
  import { portHolder } from "./port-holder.mjs";
@@ -3962,6 +3962,19 @@ const lanEngine = createEngine({
3962
3962
  // listener had one is missing half of them — see forgetReach.
3963
3963
  forgetReach();
3964
3964
  },
3965
+ // An account arrived from a paired deck, and this deck now offers it too
3966
+ // (#1188). Written before the engine hears it, so a restart between the two
3967
+ // leaves the tick on disk rather than only in the running engine's copy.
3968
+ onShared: async key => {
3969
+ try {
3970
+ const before = _prefs;
3971
+ _prefs = await updatePrefs(withShared(key));
3972
+ if (_prefs?.lan?.shared === before?.lan?.shared) return; // it was already ticked
3973
+ await lanEngine.apply({ shared: _prefs.lan.shared });
3974
+ } catch (err) {
3975
+ console.error(`${PRODUCT}: lan sync could not share the account it just received:`, err?.message ?? err);
3976
+ }
3977
+ },
3965
3978
  // A deck was accepted or unpaired. Written straight through, because the
3966
3979
  // trusted list is the whole of who this deck will talk to and a list that
3967
3980
  // only existed in memory would drop every pairing on restart.
@@ -3982,10 +3995,7 @@ const lanEngine = createEngine({
3982
3995
  // is the WHOLE array, so a stale one wins. Pressing accept on a heard deck
3983
3996
  // at the moment an invite round fires this dropped the dialled address out
3984
3997
  // of `lan.manual`, which is the failure the comment above describes.
3985
- _prefs = await updatePrefs(prev => {
3986
- const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
3987
- return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
3988
- });
3998
+ _prefs = await updatePrefs(withManualEntry(entry));
3989
3999
  // AND RECONCILE THE ENGINE'S DIAL LIST with what was just written. The
3990
4000
  // disk is now right, but `setPeers` replaces the list wholesale from
3991
4001
  // `_prefs` on every settings write — so a write that raced this one, and
@@ -4360,12 +4370,7 @@ async function handleLanPeer(req, res) {
4360
4370
  // Inside the job, like `onDial` — the same whole-array patch computed
4361
4371
  // from the same stale copy, and the same address lost when two of them
4362
4372
  // land in one turn.
4363
- try {
4364
- _prefs = await updatePrefs(prev => {
4365
- const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
4366
- return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
4367
- });
4368
- }
4373
+ try { _prefs = await updatePrefs(withManualEntry(entry)); }
4369
4374
  catch { /* it is dialled this session; the next accept re-adds it */ }
4370
4375
  }
4371
4376
  return send(res, 200, { ok: true, added, ...lanEngine.status() });
@@ -4391,11 +4396,7 @@ async function handleLanPeer(req, res) {
4391
4396
  case "alias": {
4392
4397
  if (!isAliasKey(fp)) return send(res, 400, { ok: false, reason: "bad_request" });
4393
4398
  const name = cleanAlias(body.name);
4394
- _prefs = await updatePrefs(prev => {
4395
- const next = { ...(prev?.lan?.aliases ?? {}) };
4396
- if (name) next[fp] = name; else delete next[fp];
4397
- return { lan: { aliases: next } };
4398
- });
4399
+ _prefs = await updatePrefs(withAlias(fp, name));
4399
4400
  await lanEngine.apply({ aliases: _prefs.lan.aliases });
4400
4401
  return send(res, 200, { ok: true, ...lanEngine.status() });
4401
4402
  }
@@ -6756,10 +6757,25 @@ export function requestUrl(rawUrl) {
6756
6757
  // So the Host check runs for every method now. What it asks is only the
6757
6758
  // rebinding question — did this request arrive addressed to a name that can
6758
6759
  // only ever be this machine — and it asks it of browser-shaped requests alone,
6759
- // meaning anything carrying an Origin or fetch metadata. A client sending
6760
- // neither is not a page and has no ambient authority to borrow: that is
6761
- // hook/hook.js, a plain Node http.request from the user's own machine, and it
6762
- // keeps reaching the deck under whatever name it used before.
6760
+ // meaning anything carrying an Origin, fetch metadata or a Referer. A client
6761
+ // sending none of them is not a page and has no ambient authority to borrow:
6762
+ // that is hook/hook.js, a plain Node http.request from the user's own machine,
6763
+ // and it keeps reaching the deck under whatever name it used before.
6764
+ //
6765
+ // THE REFERER IS ON THAT LIST because on one browser it is the only mark a page
6766
+ // leaves (#1168). A same-origin GET carries no Origin, and Safari 16.0-16.3
6767
+ // sends no Sec-Fetch-Site — the premise isAuthorizedDataRead's fallback is
6768
+ // built on — so a rebound page on that browser sent `Host: attacker.example:
6769
+ // 4317` and `Referer: http://attacker.example:4317/` and nothing else, was not
6770
+ // browser-shaped by the test as it stood, and was answered. Measured against a
6771
+ // running deck before this line changed: /api/health (the absolute workspace
6772
+ // path), /api/hook-challenge (the proof oracle, which handleHookChallenge says
6773
+ // a rebound page cannot see), /api/system/processes and the page itself all
6774
+ // answered 200. The guarded reads held only because isAuthorizedDataRead
6775
+ // happens to test the Host before it reads the Referer. No client of this
6776
+ // server that is not a browser sends a Referer — hook.js, the desktop app and
6777
+ // bin/ send none — so nothing that reached the deck before is turned away by
6778
+ // this, and the deck's own page names a loopback Host whatever it sends.
6763
6779
  //
6764
6780
  // Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
6765
6781
  // applies. `cross-site` on a read is an ordinary top-level navigation — a link
@@ -6767,9 +6783,10 @@ export function requestUrl(rawUrl) {
6767
6783
  // the deck's own UI on the deck's own origin, which is not an attack and used
6768
6784
  // to work. Rebinding does not need that test either: a rebound page's requests
6769
6785
  // report `same-origin`, and it is the Host that gives it away.
6770
- export function isTrustedRead({ origin, host, secFetchSite } = {}) {
6786
+ export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
6771
6787
  const browserShaped = (typeof origin === "string" && origin !== "")
6772
- || (typeof secFetchSite === "string" && secFetchSite.trim() !== "");
6788
+ || (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
6789
+ || (typeof referer === "string" && referer.trim() !== "");
6773
6790
  if (!browserShaped) return true;
6774
6791
  return isLoopbackHost(host);
6775
6792
  }
@@ -7377,6 +7394,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7377
7394
  origin: req.headers.origin,
7378
7395
  host: req.headers.host,
7379
7396
  secFetchSite: req.headers["sec-fetch-site"],
7397
+ referer: req.headers.referer,
7380
7398
  })) {
7381
7399
  return send(res, 403, { error: "cross-site request blocked" });
7382
7400
  }
@@ -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);
@@ -194,9 +194,26 @@ export function heardCurrent(raw, list) {
194
194
  * Injected rather than imported so a test can run a whole round — two engines,
195
195
  * two fake stores, one real socket pair — without claude-swap on the machine.
196
196
  */
197
+ /**
198
+ * Whether an account this round just placed is ticked for sharing here (#1188).
199
+ *
200
+ * ONLY AN ADD: a heal needed the tick to happen at all — roundWith asks for a
201
+ * heal only for an account this deck already shares — so there is nothing to
202
+ * add for one, and an account healed after somebody unticked it must not be
203
+ * ticked again behind them.
204
+ *
205
+ * ONLY FROM THE LOCAL NETWORK: the reasoning for the default is that the login
206
+ * came from the group and the group therefore has it, and a tailnet can reach
207
+ * further than one person's own machines. Sharing there stays a decision
208
+ * somebody makes rather than one an arrival makes for them.
209
+ */
210
+ export function ticksOnArrival(step, via) {
211
+ return !!step?.key && step.action === "add" && via !== "tailscale";
212
+ }
213
+
197
214
  export function createEngine({
198
215
  readAccounts, exportAccount, importAccount,
199
- onChange, onError, onIdentity, onPort, onTrust, onDial, now = Date.now,
216
+ onChange, onError, onIdentity, onPort, onTrust, onDial, onShared, now = Date.now,
200
217
  /**
201
218
  * The UDP socket the beacon shouts through, injectable for the same reason
202
219
  * lan-socket exposes it — and for one more that only showed up in use.
@@ -351,6 +368,13 @@ export function createEngine({
351
368
  * still be said to come over the tailnet or the local network — it has no
352
369
  * address of its own here, and without this its row could not say which. */
353
370
  const spokeFrom = new Map();
371
+ /** Addresses this deck added because a paired deck called in from them and
372
+ * nothing here dialled it — see learnCaller. Kept until a round proves the
373
+ * address answers: one that does is an ordinary dialled peer from then on
374
+ * and leaves this set; one that does not is a caller this deck cannot reach
375
+ * back (a strict NAT, a one-way path), and its row is taken away again so it
376
+ * reverts to "calls in" rather than failing every round. */
377
+ const calledBack = new Set();
354
378
  /**
355
379
  * When a connection from ANOTHER MACHINE last arrived on the sync listener.
356
380
  *
@@ -457,13 +481,59 @@ export function createEngine({
457
481
  * connection's — see sessionKey. `ctx.send` seals whatever it is handed when
458
482
  * both ends said they seal, so nothing below has to know which kind of deck
459
483
  * asked — see frameChannel. */
484
+ /** Does this deck already hold an address it dials for `fp`? A beacon row it
485
+ * still hears, or a typed/learned row that answered as that deck. When
486
+ * neither is true, the only way it ever reaches that deck is if the deck
487
+ * keeps calling — and a called deck is never pulled from. */
488
+ const dialsAlready = fp => {
489
+ if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && stillListed(p, now()))) return true;
490
+ for (const [at, met] of learned) if (met?.fp === fp && manual.has(at)) return true;
491
+ return false;
492
+ };
493
+
494
+ /**
495
+ * A PAIRED DECK THAT CALLS IN, AND NOTHING HERE DIALS IT.
496
+ *
497
+ * Accounts move only toward the deck that dials — roundWith pulls, serve only
498
+ * answers — so a deck this one holds no address for can offer everything and
499
+ * this one takes nothing. It is the exact state a deck falls into when it
500
+ * cannot hear beacons (a firewall, or Tailscale holding the discovery port):
501
+ * every peer becomes one that only calls, and no account ever arrives.
502
+ *
503
+ * The call itself is the address. The peer connected FROM somewhere and said
504
+ * in its hello which port it LISTENS on, and that pair is dialable. Adding it
505
+ * makes the next round reach the caller and pull — the same dial-back that
506
+ * accepting a deck and joining by invite already do, extended to a peer that
507
+ * simply calls. In memory, like those two: a settings write clears it and the
508
+ * next call re-adds it, and nothing here writes an address to disk.
509
+ */
510
+ const learnCaller = ctx => {
511
+ const fp = ctx?.peerFp;
512
+ const at = ctx?.peerAddr;
513
+ const port = ctx?.peerPort;
514
+ if (!fp || !at || !port || !engine) return;
515
+ if (!trustedPeer(cfg.trusted, fp)) return;
516
+ if (dialsAlready(fp)) return;
517
+ // As a row the deck ADDED ITSELF, not one a person typed: capped like every
518
+ // other automatic row, and — through calledBack — taken away again if the
519
+ // address turns out not to answer. The caller is already trusted, so the
520
+ // round dials and pulls without a press; `typed` decides only the cap and
521
+ // the undo, never the trust. See roundWith.
522
+ if (engine.addPeer(at, port, { typed: false })) {
523
+ learned.set(`${at}:${port}`, { fp, name: trustedPeer(cfg.trusted, fp)?.name || "" });
524
+ calledBack.add(`${at}:${port}`);
525
+ onChange?.();
526
+ }
527
+ };
528
+
460
529
  const serve = async (msg, ctx) => {
461
530
  // Before the verbs, and for every one of them: something that proved it
462
531
  // holds a key this deck accepted is talking, now.
463
532
  if (ctx?.peerFp) {
464
533
  spokeAt.set(ctx.peerFp, now());
465
- const from = String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
534
+ const from = ctx.peerAddr || String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
466
535
  if (from) spokeFrom.set(ctx.peerFp, from);
536
+ learnCaller(ctx);
467
537
  }
468
538
  try {
469
539
  if (msg.t === "manifest") {
@@ -562,6 +632,10 @@ export function createEngine({
562
632
  const waitingOnSomebody = () =>
563
633
  [...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
564
634
 
635
+ /** How this deck reached that peer. A beacon row says so; a typed address is
636
+ * read from the routing table, the way the peer list reads it. */
637
+ const viaOf = peer => peer.via ?? (routeTo(peer.addr) ? "tailscale" : "lan");
638
+
565
639
  /** Ask one peer what it has, and heal whatever it can heal. */
566
640
  const roundWith = async peer => {
567
641
  let conn = null;
@@ -633,6 +707,10 @@ export function createEngine({
633
707
  // from then on, because "Constantin-PC" is what the person who typed the
634
708
  // address was trying to reach.
635
709
  learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
710
+ // A DIAL-BACK THAT ANSWERED IS AN ORDINARY PEER NOW. It was on trial only
711
+ // until it proved the deck can reach it; from here it is dialled like any
712
+ // other and is no longer a candidate for the undo below. See learnCaller.
713
+ calledBack.delete(`${peer.addr}:${peer.port}`);
636
714
 
637
715
  // TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY NAMED. Reaching a
638
716
  // deck we have no pin for used to mean the person at this keyboard put
@@ -738,6 +816,22 @@ export function createEngine({
738
816
  // than as a healthy one.
739
817
  const got = await importAccount(blob, step);
740
818
  const ok = got === true || got?.ok === true;
819
+ // AN ACCOUNT THAT ARRIVED HERE IS SHARED ONWARD (#1188). People forget
820
+ // to tick it, and a group where one machine can heal the others and the
821
+ // others can heal nobody is the shape that costs them: the second
822
+ // machine to lose the same login has to go back to the first, which may
823
+ // be asleep or on another network. Nothing new is exposed — the login
824
+ // came FROM the group, so the group has it.
825
+ //
826
+ // ONLY AN ADD, and only from the local network. A heal already needed
827
+ // the tick to happen at all (the filter above), so there is nothing to
828
+ // add for one; and a tailnet reaches further than the person's own
829
+ // machines, which is a decision they make for themselves rather than
830
+ // one an arrival makes for them.
831
+ if (ok && ticksOnArrival(step, viaOf(peer))) {
832
+ try { await onShared?.(step.key); }
833
+ catch { /* the account is here; the tick is retried the next time one arrives */ }
834
+ }
741
835
  done.push({ ...step, ok, why: ok ? null : (got?.why ?? "import failed") });
742
836
  }
743
837
  lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
@@ -745,6 +839,20 @@ export function createEngine({
745
839
  return done;
746
840
  } catch (err) {
747
841
  lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
842
+ // A DIAL-BACK THAT NEVER ANSWERED IS TAKEN AWAY AGAIN. The address came
843
+ // from a paired deck's inbound call, and this round was the test of
844
+ // whether the call can be returned. It could not — a strict NAT, a
845
+ // one-way path — so the row is removed rather than left to fail every
846
+ // minute, and the peer goes back to "calls in". Its next call tries once
847
+ // more. A row that answered has already left calledBack above.
848
+ const at = `${peer.addr}:${peer.port}`;
849
+ if (calledBack.has(at)) {
850
+ calledBack.delete(at);
851
+ manual.delete(at);
852
+ learned.delete(at);
853
+ lastRound.delete(peer.fp);
854
+ onChange?.();
855
+ }
748
856
  return [];
749
857
  } finally {
750
858
  conn?.sock?.destroy();
@@ -967,6 +967,15 @@ export function createSyncServer({
967
967
  if (!frame) return refuse("a sealed frame did not open");
968
968
  handlers?.(frame, {
969
969
  sock, peerFp, key, sealed: !!chan,
970
+ // Where this deck dials the caller BACK. A deck that only ever calls in
971
+ // is one this deck holds no address for, so it could receive nothing —
972
+ // accounts move only toward the deck that dials (see roundWith). The
973
+ // address the caller connected from, and the port it said it listens
974
+ // on, are a dialable pair the engine can add so the next round reaches
975
+ // it. `peerAddr` is the source of this very connection; `peerPort` came
976
+ // from the hello, not the ephemeral source port.
977
+ peerAddr: from(sock),
978
+ peerPort,
970
979
  send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
971
980
  });
972
981
  }, refuse));
@@ -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.