ccdeck 3.29.11 → 3.30.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 (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +22 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  175. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  176. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -1,15 +1,27 @@
1
1
  // The thing that actually heals an account: the beacon, the listener and
2
2
  // claude-swap, wired together.
3
3
  //
4
- // The two files under this one hold everything that can be reasoned about
5
- // without a network — lan-sync.mjs decides, lan-socket.mjs carries — and what
6
- // is left here is the part that has to touch the store. It is deliberately the
7
- // smallest of the three.
4
+ // The files under this one hold everything that can be reasoned about without
5
+ // a network — lan-sync.mjs and lan-invite.mjs decide, lan-beacon.mjs and
6
+ // lan-socket.mjs carry — and what is left here is the part that has to touch
7
+ // the store. The engine's own state lives beside it, each piece behind named
8
+ // operations createEngine calls: the decks waiting on somebody here and when a
9
+ // switch answers them (lan-requests.mjs), the addresses it dials and what
10
+ // answered at each (lan-dials.mjs), what a manifest says and what is kept of
11
+ // one heard (lan-manifest.mjs), the store's rows in the rules' shape
12
+ // (lan-accounts.mjs), the line every round waits in (lan-turns.mjs), the
13
+ // clock that asks for the next one (lan-round-timer.mjs) and what the last one
14
+ // did (lan-round-record.mjs), who has called this deck (lan-inbound.mjs), the
15
+ // invite on offer (lan-invite-offer.mjs), when the tailnet is read
16
+ // (lan-tailnet-poll.mjs), and what a deck that cannot hear says
17
+ // (lan-hearing.mjs). What it answers a paired deck that asks lives in
18
+ // lan-serve.mjs; the asking — a round, and joining on an invite — is here.
8
19
  //
9
20
  // WHAT ONE ROUND LOOKS LIKE, from a deck whose copy of an account has died:
10
21
  //
11
- // 1. it hears a beacon from a deck with the same group tag
12
- // 2. it dials that deck's sync port and both sides prove the passphrase
22
+ // 1. it hears a beacon from a deck somebody here has accepted
23
+ // 2. it dials that deck's sync port and each side proves the key the other
24
+ // pinned for it
13
25
  // 3. it asks for a manifest: which accounts, and does each one work THERE
14
26
  // 4. `plan()` says "heal a@@1" — mine is quarantined, theirs is alive
15
27
  // 5. it asks for that one account, with a fresh proof naming it
@@ -27,34 +39,33 @@
27
39
  // something in it, which is when an account is actually broken. A deck whose
28
40
  // accounts all work talks to its peers every minute and never asks for
29
41
  // anything.
30
- import { accountKey, currentFor, manifestFor, onePerKey, open, peerWhy, plan, seal, SENDER_UNREADABLE, slotFor, stillListed, transferChallenge } from "./lan-sync.mjs";
31
- import { storedCopyAlive, cachedExportReadable, liveLoginIs } from "./account-health.mjs";
32
- import { connectToPeer, createBeacon, createSyncServer, DISCOVERY_PORT, MAX_FRAME_BYTES } from "./lan-socket.mjs";
33
- import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
34
- import { openAbout, sealAbout } from "./lan-about.mjs";
35
- import { beaconTargets, routeOf, IDLE_MS as TAILNET_IDLE_MS, TAILNET_MS } from "./tailscale.mjs";
42
+ import {
43
+ addTrusted, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan, stillListed, transferChallenge,
44
+ trustedPeer,
45
+ } from "./lan-sync.mjs";
46
+ import { mintInvite, readInvite } from "./lan-invite.mjs";
47
+ import { createInviteOffer } from "./lan-invite-offer.mjs";
48
+ import { syncAccounts } from "./lan-accounts.mjs";
49
+ import { createBeacon } from "./lan-beacon.mjs";
50
+ import { connectToPeer, createSyncServer, MAX_FRAME_BYTES } from "./lan-socket.mjs";
51
+ import { createTurns } from "./lan-turns.mjs";
52
+ import { ASKING_MS, createRoundTimer, SYNC_MS } from "./lan-round-timer.mjs";
53
+ import { createRoundRecord } from "./lan-round-record.mjs";
54
+ import { beaconTargets, routeOf, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
55
+ import { createTailnetPoll } from "./lan-tailnet-poll.mjs";
56
+ import { createHearing } from "./lan-hearing.mjs";
57
+ import { anotherMachine, createInbound } from "./lan-inbound.mjs";
58
+ import { createServe } from "./lan-serve.mjs";
59
+ import { createDials, MAX_AUTO_PEERS } from "./lan-dials.mjs";
60
+ import { createManifests } from "./lan-manifest.mjs";
61
+ import { asksOn, createRequests, saysYesOn } from "./lan-requests.mjs";
36
62
  import { randomBytes } from "node:crypto";
37
63
  import { hostname, networkInterfaces } from "node:os";
38
64
 
39
- /** How often a deck asks its peers what they have. A minute is far more often
40
- * than a login dies, and it is what makes the panel's list feel live rather
41
- * than something that updates when you press a button. */
42
- export const SYNC_MS = 60_000;
43
-
44
- /**
45
- * How long to wait before dialling again while somebody is deciding.
46
- *
47
- * A minute is right for the steady state — two decks whose logins all work have
48
- * nothing to say to each other — and it is far too long for the one moment
49
- * anybody is watching: the seconds after somebody presses accept on the other
50
- * machine. Reported as "it should work by itself", from a panel that had been
51
- * correct for up to fifty-nine more seconds than the person in front of it.
52
- *
53
- * So the loop tightens while a request is outstanding and relaxes the moment it
54
- * is answered — either way. A refusal is an answer, and a deck that said no is
55
- * not asked every eight seconds.
56
- */
57
- export const ASKING_MS = 8_000;
65
+ /** How often a deck asks its peers what they have, and how soon it asks again
66
+ * while somebody is deciding — see lan-round-timer.mjs, which keeps the
67
+ * clock that picks between them. */
68
+ export { ASKING_MS, SYNC_MS };
58
69
 
59
70
  /** How long to wait before trying the discovery port again while another
60
71
  * program holds it. The beacon's own interval: a port that frees up is picked
@@ -66,24 +77,9 @@ export const BIND_RETRY_MS = 30_000;
66
77
  * going to answer, and holding the attempt open would stall the next round. */
67
78
  const ROUND_MS = 10_000;
68
79
 
69
- /**
70
- * The most addresses `autoAsk` may put on the dial list on its own.
71
- *
72
- * Measured, because the shape of it is not the obvious one: the dial list is
73
- * keyed `host:port`, not by fingerprint, so five hundred beacons from one
74
- * address on one port make one row. Five hundred beacons from one address on
75
- * five hundred PORTS make five hundred rows, and a round dials them one at a
76
- * time with a ROUND_MS bell on each — so a list that size is eighty minutes of
77
- * round, and the decks somebody actually paired with sit at the end of it
78
- * waiting their turn. The ceiling on that without this number is 65,535 rows
79
- * from a single host.
80
- *
81
- * It bounds only what the deck added BY ITSELF. Addresses a person typed are
82
- * not capped: a list of those is somebody's own decision and the deck is in no
83
- * position to tell them they have too many machines.
84
- */
85
- export const MAX_AUTO_PEERS = 32;
86
-
80
+ /** The most addresses `autoAsk` may put on the dial list on its own — see
81
+ * lan-dials.mjs, which keeps the list and the cap. */
82
+ export { MAX_AUTO_PEERS };
87
83
 
88
84
  /** What this machine calls itself when the user has not said. The hostname,
89
85
  * because that is the word they already use for this machine everywhere else. */
@@ -121,86 +117,10 @@ export function localAddresses(faces = networkInterfaces()) {
121
117
  return out;
122
118
  }
123
119
 
124
- /**
125
- * Did this connection come from a DIFFERENT computer?
126
- *
127
- * The question behind "can other decks reach this one": a socket accepted from
128
- * loopback, or from one of this machine's own addresses, is the second deck on
129
- * this computer talking to the first — which happens on every developer machine
130
- * and proves nothing at all about the network. Both spellings of loopback are
131
- * named because both arrive: `127.0.0.1` from a deck that dialled an address
132
- * and `::1` from one that dialled a name.
133
- *
134
- * Pure and separate from the engine so the case that matters — a connection
135
- * from somewhere else — can be tested without a second machine.
136
- */
137
- export const anotherMachine = (from, mine = []) => {
138
- const at = String(from ?? "").replace(/^::ffff:/, "").trim();
139
- return !!at && at !== "127.0.0.1" && at !== "::1" && !mine.includes(at);
140
- };
141
-
142
- /**
143
- * The accounts a peer's manifest listed, as the panel may keep them.
144
- *
145
- * It arrived from another machine, so it is read rather than trusted: strings
146
- * where strings belong, a boolean for the verdict, and no more rows than a
147
- * manifest may carry. What is kept is only what the deck's dialog draws.
148
- */
149
- /** One peer-supplied string as the panel may draw it: no control or format
150
- * characters, whitespace collapsed, bounded. The rule cleanName applies to a
151
- * deck's name and lan-about's `field` to a card, applied to the two strings
152
- * that sit next to the fingerprint in the import dialog. */
153
- function flatten(v, max) {
154
- if (typeof v !== "string") return "";
155
- const flat = v.replace(/\p{Cc}/gu, " ").replace(/\p{Cf}/gu, "").replace(/\s+/g, " ").trim();
156
- return [...flat].slice(0, max).join("");
157
- }
158
-
159
- export function offered(list) {
160
- return onePerKey((Array.isArray(list) ? list : [])
161
- .filter(a => a && typeof a.key === "string" && typeof a.email === "string")
162
- // Character-filtered, not merely cut. These two are drawn beside the
163
- // fingerprint at the moment the operator picks which of a peer's logins to
164
- // import (LanPeerModal.tsx:497), and a bare slice let a format character
165
- // through — the same class cleanName strips from the name one frame over.
166
- .map(a => ({
167
- key: flatten(a.key, 320),
168
- email: flatten(a.email, 254),
169
- alive: storedCopyAlive(a.alive, a.collector),
170
- // Additive wire field: an older peer omitted it, which means shareable.
171
- ...(a.shareable === false ? { shareable: false } : {}),
172
- }))
173
- .filter(a => a.key && a.email))
174
- // Fifty IDENTITIES, so the cap is spent after onePerKey rather than before
175
- // it: a peer with duplicate slots could otherwise push a live copy past
176
- // row fifty and out of the list while offering far fewer than fifty
177
- // logins. The raw array is already bounded by MAX_FRAME_BYTES.
178
- .slice(0, 50);
179
- }
120
+ /** Did this connection come from a different computer — see lan-inbound.mjs,
121
+ * where the record it decides is kept. */
122
+ export { anotherMachine };
180
123
 
181
- /**
182
- * Which account a peer said it is on, as the panel may keep it: the key of one
183
- * of the accounts it listed in the same frame, that its owner is hiding it, or
184
- * that it is on one it does not share — and nothing for anything else. A key
185
- * that is not in its own list is dropped rather than drawn: a deck only ever
186
- * names an account it shares, and one that names another is saying something
187
- * this deck will not show.
188
- */
189
- export function heardCurrent(raw, list) {
190
- if (!raw || typeof raw !== "object") return null;
191
- if (raw.hidden === true) return { hidden: true };
192
- if (raw.other === true) return { other: true };
193
- if (typeof raw.key !== "string") return null;
194
- return list.some(a => a.key === raw.key) ? { key: raw.key } : null;
195
- }
196
-
197
- /**
198
- * One deck's LAN sync, from settings to a healed account.
199
- *
200
- * `deps` is every side effect: reading accounts, exporting one, importing one.
201
- * Injected rather than imported so a test can run a whole round — two engines,
202
- * two fake stores, one real socket pair — without claude-swap on the machine.
203
- */
204
124
  /**
205
125
  * Whether an account this round just placed is ticked for sharing here (#1188).
206
126
  *
@@ -218,12 +138,99 @@ export function ticksOnArrival(step, via) {
218
138
  return !!step?.key && step.action === "add" && via !== "tailscale";
219
139
  }
220
140
 
141
+ /** The two permissions that answer for one route — see lan-requests.mjs,
142
+ * where the requests they answer are kept. */
143
+ export { asksOn, saysYesOn };
144
+
145
+ /**
146
+ * One question to a deck over a connection connectToPeer opened, and the one
147
+ * frame that answers it: read off the socket, opened through the connection's
148
+ * own reader, and refused as a sentence when the socket closes, fails, runs
149
+ * past the frame cap or goes quiet for ROUND_MS.
150
+ */
151
+ function askOver(conn, frame) {
152
+ // A SECOND READER ON THE SAME SOCKET, AND IT HAS TO KEEP THE SAME CAP.
153
+ //
154
+ // lan-lines.mjs states the rule, on frameReader and MAX_FRAME_BYTES: "a
155
+ // peer that sends a megabyte with no newline in it is not sending a large
156
+ // frame, it is sending nothing at all, expensively... the buffer is
157
+ // ABANDONED rather than grown past it." frameReader enforces it; this
158
+ // reader did not.
159
+ //
160
+ // The frameReader connectToPeer installed IS still attached and does hit
161
+ // MAX_FRAME_BYTES — but it only sets its own flag and calls `fail`, which
162
+ // short-circuits on `settled`, so nothing destroys the socket. This
163
+ // buffer then grew unbounded for the full ROUND_MS at line rate. The
164
+ // reject path also never removed the listener; only roundWith's
165
+ // `finally { conn?.sock?.destroy(); }` stopped it.
166
+ return new Promise((resolve, reject) => {
167
+ let buf = "";
168
+ let settled = false;
169
+ const give = (fn, arg) => {
170
+ if (settled) return;
171
+ settled = true;
172
+ clearTimeout(bell);
173
+ conn.sock.off("data", onData);
174
+ conn.sock.off("close", onClose);
175
+ conn.sock.off("error", onError);
176
+ fn(arg);
177
+ };
178
+ const onClose = () => give(reject, new Error("peer closed the connection"));
179
+ // The errno rides along in the sentence, because faultText reads it
180
+ // there: ECONNRESET is "it hung up" on the row, and a bare "peer
181
+ // connection failed" was the one fault the panel could only repeat.
182
+ const onError = err => give(reject, new Error(`peer connection failed${err?.code ? ` (${err.code})` : ""}`));
183
+ const onData = chunk => {
184
+ buf += chunk;
185
+ if (buf.length > MAX_FRAME_BYTES) {
186
+ conn.sock.destroy();
187
+ return give(reject, new Error("frame too large"));
188
+ }
189
+ const i = buf.indexOf("\n");
190
+ if (i === -1) return;
191
+ let parsed;
192
+ try { parsed = JSON.parse(buf.slice(0, i)); } catch { return give(reject, new Error("bad reply")); }
193
+ // THROUGH THE CONNECTION'S OWN READER, and on a sealed connection
194
+ // that is the only way in. A reply that does not open is not a reply
195
+ // with something wrong in it; it is a connection that stopped being
196
+ // the one the handshake proved — altered, replayed, or plain where
197
+ // both ends agreed to seal — so the round ends here instead of
198
+ // reading it as it stands. See frameChannel.
199
+ const got = conn.read(parsed);
200
+ if (conn.sealed && !got) {
201
+ conn.sock.destroy();
202
+ return give(reject, new Error("a reply from that deck did not open"));
203
+ }
204
+ give(resolve, got);
205
+ };
206
+ const bell = setTimeout(() => give(reject, new Error("peer went quiet")), ROUND_MS);
207
+ bell.unref?.();
208
+ conn.sock.on("data", onData);
209
+ conn.sock.on("close", onClose);
210
+ conn.sock.on("error", onError);
211
+ // And out through its own writer, which seals whenever the reader opens.
212
+ // A dead socket cannot throw here — sendFrame swallows a failed write,
213
+ // and `close`/`error` above are what report it. What CAN throw is the
214
+ // seal running out of counter, which is this deck's limit and not a
215
+ // network drop, so it is rejected under its own name.
216
+ if (conn.sock.destroyed) return onClose();
217
+ try { conn.send(frame); } catch (err) { give(reject, err); }
218
+ });
219
+ }
220
+
221
+ /**
222
+ * One deck's LAN sync, from settings to a healed account.
223
+ *
224
+ * `deps` is every side effect: reading accounts, exporting one, importing one.
225
+ * Injected rather than imported so a test can run a whole round — two engines,
226
+ * two fake stores, one real socket pair — without claude-swap on the machine.
227
+ */
221
228
  export function createEngine({
222
229
  readAccounts, exportAccount, importAccount, checkArrivals, liveLogin,
223
230
  onChange, onError, onIdentity, onPort, onTrust, onUnpaired, onDial, onShared, now = Date.now,
224
231
  /**
225
232
  * The UDP socket the beacon shouts through, injectable for the same reason
226
- * lan-socket exposes it — and for one more that only showed up in use.
233
+ * lan-beacon exposes it — and for one more that only showed up in use.
227
234
  *
228
235
  * The suite runs whole engines over real sockets, which is right: a handshake
229
236
  * between two of them is the thing being tested and a mock would only check
@@ -238,13 +245,13 @@ export function createEngine({
238
245
  * paired deck and to nobody else. See lan-about.mjs. */
239
246
  about = null,
240
247
  /** Whether this deck seals every frame after the handshake with a deck that
241
- * says it does too — see frameChannel in lan-sync.mjs. Nothing in the deck
248
+ * says it does too — see frameChannel in lan-wire.mjs. Nothing in the deck
242
249
  * turns it off; the suite does, to play a deck from before #810, which is
243
250
  * the only way to show that one still heals. */
244
251
  sealFrames = true,
245
252
  /** Whether this deck mixes a key pair made for each connection into that
246
253
  * connection's key, with a deck that says it does too — see sessionKey in
247
- * lan-sync.mjs. Nothing in the deck turns this off either; the suite does,
254
+ * lan-wire.mjs. Nothing in the deck turns this off either; the suite does,
248
255
  * to play a deck of #810's version, which seals and does not mix. It rides
249
256
  * on `sealFrames`: with that off, this deck says neither. */
250
257
  ephemeral = true,
@@ -291,66 +298,20 @@ export function createEngine({
291
298
  let identity = null;
292
299
  let beacon = null;
293
300
  let server = null;
294
- let timer = null;
295
301
  /** This engine, for the helpers below `apply` that need to press its own
296
- * accept — see askToAccept. Set on the first apply, which is the only thing
297
- * that can start a round or a listener, so nothing reads it before it is
298
- * there. */
302
+ * accept or add its own peer — the requests (see lan-requests.mjs, which
303
+ * reach it through engineNow), and the callbacks apply hands the listener
304
+ * and the beacon. Set on the first apply, which is the only
305
+ * thing that can start a round or a listener, so nothing reads it before it
306
+ * is there. */
299
307
  let engine = null;
300
- /**
301
- * Decks that finished a handshake and that nobody here has accepted yet, and
302
- * decks merely heard shouting on the network. Two lists because they are two
303
- * different claims: a pending deck proved it holds the key it announced, a
304
- * heard one only said so. Both are rows with an accept on them; only the
305
- * first is evidence.
306
- *
307
- * In memory rather than on disk. A request that is a day old is not a request
308
- * any more, and a list of them that survives restarts is a list nobody reads.
309
- */
310
- const pending = new Map();
311
- const strangers = new Map();
312
- /**
313
- * Decks somebody here said no to.
314
- *
315
- * WITHOUT THIS, DECLINING DID NOTHING THAT LASTED. A deck that asks is a deck
316
- * that keeps asking — it dials on its own timer, and every dial that finds no
317
- * pin here becomes a fresh request. So `dismiss` took a row off a list that
318
- * the next minute put back, and on the other machine the refusal was
319
- * indistinguishable from a deck that had not been answered yet: both are the
320
- * same `pending` refusal on the wire, and both drew "waiting for the other
321
- * deck to accept this one" forever.
322
- *
323
- * A name kept here is therefore two answers at once. This deck stops asking
324
- * its owner, and the deck that asked is TOLD — see refuse("declined") in
325
- * lan-socket.mjs, which is the only way the far end can ever learn that the
326
- * answer was no rather than not yet.
327
- *
328
- * In memory, like the two lists above, and reversible: `allow` takes a name
329
- * out and the requests come back. Nothing about it is written down, because a
330
- * refusal that outlives the process is a decision nobody can find to undo.
331
- */
332
- const declined = new Map();
333
- /** The invite this deck is offering, or null. One at a time: a deck showing
334
- * two tokens is a deck whose owner cannot say which one they sent. */
335
- let invite = null;
336
- /** What the last round did, for the panel. Not a log: one line per peer, most
337
- * recent only, because "what happened" is a question about now. */
338
- const lastRound = new Map();
339
- /** What each paired deck said about itself — version, operating system,
340
- * architecture — keyed by fingerprint, most recent only. Filled from both
341
- * directions: the manifest a deck answers with, and the question a deck that
342
- * calls in asks. See lan-about.mjs. */
343
- const aboutBy = new Map();
344
- /** The accounts each paired deck offered in its last manifest, keyed by
345
- * fingerprint. Kept APART from lastRound on purpose: a round that fails
346
- * replaces that line, and the list a deck offered a minute ago is still the
347
- * best answer to "what does it share" while it is unreachable. */
348
- const offersBy = new Map();
349
- /** When the last round FINISHED, whatever it did or failed to do. The panel's
350
- * `↻` fires one on demand and the loop fires one on its own; a reader who
351
- * pressed it wants to know it happened, and a reader who did not wants to
352
- * know the list is not a photograph of an hour ago. */
353
- let roundAt = null;
308
+ /** The invite this deck is offering, from the press that makes it to the
309
+ * proof that spends it — see lan-invite-offer.mjs. */
310
+ const offer = createInviteOffer({ now, onChange, onError });
311
+ /** What the last round did with each deck, when the whole round last
312
+ * finished, and whether anybody it asked is still deciding — see
313
+ * lan-round-record.mjs. */
314
+ const lastRound = createRoundRecord({ now });
354
315
  /**
355
316
  * Why this deck is not listening, when it is switched on and is not.
356
317
  *
@@ -364,89 +325,30 @@ export function createEngine({
364
325
  * reach one peer is that peer's row, not the deck's.
365
326
  */
366
327
  let stalled = null;
367
- /**
368
- * When each paired deck last SPOKE TO THIS ONE, keyed by fingerprint.
369
- *
370
- * The panel had no evidence at all about a deck it does not dial. A deck that
371
- * calls in has no beacon row here (if it had one it would be dialled), never
372
- * appears in `lastRound`, and its `lastSeen` was therefore undefined forever
373
- * — so the row was drawn as live on the strength of being paired, and a
374
- * Windows deck that had been closed for an hour still read `ready`. Reported
375
- * from a screenshot of exactly that.
376
- *
377
- * Every authenticated frame lands in `serve`, which is the one place that
378
- * knows a paired deck is on the other end of an open socket right now. That
379
- * is the evidence, and it is the same kind the beacon gives: a timestamp.
380
- */
381
- const spokeAt = new Map();
382
- /** And the address it spoke FROM, so a deck that only ever calls in can
383
- * still be said to come over the tailnet or the local network — it has no
384
- * address of its own here, and without this its row could not say which. */
385
- const spokeFrom = new Map();
386
- /** Addresses this deck added because a paired deck called in from them and
387
- * nothing here dialled it — see learnCaller. Kept until a round proves the
388
- * address answers: one that does is an ordinary dialled peer from then on
389
- * and leaves this set; one that does not is a caller this deck cannot reach
390
- * back (a strict NAT, a one-way path), and its row is taken away again so it
391
- * reverts to "calls in" rather than failing every round. */
392
- const calledBack = new Set();
393
- /**
394
- * When a connection from ANOTHER MACHINE last arrived on the sync listener.
395
- *
396
- * The one fact that settles "can other decks reach this one", and the only
397
- * one on the whole question that is measured rather than reasoned about: a
398
- * firewall's configuration is read through three different tools on three
399
- * platforms, one of which (`ufw`) refuses to show its rules to a process
400
- * that is not root. An accepted socket needs none of that — the packets got
401
- * in, whatever any rule file says.
402
- *
403
- * ANOTHER MACHINE, checked here and not in the socket: a connection from
404
- * loopback or from one of this machine's own addresses is the second deck on
405
- * this computer, which proves nothing about the network. The socket does not
406
- * hold that list; this does.
407
- */
408
- let inboundAt = null;
409
-
410
- /** When this deck's listener came up, or null while it is down.
411
- *
412
- * THE OTHER HALF OF `inboundAt`. On its own, "nothing has ever connected in"
413
- * says nothing: a deck that started four seconds ago has the same null as one
414
- * that has been listening all afternoon while the network talked around it.
415
- * What makes the silence evidence is how long it has gone on for, and that is
416
- * a number only the engine holds. See silentInbound in lan-reach.mjs, which
417
- * is the one verdict in this feature that works on a platform nothing can be
418
- * asked about. */
419
- let listeningSince = null;
420
-
421
- /** The tailnet read's own timer, running only while the switch is on. */
422
- let tailTimer = null;
423
- // An in-flight refresh may finish after discovery is disabled or restarted.
424
- let tailRefreshGeneration = 0;
425
-
426
- /** Who held the discovery port the last time it was asked, for as long as
427
- * this deck cannot hear: the answer does not change between two tries half a
428
- * minute apart, and on Windows asking costs a PowerShell start. Undefined
429
- * until asked, null when the machine would not say. */
430
- let holder;
431
-
432
- /** What the panel says while this deck cannot hear — see createBeacon's
433
- * `hearing`. Null whenever it can. */
434
- const deafLine = () => {
435
- if (!beacon || beacon.hearing()) return null;
436
- const err = beacon.deafError?.();
437
- if (err && err.code !== "EADDRINUSE") {
438
- return `This deck cannot listen on UDP ${DISCOVERY_PORT} (${err.code ?? err.message}), so it hears no other deck announce itself. Other decks still find it and pair with it.`;
439
- }
440
- return `${holder ?? "Another program"} is holding UDP ${DISCOVERY_PORT}, so this deck hears no new decks. Others still find it and pair with it, and it takes the port back as soon as it is free.`;
441
- };
328
+ /** Who has called this deck — when each paired deck last spoke and from
329
+ * where, when another machine last got through, and since when the
330
+ * listener has been up — see lan-inbound.mjs. */
331
+ const inbound = createInbound({ now, localAddresses });
332
+ /** The addresses this deck dials that the beacon did not hand it, and what
333
+ * answered at each — see lan-dials.mjs, where every row says whether a
334
+ * person named it, which is the whole of roundWith's trust rule. */
335
+ const dials = createDials();
336
+ /** Whether this deck can hear other decks announce, who is holding the
337
+ * discovery port while it cannot, and the sentence the panel says about
338
+ * it — see lan-hearing.mjs. */
339
+ const hearing = createHearing({ beaconNow: () => beacon, portHolder, onChange });
442
340
 
443
341
  /** Whether an address is a tailnet one, and whose. Null is the local network
444
342
  * — and always is on a deck with no Tailscale reader. */
445
343
  const routeTo = addr => routeOf(tailnet?.snapshot?.() ?? null, addr);
344
+ /** The same answer by the name a row carries: "tailscale" for a tailnet
345
+ * address, "lan" for every other. */
346
+ const viaAt = addr => (routeTo(addr) ? "tailscale" : "lan");
446
347
 
447
- /** A pairing somebody explicitly removed. Unlike `declined`, this survives a
448
- * restart because the old dial row survives too; forgetting the decision
449
- * would let that row silently recreate the pairing on the next round. */
348
+ /** A pairing somebody explicitly removed. Unlike `declined` in
349
+ * lan-requests.mjs, this survives a restart because the old dial row
350
+ * survives too; forgetting the decision would let that row silently
351
+ * recreate the pairing on the next round. */
450
352
  const wasUnpaired = fp => Array.isArray(cfg.unpaired) && cfg.unpaired.includes(fp);
451
353
  const markUnpaired = (fp, value) => {
452
354
  const before = Array.isArray(cfg.unpaired) ? cfg.unpaired : [];
@@ -459,82 +361,52 @@ export function createEngine({
459
361
  return true;
460
362
  };
461
363
 
462
- /** The two permissions that answer for one route. */
463
- const asksOn = via => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
464
- const saysYesOn = via => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
364
+ /** The decks waiting on somebody here — asked, heard, or told no — and when
365
+ * a switch answers one for the owner by pressing this engine's accept. See
366
+ * lan-requests.mjs. */
367
+ const requests = createRequests({
368
+ now, settings: () => cfg, routeTo, wasUnpaired, engineNow: () => engine, onChange, localAddresses,
369
+ });
465
370
 
466
371
  /**
467
- * Read the tailnet on a timer while the switch is on, and not at all while it
468
- * is off — the read at start covers telling a tailnet address from a local
469
- * one, and the dialog's own poll covers whether Tailscale is there at all.
470
- *
471
- * TURNING IT ON ANNOUNCES AT ONCE, after one read, so the owner's machines
472
- * hear about this one in the second after the press rather than on the next
473
- * beacon, up to half a minute later.
372
+ * Pin a deck somebody here chose — pressed accept on, handed an invite to,
373
+ * or joined on an invite of its own — and take back any earlier unpair of
374
+ * it, because choosing it again is the undo. The list, and whether the pin
375
+ * is new.
474
376
  */
475
- const syncTailnet = () => {
476
- const want = !!(beacon && tailnet && cfg.enabled && cfg.tailscale);
477
- if (want && !tailTimer) {
478
- const startedIn = ++tailRefreshGeneration;
479
- const currentBeacon = beacon;
480
- void tailnet.refresh().then(() => {
481
- if (startedIn === tailRefreshGeneration && beacon === currentBeacon && cfg.enabled && cfg.tailscale) {
482
- currentBeacon.announce();
483
- }
484
- }, () => {});
485
- tailTimer = setInterval(() => { void tailnet.refresh(); }, TAILNET_MS);
486
- tailTimer.unref?.();
487
- } else if (!want && tailTimer) {
488
- tailRefreshGeneration++;
489
- clearInterval(tailTimer);
490
- tailTimer = null;
491
- }
377
+ const pin = ({ fp, pub, name }) => {
378
+ const { list, added } = addTrusted(cfg.trusted, { fp, pub, name, at: now() });
379
+ cfg = { ...cfg, trusted: list };
380
+ markUnpaired(fp, false);
381
+ return { list, added };
492
382
  };
493
383
 
494
- /** This deck's accounts in the shape the rules want. Read through the same
495
- * function the panel uses, so a row can never be alive here and dead there. */
496
- const localAccounts = async () => {
497
- const got = await readAccounts();
498
- return (got?.accounts ?? []).map(a => ({
499
- key: accountKey(a.email, a.orgUuid),
500
- email: a.email,
501
- org: a.orgUuid,
502
- alive: storedCopyAlive(a.alive, a.collector),
503
- // False only for a login the wiring knows this process cannot read (a
504
- // Mac whose Keychain will not open from here). Absent means readable,
505
- // which is every deck that does not say.
506
- readable: a.readable !== false && cachedExportReadable(a.collector, { active: a.active === true }),
507
- unreadableWhy: a.readable === false || a.collector === "keychain_unavailable"
508
- ? SENDER_UNREADABLE : "export failed",
509
- num: a.num,
510
- // The one this deck is on — claude-swap's own answer, one at most.
511
- active: a.active === true,
512
- }));
513
- };
384
+ /** Reading the tailnet on a timer while the switch is on, announcing at
385
+ * once when it is turned on — see lan-tailnet-poll.mjs. */
386
+ const tailPoll = createTailnetPoll({
387
+ tailnet, beaconNow: () => beacon, wanted: () => cfg.enabled && cfg.tailscale,
388
+ });
514
389
 
515
- /** This deck's card for one connection, as a frame field — or nothing, for a
516
- * deck built without one. Spread into the frame, so a deck from before this
517
- * existed receives exactly the frame it always did plus one key it never
518
- * reads. */
519
- const cardFor = (key, toFp) => {
520
- const sealed = sealAbout(key, about, identity.fp, toFp);
521
- return sealed ? { about: sealed } : {};
522
- };
390
+ /** This deck's accounts in the shape the rules want — see lan-accounts.mjs.
391
+ * Read through the same function the panel uses, so a row can never be alive
392
+ * here and dead there. */
393
+ const localAccounts = async () => syncAccounts(await readAccounts());
394
+
395
+ /** This deck's side of the manifest exchange: the frame it sends, what it
396
+ * keeps of the one it hears — a paired deck's card and the logins it offers,
397
+ * kept apart from lastRound — and what a row reads of that — see
398
+ * lan-manifest.mjs. */
399
+ const { manifestFrame, keepManifest, heardOf } = createManifests({
400
+ about, now, myFp: () => identity.fp, settings: () => cfg,
401
+ });
523
402
 
524
- /** Frames from a deck that finished the handshake AND that somebody here has
525
- * accepted. Nothing reaches this before both, which is the whole point of
526
- * where the two checks sit. `ctx.key` is this connection's key and no other
527
- * connection's — see sessionKey. `ctx.send` seals whatever it is handed when
528
- * both ends said they seal, so nothing below has to know which kind of deck
529
- * asked — see frameChannel. */
530
403
  /** Does this deck already hold an address it dials for `fp`? A beacon row it
531
404
  * still hears, or a typed/learned row that answered as that deck. When
532
405
  * neither is true, the only way it ever reaches that deck is if the deck
533
406
  * keeps calling — and a called deck is never pulled from. */
534
407
  const dialsAlready = fp => {
535
408
  if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && stillListed(p, now()))) return true;
536
- for (const [at, met] of learned) if (met?.fp === fp && manual.has(at)) return true;
537
- return false;
409
+ return dials.answersAs(fp);
538
410
  };
539
411
 
540
412
  /**
@@ -550,8 +422,9 @@ export function createEngine({
550
422
  * in its hello which port it LISTENS on, and that pair is dialable. Adding it
551
423
  * makes the next round reach the caller and pull — the same dial-back that
552
424
  * accepting a deck and joining by invite already do, extended to a peer that
553
- * simply calls. In memory, like those two: a settings write clears it and the
554
- * next call re-adds it, and nothing here writes an address to disk.
425
+ * simply calls. In memory, unlike those two: a settings write clears it and
426
+ * the next call re-adds it, and nothing here writes a caller's address to
427
+ * disk.
555
428
  */
556
429
  const learnCaller = ctx => {
557
430
  const fp = ctx?.peerFp;
@@ -561,162 +434,75 @@ export function createEngine({
561
434
  if (!trustedPeer(cfg.trusted, fp)) return;
562
435
  if (dialsAlready(fp)) return;
563
436
  // As a row the deck ADDED ITSELF, not one a person typed: capped like every
564
- // other automatic row, and — through calledBack — taken away again if the
437
+ // other automatic row, and — through its trial — taken away again if the
565
438
  // address turns out not to answer. The caller is already trusted, so the
566
439
  // round dials and pulls without a press; `typed` decides only the cap and
567
440
  // the undo, never the trust. See roundWith.
568
441
  if (engine.addPeer(at, port, { typed: false })) {
569
- learned.set(`${at}:${port}`, { fp, name: trustedPeer(cfg.trusted, fp)?.name || "" });
570
- calledBack.add(`${at}:${port}`);
442
+ dials.trial(`${at}:${port}`, { fp, name: trustedPeer(cfg.trusted, fp)?.name || "" });
571
443
  onChange?.();
572
444
  }
573
445
  };
574
446
 
575
- const serve = async (msg, ctx) => {
576
- // Authentication happened at connection setup; a previously trusted deck
577
- // may have been unpaired while this socket remained open.
578
- const mayAnswer = () => cfg.enabled && !!server && !!trustedPeer(cfg.trusted, ctx?.peerFp);
579
- if (!mayAnswer()) return ctx.send({ t: "no", why: "not paired" });
580
- // Before the verbs, and for every one of them: something that proved it
581
- // holds a key this deck accepted is talking, now.
582
- if (ctx?.peerFp) {
583
- spokeAt.set(ctx.peerFp, now());
584
- const from = ctx.peerAddr || String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
585
- if (from) spokeFrom.set(ctx.peerFp, from);
586
- learnCaller(ctx);
587
- }
588
- try {
589
- if (msg.t === "manifest") {
590
- // THE CALLER'S CARD RIDES THE QUESTION, which is the only way a deck
591
- // that calls in ever says what it is: nothing here dials it, so nothing
592
- // here ever asks. A seal that does not open is a deck that said nothing.
593
- const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
594
- if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
595
- // AND SO DOES ITS LIST, from a deck new enough to send one: what it
596
- // offers, and which of those it is on. Kept only when it came — an
597
- // older caller asks with its card alone, and a missing list is not an
598
- // empty one. This is the only way a deck nothing here dials is ever
599
- // known by what it offers.
600
- if (Array.isArray(msg.accounts)) {
601
- const list = offered(msg.accounts);
602
- offersBy.set(ctx.peerFp, { at: now(), accounts: list, current: heardCurrent(msg.current, list) });
603
- }
604
- const accounts = await localAccounts();
605
- // Reading the store can take long enough for the owner to unpair this
606
- // deck. Do not disclose account identities or the active account from
607
- // a manifest assembled before that decision.
608
- if (!mayAnswer()) return ctx.send({ t: "no", why: "not paired" });
609
- return ctx.send({
610
- t: "manifest", accounts: manifestFor(accounts, cfg.shared),
611
- ...currentFor(accounts, cfg.shared, cfg.shareActive),
612
- ...cardFor(ctx.key, ctx.peerFp),
613
- });
614
- }
615
- if (msg.t === "want") {
616
- // A listener may have authenticated this socket before its owner
617
- // unpaired the caller or switched sharing off. Recheck at the moment
618
- // a credential is requested and again after every asynchronous read.
619
- const maySend = () => cfg.enabled && !!server && !!trustedPeer(cfg.trusted, ctx.peerFp)
620
- && cfg.shared.includes(msg.key);
621
- if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
622
- // A SECOND PROOF, for the one operation that moves a credential. The
623
- // session says who connected; this says they are asking for this
624
- // account, now. A long-lived connection authenticated an hour ago is
625
- // not a statement about now.
626
- const want = transferChallenge(ctx.key, {
627
- nonce: msg.nonce, accountKey: msg.key, fromFp: ctx.peerFp, toFp: identity.fp,
628
- });
629
- if (typeof msg.proof !== "string" || msg.proof !== want) {
630
- return ctx.send({ t: "no", why: "proof" });
631
- }
632
- // Only what the user ticked, checked again here rather than trusted
633
- // from the manifest we sent: the list can change between the two, and
634
- // the answer that matters is the one at the moment of sending.
635
- const accounts = await localAccounts();
636
- if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
637
- // The slot manifestFor told the peer about, by the same rule: with two
638
- // slots for one identity, an expired one listed first must not hide a
639
- // live one behind it.
640
- const mine = slotFor(accounts, msg.key);
641
- if (!mine) return ctx.send({ t: "no", why: "not mine to give" });
642
- // A LOGIN THIS DECK CANNOT READ IS SAID SO, from state rather than from
643
- // a failed export: no subprocess, and nothing the CLI printed. The
644
- // asking deck prints it under the account, so the person learns which
645
- // machine to unlock instead of reading "export failed".
646
- if (!mine.readable) return ctx.send({ t: "no", why: mine.unreadableWhy });
647
- if (!mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
648
- // The active slot exports the live CLI login, and its verdict can
649
- // predate a `/login` as somebody else, so ask the CLI who it is now.
650
- if (mine.active && liveLogin && !liveLoginIs(await liveLogin(), mine.email, mine.org)) {
651
- return ctx.send({ t: "no", why: "export failed" });
652
- }
653
- if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
654
- const blob = await exportAccount(mine.num, msg.key);
655
- if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
656
- // A Mac's failed export refreshes the verdict behind `readable` in the
657
- // background, so when the Keychain was why, the next ask is answered by
658
- // the line above.
659
- if (!blob) return ctx.send({ t: "no", why: "export failed" });
660
- const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
661
- return ctx.send({ t: "have", key: msg.key, sealed: seal(ctx.key, blob, aad) });
662
- }
663
- } catch (err) {
664
- onError?.("serve", err);
665
- ctx.send({ t: "no", why: "error" });
666
- }
667
- };
447
+ /** What this deck answers a paired deck that asks — its list, or one login
448
+ * sealed for it — and the checks that decide whether it answers at all.
449
+ * See lan-serve.mjs. */
450
+ const { serve } = createServe({
451
+ settings: () => cfg, serverNow: () => server, myFp: () => identity.fp,
452
+ inbound, learnCaller, keepManifest, manifestFrame, localAccounts, liveLogin, exportAccount, onError,
453
+ });
668
454
 
669
455
  /**
670
- * A real deck nobody here has accepted: draw it as a row with an accept on it.
671
- *
672
- * ONE HELPER FOR BOTH DIRECTIONS, because the evidence is the same either
673
- * way. A deck that DIALLED this one finishes a handshake at the listener and
674
- * arrives through `onPending`; a deck this one dialled finishes the same
675
- * handshake in roundWith. Both have proved they hold the key they announced,
676
- * and neither has been agreed to. Before #969 only the first raised a
677
- * request and the second silently pinned itself.
456
+ * Dial a deck just paired with from now on, and keep the address: in the
457
+ * dial list, and through onDial in prefs, so a restart does not make the
458
+ * pairing one-way again.
678
459
  *
679
- * SAY YES FOR SOMEBODY WHO SAID TO. `autoAccept` is the accept button and
680
- * nothing else: the same pin, from the same key the handshake just proved.
681
- * Nothing about the wire changes — an inbound connection is still refused,
682
- * because trust is read fresh per connection, and the caller comes back a few
683
- * seconds later.
684
- *
685
- * A deck already told no does not become a row again. On the inbound path
686
- * lan-socket refuses it before this is ever called; on the outbound one there
687
- * is nothing before this, so the check lives here.
460
+ * AND SAY WHO IS THERE, NOW. What the dial list learned at an address is
461
+ * what joins a dialled row to a heard one (see `learned` in lan-dials.mjs),
462
+ * and it was only ever filled by a round that succeeded — so
463
+ * between accepting a deck and the next round, one machine appeared as two
464
+ * rows. We already know the answer here: the handshake that just finished
465
+ * said so.
688
466
  */
689
- const askToAccept = entry => {
690
- if (cfg.pairingMode === "invite" || declined.has(entry.fp)) return;
691
- const had = pending.get(entry.fp);
692
- // WHICH SWITCH ANSWERS depends on where the deck is. A request from the
693
- // tailnet is answered by the Tailscale pair, and only for a machine on this
694
- // person's own Tailscale account; anything else on the tailnet waits for a
695
- // press whatever either switch says. See routeOf.
696
- const route = routeTo(entry.addr);
697
- const via = route ? "tailscale" : "lan";
698
- const own = !!route?.own;
699
- pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
700
- if (!wasUnpaired(entry.fp) && saysYesOn(via) && (via === "lan" || own)) {
701
- engine?.accept(entry.fp, { byHand: false });
702
- return;
703
- }
704
- if (!had) onChange?.();
467
+ const keepDialling = (addr, port, met) => {
468
+ engine.addPeer(addr, port);
469
+ onDial?.(`${addr}:${port}`);
470
+ dials.meet(`${addr}:${port}`, met);
705
471
  };
706
472
 
707
- /**
708
- * Is anybody on the other end still deciding?
473
+ /** Somebody used the token. It is retired — one that pairs twice is one
474
+ * worth stealing twice — and they are pinned.
709
475
  *
710
- * The exact sentence lan-socket.mjs sends for "a real deck, not yet
711
- * accepted", which is the one state where dialling again in a few seconds
712
- * does something a minute later would not.
713
- */
714
- const waitingOnSomebody = () =>
715
- [...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
476
+ * RETIRED FIRST, so nothing that goes wrong in the pairing after it can
477
+ * leave a spent token live (#1137). The listener calls this for every
478
+ * proof that holds, a deck it already had included; pinning one of those
479
+ * again changes nothing but its name, and dialling it back is what joining
480
+ * does on the other end too. */
481
+ const inviteUsed = entry => {
482
+ offer.retire();
483
+ const { list } = pin(entry);
484
+ // AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
485
+ // deck with no way to reach it: an inbound connection puts nothing in
486
+ // the dial list. Without this the pairing is mutual in the trusted
487
+ // list and one-way in fact — and `addPeer` alone lives in memory, so
488
+ // it would be one-way again after the next restart.
489
+ if (entry.addr && entry.port) keepDialling(entry.addr, entry.port, { fp: entry.fp, name: entry.name || "" });
490
+ onTrust?.(list);
491
+ onChange?.();
492
+ };
716
493
 
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 = () => {
499
+ const fresh = identityFrom("");
500
+ onIdentity?.(fresh.secret);
501
+ onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
502
+ };
717
503
  /** How this deck reached that peer. A beacon row says so; a typed address is
718
- * read from the routing table, the way the peer list reads it. */
719
- const viaOf = peer => peer.via ?? (routeTo(peer.addr) ? "tailscale" : "lan");
504
+ * read from the routing table. The round and the peer list both ask this. */
505
+ const viaOf = peer => peer.via ?? viaAt(peer.addr);
720
506
 
721
507
  /** Checks the logins a round brought, once, and records any reason one is
722
508
  * unusable here on its row. An unanswered check is not a failure. */
@@ -751,83 +537,18 @@ export function createEngine({
751
537
  ask: cfg.pairingMode !== "invite",
752
538
  sealFrames, ephemeral,
753
539
  });
754
- // A SECOND READER ON THE SAME SOCKET, AND IT HAS TO KEEP THE SAME CAP.
755
- //
756
- // lan-socket.mjs's header states the rule: "a peer that sends a megabyte
757
- // with no newline in it is not sending a large frame, it is sending
758
- // nothing at all, expensively... the buffer is ABANDONED rather than
759
- // grown past it." frameReader enforces it; this reader did not.
760
- //
761
- // The frameReader connectToPeer installed IS still attached and does hit
762
- // MAX_FRAME_BYTES — but it only sets its own flag and calls `fail`, which
763
- // short-circuits on `settled`, so nothing destroys the socket. This
764
- // buffer then grew unbounded for the full ROUND_MS at line rate. The
765
- // reject path also never removed the listener; only roundWith's
766
- // `finally { conn?.sock?.destroy(); }` stopped it.
767
- const ask = frame => new Promise((resolve, reject) => {
768
- let buf = "";
769
- let settled = false;
770
- const give = (fn, arg) => {
771
- if (settled) return;
772
- settled = true;
773
- clearTimeout(bell);
774
- conn.sock.off("data", onData);
775
- conn.sock.off("close", onClose);
776
- conn.sock.off("error", onError);
777
- fn(arg);
778
- };
779
- const onClose = () => give(reject, new Error("peer closed the connection"));
780
- // The errno rides along in the sentence, because faultText reads it
781
- // there: ECONNRESET is "it hung up" on the row, and a bare "peer
782
- // connection failed" was the one fault the panel could only repeat.
783
- const onError = err => give(reject, new Error(`peer connection failed${err?.code ? ` (${err.code})` : ""}`));
784
- const onData = chunk => {
785
- buf += chunk;
786
- if (buf.length > MAX_FRAME_BYTES) {
787
- conn.sock.destroy();
788
- return give(reject, new Error("frame too large"));
789
- }
790
- const i = buf.indexOf("\n");
791
- if (i === -1) return;
792
- let parsed;
793
- try { parsed = JSON.parse(buf.slice(0, i)); } catch { return give(reject, new Error("bad reply")); }
794
- // THROUGH THE CONNECTION'S OWN READER, and on a sealed connection
795
- // that is the only way in. A reply that does not open is not a reply
796
- // with something wrong in it; it is a connection that stopped being
797
- // the one the handshake proved — altered, replayed, or plain where
798
- // both ends agreed to seal — so the round ends here instead of
799
- // reading it as it stands. See frameChannel.
800
- const got = conn.read(parsed);
801
- if (conn.sealed && !got) {
802
- conn.sock.destroy();
803
- return give(reject, new Error("a reply from that deck did not open"));
804
- }
805
- give(resolve, got);
806
- };
807
- const bell = setTimeout(() => give(reject, new Error("peer went quiet")), ROUND_MS);
808
- bell.unref?.();
809
- conn.sock.on("data", onData);
810
- conn.sock.on("close", onClose);
811
- conn.sock.on("error", onError);
812
- // And out through its own writer, which seals whenever the reader opens.
813
- // A dead socket cannot throw here — sendFrame swallows a failed write,
814
- // and `close`/`error` above are what report it. What CAN throw is the
815
- // seal running out of counter, which is this deck's limit and not a
816
- // network drop, so it is rejected under its own name.
817
- if (conn.sock.destroyed) return onClose();
818
- try { conn.send(frame); } catch (err) { give(reject, err); }
819
- });
540
+ const ask = frame => askOver(conn, frame);
820
541
 
821
542
  // WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
822
543
  // and nothing else until somebody answers it — and once one has, the deck
823
544
  // on the other end has told us what it calls itself. The row says that
824
545
  // from then on, because "Constantin-PC" is what the person who typed the
825
546
  // address was trying to reach.
826
- learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
547
+ //
827
548
  // A DIAL-BACK THAT ANSWERED IS AN ORDINARY PEER NOW. It was on trial only
828
549
  // until it proved the deck can reach it; from here it is dialled like any
829
550
  // other and is no longer a candidate for the undo below. See learnCaller.
830
- calledBack.delete(`${peer.addr}:${peer.port}`);
551
+ dials.answered(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
831
552
 
832
553
  // TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY NAMED. Reaching a
833
554
  // deck we have no pin for used to mean the person at this keyboard put
@@ -844,7 +565,7 @@ export function createEngine({
844
565
  // dial list. The next round reached it, arrived here with no pin, and
845
566
  // read "no pin" as "somebody typed this". Nobody typed anything. One
846
567
  // unsolicited packet, zero presses, and the far end was in `cfg.trusted`
847
- // — which index.mjs writes to prefs.json, and which is the whole inbound
568
+ // — which lan-deck.mjs writes to prefs.json, and which is the whole inbound
848
569
  // gate — so from then on it could authenticate to `serve` and ask for
849
570
  // every account the owner had ticked. Reproduced end to end before this
850
571
  // line changed.
@@ -866,7 +587,7 @@ export function createEngine({
866
587
  // handshake it just finished is the same evidence either way — a real
867
588
  // deck holding the key it announced. What is missing is the press,
868
589
  // and that is what this asks for.
869
- askToAccept({
590
+ requests.askToAccept({
870
591
  fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
871
592
  addr: peer.addr, port: peer.port,
872
593
  });
@@ -891,19 +612,12 @@ export function createEngine({
891
612
  // The owner can revoke trust or disable sync while the store is read.
892
613
  // Never send this deck's account identities on that old connection.
893
614
  if (!stillPaired()) throw new Error("peer no longer paired");
894
- const theirs = await ask({
895
- t: "manifest", accounts: manifestFor(mine, cfg.shared),
896
- ...currentFor(mine, cfg.shared, cfg.shareActive),
897
- ...cardFor(conn.key, conn.peerFp),
898
- });
615
+ const theirs = await ask(manifestFrame(mine, conn.key, conn.peerFp));
899
616
  // A response from a round that was stopped or unpaired is stale even
900
617
  // when the peer had already sent it before the setting changed.
901
618
  if (!stillPaired()) throw new Error("peer no longer paired");
902
619
  if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
903
- const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
904
- if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
905
- const list = offered(theirs.accounts);
906
- offersBy.set(conn.peerFp, { at: now(), accounts: list, current: heardCurrent(theirs.current, list) });
620
+ const list = keepManifest(conn.key, theirs, conn.peerFp);
907
621
  // Only accounts I have also ticked. Sharing is mutual by construction:
908
622
  // a peer cannot push an account at me that I never agreed to hold.
909
623
  // A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
@@ -913,7 +627,8 @@ export function createEngine({
913
627
  // them, which is the whole of "I do not want to paste blobs any more".
914
628
  // What can reach this is what a deck somebody here pressed accept on
915
629
  // chose to offer.
916
- // OVER `list`, NOT THE RAW ARRAY. `offered` above slices to 50 and type-
630
+ // OVER `list`, NOT THE RAW ARRAY. `offered`, which keepManifest runs the
631
+ // list through above (see lan-manifest.mjs), slices to 50 and type-
917
632
  // filters `key` and `email`; this line read `theirs.accounts` and got
918
633
  // neither. syncAction answers "add" for anything this deck lacks and an
919
634
  // add needs no tick, so a peer answering `manifest` with thousands of
@@ -929,10 +644,12 @@ export function createEngine({
929
644
  // round. A heal unticked mid-round ends only that heal: the adds behind
930
645
  // it need no tick, and the skipped row says why rather than vanishing.
931
646
  const stillWanted = step => step.action !== "heal" || cfg.shared.includes(step.key);
932
- let cut = false;
933
- for (const step of wanted) {
934
- if (!stillPaired()) { cut = true; break; }
935
- if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
647
+ /** One login, asked for and opened: a `want` carrying its own proof that
648
+ * names the account (see transferChallenge), and the `have` opened under
649
+ * the additional data it was sealed with. The login, or why there is
650
+ * none — the peer's refusal as this deck records it, or a seal that did
651
+ * not open. */
652
+ const wantLogin = async step => {
936
653
  const nonce = randomBytes(12).toString("hex");
937
654
  const reply = await ask({
938
655
  t: "want", key: step.key, nonce,
@@ -940,9 +657,16 @@ export function createEngine({
940
657
  nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
941
658
  }),
942
659
  });
943
- if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: peerWhy(reply?.why) }); continue; }
944
- const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
945
- if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
660
+ if (reply?.t !== "have" || !reply.sealed) return { why: peerWhy(reply?.why) };
661
+ const blob = open(conn.key, reply.sealed, credentialAad(conn.peerFp, identity.fp, step.key));
662
+ return blob ? { blob } : { why: "could not open" };
663
+ };
664
+ let cut = false;
665
+ for (const step of wanted) {
666
+ if (!stillPaired()) { cut = true; break; }
667
+ if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
668
+ const { blob, why } = await wantLogin(step);
669
+ if (!blob) { done.push({ ...step, ok: false, why }); continue; }
946
670
  // Unpairing, disabling LAN, or unticking a heal while export was in
947
671
  // progress takes effect before the received credential touches disk.
948
672
  if (!stillPaired()) { cut = true; break; }
@@ -1002,7 +726,7 @@ export function createEngine({
1002
726
  // can outlast the peer's thirty-second idle timer, and one ask covers
1003
727
  // every login the round brought.
1004
728
  await markArrivals(done);
1005
- lastRound.set(peer.fp, { at: now(), name: peer.name, offered: list.length, done });
729
+ lastRound.keep(peer.fp, { at: now(), name: peer.name, offered: list.length, done });
1006
730
  if (done.length) onChange?.();
1007
731
  return done;
1008
732
  } catch (err) {
@@ -1014,20 +738,17 @@ export function createEngine({
1014
738
  // bring are checked the same way a finished round's are.
1015
739
  await markArrivals(done);
1016
740
  if (session !== startedIn) return [];
1017
- lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message, done });
741
+ lastRound.keep(peer.fp, { at: now(), name: peer.name, error: err.message, done });
1018
742
  if (done.length) onChange?.();
1019
743
  // A DIAL-BACK THAT NEVER ANSWERED IS TAKEN AWAY AGAIN. The address came
1020
744
  // from a paired deck's inbound call, and this round was the test of
1021
745
  // whether the call can be returned. It could not — a strict NAT, a
1022
746
  // one-way path — so the row is removed rather than left to fail every
1023
747
  // minute, and the peer goes back to "calls in". Its next call tries once
1024
- // more. A row that answered has already left calledBack above.
748
+ // more. A row that answered has already left its trial above.
1025
749
  const at = `${peer.addr}:${peer.port}`;
1026
- if (calledBack.has(at)) {
1027
- calledBack.delete(at);
1028
- manual.delete(at);
1029
- learned.delete(at);
1030
- lastRound.delete(peer.fp);
750
+ if (dials.failed(at)) {
751
+ lastRound.drop(peer.fp);
1031
752
  onChange?.();
1032
753
  }
1033
754
  return done;
@@ -1036,31 +757,6 @@ export function createEngine({
1036
757
  }
1037
758
  };
1038
759
 
1039
- /** Peers the user typed in, which the beacon will never find.
1040
- *
1041
- * Broadcast dies at the first router and is dropped by a switch that
1042
- * filters it, so a deck across a VPN or on another subnet is unreachable by
1043
- * discovery and perfectly reachable by address. Typing one is a decision to
1044
- * trust whatever answers there the first time, and to pin it: an address is
1045
- * a way to reach a deck, and the accept on the other machine is what lets
1046
- * anything move.
1047
- *
1048
- * Keyed by `host:port` rather than by fingerprint, because a fingerprint is
1049
- * what a deck says about itself after the handshake and this list has to
1050
- * exist before there has been one.
1051
- *
1052
- * EVERY ROW SAYS WHERE IT CAME FROM, in `typed`, and the difference decides
1053
- * whether reaching it may pin a key sight unseen. A row somebody put in the
1054
- * address field — or pressed accept on, or joined by invite — is a person
1055
- * naming a machine. A row `autoAsk` added from a beacon is this deck
1056
- * answering a shout, which is not the same claim and must not read as one.
1057
- * See roundWith, where the difference is the whole of the trust rule. */
1058
- const manual = new Map();
1059
- /** What answered at a typed address, once something has. Keyed the same way
1060
- * `manual` is, because until a connection succeeds an address is all there
1061
- * is to key on. */
1062
- const learned = new Map();
1063
-
1064
760
  const oneRound = async () => {
1065
761
  if (!beacon) return [];
1066
762
  const startedIn = session;
@@ -1081,12 +777,12 @@ export function createEngine({
1081
777
  // AND ONE DIAL PER DECK, not one per address. A row asked from a tailnet
1082
778
  // beacon keeps that address after the same deck is heard on the local
1083
779
  // network, and both used to be dialled every round — two handshakes, and
1084
- // two lines of work for one machine. `learned` says which deck a row
780
+ // two lines of work for one machine. `dials.metAt` says which deck a row
1085
781
  // reached; the heard row already dials it by the better route.
1086
782
  const heardFps = new Set(heard.map(p => p.fp));
1087
- const typed = [...manual.values()].filter(p => {
783
+ const typed = dials.rows().filter(p => {
1088
784
  const at = `${p.addr}:${p.port}`;
1089
- if (seen.has(at) || heardFps.has(learned.get(at)?.fp)) return false;
785
+ if (seen.has(at) || heardFps.has(dials.metAt(at)?.fp)) return false;
1090
786
  return cfg.tailscale || p.typed || !routeTo(p.addr);
1091
787
  });
1092
788
  for (const peer of [...heard, ...typed]) {
@@ -1101,118 +797,130 @@ export function createEngine({
1101
797
  all.push(...await roundWith(peer));
1102
798
  }
1103
799
  if (session !== startedIn) return all;
1104
- roundAt = now();
800
+ lastRound.finished();
1105
801
  return all;
1106
802
  };
1107
803
 
1108
- /**
1109
- * Where every round waits its turn — the whole list, or one deck from its own
1110
- * dialog — so that whichever is running is running alone. `round` and
1111
- * `roundOne` are the only two ways in, and both come through here.
1112
- *
1113
- * A LINE RATHER THAN A FLAG, because the two ways in want different things
1114
- * from what is already running and both have to end up behind it. A round
1115
- * asked for during a check cannot join it — a check asks one deck, and a
1116
- * round was asked to ask all of them — so it waits. A check asked for during
1117
- * a round can often join it, and sometimes cannot. Both need somewhere to
1118
- * stand that is after whatever is in flight, and this is it.
1119
- *
1120
- * The tail never rejects: `roundWith` reports a failure per peer instead of
1121
- * throwing, and anything else is swallowed here rather than left to stop
1122
- * every turn after it. The caller of the turn that threw still hears it.
1123
- */
1124
- let _turn = Promise.resolve();
1125
- const inTurn = job => {
1126
- const run = _turn.then(job);
1127
- _turn = run.then(() => {}, () => {});
1128
- return run;
1129
- };
804
+ /** The line every round waits in, whole or one deck — see lan-turns.mjs,
805
+ * which is why a round asked for while another runs joins it (#1040) and a
806
+ * check waits behind it (#1132). */
807
+ const turns = createTurns();
808
+ const round = () => turns.round(session, oneRound);
809
+ /** When the next round runs: a minute at rest, seconds while a deck this one
810
+ * dialled is deciding — see lan-round-timer.mjs. */
811
+ const roundTimer = createRoundTimer({ round, waiting: lastRound.waitingOnSomebody });
1130
812
 
1131
- /** The whole round in flight or waiting its turn, or null. See `round` below. */
1132
- let _round = null;
1133
- /** The session `_round` was asked for in. */
1134
- let _roundIn = -1;
813
+ /** What status() says about Tailscale, for a deck that has a reader: whether
814
+ * the machine has it at all, and what it can see. */
815
+ const tailnetStatus = () => {
816
+ const t = tailnet.snapshot?.() ?? null;
817
+ return {
818
+ found: !!tailnet.found?.(),
819
+ state: t?.state ?? null,
820
+ running: !!t?.running,
821
+ on: !!cfg.tailscale,
822
+ ask: cfg.tailscaleAsk !== false,
823
+ accept: cfg.tailscaleAccept !== false,
824
+ login: t?.self?.login ?? null,
825
+ addr: t?.self?.ips?.[0] ?? null,
826
+ exitNode: !!t?.exitNode,
827
+ // The owner's machines a beacon goes to right now.
828
+ devices: beaconTargets(t).length,
829
+ };
830
+ };
1135
831
 
1136
- /**
1137
- * One round at a time, and the one already running is the answer (#1040).
1138
- *
1139
- * The sequential loop above reasons about peers being dialled one after
1140
- * another, which is a statement about the WHOLE round and was only ever true
1141
- * of a round running alone. Two ways in, and they meet: a self-scheduling
1142
- * timer (SYNC_MS, or ASKING_MS while somebody is waiting) and the "Sync now"
1143
- * press, which calls this straight from the route. A press landing on the
1144
- * timer's round gave two rounds walking the same peer list, each reading the
1145
- * same slot as empty and each force-importing a credential over the other —
1146
- * and the second one's blob wins for no reason anybody chose.
1147
- *
1148
- * JOINING rather than skipping, because the press has a reply to send: a
1149
- * caller that got `[]` for "a round is already running" would report "nothing
1150
- * to sync" about a round that was at that moment moving a credential. This is
1151
- * the shape `codexScanOnce` and ccusage's `_inflight` already use.
1152
- *
1153
- * THERE WAS A THIRD WAY IN, and it did not come through here (#1132). The
1154
- * `check now` in a deck's own dialog reaches `roundOne`, which called
1155
- * `roundWith` straight — so a press landing on the timer's round dialled the
1156
- * deck that round was already healing from, and the far side exported the
1157
- * same login twice for one account that needed repairing once. Both now take
1158
- * their turn in one line, above. A round joins only another whole round,
1159
- * never a check: joining a check would answer "ask every deck" with one
1160
- * deck's work and leave the rest waiting another minute.
1161
- */
1162
- //
1163
- // NEVER A ROUND FROM A SESSION THAT ENDED. LAN switched off and on again
1164
- // while one ran: that round stops at its next peer and reports nothing, so
1165
- // joining it would answer the new session's first tick with nothing and
1166
- // leave every deck unasked for a whole SYNC_MS. The new one queues behind it.
1167
- const round = () => {
1168
- if (_round && _roundIn === session) return _round;
1169
- _roundIn = session;
1170
- const mine = inTurn(oneRound).finally(() => { if (_round === mine) _round = null; });
1171
- _round = mine;
1172
- return mine;
832
+ /** Every deck this one dials or is paired with, one row each — see
833
+ * status()'s `peers`. Asked only while the beacon is up. */
834
+ const deckRows = () => {
835
+ // ONE DECK, ONE ROW, and it takes work because a deck can arrive here
836
+ // twice by two different routes: heard on the network, and dialled at
837
+ // an address somebody typed or that an invite carried. Both are the
838
+ // same machine and neither knows it — the beacon row is keyed by the
839
+ // fingerprint it announced, the typed row by `host:port`, and until a
840
+ // connection succeeds nothing joins them.
841
+ //
842
+ // What joins them is `dials.metAt`: the fingerprint that actually
843
+ // answered at that address. So every row is given the identity it is
844
+ // really about, and rows that turn out to share one are merged — the
845
+ // heard half brings liveness, the dialled half brings the last round.
846
+ const rows = [];
847
+ const byId = new Map();
848
+ const put = row => {
849
+ const had = byId.get(row.id);
850
+ if (!had) { byId.set(row.id, row); rows.push(row); return; }
851
+ // Keep what each half is the authority on.
852
+ had.lastSeen = had.lastSeen ?? row.lastSeen;
853
+ had.last = had.last ?? row.last;
854
+ had.manual = had.manual || row.manual;
855
+ had.met = had.met || row.met;
856
+ if (row.name && !had.name) had.name = row.name;
857
+ };
858
+ // WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
859
+ // the logins it offered last, and when somebody here said yes. All
860
+ // three are keyed by the fingerprint that proved itself, so both
861
+ // halves of a merged row read the same answer.
862
+ const card = id => ({
863
+ ...heardOf(id),
864
+ pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
865
+ });
866
+ for (const p of [...beacon.peers.values(), ...dials.rows()]) {
867
+ if (!stillListed(p, now())) continue;
868
+ const met = p.manual ? dials.metAt(`${p.addr}:${p.port}`) : null;
869
+ const id = met?.fp ?? p.fp;
870
+ put({
871
+ ...p,
872
+ id,
873
+ // How it is reached. A heard row says which route its last beacon
874
+ // took; a typed one is read from its address.
875
+ via: viaOf(p),
876
+ // The fingerprint an unpair has to name. A typed row's own `fp` is
877
+ // a placeholder built from its address and matches nothing.
878
+ peerFp: p.manual ? met?.fp ?? null : p.fp,
879
+ name: met?.name || p.name,
880
+ met: !!met,
881
+ paired: !!trustedPeer(cfg.trusted, id),
882
+ last: lastRound.of(p.fp) ?? null,
883
+ ...card(id),
884
+ });
885
+ }
886
+ // A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
887
+ // it, and nothing here has its address — which used to mean the panel
888
+ // listed failing addresses under "paired decks" and left out the one
889
+ // deck that actually was.
890
+ for (const t of cfg.trusted) {
891
+ if (byId.has(t.fp)) continue;
892
+ put({
893
+ id: t.fp, fp: t.fp, peerFp: t.fp, name: t.name || t.fp, addr: "", port: 0,
894
+ paired: true, waiting: true, last: lastRound.of(t.fp) ?? null,
895
+ // What it is to be "here" for a deck nothing dials: it called,
896
+ // and this is when. Undefined until it has, which is a row the
897
+ // panel draws as unknown rather than as live.
898
+ lastSeen: inbound.spokeAt(t.fp),
899
+ // Which way it called, once it has.
900
+ ...(inbound.spokeFrom(t.fp) ? { via: viaAt(inbound.spokeFrom(t.fp)) } : {}),
901
+ // AND WHAT IT SAID WHEN IT CALLED — its card, its list, and which
902
+ // of those it is on. The card was kept and never handed over, so
903
+ // the dialog said "it runs an older version" about a deck that
904
+ // had just told it exactly which version it runs.
905
+ ...card(t.fp),
906
+ });
907
+ }
908
+ return rows;
1173
909
  };
1174
910
 
1175
911
  return {
1176
912
  async apply(next) {
1177
- /** The engine itself, for the callbacks handed to the socket below: they
1178
- * outlive this call and `this` is not theirs to keep. */
1179
- const self = this;
1180
913
  engine = this;
1181
914
  const was = cfg;
1182
915
  cfg = { ...cfg, ...next };
1183
- // A request made before invite-only was enabled must not survive the
1184
- // switch and become an automatic approval when automatic mode returns.
1185
- // A fresh handshake after that switch may request pairing again.
1186
- if (was.pairingMode !== "invite" && cfg.pairingMode === "invite" && pending.size) {
1187
- pending.clear();
1188
- onChange?.();
1189
- }
1190
- // TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
1191
- // this on with two rows sitting in the panel means those two as much as
1192
- // the next one, and leaving them queued behind a setting called
1193
- // "automatic" is the switch not doing what it says.
1194
- //
1195
- // PER ROUTE, because each pair of switches answers for its own: turning
1196
- // the local one on does not answer a tailnet request, and turning the
1197
- // Tailscale one on answers only the owner's own machines.
1198
- const yes = c => ({ lan: c.pairingMode !== "invite" && !!c.autoAccept, tailscale: c.pairingMode !== "invite" && !!c.tailscale && c.tailscaleAccept !== false });
1199
- const ask = c => ({ lan: c.pairingMode !== "invite" && !!c.autoAsk, tailscale: c.pairingMode !== "invite" && !!c.tailscale && c.tailscaleAsk !== false });
1200
- const turnedOn = (f, via) => !f(was)[via] && f(cfg)[via];
1201
- const mayAnswer = p => (p.via === "tailscale" ? turnedOn(yes, "tailscale") && p.own : turnedOn(yes, "lan"));
1202
- for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) this.accept(fp, { byHand: false });
1203
- // The same for the other direction: switching `ask` on with four machines
1204
- // already listed asks those four.
1205
- const mayAsk = p => (p.via === "tailscale" ? turnedOn(ask, "tailscale") && p.own : turnedOn(ask, "lan"));
1206
- for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !wasUnpaired(fp) && !p.pub && mayAsk(p)) this.accept(fp, { byHand: false });
1207
- // OFF MEANS THE TAILNET GOES QUIET HERE: nobody heard over it is offered,
1208
- // and syncTailnet stops the reads. Decks already paired stay paired.
1209
- if (was.tailscale && !cfg.tailscale) {
1210
- for (const [fp, p] of [...strangers]) if (p.via === "tailscale") strangers.delete(fp);
1211
- }
916
+ // What the write does to the decks already waiting — invite-only clears
917
+ // the requests, a switch turned on answers them, the tailnet switch off
918
+ // forgets who was heard over it. See switched in lan-requests.mjs.
919
+ requests.switched(was);
1212
920
  const restart = !was.enabled !== !cfg.enabled
1213
921
  || was.secret !== cfg.secret
1214
922
  || was.name !== cfg.name;
1215
- if (!restart) { syncTailnet(); return; }
923
+ if (!restart) { tailPoll.sync(); return; }
1216
924
  this.stop(cfg.enabled);
1217
925
  if (!cfg.enabled) return;
1218
926
  const startedIn = generation;
@@ -1230,48 +938,25 @@ export function createEngine({
1230
938
  const startingServer = createSyncServer({
1231
939
  fp: identity.fp, pub: identity.pub, secret: identity.secret,
1232
940
  name: cfg.name, handlers: serve, onError, prefer: cfg.port, host, sealFrames, ephemeral,
1233
- // See inboundAt. Every connection passes here, including one that goes
1234
- // on to fail the handshake — a stranger who cannot prove anything has
1235
- // still proved the path.
1236
- onInbound: from => { if (anotherMachine(from, localAddresses())) inboundAt = now(); },
941
+ // See inboundAt in lan-inbound.mjs. Every connection passes here,
942
+ // including one that goes on to fail the handshake — a stranger who
943
+ // cannot prove anything has still proved the path.
944
+ onInbound: inbound.arrived,
1237
945
  trusted: () => cfg.trusted,
1238
- invite: () => (invite && invite.expiresAt > now() ? invite : null),
1239
- // Somebody used the token. They are pinned, and the token is retired —
1240
- // one that pairs twice is one worth stealing twice.
1241
- onInviteUsed: entry => {
1242
- const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
1243
- cfg = { ...cfg, trusted: list };
1244
- markUnpaired(entry.fp, false);
1245
- invite = null;
1246
- // AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
1247
- // deck with no way to reach it: an inbound connection puts nothing in
1248
- // the dial list. Without this the pairing is mutual in the trusted
1249
- // list and one-way in fact — and `addPeer` alone lives in memory, so
1250
- // it would be one-way again after the next restart.
1251
- if (entry.addr && entry.port) {
1252
- this.addPeer(entry.addr, entry.port);
1253
- onDial?.(`${entry.addr}:${entry.port}`);
1254
- // AND SAY WHO IS THERE, NOW. `learned` is what joins a dialled row
1255
- // to a heard one, and it was only ever filled by a round that
1256
- // succeeded — so between accepting a deck and the next round, one
1257
- // machine appeared as two rows. We already know the answer here:
1258
- // the handshake that just finished said so.
1259
- learned.set(`${entry.addr}:${entry.port}`, { fp: entry.fp, name: entry.name || "" });
1260
- }
1261
- onTrust?.(list);
1262
- onChange?.();
1263
- },
946
+ invite: offer.live,
947
+ onInviteUsed: inviteUsed,
948
+ onWrongInvite: offer.wrongInvite,
1264
949
  // Asked before the request is drawn, so a deck that was told no is
1265
950
  // told no again rather than becoming a row somebody has to answer
1266
951
  // twice. The socket sends the reason; this only knows the name.
1267
- declined: fp => declined.has(fp),
952
+ declined: requests.isDeclined,
1268
953
  // Read on every handshake rather than captured, so switching the mode
1269
954
  // takes effect on the next caller without restarting the listener.
1270
955
  inviteOnly: () => cfg.pairingMode === "invite",
1271
956
  // The same helper the outbound round uses, because a deck that called
1272
957
  // in and a deck this one called have proved exactly the same thing —
1273
- // see askToAccept.
1274
- onPending: askToAccept,
958
+ // see askToAccept in lan-requests.mjs.
959
+ onPending: requests.askToAccept,
1275
960
  });
1276
961
  server = startingServer;
1277
962
  let port;
@@ -1290,7 +975,7 @@ export function createEngine({
1290
975
  stalled = null;
1291
976
  // From here the socket is accepting, so this is the moment the silence
1292
977
  // starts being about the network rather than about a deck still starting.
1293
- listeningSince = now();
978
+ inbound.listening();
1294
979
  if (port !== cfg.port) onPort?.(port);
1295
980
  const startingBeacon = createBeacon({
1296
981
  port, name: cfg.name, fp: identity.fp,
@@ -1305,50 +990,9 @@ export function createEngine({
1305
990
  // spell, behind the sentence that does not need the name.
1306
991
  rebindMs: bindRetryMs,
1307
992
  routes,
1308
- onHearing: now => {
1309
- if (now) { holder = undefined; onChange?.(); return; }
1310
- if (holder === undefined && portHolder && beacon?.deafError?.()?.code === "EADDRINUSE") {
1311
- holder = null;
1312
- void Promise.resolve().then(() => portHolder()).then(who => { holder = who ?? null; onChange?.(); }, () => {});
1313
- }
1314
- onChange?.();
1315
- },
1316
- onStranger: entry => {
1317
- const had = strangers.get(entry.fp);
1318
- // KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
1319
- // a fresh key — a second deck sharing one config directory does, by
1320
- // design — used to leave its old key in this map for a day, and every
1321
- // one of them drew a row offering to pair with the same machine.
1322
- if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
1323
- const own = entry.via === "tailscale" && !!routeTo(entry.addr)?.own;
1324
- strangers.set(entry.fp, { ...entry, own });
1325
- // ASK IT, which is what the `ask` verb on its row does and nothing
1326
- // more: the address goes on the dial list and the next round sends a
1327
- // request that somebody over there still has to answer. A beacon
1328
- // carries a fingerprint and no key, so nothing is pinned here — see
1329
- // accept, which is deliberate about the difference.
1330
- //
1331
- // Only a deck that is NEW is asked, or a beacon every thirty seconds
1332
- // would be thirty seconds of asking; and never one this deck's owner
1333
- // already turned away.
1334
- // Never `byHand`: a beacon is a shout from an address nobody here
1335
- // named, and the row it leaves may ask rather than pin.
1336
- // Over the tailnet only a machine on this person's own account is
1337
- // asked unprompted; any other is a row for somebody to decide on.
1338
- const mayAsk = asksOn(entry.via) && (entry.via !== "tailscale" || own);
1339
- if (mayAsk && !had && !declined.has(entry.fp) && !wasUnpaired(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
1340
- // Only a deck that is new to us is news. A beacon every thirty
1341
- // seconds from one already on the list is not a reason to redraw.
1342
- if (!had) onChange?.();
1343
- },
1344
- // Take a new key and keep it. Two decks with one identity are invisible
1345
- // to each other forever otherwise, and the second one to notice moving
1346
- // is enough — whichever notices first, moves.
1347
- onIdClash: () => {
1348
- const fresh = identityFrom("");
1349
- onIdentity?.(fresh.secret);
1350
- onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
1351
- },
993
+ onHearing: hearing.hearingChanged,
994
+ onStranger: requests.heardStranger,
995
+ onIdClash: idClash,
1352
996
  onError, now,
1353
997
  ...(createSocket ? { createSocket } : {}),
1354
998
  });
@@ -1358,17 +1002,10 @@ export function createEngine({
1358
1002
  // One read of the tailnet whatever the switch says, so a packet from a
1359
1003
  // tailnet address is told apart from a local one from the first minute.
1360
1004
  void tailnet?.freshen?.(TAILNET_IDLE_MS);
1361
- syncTailnet();
1362
- // A self-scheduling loop rather than one interval, because the gap
1363
- // between rounds is not one number: see ASKING_MS.
1364
- const tick = async () => {
1365
- try { await round(); } catch { /* a round reports itself, per peer */ }
1366
- if (startedIn !== generation || beacon !== startingBeacon || !cfg.enabled) return;
1367
- timer = setTimeout(() => { void tick(); }, waitingOnSomebody() ? ASKING_MS : SYNC_MS);
1368
- timer.unref?.();
1369
- };
1370
- timer = setTimeout(() => { void tick(); }, SYNC_MS);
1371
- timer.unref?.();
1005
+ tailPoll.sync();
1006
+ // The first round a minute from now, and one after each that finishes,
1007
+ // for as long as this start stands — see lan-round-timer.mjs.
1008
+ roundTimer.start(() => startedIn !== generation || beacon !== startingBeacon || !cfg.enabled);
1372
1009
  },
1373
1010
  /**
1374
1011
  * Make an invite: every address this deck has, its port, its name, and a
@@ -1387,25 +1024,14 @@ export function createEngine({
1387
1024
  const addrs = localAddresses().map(a => `${a}:${port}`);
1388
1025
  const made = mintInvite({ addrs, name: cfg.name, now: now() });
1389
1026
  if (!made) return null;
1390
- invite = made;
1391
- onChange?.();
1027
+ offer.put(made);
1392
1028
  return { token: made.token, expiresAt: made.expiresAt, addrs };
1393
1029
  },
1394
1030
 
1395
- /** What this deck is offering right now, for the panel to draw. Null once
1396
- * it has run out, so a token nobody can use is not shown as if they could. */
1397
- offering() {
1398
- if (!invite || invite.expiresAt <= now()) return null;
1399
- return { token: invite.token, expiresAt: invite.expiresAt };
1400
- },
1401
-
1402
- /** Put it away without using it. */
1403
- withdraw() {
1404
- const had = !!invite;
1405
- invite = null;
1406
- if (had) onChange?.();
1407
- return had;
1408
- },
1031
+ /** What this deck is offering right now, and putting it away unused —
1032
+ * see lan-invite-offer.mjs. */
1033
+ offering: offer.offering,
1034
+ withdraw: offer.withdraw,
1409
1035
 
1410
1036
  /**
1411
1037
  * Join on somebody else's invite: try every address it carries until one
@@ -1446,14 +1072,8 @@ export function createEngine({
1446
1072
  // The handshake can complete after LAN was switched off (or the
1447
1073
  // identity was restarted). Never persist a pin from that old join.
1448
1074
  if (!stillJoining()) return { ok: false, reason: "not_running", tried };
1449
- const { list } = addTrusted(cfg.trusted, {
1450
- fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
1451
- });
1452
- cfg = { ...cfg, trusted: list };
1453
- markUnpaired(conn.peerFp, false);
1454
- this.addPeer(at.addr, at.port);
1455
- onDial?.(`${at.addr}:${at.port}`);
1456
- learned.set(`${at.addr}:${at.port}`, { fp: conn.peerFp, name: conn.peerName || inv.name });
1075
+ const { list } = pin({ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name });
1076
+ keepDialling(at.addr, at.port, { fp: conn.peerFp, name: conn.peerName || inv.name });
1457
1077
  onTrust?.(list);
1458
1078
  onChange?.();
1459
1079
  return {
@@ -1480,9 +1100,7 @@ export function createEngine({
1480
1100
  * trusted on a name somebody typed.
1481
1101
  */
1482
1102
  accept(fp, { byHand = true } = {}) {
1483
- const asked = pending.get(fp) ?? null;
1484
- const heard = strangers.get(fp) ?? null;
1485
- const seen = asked ?? heard;
1103
+ const seen = requests.seen(fp);
1486
1104
  if (!seen || cfg.pairingMode === "invite") return null;
1487
1105
  if (wasUnpaired(fp) && !byHand) return null;
1488
1106
  // TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
@@ -1506,15 +1124,12 @@ export function createEngine({
1506
1124
  // deck answering a shout — and the row it leaves may only ASK, which is
1507
1125
  // what the switch's own name says it does. See roundWith.
1508
1126
  this.addPeer(seen.addr, seen.port, { typed: byHand });
1509
- strangers.delete(fp);
1127
+ requests.dropHeard(fp);
1510
1128
  onChange?.();
1511
1129
  return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
1512
1130
  }
1513
- const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
1514
- cfg = { ...cfg, trusted: list };
1515
- markUnpaired(fp, false);
1516
- pending.delete(fp);
1517
- strangers.delete(fp);
1131
+ const { list, added } = pin({ fp, pub: seen.pub, name: seen.name });
1132
+ requests.drop(fp);
1518
1133
  onTrust?.(list);
1519
1134
  onChange?.();
1520
1135
  // AND DIAL IT BACK. Accepting a deck that called us made it welcome and
@@ -1522,41 +1137,20 @@ export function createEngine({
1522
1137
  // dials, and an inbound connection puts nothing in it. So the pairing was
1523
1138
  // mutual in the trusted list and one-way in fact — if the other machine
1524
1139
  // stopped calling, nothing here would ever call it. The hello carries the
1525
- // port it listens on for exactly this.
1140
+ // port it listens on for exactly this. AND KEPT, through onDial, as an
1141
+ // invite's is (#1643): `addPeer` alone lives in memory, and the next
1142
+ // settings write or restart made the pairing one-way again.
1526
1143
  const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port)
1527
- ? (learned.set(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
1144
+ ? (dials.meet(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
1145
+ onDial?.(`${seen.addr}:${seen.port}`),
1528
1146
  { addr: seen.addr, port: seen.port })
1529
1147
  : null;
1530
1148
  return added ? { fp, name: seen.name, addr: seen.addr, port: seen.port ?? null, dialBack: back } : null;
1531
1149
  },
1532
- /** Say no, and stop being asked. The deck is dropped from both lists; if it
1533
- * connects again it is a new request, because refusing is not a block. */
1534
- dismiss(fp) {
1535
- // Whatever the row said, kept — the panel draws a declined deck by name
1536
- // and address, and after the delete below there is nowhere else to read
1537
- // them from.
1538
- const was = pending.get(fp) ?? strangers.get(fp) ?? null;
1539
- const had = pending.delete(fp) || strangers.delete(fp);
1540
- if (had) {
1541
- declined.set(fp, {
1542
- fp,
1543
- name: was?.name ?? fp,
1544
- addr: was?.addr ?? "",
1545
- port: was?.port ?? 0,
1546
- at: now(),
1547
- });
1548
- onChange?.();
1549
- }
1550
- return had;
1551
- },
1552
- /** Change your mind. The name comes off the declined list and the next time
1553
- * that deck dials, it is a request again — which it will, on its own, so
1554
- * there is nothing else to press. */
1555
- allow(fp) {
1556
- const had = declined.delete(fp);
1557
- if (had) onChange?.();
1558
- return had;
1559
- },
1150
+ /** Say no and stop being asked, and change your mind — see dismiss and
1151
+ * allow in lan-requests.mjs. */
1152
+ dismiss: requests.dismiss,
1153
+ allow: requests.allow,
1560
1154
  /** Unpair. It stops what has not happened yet and takes back nothing that
1561
1155
  * has — the same sentence the panel says about a shared login. */
1562
1156
  unpair(fp) {
@@ -1577,8 +1171,9 @@ export function createEngine({
1577
1171
  * Null when it is neither, which is a deck that only calls in — nothing
1578
1172
  * here holds an address for it, so there is nobody to dial.
1579
1173
  *
1580
- * `roundAt` is left alone: it says when EVERY paired deck was last asked,
1581
- * and asking one of them does not make that true.
1174
+ * `roundAt` (see lan-round-record.mjs) is left alone: it says when EVERY
1175
+ * paired deck was last asked, and asking one of them does not make that
1176
+ * true.
1582
1177
  *
1583
1178
  * IN TURN, LIKE EVERY ROUND (#1132). This called `roundWith` straight, past
1584
1179
  * the guard `round` keeps, and a press during the timer's round dialled the
@@ -1613,64 +1208,24 @@ export function createEngine({
1613
1208
  async roundOne(fp) {
1614
1209
  if (!beacon || typeof fp !== "string" || !fp) return null;
1615
1210
  const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
1616
- const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
1211
+ const typed = dials.rowAnswering(fp);
1617
1212
  const peer = heard ?? typed;
1618
1213
  if (!peer) return null;
1619
- const had = lastRound.get(peer.fp);
1620
- await _turn;
1621
- const got = lastRound.get(peer.fp);
1214
+ const had = lastRound.of(peer.fp);
1215
+ await turns.ahead();
1216
+ const got = lastRound.of(peer.fp);
1622
1217
  if (got && got !== had) return got.done ?? [];
1623
1218
  // A deck switched off while the press waited is not dialled after all.
1624
- return inTurn(() => (beacon ? roundWith(peer) : []));
1625
- },
1626
- /** Dial this address on every round from now on. Returns false for an
1627
- * address that is not one, rather than storing a row that can never
1628
- * connect and reports an error every minute forever. */
1629
- addPeer(addr, port, { typed = true } = {}) {
1630
- const p = Number(port);
1631
- if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
1632
- const host = addr.trim();
1633
- const at = `${host}:${p}`;
1634
- // MAKING ROOM RATHER THAN REFUSING, and only among rows the deck added
1635
- // itself. A hard refusal at the cap would let whoever got there first
1636
- // keep the whole budget, so a real deck starting later would never be
1637
- // asked — which turns a cap meant to protect the round into a way to
1638
- // silence it. Evicted first is the oldest auto row that has never
1639
- // answered: `learned` holds an entry only for an address that completed a
1640
- // handshake, so a row with no entry there has cost a round and returned
1641
- // nothing. When every auto row has answered, the new one waits.
1642
- if (!typed && !manual.has(at)) {
1643
- const auto = [...manual.entries()].filter(([, v]) => !v.typed);
1644
- if (auto.length >= MAX_AUTO_PEERS) {
1645
- const stale = auto.find(([k]) => !learned.has(k));
1646
- if (!stale) return false;
1647
- manual.delete(stale[0]);
1648
- learned.delete(stale[0]);
1649
- }
1650
- }
1651
- // A row somebody typed outranks one the deck added: the same address
1652
- // arriving by hand after a beacon put it there is a person vouching for
1653
- // it, and nothing about that should be undone by the next beacon.
1654
- const was = manual.get(at);
1655
- manual.set(at, {
1656
- fp: `manual:${at}`, name: host, addr: host, port: p, manual: true,
1657
- typed: typed || was?.typed === true,
1658
- });
1659
- return true;
1660
- },
1661
- removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
1662
- /** Replace the typed list wholesale, which is what a settings write means.
1663
- * Adding one at a time would leave a removed address still being dialled
1664
- * every minute until the next restart — the row would vanish from the
1665
- * panel while the socket kept opening, which is the worst of both. */
1666
- setPeers(entries) {
1667
- manual.clear();
1668
- for (const entry of Array.isArray(entries) ? entries : []) {
1669
- const at = String(entry).lastIndexOf(":");
1670
- if (at > 0) this.addPeer(String(entry).slice(0, at), Number(String(entry).slice(at + 1)));
1671
- }
1672
- return manual.size;
1219
+ return turns.inTurn(() => (beacon ? roundWith(peer) : []));
1673
1220
  },
1221
+ /** Dial this address on every round from now on; false for an address
1222
+ * that is not one. The list, its cap and why a typed row outranks one the
1223
+ * deck added are lan-dials.mjs's. */
1224
+ addPeer(addr, port, { typed = true } = {}) { return dials.add(addr, port, { typed }); },
1225
+ removePeer(addr, port) { return dials.remove(addr, port); },
1226
+ /** Replace the typed list wholesale, which is what a settings write means
1227
+ * — see replace in lan-dials.mjs. */
1228
+ setPeers(entries) { return dials.replace(entries); },
1674
1229
  status() {
1675
1230
  return {
1676
1231
  enabled: !!cfg.enabled,
@@ -1678,14 +1233,15 @@ export function createEngine({
1678
1233
  // Said only while it is true, and it is only ever true of a deck that
1679
1234
  // is switched on and has no listener.
1680
1235
  stalled: cfg.enabled && !beacon ? stalled : null,
1681
- // Running, and unable to hear other decks announce — see deafLine.
1682
- deaf: deafLine(),
1236
+ // Running, and unable to hear other decks announce — see deafLine in
1237
+ // lan-hearing.mjs.
1238
+ deaf: hearing.deafLine(),
1683
1239
  // Every local broadcast held back, because this machine sends its
1684
1240
  // local network through a tunnel — see leavesByTunnel.
1685
1241
  lanTunneled: !!beacon?.tunneled?.(),
1686
1242
  // When every paired deck was last asked. Null until the first round,
1687
1243
  // which on a deck that has just started is the honest answer.
1688
- checkedAt: roundAt,
1244
+ checkedAt: lastRound.checkedAt(),
1689
1245
  name: cfg.name,
1690
1246
  // This deck's own card, so the panel can read a peer's version against
1691
1247
  // it; and the names somebody here gave other decks, which the panel
@@ -1701,22 +1257,7 @@ export function createEngine({
1701
1257
  shareActive: cfg.shareActive !== false,
1702
1258
  // Discovery over Tailscale: whether this machine has it at all, which
1703
1259
  // decides whether the dialog shows the switch, and what it can see.
1704
- tailscale: tailnet ? (() => {
1705
- const t = tailnet.snapshot?.() ?? null;
1706
- return {
1707
- found: !!tailnet.found?.(),
1708
- state: t?.state ?? null,
1709
- running: !!t?.running,
1710
- on: !!cfg.tailscale,
1711
- ask: cfg.tailscaleAsk !== false,
1712
- accept: cfg.tailscaleAccept !== false,
1713
- login: t?.self?.login ?? null,
1714
- addr: t?.self?.ips?.[0] ?? null,
1715
- exitNode: !!t?.exitNode,
1716
- // The owner's machines a beacon goes to right now.
1717
- devices: beaconTargets(t).length,
1718
- };
1719
- })() : null,
1260
+ tailscale: tailnet ? tailnetStatus() : null,
1720
1261
  fp: identity?.fp ?? null,
1721
1262
  // The address and port a person on another subnet types into the other
1722
1263
  // deck's field. Null when this machine has no ordinary one, which the
@@ -1724,139 +1265,49 @@ export function createEngine({
1724
1265
  port: server?.port() ?? null,
1725
1266
  // When another machine last got a connection through to this one. Null
1726
1267
  // on a deck nobody has dialled yet, which is not the same as blocked
1727
- // and is drawn as neither — see inboundAt and lan-reach.mjs.
1728
- inboundAt,
1729
- // How long that null has been true for — see listeningSince.
1730
- listeningSince,
1268
+ // and is drawn as neither — see inboundAt in lan-inbound.mjs and
1269
+ // lan-reach.mjs.
1270
+ inboundAt: inbound.inboundAt(),
1271
+ // How long that null has been true for — see listeningSince there.
1272
+ listeningSince: inbound.listeningSince(),
1731
1273
  addrs: beacon ? localAddresses() : [],
1732
1274
  shared: [...cfg.shared],
1733
1275
  // The token this deck is offering, if any. Drawn as the one thing to do
1734
- // when nobody is paired yet, and put away once somebody is.
1735
- invite: invite && invite.expiresAt > now()
1736
- ? { token: invite.token, expiresAt: invite.expiresAt }
1737
- : null,
1276
+ // when nobody is paired yet, and put away once somebody is. With how
1277
+ // many proofs of it have failed so far — the record beside the log's,
1278
+ // see wrongInvite in lan-invite-offer.mjs.
1279
+ invite: offer.row(),
1738
1280
  // Decks somebody accepted, decks that asked and have not been answered,
1739
1281
  // and decks merely heard. Three lists because they are three different
1740
1282
  // things a person does something different about.
1741
1283
  trusted: cfg.trusted.map(t => ({ fp: t.fp, name: t.name })),
1742
- pending: [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at, via: p.via ?? "lan", own: !!p.own })),
1284
+ pending: requests.pendingRows(),
1743
1285
  // Only the ones somebody could actually pair with right now, one row
1744
1286
  // per machine, newest first — see pairable, which is where the rule
1745
1287
  // that keeps this from becoming a wall of ghosts lives.
1746
- strangers: (() => {
1747
- // A deck that was told no is not somebody to offer pairing with. It
1748
- // has its own row, with the one control that undoes the decision.
1749
- const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
1750
- // pairable() collapses the rest by machine — see hostId. A computer
1751
- // that has run the deck a few times holds a key per run, and every
1752
- // one of them was a row of its own on everybody else's panel.
1753
- const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
1754
- return shown.map(p => ({
1755
- fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more,
1756
- via: p.via ?? "lan", own: !!p.own,
1757
- }));
1758
- })(),
1288
+ strangers: requests.strangerRows(),
1759
1289
  // Said no to, by somebody at this keyboard. Listed rather than merely
1760
1290
  // silenced, because a refusal nobody can see is a refusal nobody can
1761
1291
  // take back.
1762
- declined: [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
1763
- peers: beacon ? (() => {
1764
- // ONE DECK, ONE ROW, and it takes work because a deck can arrive here
1765
- // twice by two different routes: heard on the network, and dialled at
1766
- // an address somebody typed or that an invite carried. Both are the
1767
- // same machine and neither knows it — the beacon row is keyed by the
1768
- // fingerprint it announced, the typed row by `host:port`, and until a
1769
- // connection succeeds nothing joins them.
1770
- //
1771
- // What joins them is `learned`: the fingerprint that actually
1772
- // answered at that address. So every row is given the identity it is
1773
- // really about, and rows that turn out to share one are merged — the
1774
- // heard half brings liveness, the dialled half brings the last round.
1775
- const rows = [];
1776
- const byId = new Map();
1777
- const put = row => {
1778
- const had = byId.get(row.id);
1779
- if (!had) { byId.set(row.id, row); rows.push(row); return; }
1780
- // Keep what each half is the authority on.
1781
- had.lastSeen = had.lastSeen ?? row.lastSeen;
1782
- had.last = had.last ?? row.last;
1783
- had.manual = had.manual || row.manual;
1784
- had.met = had.met || row.met;
1785
- if (row.name && !had.name) had.name = row.name;
1786
- };
1787
- // WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
1788
- // the logins it offered last, and when somebody here said yes. All
1789
- // three are keyed by the fingerprint that proved itself, so both
1790
- // halves of a merged row read the same answer.
1791
- const card = id => ({
1792
- about: aboutBy.get(id) ?? null,
1793
- offers: offersBy.get(id) ?? null,
1794
- pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
1795
- });
1796
- for (const p of [...beacon.peers.values(), ...manual.values()]) {
1797
- if (!stillListed(p, now())) continue;
1798
- const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
1799
- const id = met?.fp ?? p.fp;
1800
- put({
1801
- ...p,
1802
- id,
1803
- // How it is reached. A heard row says which route its last beacon
1804
- // took; a typed one is read from its address.
1805
- via: p.via ?? (routeTo(p.addr) ? "tailscale" : "lan"),
1806
- // The fingerprint an unpair has to name. A typed row's own `fp` is
1807
- // a placeholder built from its address and matches nothing.
1808
- peerFp: p.manual ? met?.fp ?? null : p.fp,
1809
- name: met?.name || p.name,
1810
- met: !!met,
1811
- paired: !!trustedPeer(cfg.trusted, id),
1812
- last: lastRound.get(p.fp) ?? null,
1813
- ...card(id),
1814
- });
1815
- }
1816
- // A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
1817
- // it, and nothing here has its address — which used to mean the panel
1818
- // listed failing addresses under "paired decks" and left out the one
1819
- // deck that actually was.
1820
- for (const t of cfg.trusted) {
1821
- if (byId.has(t.fp)) continue;
1822
- put({
1823
- id: t.fp, fp: t.fp, peerFp: t.fp, name: t.name || t.fp, addr: "", port: 0,
1824
- paired: true, waiting: true, last: lastRound.get(t.fp) ?? null,
1825
- // What it is to be "here" for a deck nothing dials: it called,
1826
- // and this is when. Undefined until it has, which is a row the
1827
- // panel draws as unknown rather than as live.
1828
- lastSeen: spokeAt.get(t.fp),
1829
- // Which way it called, once it has.
1830
- ...(spokeFrom.has(t.fp) ? { via: routeTo(spokeFrom.get(t.fp)) ? "tailscale" : "lan" } : {}),
1831
- // AND WHAT IT SAID WHEN IT CALLED — its card, its list, and which
1832
- // of those it is on. The card was kept and never handed over, so
1833
- // the dialog said "it runs an older version" about a deck that
1834
- // had just told it exactly which version it runs.
1835
- ...card(t.fp),
1836
- });
1837
- }
1838
- return rows;
1839
- })() : [],
1292
+ declined: requests.declinedRows(),
1293
+ peers: beacon ? deckRows() : [],
1840
1294
  };
1841
1295
  },
1842
1296
  stop(restarting = false) {
1843
1297
  generation++;
1844
1298
  if (!restarting) session++;
1845
- tailRefreshGeneration++;
1846
- if (timer) clearTimeout(timer);
1847
- if (tailTimer) clearInterval(tailTimer);
1848
- tailTimer = null;
1849
- holder = undefined;
1299
+ roundTimer.stop();
1300
+ tailPoll.stop();
1301
+ hearing.forget();
1850
1302
  // A deliberate stop is not a fault, and the next start says its own.
1851
1303
  if (!cfg.enabled) stalled = null;
1852
- timer = null;
1853
1304
  beacon?.stop();
1854
1305
  server?.stop();
1855
1306
  beacon = null;
1856
1307
  server = null;
1857
1308
  // Nothing is listening, so nothing is being silent AT anybody. Leaving
1858
1309
  // this set would have the next start measure its quiet from the last one.
1859
- listeningSince = null;
1310
+ inbound.closed();
1860
1311
  },
1861
1312
  };
1862
1313
  }