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
@@ -0,0 +1,583 @@
1
+ // This deck's LAN sync: the one engine, built at import and told the settings;
2
+ // what prefs is and is not entitled to tell it; and the probe that asks whether
3
+ // other decks can reach this one.
4
+ //
5
+ // These lived in src/server/index.mjs, between the presence route and the LAN
6
+ // routes, and they could not leave while the settings they read were a `let`
7
+ // in that file that six of the engine's callbacks wrote back. prefs-state.mjs
8
+ // holds those settings now, so the callbacks write through it from here. The
9
+ // reach probe came with the engine because the two lean on each other: onPort
10
+ // forgets the verdict, and the verdict is measured off lanEngine.status().
11
+ //
12
+ // Still built at import, and at the same moment: index.mjs imports this
13
+ // statically, and neither the engine nor the tailnet reader opens anything
14
+ // until applyLanPrefs says so — which startServer does only from a listen that
15
+ // succeeded. The panel's routes, in lan-routes.mjs, drive the engine from
16
+ // here; the settings route, in prefs-routes.mjs, imports applyLanPrefs and
17
+ // forgetReach, and startServer, in index.mjs, applyLanPrefs and resetLanLoaded.
18
+ import { readFile } from "node:fs/promises";
19
+ import { networkInterfaces } from "node:os";
20
+ import { PRODUCT } from "./brand.mjs";
21
+ import { lanEnabled, withManualEntry, withShared } from "./deck-prefs.mjs";
22
+ import { heldPrefs } from "./prefs-state.mjs";
23
+ import { createEngine, defaultName } from "./lan-engine.mjs";
24
+ import { createTailnet } from "./tailscale.mjs";
25
+ import { portHolder } from "./port-holder.mjs";
26
+ import { createRouteCheck } from "./route-via.mjs";
27
+ import { DISCOVERY_PORT } from "./lan-beacon.mjs";
28
+ import { aboutThisDeck } from "./lan-about.mjs";
29
+ import { MAC_FW, PROBE_PS, UFW_CONF, UFW_DEFAULTS, isActive, localAliases, reachability, readMacProbe, readProbe, readUfw, silentInbound } from "./lan-reach.mjs";
30
+ import { run } from "./exec.mjs";
31
+ // A LAN round checks the imports it lands the way the paste box does.
32
+ import { CHECKS_IMPORTS } from "./account-routes.mjs";
33
+ import { RUNNING_VERSION } from "./running-version.mjs";
34
+
35
+ // ── LAN sync ────────────────────────────────────────────────────────────────
36
+ //
37
+ // The engine is built once and told the settings; it opens and closes its own
38
+ // sockets as those change. Nothing here touches a credential — see
39
+ // lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
40
+ /** This machine's view of its tailnet, read through the Tailscale CLI — see
41
+ * tailscale.mjs. Built whatever the switch says: the dialog asks it whether
42
+ * Tailscale is here at all before anybody can turn discovery on. */
43
+ export const tailnet = createTailnet();
44
+
45
+ const ACTIVE_VERDICT_FIRST_RETRY_MS = 60_000;
46
+ const ACTIVE_VERDICT_MAX_RETRY_MS = 30 * 60_000;
47
+ const LIVE_LOGIN_TIMEOUT_MS = 5_000;
48
+ let activeVerdictRetryMs = ACTIVE_VERDICT_FIRST_RETRY_MS;
49
+ let activeVerdictNextAt = 0;
50
+ // A claude-swap that never reports a verdict for the active slot would
51
+ // otherwise cost a usage collection every minute for as long as LAN is on.
52
+ function refreshActiveVerdict() {
53
+ if (Date.now() < activeVerdictNextAt) return;
54
+ activeVerdictNextAt = Date.now() + activeVerdictRetryMs;
55
+ activeVerdictRetryMs = Math.min(activeVerdictRetryMs * 2, ACTIVE_VERDICT_MAX_RETRY_MS);
56
+ void import("./claude-accounts.mjs")
57
+ .then(({ verdictsNow, invalidateClaudeAccountsCache }) =>
58
+ verdictsNow().then(got => { if (got) invalidateClaudeAccountsCache(); }))
59
+ .catch(() => {});
60
+ }
61
+ function activeVerdictArrived() {
62
+ activeVerdictRetryMs = ACTIVE_VERDICT_FIRST_RETRY_MS;
63
+ activeVerdictNextAt = 0;
64
+ }
65
+
66
+ export const lanEngine = createEngine({
67
+ tailnet,
68
+ // Who holds the discovery port when it is taken, so the panel can say.
69
+ portHolder: () => portHolder(DISCOVERY_PORT),
70
+ // Where each broadcast would leave by, so none goes into a VPN tunnel.
71
+ routes: createRouteCheck(),
72
+ // This deck's version and machine, for the decks it is paired with and
73
+ // nobody else. RUNNING_VERSION rather than a fresh read, for the reason it
74
+ // is read at import: it is what this process actually runs.
75
+ about: aboutThisDeck({ version: RUNNING_VERSION }),
76
+ readAccounts: async () => {
77
+ const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
78
+ const got = await fetchClaudeAccounts();
79
+ // A login this Mac's Keychain will not open from the deck's session. The
80
+ // engine refuses to export it with a fixed code rather than spawning an
81
+ // export that can only fail. Mac only: claude-swap's `keychain_unavailable`
82
+ // elsewhere is an unreadable .enc file, and the sentence the peer prints
83
+ // talks about a Keychain.
84
+ if (!got || !Array.isArray(got.accounts)) return got;
85
+ // The active slot is withheld from peers until it has a fresh verdict — see
86
+ // cachedExportReadable — so ask for one now rather than waiting on the
87
+ // collector's own schedule.
88
+ if (got.accounts.some(a => a.active === true && a.collector == null)) refreshActiveVerdict();
89
+ else activeVerdictArrived();
90
+ const { markUnreadable } = await import("./cswap-admin.mjs");
91
+ return { ...got, accounts: markUnreadable(got.accounts) };
92
+ },
93
+ // Bounded well inside a round, since a want waits on it.
94
+ liveLogin: async () => {
95
+ const { currentIdentity } = await import("./cswap-admin.mjs");
96
+ const late = new Promise(resolve => setTimeout(resolve, LIVE_LOGIN_TIMEOUT_MS, null).unref?.());
97
+ return Promise.race([currentIdentity().catch(() => null), late]);
98
+ },
99
+ exportAccount: async (num, expectedKey) => {
100
+ const { accountKey } = await import("./lan-sync.mjs");
101
+ const { shareAccounts, unwrapShare } = await import("./cswap-admin.mjs");
102
+ // Do not collect live verdicts here. A LAN want has a ten-second round
103
+ // budget, while a verdict collection may wait up to ninety seconds (or
104
+ // queue behind another one). The want handler already re-reads this deck's
105
+ // cached account state immediately before export and refuses known
106
+ // unreadable/dead copies. The share itself is therefore the only bounded
107
+ // operation left on the hot path.
108
+ //
109
+ // Without the explanation, which would outlast the asking peer's patience;
110
+ // see shareAccounts.
111
+ const out = await shareAccounts([String(num)], { explain: false });
112
+ // The blob or nothing. WHY it failed is never carried out of here: the
113
+ // engine decides what a peer is told from this deck's state — see
114
+ // `readable` in readAccounts below.
115
+ if (!out?.ok) return null;
116
+ const opened = unwrapShare(out.blob);
117
+ if (!opened.ok) return null;
118
+ let accounts;
119
+ try { accounts = JSON.parse(opened.payload)?.accounts; } catch { return null; }
120
+ if (!Array.isArray(accounts) || accounts.length !== 1) return null;
121
+ // Slot numbers are local. Verify the payload identity after export so a
122
+ // moved/reused slot can never satisfy a want for another account.
123
+ return accountKey(accounts[0]?.email, accounts[0]?.organizationUuid) === expectedKey ? out.blob : null;
124
+ },
125
+ // The logins a round just imported, checked once after it — see checkImports.
126
+ checkArrivals: async steps => {
127
+ const { checkImports } = await import("./cswap-admin.mjs");
128
+ return checkImports(steps.map(step => {
129
+ const [email, org] = String(step?.key ?? "").split("@@");
130
+ return { email, org: org ?? "" };
131
+ }));
132
+ },
133
+ importAccount: async (blob, step) => {
134
+ const { importAccount, fillEmptySlot, landed } = await import("./cswap-admin.mjs");
135
+ const [want, wantOrg] = String(step?.key ?? "").split("@@");
136
+ // NO `force`, ever, and this is the line where that promise is kept. A
137
+ // plain import adds an account that is missing and replaces exactly one
138
+ // claude-swap has quarantined; it SKIPS one that is present and healthy.
139
+ // So a peer cannot overwrite a working credential of this deck's even by
140
+ // lying about its own, because the flag that would allow it is not passed.
141
+ //
142
+ // NARROWED TO THE ONE ACCOUNT ASKED FOR, for the same reason the forced
143
+ // import below is, and this line did not have it. The seal's AAD is
144
+ // `${peerFp}->${identity.fp}|${step.key}` — it binds the ENVELOPE to the
145
+ // key that was requested and says nothing about the contents, and a share
146
+ // payload is `{ accounts: [...] }`, a bundle rather than one credential.
147
+ // So a peer asked for A could seal, under A's AAD, a bundle carrying A
148
+ // plus B, C and D it never listed in its manifest, and every one of them
149
+ // landed: `syncAction` answers "add" for anything this deck lacks, and an
150
+ // add does not need the owner's tick. The panel drew A and the store
151
+ // gained four. `only` narrows without implying `force` — `overwrite` is
152
+ // `force === true && narrowing` — so the promise above survives word for
153
+ // word, and a bundle that does not carry what was asked for is refused
154
+ // rather than unpacked.
155
+ const out = await importAccount(blob, { only: { email: want, org: wantOrg ?? "" }, collect: !CHECKS_IMPORTS });
156
+ if (!out?.ok) return { ok: false, why: out?.reason ?? "import refused" };
157
+ // A SKIP IS NOT A HEAL, and reading `ok` alone said it was. `cswap import`
158
+ // exits ZERO when it declines an account it already holds, so a round that
159
+ // changed nothing was counted as a successful repair and the panel said the
160
+ // account had been fixed. Whoever read that then waited for numbers that
161
+ // were never going to move.
162
+ //
163
+ // `landed`, NOT `added`: `added` counts new slots only, and a plain import
164
+ // that replaces a quarantined slot is `healed` — the one repair this path
165
+ // exists for. Reading `added` reported every such heal as a failure and
166
+ // then sent it on to fillEmptySlot, which declined it. A slot that stayed
167
+ // `present` is the decline.
168
+ //
169
+ // The decline is narrow and documented on claude-swap's side: a plain
170
+ // import replaces a slot only when its usage row is quarantined as
171
+ // refresh-token-dead, and is "never triggered by the live store's
172
+ // `no credentials` state". So an account whose login expired heals over the
173
+ // network, and one that has NO stored login does not — which is a true
174
+ // sentence the panel can now print instead of a false one.
175
+ if (landed(out.results)) return { ok: true };
176
+
177
+ // THE DECLINE, AND WHY IT MATTERS WHICH ONE IT IS.
178
+ //
179
+ // `cswap import` without --force declines for two opposite reasons and says
180
+ // the same thing about both: the slot is present and HEALTHY, which is the
181
+ // promise above working exactly as intended — or the slot is EMPTY, which
182
+ // is the one case pairing exists for and the one a plain import will never
183
+ // touch. claude-swap is explicit about the second: a plain import replaces
184
+ // a slot "iff its usage row is quarantined as refresh-token-dead", and is
185
+ // "never triggered by the live store's `no credentials` state".
186
+ //
187
+ // So `login expired` healed over the network and `no stored login` did not,
188
+ // and the account somebody most needed repaired was the one the feature
189
+ // could not repair.
190
+ //
191
+ // FILLING AN EMPTY SLOT, WHICH IS NOT OVERWRITING A WORKING ONE — and the
192
+ // promise above survives word for word. A peer cannot reach it: the verdict
193
+ // comes from THIS machine's claude-swap, about THIS machine's store, and
194
+ // nothing a peer sends can make a slot report that it holds nothing.
195
+ //
196
+ // THE VERDICT AND THE WRITE ARE ONE CRITICAL SECTION, which is why this is
197
+ // one call and not the pair it used to be (#1040).
198
+ //
199
+ // The verdict was already being asked for fresh rather than read from the
200
+ // ten-minute cache, under a comment naming the exact reason: "somebody who
201
+ // signed in two minutes ago still reads as `no_credentials` there, and
202
+ // acting on that would replace the login they had just created". That
203
+ // sentence is kept, and the read that served it moved rather than went —
204
+ // see fillEmptySlot, which opens with it.
205
+ //
206
+ // Freshness alone only SHORTENED the window. The asking happened out here
207
+ // while the writing took the store mutex inside importAccount, so the two
208
+ // were never one critical section: `registerSignedIn` holds that mutex for
209
+ // `cswap add` (60 s), `cswap list` (60 s) and `restoreActive`'s `cswap
210
+ // switch` (30 s), and a forced import queued behind one still acted on a
211
+ // verdict taken before any of it began. fillEmptySlot re-reads the verdict
212
+ // as its first statement INSIDE the lock, so the promise holds by
213
+ // construction rather than by how long the queue happened to be.
214
+ return fillEmptySlot(blob, { email: want, org: wantOrg ?? "", collect: !CHECKS_IMPORTS });
215
+ },
216
+ // The deck's own long-term key, kept so a restart is the same deck rather
217
+ // than a stranger to everybody who has paired with it. Written once, on the
218
+ // first start that has none. It IS a secret — prefs.json is 0600 for this.
219
+ // The port it actually got, kept so an address typed on the other machine
220
+ // still reaches this deck after it restarts. Written only when it differs
221
+ // from what is stored, so a normal start writes nothing.
222
+ onPort: async port => {
223
+ try { await heldPrefs.write({ lan: { port } }); }
224
+ catch { /* the address field still works this session; next start re-pins */ }
225
+ // The Linux fix lines name this number, so a verdict taken before the
226
+ // listener had one is missing half of them — see forgetReach.
227
+ forgetReach();
228
+ },
229
+ // An account arrived from a paired deck, and this deck now offers it too
230
+ // (#1188). Written before the engine hears it, so a restart between the two
231
+ // leaves the tick on disk rather than only in the running engine's copy.
232
+ onShared: async key => {
233
+ try {
234
+ const before = heldPrefs.current();
235
+ const after = await heldPrefs.update(withShared(key));
236
+ if (after?.lan?.shared === before?.lan?.shared) return; // it was already ticked
237
+ await lanEngine.apply({ shared: after.lan.shared });
238
+ } catch (err) {
239
+ console.error(`${PRODUCT}: lan sync could not share the account it just received:`, err?.message ?? err);
240
+ }
241
+ },
242
+ // A deck was accepted or unpaired. Written straight through, because the
243
+ // trusted list is the whole of who this deck will talk to and a list that
244
+ // only existed in memory would drop every pairing on restart.
245
+ onTrust: async trusted => {
246
+ try { await heldPrefs.write({ lan: { trusted } }); }
247
+ catch (err) { console.error(`${PRODUCT}: lan sync could not save the pairing:`, err?.message ?? err); }
248
+ },
249
+ // An explicit unpair must outlive the process too. Manual dial rows are kept
250
+ // deliberately, so without this marker the next successful round could pin
251
+ // the same fingerprint again without another press.
252
+ onUnpaired: async unpaired => {
253
+ try { await heldPrefs.write({ lan: { unpaired } }); }
254
+ catch (err) { console.error(`${PRODUCT}: lan sync could not save the unpair decision:`, err?.message ?? err); }
255
+ },
256
+ // An address this deck must keep dialling: the far end of a pairing that
257
+ // happened over an invite. Kept in prefs, because setPeers replaces the dial
258
+ // list wholesale on every settings write and a row that lives only in memory
259
+ // is a pairing that goes one-way at the next restart.
260
+ onDial: async entry => {
261
+ try {
262
+ // COMPUTED INSIDE THE JOB, out of what the write is about to read, rather
263
+ // than out of `heldPrefs.current()` — which is an in-memory copy refreshed
264
+ // only when a previous write resolves, not "the one on disk". `writePrefs`
265
+ // merges field by field and cannot merge two writes of one field: the
266
+ // patch here is the WHOLE array, so a stale one wins. Pressing accept on a
267
+ // heard deck at the moment an invite round fires this dropped the dialled
268
+ // address out of `lan.manual`, which is the failure the comment above
269
+ // describes.
270
+ const next = await heldPrefs.update(withManualEntry(entry));
271
+ // AND RECONCILE THE ENGINE'S DIAL LIST with what was just written. The
272
+ // disk is now right, but `setPeers` replaces the list wholesale from the
273
+ // held prefs on every settings write — so a write that raced this one, and
274
+ // whose copy predates it, would still take the address away.
275
+ lanEngine.setPeers(next.lan.manual);
276
+ } catch { /* dialled this session; the next round re-adds it */ }
277
+ },
278
+ onIdentity: async secret => {
279
+ try {
280
+ await heldPrefs.write({ lan: { secret } });
281
+ // AND PUT IT TO WORK. Writing it alone was not enough: a clash was
282
+ // detected, a new id was stored, and both decks kept broadcasting the old
283
+ // one — so they stayed invisible to each other with a correct file on
284
+ // disk. `apply` restarts only when the id it is holding differs from the
285
+ // one it is given, so this settles after one pass rather than looping.
286
+ //
287
+ // The key is handed over HERE rather than read back off the held prefs,
288
+ // because `applyLanPrefs` no longer round-trips the fields the engine
289
+ // authors — see what it does and does not pass. This is the one caller
290
+ // that legitimately changes one of them, and it is holding the new value.
291
+ await applyLanPrefs({ secret });
292
+ } catch { /* the next start picks it up; a shared id is the cost until then */ }
293
+ },
294
+ onError: (what, err) => {
295
+ // Reported, never thrown. A machine with no route, a firewall that refuses
296
+ // the bind, an interface that comes and goes with a VPN — none of them is a
297
+ // reason for the deck to fall over.
298
+ if (what !== "other-group") console.error(`${PRODUCT}: lan sync (${what}):`, err?.message ?? err);
299
+ },
300
+ });
301
+
302
+ /**
303
+ * What prefs is entitled to tell the LAN engine, and what it is not.
304
+ *
305
+ * FOUR FIELDS ARE THE ENGINE'S OWN AND ARE LOADED ONCE. `trusted`, `unpaired`,
306
+ * `secret` and `port` are written BY the engine, through `onTrust`,
307
+ * `onUnpaired`, `onIdentity` and `onPort` — prefs is where they are kept between runs, not where they are
308
+ * decided. Handing them back on every settings write round-trips the engine's
309
+ * live state through a module-level copy of a file, and that copy is stale for
310
+ * as long as one of those writes is queued.
311
+ *
312
+ * WHAT THAT COST, reproduced with two real engines and a real handshake: press
313
+ * accept on deck B, and `lanEngine.accept` adds it to `cfg.trusted`
314
+ * synchronously and queues `onTrust`. In the same second another tab flips
315
+ * notifications; that handler's `writePrefs` is queued BEHIND `onTrust`'s, but
316
+ * its `applyLanPrefs` runs on the prefs that write kept — merged from a disk read
317
+ * taken before `onTrust` wrote. `apply` does `cfg = { ...cfg, ...next }`, so the
318
+ * whole array is replaced:
319
+ *
320
+ * B trusted after accept : old-deck-fp, 6ef-127-19e-79e
321
+ * B trusted after a plain settings write: old-deck-fp
322
+ *
323
+ * `GET /api/lan` reads `lanEngine.status()`, so the panel then shows B unpaired
324
+ * while prefs.json says paired, and B's calls are refused. The next `onTrust`
325
+ * from any source writes `cfg.trusted` back to disk without B, making it
326
+ * permanent.
327
+ *
328
+ * So `load` is true exactly once per boot, where reading them off the file IS
329
+ * the right answer, and false thereafter. The one caller that legitimately
330
+ * changes one of them afterwards passes it in directly — see `onIdentity`.
331
+ *
332
+ * Exported because it is the rule rather than the plumbing, and the plumbing
333
+ * around it is a module-level engine no test can reach.
334
+ */
335
+ export function lanApplyFields(prefs, { load = false, env = process.env } = {}) {
336
+ const lan = prefs?.lan ?? {};
337
+ const page = {
338
+ // The file's answer unless the machine said no: AGENTS_DECK_NO_LAN=1 is
339
+ // how a launch script — or this repo's own test suite — keeps a deck off
340
+ // the network now that the default is on.
341
+ enabled: lanEnabled(prefs, env),
342
+ name: lan.name || defaultName(),
343
+ shared: Array.isArray(lan.shared) ? lan.shared : [],
344
+ autoAsk: lan.autoAsk !== false,
345
+ autoAccept: lan.autoAccept !== false,
346
+ pairingMode: lan.pairingMode === "invite" ? "invite" : "automatic",
347
+ // Whether paired decks are told which shared account this one is on.
348
+ shareActive: lan.shareActive !== false,
349
+ // Discovery over Tailscale, off unless somebody turned it on, and the two
350
+ // permissions that answer for the owner's own machines there.
351
+ tailscale: lan.tailscale === true,
352
+ tailscaleAsk: lan.tailscaleAsk !== false,
353
+ tailscaleAccept: lan.tailscaleAccept !== false,
354
+ // Names somebody here gave other decks. The engine only hands them to
355
+ // the page, so a change never restarts anything.
356
+ aliases: lan.aliases && typeof lan.aliases === "object" ? lan.aliases : {},
357
+ };
358
+ if (!load) return page;
359
+ return {
360
+ ...page,
361
+ secret: lan.secret || "",
362
+ trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
363
+ unpaired: Array.isArray(lan.unpaired) ? lan.unpaired : [],
364
+ port: lan.port || 0,
365
+ };
366
+ }
367
+
368
+ /** Whether the engine has already been handed the fields it authors.
369
+ * Reset by `startServer`, through resetLanLoaded, because a boot is what
370
+ * reads them off the file. */
371
+ let _lanLoaded = false;
372
+
373
+ /** The next applyLanPrefs hands the engine the fields it authors, off the file
374
+ * — which is what a boot is. startServer's call, and nobody else's. */
375
+ export function resetLanLoaded() { _lanLoaded = false; }
376
+
377
+ /** Push whatever is in prefs at the engine. Called at boot and after every
378
+ * write, so there is one source of truth and it is the file — for the fields
379
+ * the file is the source of truth FOR. See `lanApplyFields`.
380
+ *
381
+ * `also` is for a caller holding a value the engine itself just produced and
382
+ * that has to take effect now; nothing else may name one of the three. */
383
+ export async function applyLanPrefs(also = null) {
384
+ const prefs = heldPrefs.current();
385
+ const lan = prefs?.lan ?? {};
386
+ const load = !_lanLoaded;
387
+ _lanLoaded = true;
388
+ try {
389
+ await lanEngine.apply({ ...lanApplyFields(prefs, { load }), ...(also ?? {}) });
390
+ // Wholesale, so removing an address in the panel really stops it being
391
+ // dialled rather than only taking the row away.
392
+ lanEngine.setPeers(lan.manual);
393
+ } catch (err) {
394
+ console.error(`${PRODUCT}: lan sync could not start:`, err?.message ?? err);
395
+ }
396
+ }
397
+
398
+ // ── can other decks reach this one ──────────────────────────────────────────
399
+ //
400
+ // NEVER IN THE REQUEST. The panel polls `GET /api/lan`, and the probe is a
401
+ // PowerShell start-up — the better part of a second on a warm machine and
402
+ // worse on a cold one. So the route answers with whatever the last probe said
403
+ // and starts the next one behind it. The first poll after a deck starts
404
+ // carries no verdict, which is correct rather than merely tolerable: at that
405
+ // point the deck has not been listening long enough for an empty list to mean
406
+ // anything either.
407
+ //
408
+ // STALE IS THE RIGHT DEFAULT HERE. What this measures — a network's category, a
409
+ // firewall rule — changes when somebody changes it, which is a thing they do
410
+ // while looking at the instructions this produced. Five minutes is far tighter
411
+ // than that, and the panel's own reload picks up the change on the next poll.
412
+ let reachSaid = null;
413
+ let reachAt = 0;
414
+ let reachBusy = false;
415
+ const REACH_MS = 5 * 60_000;
416
+
417
+ /**
418
+ * What Linux can be asked without root: whether a firewall is running, and what
419
+ * it does with an inbound packet no rule matched.
420
+ *
421
+ * Three reads, none of them privileged. `systemctl is-active` answers anybody;
422
+ * both ufw files are 0644. The rules themselves are root-only on every one of
423
+ * these tools, which is why the verdict leans on the measurement instead — see
424
+ * the linux block in lan-reach.mjs.
425
+ */
426
+ async function probeLinux() {
427
+ const [conf, defaults, ufwUnit, fwUnit] = await Promise.all([
428
+ readFile(UFW_CONF, "utf8").catch(() => null),
429
+ readFile(UFW_DEFAULTS, "utf8").catch(() => null),
430
+ run("systemctl", ["is-active", "ufw"], { timeout: 5_000 }).catch(() => null),
431
+ run("systemctl", ["is-active", "firewalld"], { timeout: 5_000 }).catch(() => null),
432
+ ]);
433
+ const ufw = readUfw(conf, defaults);
434
+ return {
435
+ // ON means both: the config says yes and the unit is running. A machine
436
+ // where somebody ran `ufw disable` keeps ENABLED=no in the file, and one
437
+ // where the unit was masked keeps ENABLED=yes in it — neither is blocking
438
+ // anything, and claiming otherwise sends a person to fix what is not broken.
439
+ ufw: { ...ufw, enabled: ufw.enabled && isActive(ufwUnit?.stdout) },
440
+ firewalld: isActive(fwUnit?.stdout),
441
+ syncPort: lanEngine.status().port,
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Forget what the last probe said, so the next poll measures rather than
447
+ * answering out of the five-minute cache.
448
+ *
449
+ * TWO MOMENTS EARN IT, and both are moments where the held answer is about a
450
+ * machine in a different state from the one being asked about.
451
+ *
452
+ * A SWITCH-ON is the one somebody is watching. The panel starts polling every
453
+ * five seconds the instant it goes on, and without this the first several
454
+ * minutes of that are a verdict taken while the sockets were down — which is
455
+ * the exact question they turned it on to have answered. Five minutes is the
456
+ * right staleness for a firewall nobody is touching and the wrong one for the
457
+ * press that starts the feature.
458
+ *
459
+ * THE SYNC PORT LANDING changes what the answer CONTAINS on Linux: the fix
460
+ * lines name a TCP port (see linuxFixSteps), and a verdict taken before the
461
+ * listener had one offers the discovery line alone — half an answer, pinned for
462
+ * five minutes, on the machine that most needs the other half.
463
+ */
464
+ export function forgetReach() { reachAt = 0; }
465
+
466
+ /**
467
+ * What the macOS application firewall says about this deck.
468
+ *
469
+ * Three reads, none of them privileged — measured on macOS 26.6, where all of
470
+ * them answer an ordinary user with exit 0 and no password. See the macos block
471
+ * in lan-reach.mjs for why this platform can be asked the exact question about
472
+ * its own binary while Linux has to reason from a default policy.
473
+ *
474
+ * A call that fails comes back as an empty string rather than as a throw, which
475
+ * readMacProbe turns into a null field and macReach turns into silence: a Mac
476
+ * that will not answer is a Mac this says nothing about.
477
+ */
478
+ async function probeMac() {
479
+ const ask = args => run(MAC_FW, args, { timeout: 5_000 }).then(r => (r?.ok ? r.stdout : "")).catch(() => "");
480
+ const [globalState, blockAll, app] = await Promise.all([
481
+ ask(["--getglobalstate"]),
482
+ ask(["--getblockall"]),
483
+ // The binary the LISTENER runs as, which is this process's own executable —
484
+ // the application firewall's rules are about a program, never about a port.
485
+ ask(["--getappblocked", process.execPath]),
486
+ ]);
487
+ return readMacProbe({ global: globalState, blockAll, app });
488
+ }
489
+
490
+ /** How recently a beacon has to have arrived to count as a machine this deck
491
+ * can hear. Three announce intervals, which is the same window the panel calls
492
+ * `now` — one dropped broadcast must not retract the claim. */
493
+ const HEARD_MS = 90_000;
494
+
495
+ /**
496
+ * The verdict for a machine no probe could speak about — see silentInbound.
497
+ *
498
+ * BOTH LISTS ARE COUNTED. `strangers` is already one row per machine, and a
499
+ * deck LEAVES it for `peers` the moment somebody pairs with it — so counting
500
+ * strangers alone would make a deck that has paired with everything it can hear
501
+ * look like a deck that hears nobody, which is the one state this must not
502
+ * confuse with being blocked.
503
+ */
504
+ function measuredReach() {
505
+ const st = lanEngine.status();
506
+ const at = Date.now();
507
+ const fresh = p => typeof p?.lastSeen === "number" && at - p.lastSeen < HEARD_MS;
508
+ const heard = (st.strangers?.length ?? 0) + (st.peers ?? []).filter(fresh).length;
509
+ return silentInbound({ heard, listeningSince: st.listeningSince, inbound: st.inboundAt, now: at });
510
+ }
511
+
512
+ export function refreshReach() {
513
+ if (reachBusy) return;
514
+ if (reachAt && Date.now() - reachAt < REACH_MS) return;
515
+ reachBusy = true;
516
+ if (process.platform === "linux") {
517
+ probeLinux().then(linux => {
518
+ // A read of the real configuration beats an inference from silence, so
519
+ // the measurement only speaks where the probe had no opinion at all.
520
+ reachSaid = reachability({ platform: "linux", linux, inbound: lanEngine.status().inboundAt })
521
+ ?? measuredReach();
522
+ }).catch(() => {
523
+ reachSaid = measuredReach();
524
+ }).finally(() => {
525
+ reachAt = Date.now();
526
+ reachBusy = false;
527
+ });
528
+ return;
529
+ }
530
+ if (process.platform === "darwin") {
531
+ probeMac().then(mac => {
532
+ reachSaid = reachability({
533
+ platform: "darwin",
534
+ mac,
535
+ // The same path the probe asked about, so the lines somebody pastes name
536
+ // the binary the verdict was taken on.
537
+ exePath: process.execPath,
538
+ inbound: lanEngine.status().inboundAt,
539
+ }) ?? measuredReach();
540
+ }).catch(() => {
541
+ reachSaid = measuredReach();
542
+ }).finally(() => {
543
+ reachAt = Date.now();
544
+ reachBusy = false;
545
+ });
546
+ return;
547
+ }
548
+ // EVERY OTHER PLATFORM, which used to return before reaching any of this and
549
+ // therefore said nothing forever. FreeBSD, an unrecognised Linux, anything
550
+ // node runs on: no probe to run, and the measurement needs none.
551
+ if (process.platform !== "win32") {
552
+ reachSaid = measuredReach();
553
+ reachAt = Date.now();
554
+ reachBusy = false;
555
+ return;
556
+ }
557
+ run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PROBE_PS], {
558
+ // The program path goes through the environment rather than into the
559
+ // script, so a path with a quote in it cannot close the string it sits in.
560
+ env: { ...process.env, CCDECK_EXE: process.execPath },
561
+ timeout: 25_000,
562
+ }).then(r => {
563
+ reachSaid = reachability({
564
+ platform: process.platform,
565
+ probe: r?.ok ? readProbe(r.stdout) : null,
566
+ aliases: localAliases(networkInterfaces()),
567
+ exePath: process.execPath,
568
+ inbound: lanEngine.status().inboundAt,
569
+ }) ?? measuredReach();
570
+ }).catch(() => {
571
+ // A probe that did not run is exactly the machine silentInbound was written
572
+ // for: nothing can be asked, so what is left is what was measured. It is
573
+ // never an error the panel shows — nobody asked for it.
574
+ reachSaid = measuredReach();
575
+ }).finally(() => {
576
+ reachAt = Date.now();
577
+ reachBusy = false;
578
+ });
579
+ }
580
+
581
+ /** What the last probe said, for the route to answer with: null until one has
582
+ * finished. Read, never waited on — see the top of this section. */
583
+ export function lastReach() { return reachSaid; }