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,139 @@
1
+ // Following an append-only JSONL one bounded chunk at a time: the read that
2
+ // hands back only complete lines and moves a cursor past exactly the bytes they
3
+ // occupy, and the ceiling on what any one read may allocate.
4
+ //
5
+ // These lived in src/server/transcript-scan.mjs, which still reads every Claude
6
+ // transcript through them, and the Codex side borrowed them from there: the
7
+ // rollout watcher tails rollouts with readAppendedLines, and
8
+ // codex-enrichment.mjs reads a rollout's head and tail with readByteRange. A
9
+ // reader that knows nothing about what either kind of file says is a module of
10
+ // its own. The bodies are unchanged, and index.mjs still re-exports
11
+ // MAX_SCAN_CHUNK and readAppendedLines.
12
+ import { open } from "node:fs/promises";
13
+
14
+ // ─── Following an append-only JSONL, one bounded chunk at a time ─────────
15
+ // The most bytes ONE read of a transcript may allocate, and the ceiling that
16
+ // makes the size of the file irrelevant to the size of the allocation.
17
+ //
18
+ // #674 is what its absence cost. `readByteRange` was called with `to` set to
19
+ // `stat().size` on a path that arrives, unvalidated, in the body of a
20
+ // credential-free `POST /api/event`, and it answered by allocating the whole of
21
+ // it — once as a zero-filled Buffer and again as the string `toString` builds
22
+ // from it. Measured here on Node 22.14 / macOS against a real deck on loopback,
23
+ // sampling `process.memoryUsage.rss()` every 5 ms:
24
+ //
25
+ // 400 MB file, one POST: 51MB -> 848MB (2x: the Buffer and the string)
26
+ // 700 MB file, one POST: 52MB -> 753MB (1x: 700 MB is past V8's ~512 MB
27
+ // max string, so `toString` threw
28
+ // into scanTranscript's catch and
29
+ // only the Buffer was paid for)
30
+ //
31
+ // Both answered 200. Neither is a one-off: see `readAppendedLines` for the
32
+ // second half of that defect, the cursor that could not advance.
33
+ //
34
+ // 8 MiB is picked against the only measurement that bears on it — the longest
35
+ // single line in a real transcript. The note above `maybeResolveSessionName`
36
+ // in session-enrichment.mjs measures that at 710 KB in the 46.4 MB session on
37
+ // this machine, one big tool result on one line, so the ceiling is about eleven
38
+ // times the worst line seen and no real transcript line is ever split by it. Its worst
39
+ // case is 8 MiB of Buffer plus up to 8 MiB of string per in-flight scan, which
40
+ // is an eighth of the 128 MiB the ring buffer is already allowed to hold.
41
+ export const MAX_SCAN_CHUNK = 8 * 1024 * 1024;
42
+
43
+ const NEWLINE = 0x0a;
44
+
45
+ /** Up to MAX_SCAN_CHUNK bytes of `path` from `from`, as a Buffer of exactly the
46
+ * bytes that were read. The cap is here rather than at the call sites so that
47
+ * no caller — including one added later — can name a range that allocates more
48
+ * than the ceiling above. */
49
+ async function readByteChunk(path, from, to) {
50
+ const len = Math.min(to - from, MAX_SCAN_CHUNK);
51
+ if (len <= 0) return Buffer.alloc(0);
52
+ const fh = await open(path, "r");
53
+ try {
54
+ const buf = Buffer.alloc(len);
55
+ const { bytesRead } = await fh.read(buf, 0, len, from);
56
+ return bytesRead === len ? buf : buf.subarray(0, bytesRead);
57
+ } finally {
58
+ await fh.close();
59
+ }
60
+ }
61
+
62
+ async function readByteRange(path, from, to) {
63
+ return (await readByteChunk(path, from, to)).toString("utf8");
64
+ }
65
+
66
+ /**
67
+ * The next batch of COMPLETE lines appended to a JSONL file, and the cursor
68
+ * moved past exactly the bytes they occupy.
69
+ *
70
+ * `cursor` is `{ offset, midLine }` and is written in place; `size` is the
71
+ * caller's `stat()` reading. Returns `{ text, advanced }` where `advanced` is
72
+ * the number of bytes the cursor moved — zero means "nothing complete to fold
73
+ * yet", which is the caller's signal to stop.
74
+ *
75
+ * THE THREE THINGS THIS HAS TO GET RIGHT, and the one it used to get wrong:
76
+ *
77
+ * 1. A partial last line is not an error. A transcript is being appended to
78
+ * while it is read, so the tail of any chunk that ends at EOF is routinely
79
+ * half a line. Those bytes stay unread and the cursor does not move —
80
+ * the next pass sees the whole line once its newline lands.
81
+ *
82
+ * 2. A chunk with no newline in it that DOES NOT end at EOF is a different
83
+ * thing entirely, and the old code could not tell the two apart: it
84
+ * returned without advancing in both cases. For any file with no newline in
85
+ * it — any binary, a sparse file a caller makes for the purpose — that made
86
+ * the early return permanent, so every later POST re-read the whole file
87
+ * from byte 0. Measured before the fix, the same 400 MB file: 797 MB on the
88
+ * first post, 400 MB on the second, 400 MB on the third. Here that case is
89
+ * what `midLine` names: a full chunk with no line boundary anywhere in it
90
+ * is a line longer than the ceiling, there is no line there to wait for, so
91
+ * the cursor walks past it and the fragment that follows is dropped when
92
+ * the next boundary arrives.
93
+ *
94
+ * 3. The cursor is moved by BYTES, taken from the buffer, never by
95
+ * `Buffer.byteLength` of the decoded text. Chunking can split a multi-byte
96
+ * character at the ceiling, and `toString` turns a split character into a
97
+ * replacement character of a different width — so measuring the advance on
98
+ * the string would drift the cursor on any transcript containing non-ASCII,
99
+ * which is most of them. Slicing at the last newline (a byte that cannot be
100
+ * part of a multi-byte sequence) and advancing by its index is exact.
101
+ */
102
+ export async function readAppendedLines(path, cursor, size, chunkMax = MAX_SCAN_CHUNK) {
103
+ const from = cursor.offset;
104
+ const want = Math.min(size - from, chunkMax);
105
+ if (want <= 0) return { text: "", advanced: 0 };
106
+ const buf = await readByteChunk(path, from, from + want);
107
+ if (buf.length === 0) return { text: "", advanced: 0 };
108
+
109
+ const lastNl = buf.lastIndexOf(NEWLINE);
110
+ if (lastNl < 0) {
111
+ // Reaching the end of the file with no newline in hand is the ordinary
112
+ // partial last line: wait for its newline. (1) above. The test is where the
113
+ // read STOPPED, not how much was asked for, because `readByteChunk` clamps
114
+ // to MAX_SCAN_CHUNK and a short read is normally that clamp rather than the
115
+ // end of anything.
116
+ if (from + buf.length >= size) return { text: "", advanced: 0 };
117
+ // A chunk with no line boundary in it, and more file after it. (2).
118
+ cursor.offset = from + buf.length;
119
+ cursor.midLine = true;
120
+ return { text: "", advanced: buf.length };
121
+ }
122
+
123
+ const advanced = lastNl + 1; // (3): bytes, up to and including the \n
124
+ let text = buf.toString("utf8", 0, lastNl);
125
+ if (cursor.midLine) {
126
+ // This chunk opens in the middle of a line we already walked past, so
127
+ // everything up to the first boundary is the tail of it and folding it
128
+ // would fold a fragment. Only the lines after it are whole.
129
+ const nl = text.indexOf("\n");
130
+ text = nl < 0 ? "" : text.slice(nl + 1);
131
+ cursor.midLine = false;
132
+ }
133
+ cursor.offset = from + advanced;
134
+ return { text, advanced };
135
+ }
136
+
137
+ // What the Codex side calls besides the two exported above. Listed rather than
138
+ // marked, so the declaration reads as it did where it came from.
139
+ export { readByteRange };
@@ -0,0 +1,27 @@
1
+ // This deck's accounts in the shape the sync rules want, read off the rows
2
+ // claude-swap's reader hands back. Lifted out of createEngine in lan-engine.mjs,
3
+ // where it closed over nothing but the reader: the rule for each field is here
4
+ // and runs without a store, and the engine reads the store and hands the rows
5
+ // over. What each verdict means is account-health.mjs's.
6
+ import { accountKey, SENDER_UNREADABLE } from "./lan-sync.mjs";
7
+ import { storedCopyAlive, cachedExportReadable } from "./account-health.mjs";
8
+
9
+ /** Every account in `got` — what the deck's account reader returns, or
10
+ * nothing — as the rules in lan-copies.mjs read one. */
11
+ export function syncAccounts(got) {
12
+ return (got?.accounts ?? []).map(a => ({
13
+ key: accountKey(a.email, a.orgUuid),
14
+ email: a.email,
15
+ org: a.orgUuid,
16
+ alive: storedCopyAlive(a.alive, a.collector),
17
+ // False only for a login the wiring knows this process cannot read (a
18
+ // Mac whose Keychain will not open from here). Absent means readable,
19
+ // which is every deck that does not say.
20
+ readable: a.readable !== false && cachedExportReadable(a.collector, { active: a.active === true }),
21
+ unreadableWhy: a.readable === false || a.collector === "keychain_unavailable"
22
+ ? SENDER_UNREADABLE : "export failed",
23
+ num: a.num,
24
+ // The one this deck is on — claude-swap's own answer, one at most.
25
+ active: a.active === true,
26
+ }));
27
+ }
@@ -0,0 +1,457 @@
1
+ // The shouting half of the sockets: the UDP beacon that tells the network this
2
+ // deck is here, and hears every other deck that says the same. lan-socket.mjs
3
+ // is the other half — the listener that answers and the dialler that calls —
4
+ // and, as there, every decision this makes lives in lan-sync.mjs.
5
+ //
6
+ // A DECK HEARS ITSELF. Measured, not assumed: a broadcast to 255.255.255.255
7
+ // comes back to every socket on the sending machine bound to that port, from
8
+ // the machine's own LAN address rather than from loopback. That is what makes
9
+ // two decks on one machine find each other — which is how this gets tested at
10
+ // all — and it is why self-recognition is by fingerprint rather than by
11
+ // address.
12
+ import dgram from "node:dgram";
13
+ import { randomBytes } from "node:crypto";
14
+ import { networkInterfaces } from "node:os";
15
+ import { looksLikeTunnel } from "./route-via.mjs";
16
+ import {
17
+ beaconPayload, beaconVerdict, hostId, notePeer, readBeacon,
18
+ ANNOUNCE_MS, MAX_BEACON_BYTES,
19
+ } from "./lan-sync.mjs";
20
+
21
+ /** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
22
+ function quad(text) {
23
+ const parts = String(text ?? "").split(".");
24
+ if (parts.length !== 4) return null;
25
+ const out = parts.map(p => Number(p));
26
+ return out.every(n => Number.isInteger(n) && n >= 0 && n <= 255) ? out : null;
27
+ }
28
+
29
+ /**
30
+ * The address that reaches every host on one interface's own subnet.
31
+ *
32
+ * `address | ~netmask`, which is the definition. Null for a /32, because a
33
+ * point-to-point link — a VPN tunnel, `utun` on macOS — has a directed
34
+ * broadcast equal to its own address, and sending a beacon to ourselves down a
35
+ * tunnel is a packet nobody wanted.
36
+ */
37
+ export function directedBroadcast(address, netmask) {
38
+ const a = quad(address);
39
+ const m = quad(netmask);
40
+ if (!a || !m) return null;
41
+ if (m.every(o => o === 255)) return null;
42
+ return a.map((o, i) => o | (~m[i] & 255)).join(".");
43
+ }
44
+
45
+ /**
46
+ * Where a beacon has to go to be heard on this machine's networks.
47
+ *
48
+ * The limited broadcast first, because it is the one that works where a router
49
+ * or an access point filters the directed form, and because it is what every
50
+ * deck before this version sent — a machine that was fine stays fine. Then one
51
+ * per interface, which is what a multi-homed host actually needs: see announce.
52
+ *
53
+ * Loopback and IPv6 are skipped. A deck on `lo0` can only hear itself, and this
54
+ * protocol is IPv4 broadcast by construction — there is no such thing as an
55
+ * IPv6 broadcast address.
56
+ */
57
+ export function broadcastTargets(ifaces) {
58
+ return broadcastPlan(ifaces).map(p => p.to);
59
+ }
60
+
61
+ /**
62
+ * The same targets, each with the interface whose subnet it is — null for the
63
+ * limited broadcast, which is nobody's. What lets a beacon be held back when
64
+ * the machine would send it out by some OTHER interface: see leavesByTunnel.
65
+ */
66
+ export function broadcastPlan(ifaces) {
67
+ const out = [{ to: "255.255.255.255", iface: null }];
68
+ for (const [name, list] of Object.entries(ifaces ?? {})) {
69
+ for (const ni of list ?? []) {
70
+ if (!ni || ni.internal) continue;
71
+ // Node 18 reports `family` as the string "IPv4"; older shapes used 4.
72
+ if (ni.family !== "IPv4" && ni.family !== 4) continue;
73
+ const to = directedBroadcast(ni.address, ni.netmask);
74
+ if (to && !out.some(p => p.to === to)) out.push({ to, iface: name });
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Would this broadcast leave the local network?
82
+ *
83
+ * A directed broadcast belongs to the interface whose subnet it is, so the
84
+ * machine sending it out by any other one means something rerouted the local
85
+ * network — a VPN, or a Tailscale exit node without local network access —
86
+ * and the beacon would come out on the far end of the tunnel. The limited
87
+ * broadcast belongs to none, so it is held back only when it would go into
88
+ * something that is plainly a tunnel. Unknown answers nothing: send.
89
+ */
90
+ export function leavesByTunnel(target, via, isTunnel) {
91
+ if (!via) return false;
92
+ if (target.iface) return via !== target.iface;
93
+ return isTunnel(via);
94
+ }
95
+
96
+ /** The one fixed port in the feature. Out of the deck's HTTP range (4317-4400)
97
+ * so a beacon can never be mistaken for a deck's own traffic, and unassigned:
98
+ * 45317 reads as "4317, elsewhere", which is what a person tracing this in a
99
+ * firewall log needs it to say. */
100
+ export const DISCOVERY_PORT = 45_317;
101
+
102
+ /** The shortest gap between two "I am here too" replies to a stranger. Long
103
+ * enough that a burst of decks starting together cannot make a storm, short
104
+ * enough that starting two decks by hand feels instant. */
105
+ const REPLY_COOLDOWN_MS = 2_000;
106
+
107
+ /**
108
+ * The shouting half.
109
+ *
110
+ * `reuseAddr` is not a convenience: without it a second deck on the same
111
+ * machine cannot bind the discovery port at all, and two decks on one machine
112
+ * is both a real setup and the only way this gets tested here.
113
+ *
114
+ * Errors are reported, never thrown. A machine with no route, a firewall that
115
+ * refuses the bind, an interface that comes and goes with a VPN — none of them
116
+ * is a reason for the deck to fall over, and all of them are reasons for the
117
+ * panel to be able to say what happened.
118
+ */
119
+ export function createBeacon({
120
+ port, name, fp, onPeer, onStranger, onError, onIdClash, now = Date.now,
121
+ /** The decks somebody has accepted, read fresh each packet so an accept takes
122
+ * effect immediately rather than at the next restart. */
123
+ trusted = () => [],
124
+ // Injected so the suite can drive this with a socket it controls. CI runners
125
+ // are not a network: GitHub's have no broadcast domain worth the name, and a
126
+ // test that quietly skipped there would be a test that stopped testing
127
+ // without saying so. The REAL socket is exercised by hand, two decks on one
128
+ // machine, which works because a broadcast comes back to its own host.
129
+ createSocket = opts => dgram.createSocket(opts),
130
+ // Injected for the same reason, and read PER ANNOUNCE rather than once: a
131
+ // laptop that joins a network, or brings a VPN up, grows an interface without
132
+ // restarting the deck, and a list captured at start would announce to the
133
+ // addresses it had at breakfast.
134
+ ifaces = () => networkInterfaces(),
135
+ /**
136
+ * Addresses to send the beacon to one by one, beside the broadcast — the
137
+ * owner's machines on Tailscale, whose tunnel carries no broadcast at all.
138
+ * Read per announce like `ifaces`, because the tailnet list is re-read while
139
+ * the deck runs and a machine that just came online belongs in the next one.
140
+ */
141
+ unicast = () => [],
142
+ /**
143
+ * Which way a packet from this address came: "lan", "tailscale", or null for
144
+ * one to ignore entirely. Null is what a tailnet packet gets while this
145
+ * deck's Tailscale switch is off — the sockets hear it either way, because
146
+ * they bind every interface, and off has to mean the deck does not act on it.
147
+ */
148
+ routeFor = () => "lan",
149
+ /** How long to wait before trying the discovery port again while another
150
+ * program holds it, and who to tell when hearing stops or comes back. */
151
+ rebindMs = 30_000,
152
+ onHearing,
153
+ /**
154
+ * Where the machine would send each broadcast — see createRouteCheck in
155
+ * route-via.mjs. Absent, every broadcast goes, as before; the suite's
156
+ * beacons have none.
157
+ */
158
+ routes = null,
159
+ } = {}) {
160
+ // Randomised per process. Two beacons from one fingerprint with different
161
+ // instance ids mean the deck restarted between them, which is the signal to
162
+ // drop any session held for it rather than resume into a process that is gone.
163
+ const instance = randomBytes(8).toString("hex");
164
+ /** Which computer this is, as opposed to which process or which key. Derived
165
+ * once per beacon rather than per packet: it cannot change while a process
166
+ * is running, and hashing a hostname thirty seconds apart forever is work
167
+ * nobody asked for. See hostId. */
168
+ const host = hostId();
169
+ const peers = new Map();
170
+ let sock = null;
171
+ /**
172
+ * The socket beacons LEAVE by, on a port of its own. See start.
173
+ *
174
+ * Null until it is bound, and whenever it could not be — the listening
175
+ * socket then sends as it always did, because a beacon from the wrong port is
176
+ * still a beacon and none at all is a deck nobody finds.
177
+ */
178
+ let out = null;
179
+ let timer = null;
180
+ /**
181
+ * Whether this deck can HEAR — whether it holds the discovery port.
182
+ *
183
+ * SEPARATE FROM RUNNING, because a deck that cannot bind 45317 is still most
184
+ * of a deck. It announces from its own socket, so every other deck still
185
+ * finds it, asks it and dials it; what it has lost is hearing them announce.
186
+ * Stopping the whole feature over that — which is what this did — took a
187
+ * working sync away from somebody whose Tailscale exit node happened to be
188
+ * holding the port. It keeps trying for the port instead, and takes it back
189
+ * the moment it is free.
190
+ */
191
+ let hearing = false;
192
+ let deafError = null;
193
+ /** Whether the last announce held back every broadcast because the local
194
+ * network goes through a tunnel here — see leavesByTunnel. */
195
+ let tunneled = false;
196
+ let told = null;
197
+ let rebind = null;
198
+ let stopped = true;
199
+ // Socket binds finish asynchronously. A stopped start must not attach its
200
+ // late socket to a subsequent start or leave an outbound socket open.
201
+ let startGeneration = 0;
202
+ /** When this deck last answered a deck it had not heard, so answering cannot
203
+ * become a storm, and which decks it has already answered — without the
204
+ * second, a deck that is never accepted is answered again on every packet
205
+ * for as long as both are running. */
206
+ let repliedAt = 0;
207
+ const answered = new Set();
208
+
209
+ const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
210
+
211
+ const announce = (also = []) => {
212
+ if (!sock && !out) return;
213
+ const announcedIn = startGeneration;
214
+ // EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
215
+ //
216
+ // This used to send only to 255.255.255.255, on the argument that the
217
+ // limited broadcast "needs nothing" while a subnet-directed one needs the
218
+ // netmask of whichever interface the packet leaves by. The argument is
219
+ // sound and the machine disagreed with it. Measured on a Mac with Wi-Fi and
220
+ // a VPN tunnel up, from a bare node process with no deck involved:
221
+ //
222
+ // send to 255.255.255.255 -> EHOSTUNREACH
223
+ // send to 192.168.1.255 -> sent ok
224
+ //
225
+ // On a multi-homed host the limited broadcast has no single interface to
226
+ // leave by, and macOS refuses it rather than choosing. The deck went on
227
+ // announcing into nothing for as long as that machine was up: it could still
228
+ // HEAR colleagues, because receiving is per-port and not per-address, so the
229
+ // symptom was one-sided and looked like everybody else's problem.
230
+ //
231
+ // So the limited form stays — it is the one that works where a directed
232
+ // broadcast is filtered, and it is what Syncthing sends — and every
233
+ // interface's own directed broadcast goes out beside it. A duplicate packet
234
+ // costs one datagram; a missing one costs the whole feature.
235
+ const plan = broadcastPlan(ifaces());
236
+ // Asked behind the send, and read from what was last answered: the first
237
+ // beacon after a start goes as it always did, and the ones after it know.
238
+ void routes?.want?.(plan.map(p => p.to));
239
+ const kept = routes ? plan.filter(p => !leavesByTunnel(p, routes.via(p.to), looksLikeTunnel)) : plan;
240
+ tunneled = kept.length === 0;
241
+ const targets = kept.map(p => p.to);
242
+ const from = out ?? sock;
243
+ let left = targets.length;
244
+ const failed = [];
245
+ for (const to of targets) {
246
+ from.send(payload(), DISCOVERY_PORT, to, err => {
247
+ // A send may finish after this socket has closed and a new LAN
248
+ // session has started. Its result says nothing about the new session.
249
+ if (stopped || announcedIn !== startGeneration) return;
250
+ if (err) failed.push(`${to} (${err.code ?? err.message})`);
251
+ // REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
252
+ // the ordinary state of a machine with a VPN up, and a panel that said
253
+ // so every thirty seconds would be crying wolf about a working deck.
254
+ // No address working at all is a deck nobody can discover, which is
255
+ // exactly what the panel is for.
256
+ if (--left === 0 && failed.length === targets.length) {
257
+ onError?.("announce", new Error(`no broadcast address worked — ${failed.join(", ")}`));
258
+ }
259
+ });
260
+ }
261
+ // AND ONE PACKET PER TAILNET MACHINE. Not part of the verdict above: a node
262
+ // that has gone to sleep since the list was read is the ordinary state of a
263
+ // laptop, and it says nothing about whether this deck can be discovered.
264
+ let direct = [];
265
+ try { direct = [...(unicast() ?? []), ...also]; } catch { direct = [...also]; }
266
+ for (const to of new Set(direct)) {
267
+ if (typeof to !== "string" || !to || targets.includes(to)) continue;
268
+ from.send(payload(), DISCOVERY_PORT, to, () => {});
269
+ }
270
+ };
271
+
272
+ const onMessage = (msg, rinfo) => {
273
+ // A queued UDP callback can run after close(). It belongs to the stopped
274
+ // discovery session and must not repopulate peers or invite strangers.
275
+ if (stopped) return;
276
+ // Everything about whether to care lives in lan-sync.mjs. This hands it
277
+ // the bytes and the address and does what it is told.
278
+ if (msg.length > MAX_BEACON_BYTES) return;
279
+ let via = "lan";
280
+ try { via = routeFor(rinfo.address); } catch { via = "lan"; }
281
+ if (!via) return;
282
+ const beacon = readBeacon(msg);
283
+ const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
284
+ // ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
285
+ // part is the change. It used to answer only a deck already in the group,
286
+ // which was fine when a group existed. Now the first thing a new deck has
287
+ // to become is a row on somebody's screen, and it cannot become one if
288
+ // this deck never tells it that it exists.
289
+ //
290
+ // Measured on two real decks before any of this: deck 1 saw deck 2 the
291
+ // instant it started and deck 2 saw nobody, because deck 1's own
292
+ // immediate announce went out before deck 2 was listening. Thirty seconds
293
+ // of an empty list is how a working feature reads as broken.
294
+ //
295
+ // At most once every few seconds, because the obvious version is a shout
296
+ // storm: two decks answering each other's answers forever. A new pair
297
+ // converges in two extra packets.
298
+ const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
299
+ && !peers.has(beacon.fp) && !answered.has(beacon.fp);
300
+ if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
301
+ repliedAt = now();
302
+ answered.add(beacon.fp);
303
+ // A deck that reached this one over the tailnet is answered there too:
304
+ // a broadcast never gets back down its tunnel.
305
+ announce(via === "tailscale" ? [rinfo.address] : []);
306
+ }
307
+ if (verdict !== "peer") {
308
+ // Another deck is using this one's key — see beaconVerdict. Reported
309
+ // rather than fixed here: this file carries packets, and choosing a new
310
+ // identity for the deck belongs to whoever stores it.
311
+ if (verdict === "id-clash") onIdClash?.();
312
+ // A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
313
+ // dropped: it is a name and an address on the same network, which is a
314
+ // row somebody can accept. Nothing is asked of it and nothing is
315
+ // offered to it until they do.
316
+ if (verdict === "stranger") {
317
+ onStranger?.({
318
+ fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
319
+ // Carried through so the list can show one row per machine rather
320
+ // than one per key that machine has ever held.
321
+ host: beacon.host, at: now(), via,
322
+ });
323
+ }
324
+ return;
325
+ }
326
+ const noted = notePeer(peers, beacon, rinfo.address, now(), via);
327
+ if (noted.changed || noted.restarted) onPeer?.(noted);
328
+ };
329
+
330
+ /** Tell whoever asked, once per change rather than once per try. */
331
+ const tell = now => {
332
+ if (told === now) return;
333
+ told = now;
334
+ onHearing?.(now);
335
+ };
336
+
337
+ /** One try at the discovery port: the listening socket, or the error that
338
+ * kept it. A bind that fails never calls back; it arrives as an error. */
339
+ const listen = (startedIn) => new Promise(resolve => {
340
+ let s;
341
+ try { s = createSocket({ type: "udp4", reuseAddr: true }); } catch (err) { resolve({ err }); return; }
342
+ let bound = false;
343
+ s.on("error", err => {
344
+ if (bound) {
345
+ if (!stopped && startedIn === startGeneration) onError?.("socket", err);
346
+ return;
347
+ }
348
+ try { s.close(); } catch { /* never opened */ }
349
+ resolve({ err });
350
+ });
351
+ // close() can leave a message callback queued. After restart, stopped is
352
+ // false again, so the socket's own generation must also be checked.
353
+ s.on("message", (msg, rinfo) => {
354
+ if (startedIn !== startGeneration) return;
355
+ onMessage(msg, rinfo);
356
+ });
357
+ s.bind(DISCOVERY_PORT, "0.0.0.0", () => {
358
+ bound = true;
359
+ try { s.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
360
+ resolve({ sock: s });
361
+ });
362
+ });
363
+
364
+ const tryListen = async (startedIn) => {
365
+ if (stopped || startedIn !== startGeneration) return;
366
+ const got = await listen(startedIn);
367
+ if (stopped || startedIn !== startGeneration) { try { got.sock?.close(); } catch { /* gone */ } return; }
368
+ if (got.sock) {
369
+ sock = got.sock;
370
+ hearing = true;
371
+ deafError = null;
372
+ tell(true);
373
+ return;
374
+ }
375
+ hearing = false;
376
+ deafError = got.err;
377
+ tell(false);
378
+ rebind = setTimeout(() => { rebind = null; void tryListen(startedIn); }, rebindMs);
379
+ rebind.unref?.();
380
+ };
381
+
382
+ /**
383
+ * The socket beacons leave by, bound to whatever port the OS gives it.
384
+ *
385
+ * SENT FROM A PORT OF ITS OWN, NOT FROM 45317. Nothing that hears a beacon
386
+ * reads the port it came from — the reply goes to 45317 whatever the source
387
+ * — and sending from the discovery port gave that port away. Measured on a
388
+ * Mac that is a Tailscale exit node: a deck elsewhere on the tailnet routed a
389
+ * beacon through it, and Tailscale's forwarder binds its end of every UDP
390
+ * flow to the CLIENT's source port (netstack.go, forwardUDP), idling it out
391
+ * after two minutes. A beacon every thirty seconds never idles, so the Mac's
392
+ * own deck could never bind 45317 again. From an ephemeral port, the
393
+ * forwarder takes an ephemeral port.
394
+ */
395
+ const openOut = () => new Promise(resolve => {
396
+ let o;
397
+ try { o = createSocket({ type: "udp4" }); } catch { resolve(null); return; }
398
+ let opened = false;
399
+ o.on("error", err => {
400
+ if (opened) { onError?.("socket", err); return; }
401
+ opened = true;
402
+ try { o.close(); } catch { /* never opened */ }
403
+ resolve(null);
404
+ });
405
+ o.bind(0, "0.0.0.0", () => {
406
+ if (opened) return;
407
+ opened = true;
408
+ try { o.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
409
+ resolve(o);
410
+ });
411
+ });
412
+
413
+ const start = async () => {
414
+ const startedIn = ++startGeneration;
415
+ stopped = false;
416
+ told = null;
417
+ await tryListen(startedIn);
418
+ if (stopped || startedIn !== startGeneration) return;
419
+ const opened = await openOut();
420
+ if (stopped || startedIn !== startGeneration) {
421
+ try { opened?.close(); } catch { /* already closed */ }
422
+ return;
423
+ }
424
+ out = opened;
425
+ // Immediately, not on the next tick. Syncthing's rule: a deck that just
426
+ // came up should appear now rather than up to thirty seconds later, which
427
+ // is the difference between "it works" and "it seems broken" for anybody
428
+ // who starts two decks and watches.
429
+ announce();
430
+ timer = setInterval(announce, ANNOUNCE_MS);
431
+ timer.unref?.();
432
+ };
433
+
434
+ return {
435
+ start,
436
+ announce,
437
+ peers,
438
+ /** Whether this deck holds the discovery port, and why not when it does
439
+ * not. */
440
+ hearing: () => hearing,
441
+ deafError: () => deafError,
442
+ tunneled: () => tunneled,
443
+ stop() {
444
+ stopped = true;
445
+ startGeneration++;
446
+ if (timer) clearInterval(timer);
447
+ timer = null;
448
+ if (rebind) clearTimeout(rebind);
449
+ rebind = null;
450
+ hearing = false;
451
+ try { sock?.close(); } catch { /* already closed */ }
452
+ sock = null;
453
+ try { out?.close(); } catch { /* already closed */ }
454
+ out = null;
455
+ },
456
+ };
457
+ }