ccdeck 3.32.2 → 3.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +14 -12
  2. package/bin/agent-dag.js +6 -0
  3. package/bin/cli/help.js +2 -0
  4. package/bin/cli/one-shot.js +32 -13
  5. package/bin/cli/second-start.js +1 -0
  6. package/bin/cli/startup.js +34 -0
  7. package/bin/cli/uninstall.js +47 -2
  8. package/bin/deck.js +12 -1
  9. package/dist/web/assets/BrowserWatchModal-BTNOQdp5.js +1 -0
  10. package/dist/web/assets/UsageHistoryModal-CIDpgHTW.js +1 -0
  11. package/dist/web/assets/index-BIANlQ9A.css +1 -0
  12. package/dist/web/assets/index-D7ddxIpC.js +299 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +36 -1
  16. package/src/server/account-projects.mjs +11 -2
  17. package/src/server/args.mjs +12 -2
  18. package/src/server/auto-update.mjs +14 -1
  19. package/src/server/best-of-nostalgia.mjs +2 -1
  20. package/src/server/block-notify.mjs +114 -11
  21. package/src/server/browser-history.mjs +81 -4
  22. package/src/server/browser-presence.mjs +98 -2
  23. package/src/server/browser-react.mjs +39 -4
  24. package/src/server/browser-watch-seen.mjs +35 -1
  25. package/src/server/browser-watch.mjs +105 -18
  26. package/src/server/cafe-music-bgm.mjs +2 -1
  27. package/src/server/ccusage-install.mjs +8 -4
  28. package/src/server/ccusage.mjs +3 -1
  29. package/src/server/claude-accounts.mjs +38 -16
  30. package/src/server/codex-dir.mjs +8 -6
  31. package/src/server/codex-quota.mjs +4 -2
  32. package/src/server/codex-usage.mjs +93 -38
  33. package/src/server/codex-watch.mjs +150 -18
  34. package/src/server/cswap-auto-loop.mjs +15 -2
  35. package/src/server/cswap-auto-readers.mjs +10 -4
  36. package/src/server/cswap-auto.mjs +6 -6
  37. package/src/server/cswap-install.mjs +118 -3
  38. package/src/server/deck-prefs.mjs +4 -0
  39. package/src/server/event-pipeline.mjs +3 -0
  40. package/src/server/event-ring.mjs +2 -2
  41. package/src/server/good-life-radio.mjs +2 -1
  42. package/src/server/index.mjs +3 -2
  43. package/src/server/installer.mjs +62 -12
  44. package/src/server/lan-beacon.mjs +6 -4
  45. package/src/server/lan-call.mjs +4 -0
  46. package/src/server/lan-dials.mjs +10 -1
  47. package/src/server/lan-engine.mjs +221 -44
  48. package/src/server/lan-requests.mjs +88 -5
  49. package/src/server/lan-round-record.mjs +21 -4
  50. package/src/server/lan-socket.mjs +105 -15
  51. package/src/server/lan-sync.mjs +18 -4
  52. package/src/server/lifecycle.mjs +21 -2
  53. package/src/server/live-radio-mix.mjs +2 -1
  54. package/src/server/log-replay.mjs +55 -24
  55. package/src/server/login-service.mjs +38 -3
  56. package/src/server/metrics-run.mjs +5 -3
  57. package/src/server/network-sampler.mjs +25 -2
  58. package/src/server/npm-upgrade.mjs +8 -1
  59. package/src/server/npx.mjs +32 -5
  60. package/src/server/process-list.mjs +103 -13
  61. package/src/server/purge-key.mjs +52 -24
  62. package/src/server/request-gates.mjs +47 -14
  63. package/src/server/retire-sound-hook.mjs +56 -9
  64. package/src/server/ring-bounds.mjs +10 -0
  65. package/src/server/running-deck.mjs +72 -2
  66. package/src/server/session-enrichment.mjs +40 -8
  67. package/src/server/stop-deck.mjs +13 -4
  68. package/src/server/system-metrics.mjs +2 -2
  69. package/src/server/transcript-scan.mjs +27 -6
  70. package/dist/web/assets/BrowserWatchModal-bx_k4o-J.js +0 -1
  71. package/dist/web/assets/UsageHistoryModal-Do8S0UPG.js +0 -1
  72. package/dist/web/assets/index-DKn7nc15.css +0 -1
  73. package/dist/web/assets/index-hGO2DTdi.js +0 -299
@@ -40,8 +40,8 @@
40
40
  // accounts all work talks to its peers every minute and never asks for
41
41
  // anything.
42
42
  import {
43
- addTrusted, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan, stillListed, transferChallenge,
44
- trustedPeer,
43
+ addTrusted, ANNOUNCE_MS, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan, PRESENT_MS,
44
+ stillListed, transferChallenge, trustedPeer,
45
45
  } from "./lan-sync.mjs";
46
46
  import { mintInvite, readInvite } from "./lan-invite.mjs";
47
47
  import { createInviteOffer } from "./lan-invite-offer.mjs";
@@ -81,6 +81,21 @@ const ROUND_MS = 10_000;
81
81
  * lan-dials.mjs, which keeps the list and the cap. */
82
82
  export { MAX_AUTO_PEERS };
83
83
 
84
+ /**
85
+ * The most pairings the accept switch may make on its own (#1737).
86
+ *
87
+ * It had no limit, and a pin is a line in prefs.json and a deck every round
88
+ * dials: one host handshaking with a fresh key each time made sixty of them in
89
+ * well under a second. Only pins a SWITCH made count (`auto`, see addTrusted);
90
+ * a person's accepts are theirs, as a person's typed addresses are.
91
+ *
92
+ * THE SAME NUMBER AS MAX_AUTO_PEERS, because every pin the switch makes brings
93
+ * a dial-back row out of that budget, so a pin past it is a deck this one
94
+ * mostly could not dial back anyway. Thirty-two machines is also well past one
95
+ * person's own fleet, which is who the switch is for.
96
+ */
97
+ export const MAX_AUTO_PINS = MAX_AUTO_PEERS;
98
+
84
99
  /** What this machine calls itself when the user has not said. The hostname,
85
100
  * because that is the word they already use for this machine everywhere else. */
86
101
  export function defaultName() {
@@ -133,9 +148,14 @@ export { anotherMachine };
133
148
  * came from the group and the group therefore has it, and a tailnet can reach
134
149
  * further than one person's own machines. Sharing there stays a decision
135
150
  * somebody makes rather than one an arrival makes for them.
151
+ *
152
+ * AND ONLY FROM A DECK SOMEBODY HERE CHOSE. "The group has it" is true of a
153
+ * group a person put together; a deck the accept switch paired (`pin.auto`,
154
+ * see addTrusted) is one nobody here chose, and what arrives from it is not
155
+ * passed on in this deck's name.
136
156
  */
137
- export function ticksOnArrival(step, via) {
138
- return !!step?.key && step.action === "add" && via !== "tailscale";
157
+ export function ticksOnArrival(step, via, pin = null) {
158
+ return !!step?.key && step.action === "add" && via !== "tailscale" && pin?.auto !== true;
139
159
  }
140
160
 
141
161
  /** The two permissions that answer for one route — see lan-requests.mjs,
@@ -373,9 +393,12 @@ export function createEngine({
373
393
  * or joined on an invite of its own — and take back any earlier unpair of
374
394
  * it, because choosing it again is the undo. The list, and whether the pin
375
395
  * is new.
396
+ *
397
+ * Or one the accept switch chose for them, which `auto` says and the pin
398
+ * keeps — see addTrusted, and roundWith for what such a deck may not do.
376
399
  */
377
- const pin = ({ fp, pub, name }) => {
378
- const { list, added } = addTrusted(cfg.trusted, { fp, pub, name, at: now() });
400
+ const pin = ({ fp, pub, name }, { auto = false } = {}) => {
401
+ const { list, added } = addTrusted(cfg.trusted, { fp, pub, name, at: now(), ...(auto ? { auto: true } : {}) });
379
402
  cfg = { ...cfg, trusted: list };
380
403
  markUnpaired(fp, false);
381
404
  return { list, added };
@@ -400,12 +423,35 @@ export function createEngine({
400
423
  about, now, myFp: () => identity.fp, settings: () => cfg,
401
424
  });
402
425
 
426
+ /**
427
+ * WHERE EACH PAIRED DECK LAST ANSWERED: the address and port of the last
428
+ * round whose handshake with it completed against its pin, by fingerprint.
429
+ *
430
+ * A beacon carries a fingerprint and a port, and nothing binds either to the
431
+ * address it came from — so for a paired deck it may say where to try FIRST
432
+ * and nothing more. It used to be the whole answer: the heard row took the
433
+ * beacon's address, was dialled for a day, and a typed row for the same deck
434
+ * was skipped as dialled already. A deck reached by typed address, which is
435
+ * the usual reason to type one, sends no beacon that would ever correct it.
436
+ * This is what a round falls back to when a heard address fails, and what
437
+ * lets a heard address that did answer stay dialled after its beacons stop.
438
+ */
439
+ const lastGood = new Map();
440
+ const answeredThere = p => {
441
+ const g = lastGood.get(p.fp);
442
+ return !!g && g.addr === p.addr && g.port === p.port;
443
+ };
444
+ /** Whether a round dials this heard row: listed, and either answered at that
445
+ * address or still being announced there. One that never answered is
446
+ * dialled for as long as a deck stays present (PRESENT_MS), not a day. */
447
+ const dialsHeard = p => stillListed(p, now()) && (answeredThere(p) || now() - p.lastSeen < PRESENT_MS);
448
+
403
449
  /** Does this deck already hold an address it dials for `fp`? A beacon row it
404
- * still hears, or a typed/learned row that answered as that deck. When
450
+ * still dials, or a typed/learned row that answered as that deck. When
405
451
  * neither is true, the only way it ever reaches that deck is if the deck
406
452
  * keeps calling — and a called deck is never pulled from. */
407
453
  const dialsAlready = fp => {
408
- if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && stillListed(p, now()))) return true;
454
+ if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && dialsHeard(p))) return true;
409
455
  return dials.answersAs(fp);
410
456
  };
411
457
 
@@ -491,11 +537,58 @@ export function createEngine({
491
537
  onChange?.();
492
538
  };
493
539
 
494
- /** Another deck is using this one's key. Take a new key and keep it. Two
495
- * decks with one identity are invisible to each other forever otherwise,
496
- * and the second one to notice moving is enough — whichever notices
497
- * first, moves. */
498
- const idClash = () => {
540
+ /** The identity a key check is running for, or has already moved away from,
541
+ * and when the last one started — see idClash. */
542
+ let clashFor = null;
543
+ let clashAt = -Infinity;
544
+
545
+ /**
546
+ * Another deck may be using this one's key. When it is, take a new key and
547
+ * keep it. Two decks with one identity are invisible to each other forever
548
+ * otherwise, and the second one to notice moving is enough — whichever
549
+ * notices first, moves.
550
+ *
551
+ * THE BEACON SAYS WHERE TO LOOK, NOT THAT IT IS SO. This deck's fingerprint
552
+ * is in every beacon it sends, so a beacon wearing it is no evidence that
553
+ * anybody else holds the key — and a new key is a stranger to every deck
554
+ * paired with this one, which an invite-only pairing does not recover from
555
+ * without a new invite. What only another holder of the key can do is finish
556
+ * a handshake against this deck's own pin, so the deck dials the address the
557
+ * beacon named and moves only if that handshake completes. Saying it is not
558
+ * asking to pair, so a deck from before its listener answered this is simply
559
+ * not a copy here — and it moves on its own when it hears this deck.
560
+ *
561
+ * NOT ITS OWN LISTENER, which holds the key too and would answer the same
562
+ * way: the challenge on that connection says which one answered.
563
+ *
564
+ * ONE CHECK AT A TIME, AT MOST ONE PER BEACON INTERVAL, AND NONE ONCE IT HAS
565
+ * MOVED. What arrives decides how often this dials, never how much; a real
566
+ * copy announces every ANNOUNCE_MS, so it is looked at by its next beacon at
567
+ * the latest; and after the new key is handed over nothing more is checked
568
+ * until the deck restarts on it.
569
+ */
570
+ const idClash = async ({ addr, port } = {}) => {
571
+ const mine = identity;
572
+ if (!mine || !server || typeof addr !== "string" || !addr || !port) return;
573
+ if (clashFor === mine || now() - clashAt < ANNOUNCE_MS) return;
574
+ clashFor = mine;
575
+ clashAt = now();
576
+ const startedIn = generation;
577
+ let copy = false;
578
+ let conn = null;
579
+ try {
580
+ conn = await connectToPeer({
581
+ host: addr, port, fp: mine.fp, pub: mine.pub, secret: mine.secret, name: cfg.name,
582
+ myPort: server?.port() ?? null, expectPub: mine.pub, ask: false, sealFrames, ephemeral,
583
+ });
584
+ copy = conn.peerFp === mine.fp && startedIn === generation && !server?.issued(conn.peerChallenge);
585
+ } catch { /* nothing there holds this key */ } finally {
586
+ conn?.sock?.destroy();
587
+ }
588
+ if (!copy || startedIn !== generation || identity !== mine) {
589
+ if (clashFor === mine) clashFor = null;
590
+ return;
591
+ }
499
592
  const fresh = identityFrom("");
500
593
  onIdentity?.(fresh.secret);
501
594
  onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
@@ -514,8 +607,9 @@ export function createEngine({
514
607
  arrived.forEach((d, i) => { if (typeof found?.[i] === "string") d.why = found[i]; });
515
608
  };
516
609
 
517
- /** Ask one peer what it has, and heal whatever it can heal. */
518
- const roundWith = async peer => {
610
+ /** Ask one peer what it has, and heal whatever it can heal. `reached` is
611
+ * told the deck's fingerprint once the handshake with it has held. */
612
+ const roundWith = async (peer, reached = null) => {
519
613
  let conn = null;
520
614
  const startedIn = session;
521
615
  // Out here, so a round that dies after some logins arrived still reports
@@ -539,6 +633,13 @@ export function createEngine({
539
633
  });
540
634
  const ask = frame => askOver(conn, frame);
541
635
 
636
+ // THIS DECK'S OWN KEY, ANSWERING — its own listener at an address
637
+ // somebody typed, or another machine holding a copy of the key, which a
638
+ // listener now answers (see idClash). Neither is a deck to pin, ask or
639
+ // take anything from, and a pin of this deck's own key would be one
640
+ // every handler here then trusted.
641
+ if (conn.peerFp === identity.fp) throw new Error("that address answers with this deck's own key");
642
+
542
643
  // WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
543
644
  // and nothing else until somebody answers it — and once one has, the deck
544
645
  // on the other end has told us what it calls itself. The row says that
@@ -599,6 +700,12 @@ export function createEngine({
599
700
  if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
600
701
  }
601
702
 
703
+ // A PAIRED DECK, ANSWERING HERE against its pin — which is what makes
704
+ // this address one to fall back to, and a heard one worth dialling after
705
+ // its beacons stop. See lastGood.
706
+ lastGood.set(conn.peerFp, { addr: peer.addr, port: peer.port, via: viaOf(peer) });
707
+ reached?.(conn.peerFp);
708
+
602
709
  // Our card goes with the question and theirs comes back with the answer
603
710
  // — see lan-about.mjs for why it is here and nowhere earlier.
604
711
  // THE QUESTION CARRIES THIS DECK'S LIST TOO, and which of it this deck is
@@ -627,6 +734,14 @@ export function createEngine({
627
734
  // them, which is the whole of "I do not want to paste blobs any more".
628
735
  // What can reach this is what a deck somebody here pressed accept on
629
736
  // chose to offer.
737
+ //
738
+ // WHICH IS ONLY TRUE OF A DECK SOMEBODY CHOSE. The accept switch presses
739
+ // accept for the owner, so "somebody here pressed accept" is not a
740
+ // premise about a deck it paired: nobody here chose that deck, and the
741
+ // logins this store holds would be whatever it decided to offer. A deck
742
+ // the switch paired is treated as a heal is — what it offers comes in
743
+ // only for an account ticked here — and a person's own press or invite
744
+ // takes the mark off its pin (see addTrusted).
630
745
  // OVER `list`, NOT THE RAW ARRAY. `offered`, which keepManifest runs the
631
746
  // list through above (see lan-manifest.mjs), slices to 50 and type-
632
747
  // filters `key` and `email`; this line read `theirs.accounts` and got
@@ -637,13 +752,14 @@ export function createEngine({
637
752
  // the store lock, while the panel drew 50 and every other paired deck
638
753
  // waited behind it. `step.key` also reached transferChallenge and
639
754
  // importAccount untyped, which `offered`'s filter would have caught.
640
- const wanted = plan(mine, list)
641
- .filter(step => step.action === "add" || cfg.shared.includes(step.key));
755
+ const chosen = () => trustedPeer(cfg.trusted, conn.peerFp)?.auto !== true;
756
+ const takes = step => cfg.shared.includes(step.key) || (step.action === "add" && chosen());
757
+ const wanted = plan(mine, list).filter(takes);
642
758
  // TWO CHECKS, BECAUSE THEY END DIFFERENT THINGS. Losing the session —
643
759
  // LAN switched off, the peer unpaired (`stillPaired`, above) — ends the
644
760
  // round. A heal unticked mid-round ends only that heal: the adds behind
645
761
  // it need no tick, and the skipped row says why rather than vanishing.
646
- const stillWanted = step => step.action !== "heal" || cfg.shared.includes(step.key);
762
+ const stillWanted = takes;
647
763
  /** One login, asked for and opened: a `want` carrying its own proof that
648
764
  * names the account (see transferChallenge), and the `have` opened under
649
765
  * the additional data it was sealed with. The login, or why there is
@@ -705,7 +821,7 @@ export function createEngine({
705
821
  // The store can finish an import after the owner disabled LAN or
706
822
  // revoked this peer. Keep the imported slot, but do not turn it into
707
823
  // a newly shared credential on behalf of an obsolete transfer.
708
- if (ok && stillPaired() && ticksOnArrival(step, viaOf(peer))) {
824
+ if (ok && stillPaired() && ticksOnArrival(step, viaOf(peer), trustedPeer(cfg.trusted, conn.peerFp))) {
709
825
  try { await onShared?.(step.key); }
710
826
  catch { /* the account is here; the tick is retried the next time one arrives */ }
711
827
  }
@@ -761,40 +877,65 @@ export function createEngine({
761
877
  if (!beacon) return [];
762
878
  const startedIn = session;
763
879
  const all = [];
764
- // Heard first, typed second, and a typed one is skipped when the beacon
765
- // already found that address: otherwise a deck that is both would be dialled
766
- // twice a round and its work counted twice.
880
+ // Heard first, typed second, and a typed one is skipped when this round
881
+ // already dialled that address: otherwise a deck that is both would be
882
+ // dialled twice a round and its work counted twice.
767
883
  // The same rule the list uses. A deck that has been silent for a day is not
768
- // dialled once a minute forever on the chance it comes back.
884
+ // dialled once a minute forever on the chance it comes back — and one that
885
+ // never answered at the address it announced, not past PRESENT_MS (see
886
+ // dialsHeard).
769
887
  //
770
888
  // A deck heard over the tailnet is dialled only while that switch is on, and
771
889
  // so is a row the deck added itself from a tailnet beacon. An address a
772
890
  // person typed is theirs whatever the switch says — pairing by a typed
773
891
  // 100.x address worked before any of this.
774
892
  const heard = [...beacon.peers.values()]
775
- .filter(p => stillListed(p, now()) && (cfg.tailscale || p.via !== "tailscale"));
776
- const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
893
+ .filter(p => dialsHeard(p) && (cfg.tailscale || p.via !== "tailscale"));
894
+ /** Every address this round has dialled, and every paired deck it reached
895
+ * by any of them. */
896
+ const tried = new Set();
897
+ const reached = new Set();
898
+ const dial = async peer => {
899
+ tried.add(`${peer.addr}:${peer.port}`);
900
+ all.push(...await roundWith(peer, fp => reached.add(fp)));
901
+ };
902
+ // Sequential rather than parallel. The store takes one mutation at a
903
+ // time anyway (the mutex in store-lock.mjs), and two peers healing the
904
+ // same account at once would race for a slot number claude-swap assigns
905
+ // as max+1 without a lock of its own.
906
+ //
907
+ // And given up when LAN is switched off under it: the rest of the list
908
+ // belongs to no session, and dialling it only delays the next round.
909
+ for (const peer of heard) {
910
+ if (session !== startedIn) return all;
911
+ await dial(peer);
912
+ // A HEARD ADDRESS THAT DID NOT ANSWER, for a deck that answered somewhere
913
+ // else before: that address, in the same round. A beacon says where a
914
+ // deck moved and cannot say it truly — see lastGood. One on the dial list
915
+ // is tried below with the rest of the list; one heard over the tailnet
916
+ // waits for that switch, as its beacon would.
917
+ const back = reached.has(peer.fp) ? null : lastGood.get(peer.fp);
918
+ if (!back || (back.via === "tailscale" && !cfg.tailscale)) continue;
919
+ const at = `${back.addr}:${back.port}`;
920
+ if (tried.has(at) || dials.rows().some(r => `${r.addr}:${r.port}` === at)) continue;
921
+ if (session !== startedIn) return all;
922
+ await dial({ fp: peer.fp, name: peer.name, addr: back.addr, port: back.port, via: back.via });
923
+ }
777
924
  // AND ONE DIAL PER DECK, not one per address. A row asked from a tailnet
778
925
  // beacon keeps that address after the same deck is heard on the local
779
926
  // network, and both used to be dialled every round — two handshakes, and
780
927
  // two lines of work for one machine. `dials.metAt` says which deck a row
781
- // reached; the heard row already dials it by the better route.
782
- const heardFps = new Set(heard.map(p => p.fp));
928
+ // reached; when this round already reached that deck, by the heard route
929
+ // or any other, the row is skipped. When it did not, the row is the next
930
+ // way to it and is dialled — a heard address alone never stands in for it.
783
931
  const typed = dials.rows().filter(p => {
784
932
  const at = `${p.addr}:${p.port}`;
785
- if (seen.has(at) || heardFps.has(dials.metAt(at)?.fp)) return false;
933
+ if (tried.has(at) || reached.has(dials.metAt(at)?.fp)) return false;
786
934
  return cfg.tailscale || p.typed || !routeTo(p.addr);
787
935
  });
788
- for (const peer of [...heard, ...typed]) {
789
- // Sequential rather than parallel. The store takes one mutation at a
790
- // time anyway (the mutex in store-lock.mjs), and two peers healing the
791
- // same account at once would race for a slot number claude-swap assigns
792
- // as max+1 without a lock of its own.
793
- //
794
- // And given up when LAN is switched off under it: the rest of the list
795
- // belongs to no session, and dialling it only delays the next round.
936
+ for (const peer of typed) {
796
937
  if (session !== startedIn) return all;
797
- all.push(...await roundWith(peer));
938
+ await dial(peer);
798
939
  }
799
940
  if (session !== startedIn) return all;
800
941
  lastRound.finished();
@@ -845,12 +986,16 @@ export function createEngine({
845
986
  // heard half brings liveness, the dialled half brings the last round.
846
987
  const rows = [];
847
988
  const byId = new Map();
989
+ /** Of two records of asking one deck, the one written last. */
990
+ const later = (x, y) => (!x ? y : !y ? x : (y.at ?? 0) >= (x.at ?? 0) ? y : x);
848
991
  const put = row => {
849
992
  const had = byId.get(row.id);
850
993
  if (!had) { byId.set(row.id, row); rows.push(row); return; }
851
- // Keep what each half is the authority on.
994
+ // Keep what each half is the authority on — and the LATER ask of the
995
+ // two, which is how the deck is now: a round that could not reach it by
996
+ // its heard address and reached it by the typed one wrote both.
852
997
  had.lastSeen = had.lastSeen ?? row.lastSeen;
853
- had.last = had.last ?? row.last;
998
+ had.last = later(had.last, row.last);
854
999
  had.manual = had.manual || row.manual;
855
1000
  had.met = had.met || row.met;
856
1001
  if (row.name && !had.name) had.name = row.name;
@@ -862,6 +1007,9 @@ export function createEngine({
862
1007
  const card = id => ({
863
1008
  ...heardOf(id),
864
1009
  pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
1010
+ // And whether a switch said that yes rather than a person, which is
1011
+ // what the dialog needs to say which logins will not arrive from it.
1012
+ ...(trustedPeer(cfg.trusted, id)?.auto ? { autoPaired: true } : {}),
865
1013
  });
866
1014
  for (const p of [...beacon.peers.values(), ...dials.rows()]) {
867
1015
  if (!stillListed(p, now())) continue;
@@ -976,7 +1124,16 @@ export function createEngine({
976
1124
  // From here the socket is accepting, so this is the moment the silence
977
1125
  // starts being about the network rather than about a deck still starting.
978
1126
  inbound.listening();
979
- if (port !== cfg.port) onPort?.(port);
1127
+ // AND THE NEXT RESTART PREFERS IT (#1740). The port is the engine's own
1128
+ // field, as the key is: prefs hands it over once per boot (see
1129
+ // lanApplyFields), so without this every later restart in the process
1130
+ // preferred the boot's value — 0 on a first run, which is a fresh random
1131
+ // port for each rename or off-and-on, and the old busy port after one
1132
+ // that moved — and addresses saved on other decks stopped answering.
1133
+ if (port !== cfg.port) {
1134
+ cfg = { ...cfg, port };
1135
+ onPort?.(port);
1136
+ }
980
1137
  const startingBeacon = createBeacon({
981
1138
  port, name: cfg.name, fp: identity.fp,
982
1139
  trusted: () => cfg.trusted,
@@ -1128,7 +1285,19 @@ export function createEngine({
1128
1285
  onChange?.();
1129
1286
  return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
1130
1287
  }
1131
- const { list, added } = pin({ fp, pub: seen.pub, name: seen.name });
1288
+ // THE SWITCH HAS A BUDGET OF ITS OWN (#1737) — see MAX_AUTO_PINS.
1289
+ //
1290
+ // REFUSED RATHER THAN MAKING ROOM, which is the opposite of the dial
1291
+ // list's choice and for a reason. Evicting a dial row the deck added
1292
+ // undoes nothing anybody chose; evicting a pin is an unpair nobody asked
1293
+ // for, of a deck that may be one of the owner's own machines — and which
1294
+ // pins ever finished a round is known only in memory, so after a restart
1295
+ // there is no telling. Past the cap the switch simply stops pressing,
1296
+ // and the request waits in the panel for a person, as it would with the
1297
+ // switch off.
1298
+ const bySwitch = cfg.trusted.filter(t => t?.auto === true).length;
1299
+ if (!byHand && !trustedPeer(cfg.trusted, fp) && bySwitch >= MAX_AUTO_PINS) return null;
1300
+ const { list, added } = pin({ fp, pub: seen.pub, name: seen.name }, { auto: !byHand });
1132
1301
  requests.drop(fp);
1133
1302
  onTrust?.(list);
1134
1303
  onChange?.();
@@ -1140,9 +1309,16 @@ export function createEngine({
1140
1309
  // port it listens on for exactly this. AND KEPT, through onDial, as an
1141
1310
  // invite's is (#1643): `addPeer` alone lives in memory, and the next
1142
1311
  // settings write or restart made the pairing one-way again.
1143
- const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port)
1312
+ //
1313
+ // ONLY A PERSON'S ACCEPT IS KEPT, OR COUNTED AS TYPED (#1737). The row
1314
+ // the switch leaves is one the deck added itself, the way a beacon's is:
1315
+ // capped by MAX_AUTO_PEERS, in memory only, and never written into prefs
1316
+ // as an address somebody named — the port in a hello is whatever the
1317
+ // caller claimed. After a restart such a deck is reached as any paired
1318
+ // deck is: by its beacon, or by calling in (see learnCaller).
1319
+ const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port, { typed: byHand })
1144
1320
  ? (dials.meet(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
1145
- onDial?.(`${seen.addr}:${seen.port}`),
1321
+ byHand && onDial?.(`${seen.addr}:${seen.port}`),
1146
1322
  { addr: seen.addr, port: seen.port })
1147
1323
  : null;
1148
1324
  return added ? { fp, name: seen.name, addr: seen.addr, port: seen.port ?? null, dialBack: back } : null;
@@ -1157,6 +1333,7 @@ export function createEngine({
1157
1333
  const list = dropTrusted(cfg.trusted, fp);
1158
1334
  if (list.length === cfg.trusted.length) return false;
1159
1335
  cfg = { ...cfg, trusted: list };
1336
+ lastGood.delete(fp);
1160
1337
  markUnpaired(fp, true);
1161
1338
  onTrust?.(list);
1162
1339
  onChange?.();
@@ -1207,7 +1384,7 @@ export function createEngine({
1207
1384
  */
1208
1385
  async roundOne(fp) {
1209
1386
  if (!beacon || typeof fp !== "string" || !fp) return null;
1210
- const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
1387
+ const heard = [...beacon.peers.values()].find(p => p.fp === fp && dialsHeard(p));
1211
1388
  const typed = dials.rowAnswering(fp);
1212
1389
  const peer = heard ?? typed;
1213
1390
  if (!peer) return null;
@@ -5,7 +5,44 @@
5
5
  // key, or putting an address on the dial list — stays the engine's accept;
6
6
  // this decides who is on which list and when that accept is pressed.
7
7
 
8
- import { pairable } from "./lan-sync.mjs";
8
+ import { pairable, PRESENT_MS } from "./lan-sync.mjs";
9
+
10
+ /**
11
+ * The most decks the heard list keeps at once (#1738).
12
+ *
13
+ * A beacon is one UDP packet, its source address can be anything, and every
14
+ * new fingerprint used to be a row kept for as long as the process lived: 52 MB
15
+ * of heap for 200,000 forged ones, and every panel poll sorting all of them.
16
+ * The panel draws eight (see pairable) and collapses one machine's keys into
17
+ * one row, so a few hundred is room for an office of decks each restarted a
18
+ * few times inside the presence window, and small enough that the sort every
19
+ * poll does is nothing. The deck heard longest ago goes first; a real one is
20
+ * heard again within thirty seconds and comes straight back.
21
+ */
22
+ export const MAX_STRANGERS = 256;
23
+
24
+ /**
25
+ * The most requests kept at once (#1738).
26
+ *
27
+ * Each is a row in the panel with an accept on it, and all of them are drawn:
28
+ * one host handshaking with fresh keys put two hundred there in a second. The
29
+ * same thirty-two the accept switch may pin (MAX_AUTO_PINS in lan-engine.mjs),
30
+ * which is well past one person's own machines and still a list somebody can
31
+ * read. The request asked longest ago goes first — a deck that is really
32
+ * waiting asks again every round, so it is never the one that has.
33
+ */
34
+ export const MAX_PENDING = 32;
35
+
36
+ /**
37
+ * How long a request stands after its deck last asked (#1738).
38
+ *
39
+ * A deck waiting on an answer dials again every round — every few seconds while
40
+ * it waits (ASKING_MS), once a minute at the slowest (SYNC_MS) — and each dial
41
+ * that finds no pin here asks again. Ten minutes is ten of its slowest rounds
42
+ * without a word, which is a deck that stopped asking; if it starts again it
43
+ * is a request again on its next dial.
44
+ */
45
+ export const REQUEST_MS = 10 * 60_000;
9
46
 
10
47
  /**
11
48
  * The two permissions that answer for one route.
@@ -62,6 +99,26 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
62
99
  */
63
100
  const declined = new Map();
64
101
 
102
+ /**
103
+ * Take off both lists what has stopped being true (#1738): a heard deck quiet
104
+ * for longer than presence lasts — the window strangerRows already drew by,
105
+ * so this forgets only what nobody could see — and a request whose deck has
106
+ * not asked for REQUEST_MS. Run before each list is written or read, so what
107
+ * is kept and what is shown agree.
108
+ *
109
+ * The heard list is kept in the order decks were last heard (heardStranger
110
+ * moves a deck to the end each time), so its quiet ones are at the front and
111
+ * this stops at the first that is not. The requests are few enough to walk.
112
+ */
113
+ const forget = () => {
114
+ const t = now();
115
+ for (const [fp, p] of strangers) {
116
+ if (t - (p.at ?? 0) <= PRESENT_MS) break;
117
+ strangers.delete(fp);
118
+ }
119
+ for (const [fp, p] of pending) if (t - (p.lastAt ?? p.at ?? 0) > REQUEST_MS) pending.delete(fp);
120
+ };
121
+
65
122
  /**
66
123
  * A real deck nobody here has accepted: draw it as a row with an accept on it.
67
124
  *
@@ -85,7 +142,17 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
85
142
  const askToAccept = entry => {
86
143
  const cfg = settings();
87
144
  if (cfg.pairingMode === "invite" || declined.has(entry.fp)) return;
145
+ forget();
88
146
  const had = pending.get(entry.fp);
147
+ // A NEW ONE PAST THE CAP MAKES ROOM (#1738) — see MAX_PENDING. The one
148
+ // asked longest ago goes, not the newcomer: refusing new ones would let
149
+ // whoever filled the list first keep it, and a real deck asking later
150
+ // would never be drawn.
151
+ if (!had && pending.size >= MAX_PENDING) {
152
+ let oldest = null;
153
+ for (const [fp, p] of pending) if (!oldest || p.lastAt < oldest[1].lastAt) oldest = [fp, p];
154
+ if (oldest) pending.delete(oldest[0]);
155
+ }
89
156
  // WHICH SWITCH ANSWERS depends on where the deck is. A request from the
90
157
  // tailnet is answered by the Tailscale pair, and only for a machine on this
91
158
  // person's own Tailscale account; anything else on the tailnet waits for a
@@ -94,9 +161,11 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
94
161
  const via = route ? "tailscale" : "lan";
95
162
  const own = !!route?.own;
96
163
  pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
164
+ // An accept the switch pressed that did not pin — the switch's own budget
165
+ // is spent, see MAX_AUTO_PINS in lan-engine.mjs — leaves the request as it
166
+ // would be with the switch off: a row, drawn, for somebody to answer.
97
167
  if (!wasUnpaired(entry.fp) && saysYesOn(cfg, via) && (via === "lan" || own)) {
98
- engineNow()?.accept(entry.fp, { byHand: false });
99
- return;
168
+ if (engineNow()?.accept(entry.fp, { byHand: false })) return;
100
169
  }
101
170
  if (!had) onChange?.();
102
171
  };
@@ -105,6 +174,7 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
105
174
  * can accept, and — when the ask switch for its route is on — asked. */
106
175
  const heardStranger = entry => {
107
176
  const cfg = settings();
177
+ forget();
108
178
  const had = strangers.get(entry.fp);
109
179
  // KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
110
180
  // a fresh key — a second deck sharing one config directory does, by
@@ -112,7 +182,16 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
112
182
  // one of them drew a row offering to pair with the same machine.
113
183
  if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
114
184
  const own = entry.via === "tailscale" && !!routeTo(entry.addr)?.own;
185
+ // To the END, so the list stays in the order decks were last heard — which
186
+ // is what lets `forget` stop at the first one still present, and what
187
+ // makes the first one here the one to drop when the list is full.
188
+ strangers.delete(entry.fp);
115
189
  strangers.set(entry.fp, { ...entry, own });
190
+ // AND NEVER PAST ITS SIZE (#1738) — see MAX_STRANGERS.
191
+ for (const fp of strangers.keys()) {
192
+ if (strangers.size <= MAX_STRANGERS) break;
193
+ strangers.delete(fp);
194
+ }
116
195
  // ASK IT, which is what the `ask` verb on its row does and nothing
117
196
  // more: the address goes on the dial list and the next round sends a
118
197
  // request that somebody over there still has to answer. A beacon
@@ -171,6 +250,7 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
171
250
 
172
251
  /** The heard decks status() offers to pair with — see its `strangers`. */
173
252
  const strangerRows = () => {
253
+ forget();
174
254
  // A deck that was told no is not somebody to offer pairing with. It
175
255
  // has its own row, with the one control that undoes the decision.
176
256
  const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
@@ -193,7 +273,7 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
193
273
  isDeclined: fp => declined.has(fp),
194
274
  /** The row a deck is waiting on: its request when it asked, its beacon
195
275
  * when it was only heard, or null. */
196
- seen: fp => (pending.get(fp) ?? null) ?? (strangers.get(fp) ?? null),
276
+ seen: fp => { forget(); return (pending.get(fp) ?? null) ?? (strangers.get(fp) ?? null); },
197
277
  /** It was answered: off both lists. */
198
278
  drop(fp) {
199
279
  pending.delete(fp);
@@ -242,7 +322,10 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
242
322
  return had;
243
323
  },
244
324
  /** The rows status() draws for each list. */
245
- pendingRows: () => [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at, via: p.via ?? "lan", own: !!p.own })),
325
+ pendingRows: () => {
326
+ forget();
327
+ return [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at, via: p.via ?? "lan", own: !!p.own }));
328
+ },
246
329
  strangerRows,
247
330
  declinedRows: () => [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
248
331
  };
@@ -15,6 +15,11 @@ export function createRoundRecord({ now }) {
15
15
  * pressed it wants to know it happened, and a reader who did not wants to
16
16
  * know the list is not a photograph of an hour ago. */
17
17
  let roundAt = null;
18
+ /** The rows the last whole round asked, and the rows asked since it — the
19
+ * only ones whose line still says anything about who is deciding. See
20
+ * waitingOnSomebody. */
21
+ let asked = new Set();
22
+ let asking = new Set();
18
23
 
19
24
  /**
20
25
  * Is anybody on the other end still deciding?
@@ -22,9 +27,17 @@ export function createRoundRecord({ now }) {
22
27
  * The exact sentence lan-call.mjs gives for "a real deck, not yet
23
28
  * accepted", which is the one state where dialling again in a few seconds
24
29
  * does something a minute later would not.
30
+ *
31
+ * AND ONLY OF A ROW A ROUND STILL ASKS (#1742). A line stays here after its
32
+ * row is gone — removed in the panel, dropped by a settings write, evicted
33
+ * by the cap, or no longer dialled at all — and nothing asks that row again
34
+ * to overwrite it, so one that said "waiting" kept every round eight seconds
35
+ * apart until the deck restarted. What the last whole round asked is the
36
+ * list the next one dials, give or take the row somebody just changed; a
37
+ * row taken off costs one more quick round, never the rest of the process.
25
38
  */
26
39
  const waitingOnSomebody = () =>
27
- [...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
40
+ [...asked, ...asking].some(fp => lastRound.get(fp)?.error === "waiting for the other deck to accept this one");
28
41
 
29
42
  return {
30
43
  /** What the last ask of a row did, or undefined. Keyed by the ROW the
@@ -34,11 +47,15 @@ export function createRoundRecord({ now }) {
34
47
  /** An ask of that row finished, well or not, and this is what it did. The
35
48
  * record is kept as it is handed over, so the same object before and
36
49
  * after a wait means nothing asked that row in between — see roundOne. */
37
- keep(fp, record) { lastRound.set(fp, record); },
50
+ keep(fp, record) { lastRound.set(fp, record); asking.add(fp); },
38
51
  /** Nothing of that row is drawn any more: a dial-back taken away. */
39
52
  drop(fp) { lastRound.delete(fp); },
40
- /** A whole round finished — see roundAt. */
41
- finished() { roundAt = now(); },
53
+ /** A whole round finished — see roundAt, and asked. */
54
+ finished() {
55
+ roundAt = now();
56
+ asked = asking;
57
+ asking = new Set();
58
+ },
42
59
  checkedAt: () => roundAt,
43
60
  waitingOnSomebody,
44
61
  };