ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  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-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -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 +40 -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 +7 -3
  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 +2 -2
  67. package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -212,6 +212,67 @@ export function fixSteps({ category, alias, exePath, rules = [] }) {
212
212
  return steps;
213
213
  }
214
214
 
215
+ /**
216
+ * Whether other decks can reach this Windows machine, from what the probe read:
217
+ * the network the deck is on, the firewall profile that network answers to,
218
+ * and the rules for this program.
219
+ *
220
+ * Null for a probe that did not come back, for a network that is not Public,
221
+ * Private or Domain, and for rules that could not be read on a network that is
222
+ * not Public — see below for why that last one is silence rather than a guess.
223
+ */
224
+ function windowsReach({ probe = null, aliases = [], exePath = "", inbound = null } = {}) {
225
+ if (!probe) return null;
226
+ // The broadcast route first, then the interfaces the deck holds an address
227
+ // on. The routing table is the authority on where a beacon goes; the deck's
228
+ // own addresses are the fallback for a machine where that read came back
229
+ // empty, and the probe's first connected network is the fallback for that.
230
+ const net = lanNet(probe.nets, [probe.bcast, ...aliases]);
231
+ const category = net?.category ?? "";
232
+ const name = profileFor(category);
233
+ if (!name) return null;
234
+ // MEASURED BEATS READ, here as on Linux: a connection from another machine
235
+ // has arrived on the sync listener, so the path is open whatever rule this
236
+ // was about to fail to find. See inboundAt in lan-inbound.mjs.
237
+ if (inbound) return { blocked: false, why: "inbound seen", category, alias: net?.alias ?? "" };
238
+ const prof = probe.profiles.find(p => p.name.toLowerCase() === name.toLowerCase());
239
+ // A firewall that is off blocks nothing, and saying otherwise would send
240
+ // somebody to add a rule that changes nothing.
241
+ if (prof && !prof.enabled) return { blocked: false, why: "firewall off", category, alias: net?.alias ?? "" };
242
+ if (ruleCovers(probe.rules, name)) {
243
+ return { blocked: false, why: "rule present", category, alias: net?.alias ?? "" };
244
+ }
245
+ // COULD NOT READ THE RULES, SO CANNOT SAY THERE IS NONE. An empty list here
246
+ // is "Access is denied", not "no rule" (see PROBE_PS), so claiming a missing
247
+ // rule would be a guess dressed as a read — and the command that guess hands
248
+ // over adds a duplicate rule on a machine that may already be covered. On a
249
+ // Public network the fix is the category, which was read reliably, so that
250
+ // verdict still stands; anywhere else the honest answer is the measurement,
251
+ // which `refreshReach` falls back to when this returns null.
252
+ // Only an EMPTY list is ambiguous — a rule in hand was plainly readable,
253
+ // whatever the flag says.
254
+ if (!probe.rulesReadable && probe.rules.length === 0 && name !== "Public") return null;
255
+ return {
256
+ blocked: true,
257
+ why: "no inbound rule",
258
+ category,
259
+ alias: net?.alias ?? "",
260
+ // Which shell the steps are written in, so the panel says where to paste
261
+ // them without asking what platform it is drawing for. Every other field
262
+ // here is already the verdict's to choose; this is one more.
263
+ shell: "powershell",
264
+ // The reason in the reader's terms, not the registry's. What they need to
265
+ // know is which half is broken, because the other half is what makes the
266
+ // workaround below obvious rather than magic.
267
+ text: name === "Public"
268
+ ? "This network is set to Public, and Windows drops what other decks send. They can still hear this deck — that is why one of them may already show it."
269
+ : name === "Domain" && probe.rules.length
270
+ ? "Windows lets this deck in on Private networks only, and this one is a company (Domain) network, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine."
271
+ : "Windows has no inbound rule for this deck, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine.",
272
+ steps: fixSteps({ category, alias: net?.alias ?? "", exePath, rules: probe.rules }),
273
+ };
274
+ }
275
+
215
276
  // ── linux ───────────────────────────────────────────────────────────────────
216
277
  //
217
278
  // THE SAME FAILURE, ONE DISTRIBUTION FURTHER. Reported from a pair of decks on
@@ -230,8 +291,8 @@ export function fixSteps({ category, alias, exePath, rules = [] }) {
230
291
  //
231
292
  // SO THE MEASUREMENT CARRIES THE VERDICT AND THE CONFIGURATION ONLY RAISES
232
293
  // THE QUESTION. `inbound` is when a connection from another machine last
233
- // arrived on the sync listener (see lan-engine's inboundAt). If one ever has,
234
- // the path is open and nothing here has an opinion, whatever the rule files
294
+ // arrived on the sync listener (see inboundAt in lan-inbound.mjs). If one ever
295
+ // has, the path is open and nothing here has an opinion, whatever the rule files
235
296
  // say. Only when the firewall is on, its inbound default drops, and nothing
236
297
  // has ever got in does this speak — and it says what it measured, in those
237
298
  // terms, rather than claiming to have read a rule it cannot read.
@@ -472,75 +533,9 @@ export function reachability({ platform, probe, aliases = [], exePath = "", inbo
472
533
  if (platform === "linux") return linux ? linuxReach({ ...linux, inbound }) : null;
473
534
  if (platform === "darwin") return mac ? macReach({ probe: mac, exePath, inbound }) : null;
474
535
  if (platform !== "win32") return null;
475
- if (!probe) return null;
476
- // The broadcast route first, then the interfaces the deck holds an address
477
- // on. The routing table is the authority on where a beacon goes; the deck's
478
- // own addresses are the fallback for a machine where that read came back
479
- // empty, and the probe's first connected network is the fallback for that.
480
- const net = lanNet(probe.nets, [probe.bcast, ...aliases]);
481
- const category = net?.category ?? "";
482
- const name = profileFor(category);
483
- if (!name) return null;
484
- // MEASURED BEATS READ, here as on Linux: a connection from another machine
485
- // has arrived on the sync listener, so the path is open whatever rule this
486
- // was about to fail to find. See lan-engine's inboundAt.
487
- if (inbound) return { blocked: false, why: "inbound seen", category, alias: net?.alias ?? "" };
488
- const prof = probe.profiles.find(p => p.name.toLowerCase() === name.toLowerCase());
489
- // A firewall that is off blocks nothing, and saying otherwise would send
490
- // somebody to add a rule that changes nothing.
491
- if (prof && !prof.enabled) return { blocked: false, why: "firewall off", category, alias: net?.alias ?? "" };
492
- if (ruleCovers(probe.rules, name)) {
493
- return { blocked: false, why: "rule present", category, alias: net?.alias ?? "" };
494
- }
495
- // COULD NOT READ THE RULES, SO CANNOT SAY THERE IS NONE. An empty list here
496
- // is "Access is denied", not "no rule" (see PROBE_PS), so claiming a missing
497
- // rule would be a guess dressed as a read — and the command that guess hands
498
- // over adds a duplicate rule on a machine that may already be covered. On a
499
- // Public network the fix is the category, which was read reliably, so that
500
- // verdict still stands; anywhere else the honest answer is the measurement,
501
- // which `refreshReach` falls back to when this returns null.
502
- // Only an EMPTY list is ambiguous — a rule in hand was plainly readable,
503
- // whatever the flag says.
504
- if (!probe.rulesReadable && probe.rules.length === 0 && name !== "Public") return null;
505
- return {
506
- blocked: true,
507
- why: "no inbound rule",
508
- category,
509
- alias: net?.alias ?? "",
510
- // Which shell the steps are written in, so the panel says where to paste
511
- // them without asking what platform it is drawing for. Every other field
512
- // here is already the verdict's to choose; this is one more.
513
- shell: "powershell",
514
- // The reason in the reader's terms, not the registry's. What they need to
515
- // know is which half is broken, because the other half is what makes the
516
- // workaround below obvious rather than magic.
517
- text: name === "Public"
518
- ? "This network is set to Public, and Windows drops what other decks send. They can still hear this deck — that is why one of them may already show it."
519
- : name === "Domain" && probe.rules.length
520
- ? "Windows lets this deck in on Private networks only, and this one is a company (Domain) network, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine."
521
- : "Windows has no inbound rule for this deck, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine.",
522
- steps: fixSteps({ category, alias: net?.alias ?? "", exePath, rules: probe.rules }),
523
- };
536
+ return windowsReach({ probe, aliases, exePath, inbound });
524
537
  }
525
538
 
526
- /**
527
- * The way out that needs no rule at all, and the reason this module is not the
528
- * whole answer.
529
- *
530
- * A round is one OUTBOUND connection — `roundWith` dials, both sides prove
531
- * themselves over that socket, and the manifest and any transfer ride it home.
532
- * Nothing in a round needs this machine to accept a connection. So a deck that
533
- * cannot be reached can still do every part of this by dialling first, and the
534
- * two controls that make it dial are an address somebody typed and an invite
535
- * somebody else minted.
536
- *
537
- * WHICH WAY THE INVITE GOES IS THE WHOLE OF IT, and nothing said so before.
538
- * An invite carries the addresses of the deck that MINTED it, and the deck that
539
- * PASTES it dials them. So a blocked machine that mints an invite is asking to
540
- * be called, which is the one thing it cannot receive; the same pair works on
541
- * the first try if the invite is minted at the other end and pasted here. Same
542
- * two controls, opposite order, and only one of the orders works.
543
- */
544
539
  // ── the machine nothing can be asked about ──────────────────────────────────
545
540
  //
546
541
  // Every block above depends on the operating system answering a question.
@@ -598,6 +593,24 @@ export function silentInbound({ heard = 0, listeningSince = null, inbound = null
598
593
  };
599
594
  }
600
595
 
596
+ /**
597
+ * The way out that needs no rule at all, and the reason this module is not the
598
+ * whole answer.
599
+ *
600
+ * A round is one OUTBOUND connection — `roundWith` dials, both sides prove
601
+ * themselves over that socket, and the manifest and any transfer ride it home.
602
+ * Nothing in a round needs this machine to accept a connection. So a deck that
603
+ * cannot be reached can still do every part of this by dialling first, and the
604
+ * two controls that make it dial are an address somebody typed and an invite
605
+ * somebody else minted.
606
+ *
607
+ * WHICH WAY THE INVITE GOES IS THE WHOLE OF IT, and nothing said so before.
608
+ * An invite carries the addresses of the deck that MINTED it, and the deck that
609
+ * PASTES it dials them. So a blocked machine that mints an invite is asking to
610
+ * be called, which is the one thing it cannot receive; the same pair works on
611
+ * the first try if the invite is minted at the other end and pasted here. Same
612
+ * two controls, opposite order, and only one of the orders works.
613
+ */
601
614
  export function workaround(blocked) {
602
615
  if (!blocked) return null;
603
616
  return {
@@ -0,0 +1,249 @@
1
+ // The decks waiting on somebody here: the ones that asked, the ones only heard,
2
+ // and the ones somebody told no — and when each is answered for the owner by a
3
+ // switch rather than a press. Lifted out of createEngine in lan-engine.mjs with
4
+ // the three lists only these read and write. What an answer DOES — pinning a
5
+ // key, or putting an address on the dial list — stays the engine's accept;
6
+ // this decides who is on which list and when that accept is pressed.
7
+
8
+ import { pairable } from "./lan-sync.mjs";
9
+
10
+ /**
11
+ * The two permissions that answer for one route.
12
+ *
13
+ * READ OFF WHICHEVER SETTINGS ARE ASKED ABOUT, because two places ask. A beacon
14
+ * or a request asks of the settings in force; `apply` asks of the settings
15
+ * before a write and after it, to tell which switch the write turned on. The
16
+ * second used to spell the same conditions out again as its own pair, and a
17
+ * switch read one way here and another way there would answer a request in
18
+ * one place and leave it waiting in the other.
19
+ */
20
+ export const asksOn = (cfg, via) => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
21
+ export const saysYesOn = (cfg, via) => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
22
+
23
+ /**
24
+ * `settings` answers the engine's settings in force at the moment of asking,
25
+ * and `engineNow` the engine itself, whose accept is what a switch presses.
26
+ * `routeTo` says whether an address is a tailnet one and whose, `wasUnpaired`
27
+ * whether somebody here unpaired a fingerprint, and `localAddresses` this
28
+ * machine's own addresses. `now` and `onChange` are the engine's own.
29
+ */
30
+ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow, onChange, localAddresses }) {
31
+ /**
32
+ * Decks that finished a handshake and that nobody here has accepted yet, and
33
+ * decks merely heard shouting on the network. Two lists because they are two
34
+ * different claims: a pending deck proved it holds the key it announced, a
35
+ * heard one only said so. Both are rows with an accept on them; only the
36
+ * first is evidence.
37
+ *
38
+ * In memory rather than on disk. A request that is a day old is not a request
39
+ * any more, and a list of them that survives restarts is a list nobody reads.
40
+ */
41
+ const pending = new Map();
42
+ const strangers = new Map();
43
+ /**
44
+ * Decks somebody here said no to.
45
+ *
46
+ * WITHOUT THIS, DECLINING DID NOTHING THAT LASTED. A deck that asks is a deck
47
+ * that keeps asking — it dials on its own timer, and every dial that finds no
48
+ * pin here becomes a fresh request. So `dismiss` took a row off a list that
49
+ * the next minute put back, and on the other machine the refusal was
50
+ * indistinguishable from a deck that had not been answered yet: both are the
51
+ * same `pending` refusal on the wire, and both drew "waiting for the other
52
+ * deck to accept this one" forever.
53
+ *
54
+ * A name kept here is therefore two answers at once. This deck stops asking
55
+ * its owner, and the deck that asked is TOLD — see refuse("declined") in
56
+ * lan-socket.mjs, which is the only way the far end can ever learn that the
57
+ * answer was no rather than not yet.
58
+ *
59
+ * In memory, like the two lists above, and reversible: `allow` takes a name
60
+ * out and the requests come back. Nothing about it is written down, because a
61
+ * refusal that outlives the process is a decision nobody can find to undo.
62
+ */
63
+ const declined = new Map();
64
+
65
+ /**
66
+ * A real deck nobody here has accepted: draw it as a row with an accept on it.
67
+ *
68
+ * ONE HELPER FOR BOTH DIRECTIONS, because the evidence is the same either
69
+ * way. A deck that DIALLED this one finishes a handshake at the listener and
70
+ * arrives through `onPending`; a deck this one dialled finishes the same
71
+ * handshake in roundWith. Both have proved they hold the key they announced,
72
+ * and neither has been agreed to. Before #969 only the first raised a
73
+ * request and the second silently pinned itself.
74
+ *
75
+ * SAY YES FOR SOMEBODY WHO SAID TO. `autoAccept` is the accept button and
76
+ * nothing else: the same pin, from the same key the handshake just proved.
77
+ * Nothing about the wire changes — an inbound connection is still refused,
78
+ * because trust is read fresh per connection, and the caller comes back a few
79
+ * seconds later.
80
+ *
81
+ * A deck already told no does not become a row again. On the inbound path
82
+ * lan-socket refuses it before this is ever called; on the outbound one there
83
+ * is nothing before this, so the check lives here.
84
+ */
85
+ const askToAccept = entry => {
86
+ const cfg = settings();
87
+ if (cfg.pairingMode === "invite" || declined.has(entry.fp)) return;
88
+ const had = pending.get(entry.fp);
89
+ // WHICH SWITCH ANSWERS depends on where the deck is. A request from the
90
+ // tailnet is answered by the Tailscale pair, and only for a machine on this
91
+ // person's own Tailscale account; anything else on the tailnet waits for a
92
+ // press whatever either switch says. See routeOf.
93
+ const route = routeTo(entry.addr);
94
+ const via = route ? "tailscale" : "lan";
95
+ const own = !!route?.own;
96
+ pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
97
+ if (!wasUnpaired(entry.fp) && saysYesOn(cfg, via) && (via === "lan" || own)) {
98
+ engineNow()?.accept(entry.fp, { byHand: false });
99
+ return;
100
+ }
101
+ if (!had) onChange?.();
102
+ };
103
+
104
+ /** A deck the beacon heard that nobody here has accepted: a row somebody
105
+ * can accept, and — when the ask switch for its route is on — asked. */
106
+ const heardStranger = entry => {
107
+ const cfg = settings();
108
+ const had = strangers.get(entry.fp);
109
+ // KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
110
+ // a fresh key — a second deck sharing one config directory does, by
111
+ // design — used to leave its old key in this map for a day, and every
112
+ // one of them drew a row offering to pair with the same machine.
113
+ if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
114
+ const own = entry.via === "tailscale" && !!routeTo(entry.addr)?.own;
115
+ strangers.set(entry.fp, { ...entry, own });
116
+ // ASK IT, which is what the `ask` verb on its row does and nothing
117
+ // more: the address goes on the dial list and the next round sends a
118
+ // request that somebody over there still has to answer. A beacon
119
+ // carries a fingerprint and no key, so nothing is pinned here — see
120
+ // accept, which is deliberate about the difference.
121
+ //
122
+ // Only a deck that is NEW is asked, or a beacon every thirty seconds
123
+ // would be thirty seconds of asking; and never one this deck's owner
124
+ // already turned away.
125
+ // Never `byHand`: a beacon is a shout from an address nobody here
126
+ // named, and the row it leaves may ask rather than pin.
127
+ // Over the tailnet only a machine on this person's own account is
128
+ // asked unprompted; any other is a row for somebody to decide on.
129
+ const mayAsk = asksOn(cfg, entry.via) && (entry.via !== "tailscale" || own);
130
+ if (mayAsk && !had && !declined.has(entry.fp) && !wasUnpaired(entry.fp)) { engineNow().accept(entry.fp, { byHand: false }); return; }
131
+ // Only a deck that is new to us is news. A beacon every thirty
132
+ // seconds from one already on the list is not a reason to redraw.
133
+ if (!had) onChange?.();
134
+ };
135
+
136
+ /**
137
+ * What a settings write does to the decks already waiting, given the
138
+ * settings before it — the engine's apply calls this with them, after the
139
+ * write is in force.
140
+ */
141
+ const switched = was => {
142
+ // A request made before invite-only was enabled must not survive the
143
+ // switch and become an automatic approval when automatic mode returns.
144
+ // A fresh handshake after that switch may request pairing again.
145
+ if (was.pairingMode !== "invite" && settings().pairingMode === "invite" && pending.size) {
146
+ pending.clear();
147
+ onChange?.();
148
+ }
149
+ // TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
150
+ // this on with two rows sitting in the panel means those two as much as
151
+ // the next one, and leaving them queued behind a setting called
152
+ // "automatic" is the switch not doing what it says.
153
+ //
154
+ // PER ROUTE, because each pair of switches answers for its own: turning
155
+ // the local one on does not answer a tailnet request, and turning the
156
+ // Tailscale one on answers only the owner's own machines.
157
+ const turnedOn = (f, via) => !f(was, via) && f(settings(), via);
158
+ const mayAnswer = p => (p.via === "tailscale" ? turnedOn(saysYesOn, "tailscale") && p.own : turnedOn(saysYesOn, "lan"));
159
+ for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) engineNow().accept(fp, { byHand: false });
160
+ // The same for the other direction: switching `ask` on with four machines
161
+ // already listed asks those four.
162
+ const mayAsk = p => (p.via === "tailscale" ? turnedOn(asksOn, "tailscale") && p.own : turnedOn(asksOn, "lan"));
163
+ for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !wasUnpaired(fp) && !p.pub && mayAsk(p)) engineNow().accept(fp, { byHand: false });
164
+ // OFF MEANS THE TAILNET GOES QUIET HERE: nobody heard over it is offered,
165
+ // and the engine's tailnet poll stops the reads. Decks already paired stay
166
+ // paired.
167
+ if (was.tailscale && !settings().tailscale) {
168
+ for (const [fp, p] of [...strangers]) if (p.via === "tailscale") strangers.delete(fp);
169
+ }
170
+ };
171
+
172
+ /** The heard decks status() offers to pair with — see its `strangers`. */
173
+ const strangerRows = () => {
174
+ // A deck that was told no is not somebody to offer pairing with. It
175
+ // has its own row, with the one control that undoes the decision.
176
+ const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
177
+ // pairable() collapses the rest by machine — see hostId. A computer
178
+ // that has run the deck a few times holds a key per run, and every
179
+ // one of them was a row of its own on everybody else's panel.
180
+ const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
181
+ return shown.map(p => ({
182
+ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more,
183
+ via: p.via ?? "lan", own: !!p.own,
184
+ }));
185
+ };
186
+
187
+ return {
188
+ askToAccept,
189
+ heardStranger,
190
+ switched,
191
+ /** Whether somebody here told this deck no — asked before a request is
192
+ * drawn, on both paths. */
193
+ isDeclined: fp => declined.has(fp),
194
+ /** The row a deck is waiting on: its request when it asked, its beacon
195
+ * when it was only heard, or null. */
196
+ seen: fp => (pending.get(fp) ?? null) ?? (strangers.get(fp) ?? null),
197
+ /** It was answered: off both lists. */
198
+ drop(fp) {
199
+ pending.delete(fp);
200
+ strangers.delete(fp);
201
+ },
202
+ /** A heard deck was asked: its address is on the dial list now, and its
203
+ * row comes back as a request once the round reaches it. */
204
+ dropHeard(fp) {
205
+ strangers.delete(fp);
206
+ },
207
+ /** Say no, and stop being asked. The deck comes off the first list it is
208
+ * on — its request if it asked, its heard row if it was only heard — and
209
+ * onto the declined list, which is what stops the asking: a dial from it
210
+ * is refused as "declined" (lan-socket.mjs), askToAccept draws no request
211
+ * for it, and nothing here asks it. Not a block, though: `allow` undoes it.
212
+ *
213
+ * A deck that asked AND was heard keeps its heard row. That is fine: the
214
+ * next beacon would write that row back whatever this did, because
215
+ * heardStranger records a declined deck like any other and only never
216
+ * asks it, and what offers or asks from the heard list — strangerRows,
217
+ * heardStranger and switched — checks the declined list first. */
218
+ dismiss(fp) {
219
+ // Whatever the row said, kept — the panel draws a declined deck by name
220
+ // and address, and after the delete below there is nowhere else to read
221
+ // them from.
222
+ const was = pending.get(fp) ?? strangers.get(fp) ?? null;
223
+ const had = pending.delete(fp) || strangers.delete(fp);
224
+ if (had) {
225
+ declined.set(fp, {
226
+ fp,
227
+ name: was?.name ?? fp,
228
+ addr: was?.addr ?? "",
229
+ port: was?.port ?? 0,
230
+ at: now(),
231
+ });
232
+ onChange?.();
233
+ }
234
+ return had;
235
+ },
236
+ /** Change your mind. The name comes off the declined list and the next time
237
+ * that deck dials, it is a request again — which it will, on its own, so
238
+ * there is nothing else to press. */
239
+ allow(fp) {
240
+ const had = declined.delete(fp);
241
+ if (had) onChange?.();
242
+ return had;
243
+ },
244
+ /** The rows status() draws for each list. */
245
+ pendingRows: () => [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at, via: p.via ?? "lan", own: !!p.own })),
246
+ strangerRows,
247
+ declinedRows: () => [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
248
+ };
249
+ }
@@ -0,0 +1,45 @@
1
+ // What the last round did with each deck, and when the whole round last
2
+ // finished: lifted out of createEngine in lan-engine.mjs with the two
3
+ // variables only these read and write, and the one rule read off them — is
4
+ // anybody on the other end still deciding. The round in the engine writes a
5
+ // record per deck it asked; the panel's rows, a deck's own `check now` and the
6
+ // clock that picks the next round's gap (lan-round-timer.mjs) read them.
7
+
8
+ /** `now` is the engine's clock. */
9
+ export function createRoundRecord({ now }) {
10
+ /** What the last round did, for the panel. Not a log: one line per peer, most
11
+ * recent only, because "what happened" is a question about now. */
12
+ const lastRound = new Map();
13
+ /** When the last round FINISHED, whatever it did or failed to do. The panel's
14
+ * `↻` fires one on demand and the loop fires one on its own; a reader who
15
+ * pressed it wants to know it happened, and a reader who did not wants to
16
+ * know the list is not a photograph of an hour ago. */
17
+ let roundAt = null;
18
+
19
+ /**
20
+ * Is anybody on the other end still deciding?
21
+ *
22
+ * The exact sentence lan-call.mjs gives for "a real deck, not yet
23
+ * accepted", which is the one state where dialling again in a few seconds
24
+ * does something a minute later would not.
25
+ */
26
+ const waitingOnSomebody = () =>
27
+ [...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
28
+
29
+ return {
30
+ /** What the last ask of a row did, or undefined. Keyed by the ROW the
31
+ * round dialled, which for a typed address is the placeholder built from
32
+ * it rather than the fingerprint that answered there. */
33
+ of: fp => lastRound.get(fp),
34
+ /** An ask of that row finished, well or not, and this is what it did. The
35
+ * record is kept as it is handed over, so the same object before and
36
+ * after a wait means nothing asked that row in between — see roundOne. */
37
+ keep(fp, record) { lastRound.set(fp, record); },
38
+ /** Nothing of that row is drawn any more: a dial-back taken away. */
39
+ drop(fp) { lastRound.delete(fp); },
40
+ /** A whole round finished — see roundAt. */
41
+ finished() { roundAt = now(); },
42
+ checkedAt: () => roundAt,
43
+ waitingOnSomebody,
44
+ };
45
+ }
@@ -0,0 +1,62 @@
1
+ // When the next sync round runs, lifted out of createEngine in lan-engine.mjs
2
+ // with the one variable only it used — the timer — and the two gaps it picks
3
+ // between. What a round does is the engine's, and the line every round waits
4
+ // in is lan-turns.mjs's; this is only the clock that asks for the next one.
5
+
6
+ /** How often a deck asks its peers what they have. A minute is far more often
7
+ * than a login dies, and it is what makes the panel's list feel live rather
8
+ * than something that updates when you press a button. */
9
+ export const SYNC_MS = 60_000;
10
+
11
+ /**
12
+ * How long to wait before dialling again while somebody is deciding.
13
+ *
14
+ * A minute is right for the steady state — two decks whose logins all work have
15
+ * nothing to say to each other — and it is far too long for the one moment
16
+ * anybody is watching: the seconds after somebody presses accept on the other
17
+ * machine. Reported as "it should work by itself", from a panel that had been
18
+ * correct for up to fifty-nine more seconds than the person in front of it.
19
+ *
20
+ * So the loop tightens while a request is outstanding and relaxes the moment it
21
+ * is answered — either way. A refusal is an answer, and a deck that said no is
22
+ * not asked every eight seconds.
23
+ */
24
+ export const ASKING_MS = 8_000;
25
+
26
+ /**
27
+ * `round` runs one whole round, the engine's own, which waits its turn in the
28
+ * line. `waiting` says whether a deck this one dialled is still deciding
29
+ * whether to accept it — the one state where asking again in a few seconds
30
+ * does something a minute later would not.
31
+ */
32
+ export function createRoundTimer({ round, waiting }) {
33
+ /** The next round, while one is scheduled. */
34
+ let timer = null;
35
+
36
+ /**
37
+ * Run a round a whole SYNC_MS after the listener comes up, and another each
38
+ * time the last one finishes, until `ended` says the start that began the
39
+ * loop is over — stopped, restarted or switched off.
40
+ */
41
+ const start = ended => {
42
+ // A self-scheduling loop rather than one interval, because the gap
43
+ // between rounds is not one number: see ASKING_MS.
44
+ const tick = async () => {
45
+ try { await round(); } catch { /* a round reports itself, per peer */ }
46
+ if (ended()) return;
47
+ timer = setTimeout(() => { void tick(); }, waiting() ? ASKING_MS : SYNC_MS);
48
+ timer.unref?.();
49
+ };
50
+ timer = setTimeout(() => { void tick(); }, SYNC_MS);
51
+ timer.unref?.();
52
+ };
53
+
54
+ /** Call off the round scheduled next. One already running finishes, and
55
+ * schedules nothing after it once its start has ended. */
56
+ const stop = () => {
57
+ if (timer) clearTimeout(timer);
58
+ timer = null;
59
+ };
60
+
61
+ return { start, stop };
62
+ }