agent-dag 3.13.0 → 3.15.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.
@@ -16,7 +16,9 @@ import { claudeConfigDir } from "./claude-dir.mjs";
16
16
  import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
17
17
  import { PRODUCT } from "./brand.mjs";
18
18
  import { createBlockNotifier } from "./block-notify.mjs";
19
- import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, readPrefs, writePrefs } from "./deck-prefs.mjs";
19
+ import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
20
+ import { createEngine, defaultName } from "./lan-engine.mjs";
21
+ import { suggestPassphrase } from "./lan-sync.mjs";
20
22
  import { notify as osNotify } from "./browser-react.mjs";
21
23
  import { invokedName, renameNotice } from "./invoked-as.mjs";
22
24
  import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
@@ -3169,7 +3171,10 @@ function redactDeckToken(raw) {
3169
3171
  * same freshness every other cross-deck setting has.
3170
3172
  */
3171
3173
  let _prefs = { ...PREF_DEFAULTS };
3172
- readPrefs().then(p => { _prefs = p; }).catch(() => {});
3174
+ // And LAN sync starts from the same read, so a deck that had it on comes back
3175
+ // with it on. `applyLanPrefs` is defined below — this runs after module
3176
+ // evaluation, which is what makes the forward reference fine.
3177
+ readPrefs().then(async p => { _prefs = p; await applyLanPrefs(); }).catch(() => {});
3173
3178
 
3174
3179
  const blockNotifier = createBlockNotifier({
3175
3180
  notify: osNotify,
@@ -3197,7 +3202,11 @@ const blockNotifier = createBlockNotifier({
3197
3202
  function prefsPayload() {
3198
3203
  return {
3199
3204
  ok: true,
3200
- prefs: _prefs,
3205
+ // `publicPrefs`, never `_prefs`. This route answers anything that can reach
3206
+ // the loopback port, and the LAN group passphrase is in the stored object —
3207
+ // one field, and sending it here would put it in front of every page on the
3208
+ // machine. What goes out is whether one is set.
3209
+ prefs: publicPrefs(_prefs),
3201
3210
  notificationsAllowed: notificationsOn(_prefs),
3202
3211
  notificationsVetoed: notificationsVetoed(),
3203
3212
  };
@@ -3214,9 +3223,113 @@ async function handlePrefsWrite(req, res) {
3214
3223
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3215
3224
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
3216
3225
  _prefs = await writePrefs(body);
3226
+ // The engine reads its settings from here rather than holding its own copy,
3227
+ // so turning the switch off in the panel really does stop the sockets rather
3228
+ // than only changing what the panel says.
3229
+ await applyLanPrefs();
3217
3230
  return send(res, 200, prefsPayload());
3218
3231
  }
3219
3232
 
3233
+ // ── LAN sync ────────────────────────────────────────────────────────────────
3234
+ //
3235
+ // The engine is built once and told the settings; it opens and closes its own
3236
+ // sockets as those change. Nothing here touches a credential — see
3237
+ // lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
3238
+ const lanEngine = createEngine({
3239
+ readAccounts: async () => {
3240
+ const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
3241
+ return fetchClaudeAccounts();
3242
+ },
3243
+ exportAccount: async num => {
3244
+ const { shareAccounts } = await import("./cswap-admin.mjs");
3245
+ const out = await shareAccounts([String(num)]);
3246
+ return out?.ok ? out.blob : null;
3247
+ },
3248
+ importAccount: async blob => {
3249
+ const { importAccount } = await import("./cswap-admin.mjs");
3250
+ // NO `force`, ever, and this is the line where that promise is kept. A
3251
+ // plain import adds an account that is missing and replaces exactly one
3252
+ // claude-swap has quarantined; it SKIPS one that is present and healthy.
3253
+ // So a peer cannot overwrite a working credential of this deck's even by
3254
+ // lying about its own, because the flag that would allow it is not passed.
3255
+ const out = await importAccount(blob);
3256
+ return !!out?.ok;
3257
+ },
3258
+ // The deck's own public id, kept so a restart is the same deck rather than a
3259
+ // new row in every peer's list. Written once, on the first start that has
3260
+ // none — not a secret, and in every beacon this deck sends.
3261
+ onIdentity: async id => {
3262
+ try {
3263
+ _prefs = await writePrefs({ lan: { deckId: id } });
3264
+ // AND PUT IT TO WORK. Writing it alone was not enough: a clash was
3265
+ // detected, a new id was stored, and both decks kept broadcasting the old
3266
+ // one — so they stayed invisible to each other with a correct file on
3267
+ // disk. `apply` restarts only when the id it is holding differs from the
3268
+ // one it is given, so this settles after one pass rather than looping.
3269
+ await applyLanPrefs();
3270
+ } catch { /* the next start picks it up; a shared id is the cost until then */ }
3271
+ },
3272
+ onError: (what, err) => {
3273
+ // Reported, never thrown. A machine with no route, a firewall that refuses
3274
+ // the bind, an interface that comes and goes with a VPN — none of them is a
3275
+ // reason for the deck to fall over.
3276
+ if (what !== "other-group") console.error(`${PRODUCT}: lan sync (${what}):`, err?.message ?? err);
3277
+ },
3278
+ });
3279
+
3280
+ /** Push whatever is in prefs at the engine. Called at boot and after every
3281
+ * write, so there is one source of truth and it is the file. */
3282
+ async function applyLanPrefs() {
3283
+ const lan = _prefs?.lan ?? {};
3284
+ try {
3285
+ await lanEngine.apply({
3286
+ enabled: !!lan.enabled,
3287
+ name: lan.name || defaultName(),
3288
+ passphrase: lan.passphrase || "",
3289
+ shared: Array.isArray(lan.shared) ? lan.shared : [],
3290
+ deckId: lan.deckId || "",
3291
+ });
3292
+ // Wholesale, so removing an address in the panel really stops it being
3293
+ // dialled rather than only taking the row away.
3294
+ lanEngine.setPeers(lan.manual);
3295
+ } catch (err) {
3296
+ console.error(`${PRODUCT}: lan sync could not start:`, err?.message ?? err);
3297
+ }
3298
+ }
3299
+
3300
+ /** What the panel draws: the switch, this deck's own name and address, and who
3301
+ * else is in the group. No passphrase, for the reason prefsPayload gives. */
3302
+ function handleLanStatus(req, res) {
3303
+ return send(res, 200, { ok: true, ...lanEngine.status() });
3304
+ }
3305
+
3306
+ /**
3307
+ * A strong group passphrase, for the field to open with.
3308
+ *
3309
+ * THE WHOLE FEATURE'S SECURITY IS THIS STRING — groupKey scrypts it and
3310
+ * everything else hangs off that — and until this route existed the field
3311
+ * opened empty under a placeholder reading "the same words on every deck",
3312
+ * which is an invitation to type something two people can both remember.
3313
+ * suggestPassphrase has been written, documented and tested since the first
3314
+ * commit of the feature and was reachable from nothing.
3315
+ *
3316
+ * Generated per request rather than folded into the status route, because the
3317
+ * status route is polled every five seconds and a suggestion that changed
3318
+ * under somebody's fingers while they read it out loud would be worse than no
3319
+ * suggestion at all.
3320
+ */
3321
+ function handleLanSuggest(req, res) {
3322
+ return send(res, 200, { ok: true, passphrase: suggestPassphrase() });
3323
+ }
3324
+
3325
+ /** Ask every peer now rather than at the next tick — the button beside the
3326
+ * list, for somebody who has just fixed a login on the other machine and does
3327
+ * not want to wait a minute to see it arrive. */
3328
+ async function handleLanSync(req, res) {
3329
+ const done = await lanEngine.round();
3330
+ return send(res, 200, { ok: true, done, ...lanEngine.status() });
3331
+ }
3332
+
3220
3333
  function pushEvent(raw, source, opts = {}) {
3221
3334
  // First, before anything below can see it: the deck's own credential does not
3222
3335
  // belong in a store that is served without one. Every entry point to the
@@ -5444,6 +5557,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5444
5557
  // Machine state, not session state: sampled on the server's own timer and
5445
5558
  // deliberately kept out of the event stream. See src/server/system-metrics.mjs.
5446
5559
  if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
5560
+ if (req.method === "GET" && url.pathname === "/api/lan") return handleLanStatus(req, res);
5561
+ if (req.method === "GET" && url.pathname === "/api/lan/passphrase") return handleLanSuggest(req, res);
5562
+ if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
5447
5563
  if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
5448
5564
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
5449
5565
  // On demand only — the process list costs a subprocess on every platform,
@@ -0,0 +1,359 @@
1
+ // The thing that actually heals an account: the beacon, the listener and
2
+ // claude-swap, wired together.
3
+ //
4
+ // The two files under this one hold everything that can be reasoned about
5
+ // without a network — lan-sync.mjs decides, lan-socket.mjs carries — and what
6
+ // is left here is the part that has to touch the store. It is deliberately the
7
+ // smallest of the three.
8
+ //
9
+ // WHAT ONE ROUND LOOKS LIKE, from a deck whose copy of an account has died:
10
+ //
11
+ // 1. it hears a beacon from a deck with the same group tag
12
+ // 2. it dials that deck's sync port and both sides prove the passphrase
13
+ // 3. it asks for a manifest: which accounts, and does each one work THERE
14
+ // 4. `plan()` says "heal a@@1" — mine is quarantined, theirs is alive
15
+ // 5. it asks for that one account, with a fresh proof naming it
16
+ // 6. the peer runs `cswap export - --account N`, seals it, sends it
17
+ // 7. it opens the envelope and runs `cswap import -`
18
+ //
19
+ // Steps 6 and 7 are the only ones that touch a credential, and neither of them
20
+ // reads one: claude-swap does the reading and the writing, this passes an
21
+ // opaque blob between two of its commands. That is the same division the manual
22
+ // share already uses, which is why this needed no new credential handling at
23
+ // all.
24
+ //
25
+ // NOTHING HAPPENS ON A SCHEDULE THAT MOVES A CREDENTIAL. The manifest round is
26
+ // periodic and carries no credential; the transfer happens when a plan has
27
+ // something in it, which is when an account is actually broken. A deck whose
28
+ // accounts all work talks to its peers every minute and never asks for
29
+ // anything.
30
+ import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
31
+ import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
32
+ import { fingerprint, groupKey } from "./lan-sync.mjs";
33
+ import { generateKeyPairSync, randomBytes } from "node:crypto";
34
+ import { hostname, networkInterfaces } from "node:os";
35
+
36
+ /** How often a deck asks its peers what they have. A minute is far more often
37
+ * than a login dies, and it is what makes the panel's list feel live rather
38
+ * than something that updates when you press a button. */
39
+ export const SYNC_MS = 60_000;
40
+
41
+ /** How long one peer round may take before it is abandoned. A manifest is one
42
+ * round trip on a local network; anything past this is a peer that is not
43
+ * going to answer, and holding the attempt open would stall the next round. */
44
+ export const ROUND_MS = 10_000;
45
+
46
+
47
+ /**
48
+ * The name everybody knows this deck by.
49
+ *
50
+ * PERSISTED, and the first version was not. It regenerated on every start, on
51
+ * the argument that a stored key is one more secret to protect — and that
52
+ * argument was about the wrong thing, because this is not a key. Nothing is
53
+ * signed with it and nothing is decrypted with it; the group passphrase is what
54
+ * authenticates, and this is only an identifier, broadcast in the clear in
55
+ * every beacon. There is nothing here to steal.
56
+ *
57
+ * What regenerating it cost was measured rather than guessed: on a machine
58
+ * where decks had been restarted a few times, every peer's list held a row per
59
+ * restart — dozens of them, each reporting ECONNREFUSED every minute against a
60
+ * port nothing has listened on for an hour. Remembered forever and never
61
+ * recognised again is the worst of both, and a stable id is the whole fix.
62
+ */
63
+ export function newIdentity(stored) {
64
+ if (typeof stored === "string" && /^[0-9a-f]{12}$/.test(stored)) {
65
+ return { fp: stored.replace(/(.{3})(?=.)/g, "$1-"), id: stored };
66
+ }
67
+ const { publicKey } = generateKeyPairSync("x25519");
68
+ const raw = publicKey.export({ type: "spki", format: "der" });
69
+ const fp = fingerprint(raw);
70
+ return { fp, id: fp.replace(/-/g, "") };
71
+ }
72
+
73
+ /** What this machine calls itself when the user has not said. The hostname,
74
+ * because that is the word they already use for this machine everywhere else. */
75
+ export function defaultName() {
76
+ return hostname().replace(/\.local$/i, "") || "this machine";
77
+ }
78
+
79
+ /**
80
+ * EVERY address another deck might dial, for the panel to print.
81
+ *
82
+ * It returned the first one, and that was wrong the first time somebody
83
+ * checked: on this machine the first is 192.168.1.82 and the deck it needs to
84
+ * reach is on Tailscale at 100.67.32.58, so the panel would have offered an
85
+ * address that peer cannot route to and left them to work out why.
86
+ *
87
+ * WHICH ONE IS RIGHT DEPENDS ON WHERE THE PEER IS, which this side cannot
88
+ * answer — a VPN, a second NIC, a container bridge, all real and all at once.
89
+ * So it offers them all and the person picks: they are the only one who knows
90
+ * how the other machine sees this one, and a list of two is a smaller ask than
91
+ * a wrong answer.
92
+ *
93
+ * `internal` is node's word for loopback, and a link-local 169.254 address is a
94
+ * machine whose DHCP failed — reachable by nobody worth telling about.
95
+ */
96
+ export function localAddresses(faces = networkInterfaces()) {
97
+ const out = [];
98
+ for (const list of Object.values(faces ?? {})) {
99
+ for (const n of list ?? []) {
100
+ if (n.internal) continue;
101
+ if (n.family !== "IPv4" && n.family !== 4) continue;
102
+ if (typeof n.address !== "string" || n.address.startsWith("169.254.")) continue;
103
+ if (!out.includes(n.address)) out.push(n.address);
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * One deck's LAN sync, from settings to a healed account.
111
+ *
112
+ * `deps` is every side effect: reading accounts, exporting one, importing one.
113
+ * Injected rather than imported so a test can run a whole round — two engines,
114
+ * two fake stores, one real socket pair — without claude-swap on the machine.
115
+ */
116
+ export function createEngine({
117
+ readAccounts, exportAccount, importAccount,
118
+ onChange, onError, onIdentity, now = Date.now,
119
+ } = {}) {
120
+ let cfg = { enabled: false, name: defaultName(), passphrase: "", shared: [], deckId: "" };
121
+ let identity = null;
122
+ let beacon = null;
123
+ let server = null;
124
+ let timer = null;
125
+ let key = null;
126
+ /** What the last round did, for the panel. Not a log: one line per peer, most
127
+ * recent only, because "what happened" is a question about now. */
128
+ const lastRound = new Map();
129
+
130
+ /** This deck's accounts in the shape the rules want. Read through the same
131
+ * function the panel uses, so a row can never be alive here and dead there. */
132
+ const localAccounts = async () => {
133
+ const got = await readAccounts();
134
+ return (got?.accounts ?? []).map(a => ({
135
+ key: accountKey(a.email, a.orgUuid),
136
+ email: a.email,
137
+ alive: a.alive === true,
138
+ num: a.num,
139
+ }));
140
+ };
141
+
142
+ /** Frames from a peer that has proved the passphrase. Nothing reaches this
143
+ * before that, which is the whole point of where the check sits. */
144
+ const serve = async (msg, ctx) => {
145
+ try {
146
+ if (msg.t === "manifest") {
147
+ const accounts = await localAccounts();
148
+ return ctx.send({ t: "manifest", accounts: manifestFor(accounts, cfg.shared) });
149
+ }
150
+ if (msg.t === "want") {
151
+ // A SECOND PROOF, for the one operation that moves a credential. The
152
+ // session says who connected; this says they are asking for this
153
+ // account, now. A long-lived connection authenticated an hour ago is
154
+ // not a statement about now.
155
+ const want = transferChallenge(key, {
156
+ nonce: msg.nonce, accountKey: msg.key, fromFp: ctx.peerFp, toFp: identity.fp,
157
+ });
158
+ if (typeof msg.proof !== "string" || msg.proof !== want) {
159
+ return ctx.send({ t: "no", why: "proof" });
160
+ }
161
+ // Only what the user ticked, checked again here rather than trusted
162
+ // from the manifest we sent: the list can change between the two, and
163
+ // the answer that matters is the one at the moment of sending.
164
+ if (!cfg.shared.includes(msg.key)) return ctx.send({ t: "no", why: "not shared" });
165
+ const accounts = await localAccounts();
166
+ const mine = accounts.find(a => a.key === msg.key);
167
+ if (!mine || !mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
168
+ const blob = await exportAccount(mine.num);
169
+ if (!blob) return ctx.send({ t: "no", why: "export failed" });
170
+ const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
171
+ return ctx.send({ t: "have", key: msg.key, sealed: seal(key, blob, aad) });
172
+ }
173
+ } catch (err) {
174
+ onError?.("serve", err);
175
+ ctx.send({ t: "no", why: "error" });
176
+ }
177
+ };
178
+
179
+ /** Ask one peer what it has, and heal whatever it can heal. */
180
+ const roundWith = async peer => {
181
+ let conn = null;
182
+ try {
183
+ conn = await connectToPeer({ host: peer.addr, port: peer.port, fp: identity.fp, key, timeoutMs: ROUND_MS });
184
+ const ask = frame => new Promise((resolve, reject) => {
185
+ const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
186
+ bell.unref?.();
187
+ let buf = "";
188
+ const onData = chunk => {
189
+ buf += chunk;
190
+ const i = buf.indexOf("\n");
191
+ if (i === -1) return;
192
+ clearTimeout(bell);
193
+ conn.sock.off("data", onData);
194
+ try { resolve(JSON.parse(buf.slice(0, i))); } catch { reject(new Error("bad reply")); }
195
+ };
196
+ conn.sock.on("data", onData);
197
+ sendFrame(conn.sock, frame);
198
+ });
199
+
200
+ const theirs = await ask({ t: "manifest" });
201
+ if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
202
+ const mine = await localAccounts();
203
+ // Only accounts I have also ticked. Sharing is mutual by construction:
204
+ // a peer cannot push an account at me that I never agreed to hold.
205
+ // A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
206
+ // Healing replaces a slot I already have, so it is only reasonable for an
207
+ // account I said I share. Adding is the case the owner asked for by name:
208
+ // an account that appears in the group appears everywhere in it, which is
209
+ // the whole of "I do not want to paste blobs any more". The group is
210
+ // passphrase-gated, so what can reach this is what somebody I trusted
211
+ // with that passphrase chose to offer.
212
+ const wanted = plan(mine, theirs.accounts)
213
+ .filter(step => step.action === "add" || cfg.shared.includes(step.key));
214
+ const done = [];
215
+ for (const step of wanted) {
216
+ const nonce = randomBytes(12).toString("hex");
217
+ const reply = await ask({
218
+ t: "want", key: step.key, nonce,
219
+ proof: transferChallenge(key, {
220
+ nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
221
+ }),
222
+ });
223
+ if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
224
+ const blob = open(key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
225
+ if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
226
+ const ok = await importAccount(blob);
227
+ done.push({ ...step, ok: !!ok, why: ok ? null : "import failed" });
228
+ }
229
+ lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
230
+ if (done.length) onChange?.();
231
+ return done;
232
+ } catch (err) {
233
+ lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
234
+ return [];
235
+ } finally {
236
+ conn?.sock?.destroy();
237
+ }
238
+ };
239
+
240
+ /** Peers the user typed in, which the beacon will never find.
241
+ *
242
+ * Broadcast dies at the first router and is dropped by a switch that
243
+ * filters it, so a deck across a VPN or on another subnet is unreachable by
244
+ * discovery and perfectly reachable by address. The passphrase remains the
245
+ * only gate, so an address is not a way in — it is a way to be dialled.
246
+ *
247
+ * Keyed by `host:port` rather than by fingerprint, because a fingerprint is
248
+ * what a deck says about itself after the handshake and this list has to
249
+ * exist before there has been one. */
250
+ const manual = new Map();
251
+
252
+ const round = async () => {
253
+ if (!beacon) return [];
254
+ const all = [];
255
+ // Heard first, typed second, and a typed one is skipped when the beacon
256
+ // already found that address: otherwise a deck that is both would be dialled
257
+ // twice a round and its work counted twice.
258
+ // The same rule the list uses. A deck that has been silent for a day is not
259
+ // dialled once a minute forever on the chance it comes back.
260
+ const heard = [...beacon.peers.values()].filter(p => stillListed(p, now()));
261
+ const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
262
+ for (const peer of [...heard, ...[...manual.values()].filter(p => !seen.has(`${p.addr}:${p.port}`))]) {
263
+ // Sequential rather than parallel. The store takes one mutation at a
264
+ // time anyway (cswap-admin's lock), and two peers healing the same
265
+ // account at once would race for a slot number claude-swap assigns as
266
+ // max+1 without a lock of its own.
267
+ all.push(...await roundWith(peer));
268
+ }
269
+ return all;
270
+ };
271
+
272
+ return {
273
+ async apply(next) {
274
+ const was = cfg;
275
+ cfg = { ...cfg, ...next };
276
+ const restart = !was.enabled !== !cfg.enabled
277
+ || was.passphrase !== cfg.passphrase
278
+ || was.name !== cfg.name
279
+ || was.deckId !== cfg.deckId;
280
+ if (!restart) return;
281
+ this.stop();
282
+ if (!cfg.enabled || !cfg.passphrase) return;
283
+ key = groupKey(cfg.passphrase);
284
+ identity = newIdentity(cfg.deckId);
285
+ // Hand the caller an id to keep when there was none, so the next start is
286
+ // the same deck rather than a new row in everybody's list.
287
+ if (identity.id !== cfg.deckId) onIdentity?.(identity.id);
288
+ server = createSyncServer({ fp: identity.fp, name: cfg.name, key, handlers: serve, onError });
289
+ const port = await server.start();
290
+ beacon = createBeacon({
291
+ port, name: cfg.name, fp: identity.fp, key,
292
+ onPeer: () => onChange?.(),
293
+ // Take a new name and keep it. Two decks with one id are invisible to
294
+ // each other forever otherwise, and the second one to notice moving is
295
+ // enough — whichever notices first, moves.
296
+ onIdClash: () => {
297
+ const fresh = newIdentity();
298
+ onIdentity?.(fresh.id);
299
+ onError?.("id-clash", new Error("another deck was using this one's id; taking a new one"));
300
+ },
301
+ onError, now,
302
+ });
303
+ await beacon.start();
304
+ timer = setInterval(() => { void round(); }, SYNC_MS);
305
+ timer.unref?.();
306
+ },
307
+ round,
308
+ /** Dial this address on every round from now on. Returns false for an
309
+ * address that is not one, rather than storing a row that can never
310
+ * connect and reports an error every minute forever. */
311
+ addPeer(addr, port) {
312
+ const p = Number(port);
313
+ if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
314
+ const host = addr.trim();
315
+ manual.set(`${host}:${p}`, { fp: `manual:${host}:${p}`, name: host, addr: host, port: p, manual: true });
316
+ return true;
317
+ },
318
+ removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
319
+ /** Replace the typed list wholesale, which is what a settings write means.
320
+ * Adding one at a time would leave a removed address still being dialled
321
+ * every minute until the next restart — the row would vanish from the
322
+ * panel while the socket kept opening, which is the worst of both. */
323
+ setPeers(entries) {
324
+ manual.clear();
325
+ for (const entry of Array.isArray(entries) ? entries : []) {
326
+ const at = String(entry).lastIndexOf(":");
327
+ if (at > 0) this.addPeer(String(entry).slice(0, at), Number(String(entry).slice(at + 1)));
328
+ }
329
+ return manual.size;
330
+ },
331
+ status() {
332
+ return {
333
+ enabled: !!cfg.enabled,
334
+ running: !!beacon,
335
+ name: cfg.name,
336
+ fp: identity?.fp ?? null,
337
+ // The address and port a person on another subnet types into the other
338
+ // deck's field. Null when this machine has no ordinary one, which the
339
+ // panel says rather than printing a placeholder.
340
+ port: server?.port() ?? null,
341
+ addrs: beacon ? localAddresses() : [],
342
+ shared: [...cfg.shared],
343
+ // The rule is stillListed's, in lan-sync.mjs, where it can be tested.
344
+ peers: beacon ? [...beacon.peers.values(), ...manual.values()]
345
+ .filter(p => stillListed(p, now()))
346
+ .map(p => ({ ...p, last: lastRound.get(p.fp) ?? null })) : [],
347
+ };
348
+ },
349
+ stop() {
350
+ if (timer) clearInterval(timer);
351
+ timer = null;
352
+ beacon?.stop();
353
+ server?.stop();
354
+ beacon = null;
355
+ server = null;
356
+ key = null;
357
+ },
358
+ };
359
+ }