ccdeck 3.22.14 → 3.23.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 (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
@@ -27,8 +27,8 @@
27
27
  // something in it, which is when an account is actually broken. A deck whose
28
28
  // accounts all work talks to its peers every minute and never asks for
29
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";
30
+ import { accountKey, currentFor, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
31
+ import { connectToPeer, createBeacon, createSyncServer, MAX_FRAME_BYTES } from "./lan-socket.mjs";
32
32
  import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
33
33
  import { openAbout, sealAbout } from "./lan-about.mjs";
34
34
  import { randomBytes } from "node:crypto";
@@ -57,7 +57,25 @@ export const ASKING_MS = 8_000;
57
57
  /** How long one peer round may take before it is abandoned. A manifest is one
58
58
  * round trip on a local network; anything past this is a peer that is not
59
59
  * going to answer, and holding the attempt open would stall the next round. */
60
- export const ROUND_MS = 10_000;
60
+ const ROUND_MS = 10_000;
61
+
62
+ /**
63
+ * The most addresses `autoAsk` may put on the dial list on its own.
64
+ *
65
+ * Measured, because the shape of it is not the obvious one: the dial list is
66
+ * keyed `host:port`, not by fingerprint, so five hundred beacons from one
67
+ * address on one port make one row. Five hundred beacons from one address on
68
+ * five hundred PORTS make five hundred rows, and a round dials them one at a
69
+ * time with a ROUND_MS bell on each — so a list that size is eighty minutes of
70
+ * round, and the decks somebody actually paired with sit at the end of it
71
+ * waiting their turn. The ceiling on that without this number is 65,535 rows
72
+ * from a single host.
73
+ *
74
+ * It bounds only what the deck added BY ITSELF. Addresses a person typed are
75
+ * not capped: a list of those is somebody's own decision and the deck is in no
76
+ * position to tell them they have too many machines.
77
+ */
78
+ export const MAX_AUTO_PEERS = 32;
61
79
 
62
80
 
63
81
  /** What this machine calls itself when the user has not said. The hostname,
@@ -103,11 +121,46 @@ export function localAddresses(faces = networkInterfaces()) {
103
121
  * where strings belong, a boolean for the verdict, and no more rows than a
104
122
  * manifest may carry. What is kept is only what the deck's dialog draws.
105
123
  */
124
+ /** One peer-supplied string as the panel may draw it: no control or format
125
+ * characters, whitespace collapsed, bounded. The rule cleanName applies to a
126
+ * deck's name and lan-about's `field` to a card, applied to the two strings
127
+ * that sit next to the fingerprint in the import dialog. */
128
+ function flatten(v, max) {
129
+ if (typeof v !== "string") return "";
130
+ const flat = v.replace(/\p{Cc}/gu, " ").replace(/\p{Cf}/gu, "").replace(/\s+/g, " ").trim();
131
+ return [...flat].slice(0, max).join("");
132
+ }
133
+
106
134
  export function offered(list) {
107
135
  return (Array.isArray(list) ? list : [])
108
136
  .filter(a => a && typeof a.key === "string" && typeof a.email === "string")
109
137
  .slice(0, 50)
110
- .map(a => ({ key: a.key.slice(0, 320), email: a.email.slice(0, 254), alive: a.alive === true }));
138
+ // Character-filtered, not merely cut. These two are drawn beside the
139
+ // fingerprint at the moment the operator picks which of a peer's logins to
140
+ // import (LanPeerModal.tsx:497), and a bare slice let a format character
141
+ // through — the same class cleanName strips from the name one frame over.
142
+ .map(a => ({
143
+ key: flatten(a.key, 320),
144
+ email: flatten(a.email, 254),
145
+ alive: a.alive === true,
146
+ }))
147
+ .filter(a => a.key && a.email);
148
+ }
149
+
150
+ /**
151
+ * Which account a peer said it is on, as the panel may keep it: the key of one
152
+ * of the accounts it listed in the same frame, that its owner is hiding it, or
153
+ * that it is on one it does not share — and nothing for anything else. A key
154
+ * that is not in its own list is dropped rather than drawn: a deck only ever
155
+ * names an account it shares, and one that names another is saying something
156
+ * this deck will not show.
157
+ */
158
+ export function heardCurrent(raw, list) {
159
+ if (!raw || typeof raw !== "object") return null;
160
+ if (raw.hidden === true) return { hidden: true };
161
+ if (raw.other === true) return { other: true };
162
+ if (typeof raw.key !== "string") return null;
163
+ return list.some(a => a.key === raw.key) ? { key: raw.key } : null;
111
164
  }
112
165
 
113
166
  /**
@@ -136,15 +189,37 @@ export function createEngine({
136
189
  /** This deck's own card — its version and its machine — handed to every
137
190
  * paired deck and to nobody else. See lan-about.mjs. */
138
191
  about = null,
192
+ /** Whether this deck seals every frame after the handshake with a deck that
193
+ * says it does too — see frameChannel in lan-sync.mjs. Nothing in the deck
194
+ * turns it off; the suite does, to play a deck from before #810, which is
195
+ * the only way to show that one still heals. */
196
+ sealFrames = true,
197
+ /** Whether this deck mixes a key pair made for each connection into that
198
+ * connection's key, with a deck that says it does too — see sessionKey in
199
+ * lan-sync.mjs. Nothing in the deck turns this off either; the suite does,
200
+ * to play a deck of #810's version, which seals and does not mix. It rides
201
+ * on `sealFrames`: with that off, this deck says neither. */
202
+ ephemeral = true,
203
+ /** Where the listener binds: every interface, which is what a peer dials,
204
+ * unless the suite says loopback — a test deck has no business being
205
+ * reachable from the office for the seconds it runs. */
206
+ host,
139
207
  } = {}) {
140
208
  let cfg = {
141
209
  enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
142
210
  autoAsk: true, autoAccept: true, aliases: {},
211
+ // Tell paired decks which shared account this one is on — see currentFor.
212
+ shareActive: true,
143
213
  };
144
214
  let identity = null;
145
215
  let beacon = null;
146
216
  let server = null;
147
217
  let timer = null;
218
+ /** This engine, for the helpers below `apply` that need to press its own
219
+ * accept — see askToAccept. Set on the first apply, which is the only thing
220
+ * that can start a round or a listener, so nothing reads it before it is
221
+ * there. */
222
+ let engine = null;
148
223
  /**
149
224
  * Decks that finished a handshake and that nobody here has accepted yet, and
150
225
  * decks merely heard shouting on the network. Two lists because they are two
@@ -237,6 +312,8 @@ export function createEngine({
237
312
  email: a.email,
238
313
  alive: a.alive === true,
239
314
  num: a.num,
315
+ // The one this deck is on — claude-swap's own answer, one at most.
316
+ active: a.active === true,
240
317
  }));
241
318
  };
242
319
 
@@ -252,7 +329,9 @@ export function createEngine({
252
329
  /** Frames from a deck that finished the handshake AND that somebody here has
253
330
  * accepted. Nothing reaches this before both, which is the whole point of
254
331
  * where the two checks sit. `ctx.key` is this connection's key and no other
255
- * connection's — see sessionKey. */
332
+ * connection's — see sessionKey. `ctx.send` seals whatever it is handed when
333
+ * both ends said they seal, so nothing below has to know which kind of deck
334
+ * asked — see frameChannel. */
256
335
  const serve = async (msg, ctx) => {
257
336
  // Before the verbs, and for every one of them: something that proved it
258
337
  // holds a key this deck accepted is talking, now.
@@ -264,9 +343,19 @@ export function createEngine({
264
343
  // here ever asks. A seal that does not open is a deck that said nothing.
265
344
  const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
266
345
  if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
346
+ // AND SO DOES ITS LIST, from a deck new enough to send one: what it
347
+ // offers, and which of those it is on. Kept only when it came — an
348
+ // older caller asks with its card alone, and a missing list is not an
349
+ // empty one. This is the only way a deck nothing here dials is ever
350
+ // known by what it offers.
351
+ if (Array.isArray(msg.accounts)) {
352
+ const list = offered(msg.accounts);
353
+ offersBy.set(ctx.peerFp, { at: now(), accounts: list, current: heardCurrent(msg.current, list) });
354
+ }
267
355
  const accounts = await localAccounts();
268
356
  return ctx.send({
269
357
  t: "manifest", accounts: manifestFor(accounts, cfg.shared),
358
+ ...currentFor(accounts, cfg.shared, cfg.shareActive),
270
359
  ...cardFor(ctx.key, ctx.peerFp),
271
360
  });
272
361
  }
@@ -299,6 +388,34 @@ export function createEngine({
299
388
  }
300
389
  };
301
390
 
391
+ /**
392
+ * A real deck nobody here has accepted: draw it as a row with an accept on it.
393
+ *
394
+ * ONE HELPER FOR BOTH DIRECTIONS, because the evidence is the same either
395
+ * way. A deck that DIALLED this one finishes a handshake at the listener and
396
+ * arrives through `onPending`; a deck this one dialled finishes the same
397
+ * handshake in roundWith. Both have proved they hold the key they announced,
398
+ * and neither has been agreed to. Before #969 only the first raised a
399
+ * request and the second silently pinned itself.
400
+ *
401
+ * SAY YES FOR SOMEBODY WHO SAID TO. `autoAccept` is the accept button and
402
+ * nothing else: the same pin, from the same key the handshake just proved.
403
+ * Nothing about the wire changes — an inbound connection is still refused,
404
+ * because trust is read fresh per connection, and the caller comes back a few
405
+ * seconds later.
406
+ *
407
+ * A deck already told no does not become a row again. On the inbound path
408
+ * lan-socket refuses it before this is ever called; on the outbound one there
409
+ * is nothing before this, so the check lives here.
410
+ */
411
+ const askToAccept = entry => {
412
+ if (declined.has(entry.fp)) return;
413
+ const had = pending.get(entry.fp);
414
+ pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
415
+ if (cfg.autoAccept) { engine?.accept(entry.fp); return; }
416
+ if (!had) onChange?.();
417
+ };
418
+
302
419
  /**
303
420
  * Is anybody on the other end still deciding?
304
421
  *
@@ -322,33 +439,58 @@ export function createEngine({
322
439
  // The key pinned when this deck was accepted, so a second machine
323
440
  // answering at that address is refused rather than talked to.
324
441
  expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
442
+ sealFrames, ephemeral,
325
443
  });
444
+ // A SECOND READER ON THE SAME SOCKET, AND IT HAS TO KEEP THE SAME CAP.
445
+ //
446
+ // lan-socket.mjs's header states the rule: "a peer that sends a megabyte
447
+ // with no newline in it is not sending a large frame, it is sending
448
+ // nothing at all, expensively... the buffer is ABANDONED rather than
449
+ // grown past it." frameReader enforces it; this reader did not.
450
+ //
451
+ // The frameReader connectToPeer installed IS still attached and does hit
452
+ // MAX_FRAME_BYTES — but it only sets its own flag and calls `fail`, which
453
+ // short-circuits on `settled`, so nothing destroys the socket. This
454
+ // buffer then grew unbounded for the full ROUND_MS at line rate. The
455
+ // reject path also never removed the listener; only roundWith's
456
+ // `finally { conn?.sock?.destroy(); }` stopped it.
326
457
  const ask = frame => new Promise((resolve, reject) => {
327
458
  const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
328
459
  bell.unref?.();
329
460
  let buf = "";
461
+ const give = (fn, arg) => {
462
+ clearTimeout(bell);
463
+ conn.sock.off("data", onData);
464
+ fn(arg);
465
+ };
330
466
  const onData = chunk => {
331
467
  buf += chunk;
468
+ if (buf.length > MAX_FRAME_BYTES) {
469
+ conn.sock.destroy();
470
+ return give(reject, new Error("frame too large"));
471
+ }
332
472
  const i = buf.indexOf("\n");
333
473
  if (i === -1) return;
334
- clearTimeout(bell);
335
- conn.sock.off("data", onData);
336
- try { resolve(JSON.parse(buf.slice(0, i))); } catch { reject(new Error("bad reply")); }
474
+ let parsed;
475
+ try { parsed = JSON.parse(buf.slice(0, i)); } catch { return give(reject, new Error("bad reply")); }
476
+ // THROUGH THE CONNECTION'S OWN READER, and on a sealed connection
477
+ // that is the only way in. A reply that does not open is not a reply
478
+ // with something wrong in it; it is a connection that stopped being
479
+ // the one the handshake proved — altered, replayed, or plain where
480
+ // both ends agreed to seal — so the round ends here instead of
481
+ // reading it as it stands. See frameChannel.
482
+ const got = conn.read(parsed);
483
+ if (conn.sealed && !got) {
484
+ conn.sock.destroy();
485
+ return give(reject, new Error("a reply from that deck did not open"));
486
+ }
487
+ give(resolve, got);
337
488
  };
338
489
  conn.sock.on("data", onData);
339
- sendFrame(conn.sock, frame);
490
+ // And out through its own writer, which seals whenever the reader opens.
491
+ conn.send(frame);
340
492
  });
341
493
 
342
- // TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY TYPED. Reaching a
343
- // deck we have no pin for means the person at this keyboard put its
344
- // address in the field, which is the same decision the accept button is
345
- // on the other side. Pinning it here is what makes the two lists agree —
346
- // without it this deck would dial a peer every minute and still show it
347
- // as nobody, and its own listener would refuse the same deck calling
348
- // back.
349
- //
350
- // A deck we DO have a pin for was checked before this line: connectToPeer
351
- // was given expectPub and refuses a different key at that address.
352
494
  // WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
353
495
  // and nothing else until somebody answers it — and once one has, the deck
354
496
  // on the other end has told us what it calls itself. The row says that
@@ -356,7 +498,46 @@ export function createEngine({
356
498
  // address was trying to reach.
357
499
  learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
358
500
 
501
+ // TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY NAMED. Reaching a
502
+ // deck we have no pin for used to mean the person at this keyboard put
503
+ // its address in the field, which is the same decision the accept button
504
+ // is on the other side. Pinning it here is what makes the two lists agree
505
+ // — without it this deck would dial a peer every minute and still show it
506
+ // as nobody, and its own listener would refuse the same deck calling back.
507
+ //
508
+ // THE PREMISE WAS NOT CHECKED, AND `autoAsk` BREAKS IT — which is #969,
509
+ // and it is a chain rather than one mistake. A beacon authenticates
510
+ // nothing, by construction: it carries a fingerprint and a port and
511
+ // nothing binds either to the address it came from. `autoAsk` ships on,
512
+ // and it answered every new fingerprint by putting that address on the
513
+ // dial list. The next round reached it, arrived here with no pin, and
514
+ // read "no pin" as "somebody typed this". Nobody typed anything. One
515
+ // unsolicited packet, zero presses, and the far end was in `cfg.trusted`
516
+ // — which index.mjs writes to prefs.json, and which is the whole inbound
517
+ // gate — so from then on it could authenticate to `serve` and ask for
518
+ // every account the owner had ticked. Reproduced end to end before this
519
+ // line changed.
520
+ //
521
+ // So the row has to say where it came from, and only a row a person
522
+ // named may be pinned unseen. A row the deck added itself raises the
523
+ // request instead — which is all `autoAsk` ever promised: it is the ASK
524
+ // switch, and the accept switch is the other one.
525
+ //
526
+ // A deck we DO have a pin for was checked before this line: connectToPeer
527
+ // was given expectPub and refuses a different key at that address.
359
528
  if (!trustedPeer(cfg.trusted, conn.peerFp)) {
529
+ if (!peer.typed) {
530
+ // The same row the listener's own `onPending` draws, from the other
531
+ // direction: this deck dialled rather than being dialled, and the
532
+ // handshake it just finished is the same evidence either way — a real
533
+ // deck holding the key it announced. What is missing is the press,
534
+ // and that is what this asks for.
535
+ askToAccept({
536
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
537
+ addr: peer.addr, port: peer.port,
538
+ });
539
+ throw new Error("waiting for somebody here to accept that deck");
540
+ }
360
541
  const { list, added } = addTrusted(cfg.trusted, {
361
542
  fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
362
543
  });
@@ -365,12 +546,21 @@ export function createEngine({
365
546
 
366
547
  // Our card goes with the question and theirs comes back with the answer
367
548
  // — see lan-about.mjs for why it is here and nowhere earlier.
368
- const theirs = await ask({ t: "manifest", ...cardFor(conn.key, conn.peerFp) });
549
+ // THE QUESTION CARRIES THIS DECK'S LIST TOO, and which of it this deck is
550
+ // on — so the deck being asked knows both without dialling back, which a
551
+ // deck with no address for this one never could. An older deck reads the
552
+ // question's `t` and card and nothing else, so it answers as it always did.
553
+ const mine = await localAccounts();
554
+ const theirs = await ask({
555
+ t: "manifest", accounts: manifestFor(mine, cfg.shared),
556
+ ...currentFor(mine, cfg.shared, cfg.shareActive),
557
+ ...cardFor(conn.key, conn.peerFp),
558
+ });
369
559
  if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
370
560
  const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
371
561
  if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
372
- offersBy.set(conn.peerFp, { at: now(), accounts: offered(theirs.accounts) });
373
- const mine = await localAccounts();
562
+ const list = offered(theirs.accounts);
563
+ offersBy.set(conn.peerFp, { at: now(), accounts: list, current: heardCurrent(theirs.current, list) });
374
564
  // Only accounts I have also ticked. Sharing is mutual by construction:
375
565
  // a peer cannot push an account at me that I never agreed to hold.
376
566
  // A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
@@ -380,7 +570,16 @@ export function createEngine({
380
570
  // them, which is the whole of "I do not want to paste blobs any more".
381
571
  // What can reach this is what a deck somebody here pressed accept on
382
572
  // chose to offer.
383
- const wanted = plan(mine, theirs.accounts)
573
+ // OVER `list`, NOT THE RAW ARRAY. `offered` above slices to 50 and type-
574
+ // filters `key` and `email`; this line read `theirs.accounts` and got
575
+ // neither. syncAction answers "add" for anything this deck lacks and an
576
+ // add needs no tick, so a peer answering `manifest` with thousands of
577
+ // rows produced thousands of steps — each a sequential `want`/`have`
578
+ // round trip with its own 10s bell plus a claude-swap subprocess holding
579
+ // the store lock, while the panel drew 50 and every other paired deck
580
+ // waited behind it. `step.key` also reached transferChallenge and
581
+ // importAccount untyped, which `offered`'s filter would have caught.
582
+ const wanted = plan(mine, list)
384
583
  .filter(step => step.action === "add" || cfg.shared.includes(step.key));
385
584
  const done = [];
386
585
  for (const step of wanted) {
@@ -427,14 +626,21 @@ export function createEngine({
427
626
  *
428
627
  * Keyed by `host:port` rather than by fingerprint, because a fingerprint is
429
628
  * what a deck says about itself after the handshake and this list has to
430
- * exist before there has been one. */
629
+ * exist before there has been one.
630
+ *
631
+ * EVERY ROW SAYS WHERE IT CAME FROM, in `typed`, and the difference decides
632
+ * whether reaching it may pin a key sight unseen. A row somebody put in the
633
+ * address field — or pressed accept on, or joined by invite — is a person
634
+ * naming a machine. A row `autoAsk` added from a beacon is this deck
635
+ * answering a shout, which is not the same claim and must not read as one.
636
+ * See roundWith, where the difference is the whole of the trust rule. */
431
637
  const manual = new Map();
432
638
  /** What answered at a typed address, once something has. Keyed the same way
433
639
  * `manual` is, because until a connection succeeds an address is all there
434
640
  * is to key on. */
435
641
  const learned = new Map();
436
642
 
437
- const round = async () => {
643
+ const oneRound = async () => {
438
644
  if (!beacon) return [];
439
645
  const all = [];
440
646
  // Heard first, typed second, and a typed one is skipped when the beacon
@@ -446,20 +652,75 @@ export function createEngine({
446
652
  const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
447
653
  for (const peer of [...heard, ...[...manual.values()].filter(p => !seen.has(`${p.addr}:${p.port}`))]) {
448
654
  // Sequential rather than parallel. The store takes one mutation at a
449
- // time anyway (cswap-admin's lock), and two peers healing the same
450
- // account at once would race for a slot number claude-swap assigns as
451
- // max+1 without a lock of its own.
655
+ // time anyway (the mutex in store-lock.mjs), and two peers healing the
656
+ // same account at once would race for a slot number claude-swap assigns
657
+ // as max+1 without a lock of its own.
452
658
  all.push(...await roundWith(peer));
453
659
  }
454
660
  roundAt = now();
455
661
  return all;
456
662
  };
457
663
 
664
+ /**
665
+ * Where every round waits its turn — the whole list, or one deck from its own
666
+ * dialog — so that whichever is running is running alone. `round` and
667
+ * `roundOne` are the only two ways in, and both come through here.
668
+ *
669
+ * A LINE RATHER THAN A FLAG, because the two ways in want different things
670
+ * from what is already running and both have to end up behind it. A round
671
+ * asked for during a check cannot join it — a check asks one deck, and a
672
+ * round was asked to ask all of them — so it waits. A check asked for during
673
+ * a round can often join it, and sometimes cannot. Both need somewhere to
674
+ * stand that is after whatever is in flight, and this is it.
675
+ *
676
+ * The tail never rejects: `roundWith` reports a failure per peer instead of
677
+ * throwing, and anything else is swallowed here rather than left to stop
678
+ * every turn after it. The caller of the turn that threw still hears it.
679
+ */
680
+ let _turn = Promise.resolve();
681
+ const inTurn = job => {
682
+ const run = _turn.then(job);
683
+ _turn = run.then(() => {}, () => {});
684
+ return run;
685
+ };
686
+
687
+ /** The whole round in flight or waiting its turn, or null. See `round` below. */
688
+ let _round = null;
689
+
690
+ /**
691
+ * One round at a time, and the one already running is the answer (#1040).
692
+ *
693
+ * The sequential loop above reasons about peers being dialled one after
694
+ * another, which is a statement about the WHOLE round and was only ever true
695
+ * of a round running alone. Two ways in, and they meet: a self-scheduling
696
+ * timer (SYNC_MS, or ASKING_MS while somebody is waiting) and the "Sync now"
697
+ * press, which calls this straight from the route. A press landing on the
698
+ * timer's round gave two rounds walking the same peer list, each reading the
699
+ * same slot as empty and each force-importing a credential over the other —
700
+ * and the second one's blob wins for no reason anybody chose.
701
+ *
702
+ * JOINING rather than skipping, because the press has a reply to send: a
703
+ * caller that got `[]` for "a round is already running" would report "nothing
704
+ * to sync" about a round that was at that moment moving a credential. This is
705
+ * the shape `codexScanOnce` and ccusage's `_inflight` already use.
706
+ *
707
+ * THERE WAS A THIRD WAY IN, and it did not come through here (#1132). The
708
+ * `check now` in a deck's own dialog reaches `roundOne`, which called
709
+ * `roundWith` straight — so a press landing on the timer's round dialled the
710
+ * deck that round was already healing from, and the far side exported the
711
+ * same login twice for one account that needed repairing once. Both now take
712
+ * their turn in one line, above. A round joins only another whole round,
713
+ * never a check: joining a check would answer "ask every deck" with one
714
+ * deck's work and leave the rest waiting another minute.
715
+ */
716
+ const round = () => (_round ??= inTurn(oneRound).finally(() => { _round = null; }));
717
+
458
718
  return {
459
719
  async apply(next) {
460
720
  /** The engine itself, for the callbacks handed to the socket below: they
461
721
  * outlive this call and `this` is not theirs to keep. */
462
722
  const self = this;
723
+ engine = this;
463
724
  const was = cfg;
464
725
  cfg = { ...cfg, ...next };
465
726
  // TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
@@ -472,7 +733,7 @@ export function createEngine({
472
733
  // The same for the other direction: switching `ask` on with four machines
473
734
  // already listed asks those four.
474
735
  if (!was.autoAsk && cfg.autoAsk) {
475
- for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub) this.accept(fp);
736
+ for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub) this.accept(fp, { byHand: false });
476
737
  }
477
738
  const restart = !was.enabled !== !cfg.enabled
478
739
  || was.secret !== cfg.secret
@@ -493,7 +754,7 @@ export function createEngine({
493
754
  // back — so the pin drifts to a free port rather than failing.
494
755
  server = createSyncServer({
495
756
  fp: identity.fp, pub: identity.pub, secret: identity.secret,
496
- name: cfg.name, handlers: serve, onError, prefer: cfg.port,
757
+ name: cfg.name, handlers: serve, onError, prefer: cfg.port, host, sealFrames, ephemeral,
497
758
  trusted: () => cfg.trusted,
498
759
  invite: () => (invite && invite.expiresAt > now() ? invite : null),
499
760
  // Somebody used the token. They are pinned, and the token is retired —
@@ -524,21 +785,10 @@ export function createEngine({
524
785
  // told no again rather than becoming a row somebody has to answer
525
786
  // twice. The socket sends the reason; this only knows the name.
526
787
  declined: fp => declined.has(fp),
527
- onPending: entry => {
528
- const had = pending.get(entry.fp);
529
- pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
530
- // SAY YES FOR SOMEBODY WHO SAID TO. It is the accept button and
531
- // nothing else: the same pin, from the same key this handshake just
532
- // proved, so the deck is trusted on its next attempt a few seconds
533
- // later exactly as it would be if a person had pressed it. Nothing
534
- // about the wire changes — this connection is still refused, because
535
- // trust is read fresh per connection.
536
- //
537
- // A deck already told no does NOT come back this way: lan-socket
538
- // refuses it before this is ever called.
539
- if (cfg.autoAccept) { self.accept(entry.fp); return; }
540
- if (!had) onChange?.();
541
- },
788
+ // The same helper the outbound round uses, because a deck that called
789
+ // in and a deck this one called have proved exactly the same thing —
790
+ // see askToAccept.
791
+ onPending: askToAccept,
542
792
  });
543
793
  let port;
544
794
  try {
@@ -572,7 +822,9 @@ export function createEngine({
572
822
  // Only a deck that is NEW is asked, or a beacon every thirty seconds
573
823
  // would be thirty seconds of asking; and never one this deck's owner
574
824
  // already turned away.
575
- if (cfg.autoAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp); return; }
825
+ // Never `byHand`: a beacon is a shout from an address nobody here
826
+ // named, and the row it leaves may ask rather than pin.
827
+ if (cfg.autoAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
576
828
  // Only a deck that is new to us is news. A beacon every thirty
577
829
  // seconds from one already on the list is not a reason to redraw.
578
830
  if (!had) onChange?.();
@@ -660,6 +912,15 @@ export function createEngine({
660
912
  host: at.addr, port: at.port, timeoutMs: ROUND_MS,
661
913
  fp: identity.fp, pub: identity.pub, secret: identity.secret,
662
914
  name: cfg.name, myPort: server.port(), code: inv.code,
915
+ // MAKE IT PROVE IT HOLDS THE CODE. Without this the loop pinned
916
+ // whatever answered first, and the addresses in a token are only as
917
+ // trustworthy as the network they name: `localAddresses` keeps
918
+ // RFC1918, so a container bridge address or a lease that has since
919
+ // moved to somebody else's machine is an ordinary thing to find in
920
+ // one. Stopping at the first that ANSWERS is right; stopping at the
921
+ // first that answers CORRECTLY is what it was supposed to mean.
922
+ inviteProvesBack: inv.provesBack,
923
+ sealFrames, ephemeral,
663
924
  });
664
925
  const { list } = addTrusted(cfg.trusted, {
665
926
  fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
@@ -693,7 +954,7 @@ export function createEngine({
693
954
  * thing. A fingerprint nobody has actually met is refused rather than
694
955
  * trusted on a name somebody typed.
695
956
  */
696
- accept(fp) {
957
+ accept(fp, { byHand = true } = {}) {
697
958
  const asked = pending.get(fp) ?? null;
698
959
  const heard = strangers.get(fp) ?? null;
699
960
  const seen = asked ?? heard;
@@ -712,7 +973,12 @@ export function createEngine({
712
973
  // is the same two presses, in the other order.
713
974
  if (!seen.pub) {
714
975
  if (!seen.addr || !seen.port) return null;
715
- this.addPeer(seen.addr, seen.port);
976
+ // `byHand` IS WHAT THE ROW WILL BE ALLOWED TO DO LATER. A press here is
977
+ // a person naming a machine, so the row may be pinned on the round that
978
+ // reaches it. `autoAsk` reaches this same line with byHand false — the
979
+ // deck answering a shout — and the row it leaves may only ASK, which is
980
+ // what the switch's own name says it does. See roundWith.
981
+ this.addPeer(seen.addr, seen.port, { typed: byHand });
716
982
  strangers.delete(fp);
717
983
  onChange?.();
718
984
  return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
@@ -784,22 +1050,83 @@ export function createEngine({
784
1050
  *
785
1051
  * `roundAt` is left alone: it says when EVERY paired deck was last asked,
786
1052
  * and asking one of them does not make that true.
1053
+ *
1054
+ * IN TURN, LIKE EVERY ROUND (#1132). This called `roundWith` straight, past
1055
+ * the guard `round` keeps, and a press during the timer's round dialled the
1056
+ * deck that round was mid-way through healing from: the far side exported
1057
+ * `[5, 5]` and this side imported twice. fillEmptySlot's verdict inside the
1058
+ * lock limited what the second write could do on the forced path; it did
1059
+ * not stop the dial, the export, or a second write behind one verdict.
1060
+ *
1061
+ * WHAT IS ALREADY RUNNING IS THE ANSWER, WHEN IT HAS ONE. The press waits
1062
+ * for everything ahead of it, and if an ask of this deck finished in that
1063
+ * time — the round was dialling it when the press landed, or reached it
1064
+ * after — that ask was the check, and its result is returned instead of
1065
+ * asking again. A second ask would be a second dial for nothing, and it
1066
+ * would also be WORSE for the person who pressed. The dialog does not read
1067
+ * this list: it redraws from `lastRound`, where a login that moved says
1068
+ * "arrived last round". An ask after a heal finds that login healthy —
1069
+ * importAccount drops the accounts cache, so the next read is claude-swap's
1070
+ * — moves nothing, and writes that over the record. The lane repaired from
1071
+ * this very deck a moment ago would stop saying so, in answer to the press
1072
+ * that asked about it.
1073
+ *
1074
+ * AND ONLY THEN. When nothing that ran asked this deck after the press — the
1075
+ * round had been past it already, or never had it on its list, as with a
1076
+ * deck first heard mid-round — joining would be a check that checked
1077
+ * nothing. So it asks this deck itself, in its turn, behind whatever was
1078
+ * ahead of it and never beside it. Told apart by the record, not the clock:
1079
+ * every ask writes a new one, so the same object before and after means
1080
+ * nothing here asked this deck since the press. Keyed by the ROW, because
1081
+ * that is what roundWith writes under, and a typed row's `fp` is the
1082
+ * placeholder built from its address rather than the fingerprint asked for.
787
1083
  */
788
1084
  async roundOne(fp) {
789
1085
  if (!beacon || typeof fp !== "string" || !fp) return null;
790
1086
  const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
791
1087
  const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
792
1088
  const peer = heard ?? typed;
793
- return peer ? roundWith(peer) : null;
1089
+ if (!peer) return null;
1090
+ const had = lastRound.get(peer.fp);
1091
+ await _turn;
1092
+ const got = lastRound.get(peer.fp);
1093
+ if (got && got !== had) return got.done ?? [];
1094
+ // A deck switched off while the press waited is not dialled after all.
1095
+ return inTurn(() => (beacon ? roundWith(peer) : []));
794
1096
  },
795
1097
  /** Dial this address on every round from now on. Returns false for an
796
1098
  * address that is not one, rather than storing a row that can never
797
1099
  * connect and reports an error every minute forever. */
798
- addPeer(addr, port) {
1100
+ addPeer(addr, port, { typed = true } = {}) {
799
1101
  const p = Number(port);
800
1102
  if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
801
1103
  const host = addr.trim();
802
- manual.set(`${host}:${p}`, { fp: `manual:${host}:${p}`, name: host, addr: host, port: p, manual: true });
1104
+ const at = `${host}:${p}`;
1105
+ // MAKING ROOM RATHER THAN REFUSING, and only among rows the deck added
1106
+ // itself. A hard refusal at the cap would let whoever got there first
1107
+ // keep the whole budget, so a real deck starting later would never be
1108
+ // asked — which turns a cap meant to protect the round into a way to
1109
+ // silence it. Evicted first is the oldest auto row that has never
1110
+ // answered: `learned` holds an entry only for an address that completed a
1111
+ // handshake, so a row with no entry there has cost a round and returned
1112
+ // nothing. When every auto row has answered, the new one waits.
1113
+ if (!typed && !manual.has(at)) {
1114
+ const auto = [...manual.entries()].filter(([, v]) => !v.typed);
1115
+ if (auto.length >= MAX_AUTO_PEERS) {
1116
+ const stale = auto.find(([k]) => !learned.has(k));
1117
+ if (!stale) return false;
1118
+ manual.delete(stale[0]);
1119
+ learned.delete(stale[0]);
1120
+ }
1121
+ }
1122
+ // A row somebody typed outranks one the deck added: the same address
1123
+ // arriving by hand after a beacon put it there is a person vouching for
1124
+ // it, and nothing about that should be undone by the next beacon.
1125
+ const was = manual.get(at);
1126
+ manual.set(at, {
1127
+ fp: `manual:${at}`, name: host, addr: host, port: p, manual: true,
1128
+ typed: typed || was?.typed === true,
1129
+ });
803
1130
  return true;
804
1131
  },
805
1132
  removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
@@ -835,6 +1162,8 @@ export function createEngine({
835
1162
  // arrives is answered here or answered for you.
836
1163
  autoAsk: !!cfg.autoAsk,
837
1164
  autoAccept: !!cfg.autoAccept,
1165
+ // Whether paired decks are told which shared account this one is on.
1166
+ shareActive: cfg.shareActive !== false,
838
1167
  fp: identity?.fp ?? null,
839
1168
  // The address and port a person on another subnet types into the other
840
1169
  // deck's field. Null when this machine has no ordinary one, which the
@@ -932,6 +1261,11 @@ export function createEngine({
932
1261
  // and this is when. Undefined until it has, which is a row the
933
1262
  // panel draws as unknown rather than as live.
934
1263
  lastSeen: spokeAt.get(t.fp),
1264
+ // AND WHAT IT SAID WHEN IT CALLED — its card, its list, and which
1265
+ // of those it is on. The card was kept and never handed over, so
1266
+ // the dialog said "it runs an older version" about a deck that
1267
+ // had just told it exactly which version it runs.
1268
+ ...card(t.fp),
935
1269
  });
936
1270
  }
937
1271
  return rows;