agent-dag 3.18.0 → 3.18.3

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.
@@ -40,8 +40,8 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-DYh8wtpn.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-Dp5_CD7C.css">
43
+ <script type="module" crossorigin src="/assets/index-gg-y6G2I.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-BeMPJP5g.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "3.18.0",
3
+ "version": "3.18.3",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,18 @@
31
31
  "about a defect in the type, and the suite refuses both it and no space at",
32
32
  "all."
33
33
  ],
34
+ "3.18.3": [
35
+ {
36
+ "title": "🟢 The list shows what is on, and folds the rest",
37
+ "body": "A machine that is off, or nearby and unpaired, or one you said no to, is context rather than an answer to \"who can I use right now\" — so the decks that are **on** lead the list and everything else is one line: `2 more`. Nothing worrying is hidden by it: a deck that cannot be reached is counted in that line, in the warning colour, so the fold says whether it is worth opening.\n\nThe round moved to a **↻ beside the switch**, where the accounts header has kept the same act since it was written, and the line under the state now says when it last ran — `checked 2m ago`, or `not checked yet`. A list that refreshes itself and a list that has stopped used to look identical, and pressing the button to find out was the only way to tell."
38
+ }
39
+ ],
40
+ "3.18.1": [
41
+ {
42
+ "title": "🔁 A deck stops asking to pair over and over",
43
+ "body": "Several decks on one computer share a config directory, so they start with one key. Each read the others' beacons as somebody wearing its name, took a fresh key, and wrote it to the file the others read — a loop that never settles. Every new key is a **new deck** to everyone else on the network, so one machine sent a fresh pairing request every few seconds and accepting one accomplished nothing: the deck that asked no longer existed by the time the answer arrived.\n\nDecks on one computer now recognise each other as the same machine and leave the key alone. The clash still worth healing — a `~/.claude` copied to a second machine — is unchanged.\n\nThis also collapses the rows: a computer that has run the deck a few times appeared several times on everybody else's panel and is one row now. **Both machines need this version** for that to take effect on the other one's screen.\n\nA deck that answered is no longer counted as unreachable, the list refreshes itself in seconds while somebody is deciding rather than at the next minute, and a failed round says what happened — `not listening`, `no route to it` — with the address and the raw error on the hover."
44
+ }
45
+ ],
34
46
  "3.18.0": [
35
47
  {
36
48
  "title": "🟢 A deck that is switched off no longer reads as on",
@@ -38,6 +38,21 @@ import { hostname, networkInterfaces } from "node:os";
38
38
  * than something that updates when you press a button. */
39
39
  export const SYNC_MS = 60_000;
40
40
 
41
+ /**
42
+ * How long to wait before dialling again while somebody is deciding.
43
+ *
44
+ * A minute is right for the steady state — two decks whose logins all work have
45
+ * nothing to say to each other — and it is far too long for the one moment
46
+ * anybody is watching: the seconds after somebody presses accept on the other
47
+ * machine. Reported as "it should work by itself", from a panel that had been
48
+ * correct for up to fifty-nine more seconds than the person in front of it.
49
+ *
50
+ * So the loop tightens while a request is outstanding and relaxes the moment it
51
+ * is answered — either way. A refusal is an answer, and a deck that said no is
52
+ * not asked every eight seconds.
53
+ */
54
+ export const ASKING_MS = 8_000;
55
+
41
56
  /** How long one peer round may take before it is abandoned. A manifest is one
42
57
  * round trip on a local network; anything past this is a peer that is not
43
58
  * going to answer, and holding the attempt open would stall the next round. */
@@ -148,6 +163,11 @@ export function createEngine({
148
163
  /** What the last round did, for the panel. Not a log: one line per peer, most
149
164
  * recent only, because "what happened" is a question about now. */
150
165
  const lastRound = new Map();
166
+ /** When the last round FINISHED, whatever it did or failed to do. The panel's
167
+ * `↻` fires one on demand and the loop fires one on its own; a reader who
168
+ * pressed it wants to know it happened, and a reader who did not wants to
169
+ * know the list is not a photograph of an hour ago. */
170
+ let roundAt = null;
151
171
  /**
152
172
  * Why this deck is not listening, when it is switched on and is not.
153
173
  *
@@ -231,6 +251,16 @@ export function createEngine({
231
251
  }
232
252
  };
233
253
 
254
+ /**
255
+ * Is anybody on the other end still deciding?
256
+ *
257
+ * The exact sentence lan-socket.mjs sends for "a real deck, not yet
258
+ * accepted", which is the one state where dialling again in a few seconds
259
+ * does something a minute later would not.
260
+ */
261
+ const waitingOnSomebody = () =>
262
+ [...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
263
+
234
264
  /** Ask one peer what it has, and heal whatever it can heal. */
235
265
  const roundWith = async peer => {
236
266
  let conn = null;
@@ -360,6 +390,7 @@ export function createEngine({
360
390
  // max+1 without a lock of its own.
361
391
  all.push(...await roundWith(peer));
362
392
  }
393
+ roundAt = now();
363
394
  return all;
364
395
  };
365
396
 
@@ -440,6 +471,11 @@ export function createEngine({
440
471
  onPeer: () => onChange?.(),
441
472
  onStranger: entry => {
442
473
  const had = strangers.get(entry.fp);
474
+ // KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
475
+ // a fresh key — a second deck sharing one config directory does, by
476
+ // design — used to leave its old key in this map for a day, and every
477
+ // one of them drew a row offering to pair with the same machine.
478
+ if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
443
479
  strangers.set(entry.fp, entry);
444
480
  // Only a deck that is new to us is news. A beacon every thirty
445
481
  // seconds from one already on the list is not a reason to redraw.
@@ -457,7 +493,15 @@ export function createEngine({
457
493
  ...(createSocket ? { createSocket } : {}),
458
494
  });
459
495
  await beacon.start();
460
- timer = setInterval(() => { void round(); }, SYNC_MS);
496
+ // A self-scheduling loop rather than one interval, because the gap
497
+ // between rounds is not one number: see ASKING_MS.
498
+ const tick = async () => {
499
+ try { await round(); } catch { /* a round reports itself, per peer */ }
500
+ if (!beacon) return;
501
+ timer = setTimeout(() => { void tick(); }, waitingOnSomebody() ? ASKING_MS : SYNC_MS);
502
+ timer.unref?.();
503
+ };
504
+ timer = setTimeout(() => { void tick(); }, SYNC_MS);
461
505
  timer.unref?.();
462
506
  },
463
507
  /**
@@ -664,6 +708,9 @@ export function createEngine({
664
708
  // Said only while it is true, and it is only ever true of a deck that
665
709
  // is switched on and has no listener.
666
710
  stalled: cfg.enabled && !beacon ? stalled : null,
711
+ // When every paired deck was last asked. Null until the first round,
712
+ // which on a deck that has just started is the honest answer.
713
+ checkedAt: roundAt,
667
714
  name: cfg.name,
668
715
  fp: identity?.fp ?? null,
669
716
  // The address and port a person on another subnet types into the other
@@ -689,6 +736,9 @@ export function createEngine({
689
736
  // A deck that was told no is not somebody to offer pairing with. It
690
737
  // has its own row, with the one control that undoes the decision.
691
738
  const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
739
+ // pairable() collapses the rest by machine — see hostId. A computer
740
+ // that has run the deck a few times holds a key per run, and every
741
+ // one of them was a row of its own on everybody else's panel.
692
742
  const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
693
743
  return shown.map(p => ({ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more }));
694
744
  })(),
@@ -756,7 +806,7 @@ export function createEngine({
756
806
  };
757
807
  },
758
808
  stop() {
759
- if (timer) clearInterval(timer);
809
+ if (timer) clearTimeout(timer);
760
810
  // A deliberate stop is not a fault, and the next start says its own.
761
811
  if (!cfg.enabled) stalled = null;
762
812
  timer = null;
@@ -25,7 +25,7 @@ import dgram from "node:dgram";
25
25
  import net from "node:net";
26
26
  import { randomBytes } from "node:crypto";
27
27
  import {
28
- beaconPayload, beaconVerdict, handshakeTranscript, notePeer, proof, proofOk,
28
+ beaconPayload, beaconVerdict, handshakeTranscript, hostId, notePeer, proof, proofOk,
29
29
  inviteProof, readBeacon, readPub, sessionKey, trustedPeer,
30
30
  ANNOUNCE_MS, MAX_BEACON_BYTES, MAX_MANIFEST_BYTES,
31
31
  } from "./lan-sync.mjs";
@@ -89,6 +89,11 @@ export function createBeacon({
89
89
  // instance ids mean the deck restarted between them, which is the signal to
90
90
  // drop any session held for it rather than resume into a process that is gone.
91
91
  const instance = randomBytes(8).toString("hex");
92
+ /** Which computer this is, as opposed to which process or which key. Derived
93
+ * once per beacon rather than per packet: it cannot change while a process
94
+ * is running, and hashing a hostname thirty seconds apart forever is work
95
+ * nobody asked for. See hostId. */
96
+ const host = hostId();
92
97
  const peers = new Map();
93
98
  let sock = null;
94
99
  let timer = null;
@@ -99,7 +104,7 @@ export function createBeacon({
99
104
  let repliedAt = 0;
100
105
  const answered = new Set();
101
106
 
102
- const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance })));
107
+ const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
103
108
 
104
109
  const announce = () => {
105
110
  if (!sock) return;
@@ -123,7 +128,7 @@ export function createBeacon({
123
128
  // the bytes and the address and does what it is told.
124
129
  if (msg.length > MAX_BEACON_BYTES) return;
125
130
  const beacon = readBeacon(msg);
126
- const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, trusted: trusted() });
131
+ const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
127
132
  // ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
128
133
  // part is the change. It used to answer only a deck already in the group,
129
134
  // which was fine when a group existed. Now the first thing a new deck has
@@ -155,7 +160,12 @@ export function createBeacon({
155
160
  // row somebody can accept. Nothing is asked of it and nothing is
156
161
  // offered to it until they do.
157
162
  if (verdict === "stranger") {
158
- onStranger?.({ fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port, at: now() });
163
+ onStranger?.({
164
+ fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
165
+ // Carried through so the list can show one row per machine rather
166
+ // than one per key that machine has ever held.
167
+ host: beacon.host, at: now(),
168
+ });
159
169
  }
160
170
  return;
161
171
  }
@@ -98,6 +98,7 @@ import {
98
98
  createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes,
99
99
  timingSafeEqual,
100
100
  } from "node:crypto";
101
+ import os from "node:os";
101
102
 
102
103
  /** Marks a packet as ours before anything reads a field of it. Four bytes of
103
104
  * ASCII rather than a number, so a stray packet on the port is recognisable
@@ -419,7 +420,31 @@ export function inviteProof(code, transcript) {
419
420
  * from a deck this one does not trust becomes a row somebody can accept, and
420
421
  * nothing at all happens until they do.
421
422
  */
422
- export function beaconPayload({ name, fp, port, instance }) {
423
+ /**
424
+ * WHICH MACHINE THIS IS, as opposed to which PROCESS or which KEY.
425
+ *
426
+ * Reported by somebody looking at a colleague's screen: "I appear three or four
427
+ * times". Every one of those rows was honest — a deck's identity is its key, a
428
+ * second deck sharing a config directory is told to take a fresh one
429
+ * (`id-clash`), and a deck heard yesterday stays listed for a day — so a
430
+ * machine that has run the deck a few times becomes a column of itself on
431
+ * everybody else's panel, under one hostname, offering to pair with each.
432
+ *
433
+ * A key cannot answer "same machine?" and was never meant to. This can: it is
434
+ * derived rather than stored, so two processes on one computer agree without
435
+ * coordinating and a first run needs nothing written; and it is HASHED, because
436
+ * a hostname and a home directory carry a person's name and this goes out in
437
+ * the clear to everyone on the network, thirty seconds apart, forever.
438
+ *
439
+ * The home directory is in it so that a copied `~/.claude` — which is how two
440
+ * real machines end up holding one key, and the reason `id-clash` exists —
441
+ * still reads as two machines when the hostnames differ, which they do.
442
+ */
443
+ export function hostId({ hostname = os.hostname(), home = os.homedir() } = {}) {
444
+ return createHash("sha256").update(`${hostname}\u0000${home}`).digest("hex").slice(0, 12);
445
+ }
446
+
447
+ export function beaconPayload({ name, fp, port, instance, host }) {
423
448
  return {
424
449
  m: MAGIC,
425
450
  v: PROTOCOL,
@@ -431,6 +456,11 @@ export function beaconPayload({ name, fp, port, instance }) {
431
456
  // drop whatever session state was held for it rather than trying to resume
432
457
  // into a process that no longer exists. Syncthing's field, same job.
433
458
  i: instance,
459
+ // Which MACHINE, so several processes on one computer are one row rather
460
+ // than one row each. Optional on the wire: a deck older than this sends no
461
+ // `h`, and a reader that requires one would stop seeing every deck already
462
+ // installed.
463
+ ...(host ? { h: host } : {}),
434
464
  };
435
465
  }
436
466
 
@@ -464,7 +494,11 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
464
494
  const port = raw.p;
465
495
  if (typeof raw.f !== "string" || !/^[0-9a-f]{3}(-[0-9a-f]{3}){3}$/.test(raw.f)) return null;
466
496
  if (typeof raw.i !== "string" || !/^[0-9a-f]{8,32}$/.test(raw.i)) return null;
467
- return { name: cleanName(raw.n, raw.f), fp: raw.f, port, instance: raw.i };
497
+ // Optional, and refused rather than tolerated when it is malformed: a field
498
+ // that decides which rows collapse into one is a field worth being strict
499
+ // about. Absent is fine and means "a deck older than this".
500
+ if (raw.h !== undefined && (typeof raw.h !== "string" || !/^[0-9a-f]{6,32}$/.test(raw.h))) return null;
501
+ return { name: cleanName(raw.n, raw.f), fp: raw.f, port, instance: raw.i, host: raw.h };
468
502
  }
469
503
 
470
504
  /**
@@ -480,8 +514,15 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
480
514
  * broadcast on every interface it owns, and filtering by address would need a
481
515
  * list of them that changes when a VPN comes up.
482
516
  */
483
- export function beaconVerdict(beacon, { selfFp, selfInstance, trusted } = {}) {
517
+ export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, trusted } = {}) {
484
518
  if (!beacon) return "unreadable";
519
+ // ANOTHER DECK ON THIS COMPUTER. Not this process — a different key, honestly
520
+ // its own — and still nothing to pair with: both read one claude-swap store,
521
+ // so neither holds a login the other could heal. It used to be filtered by
522
+ // the address the packet came from, which is true and needs the list of this
523
+ // machine's addresses to be current; the machine's own id needs nothing and
524
+ // is the same answer.
525
+ if (selfHost && beacon.host && beacon.host === selfHost && beacon.fp !== selfFp) return "self";
485
526
  if (beacon.fp === selfFp) {
486
527
  // OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. Two decks sharing a config
487
528
  // directory hold the same key — and so does the second machine when
@@ -493,7 +534,27 @@ export function beaconVerdict(beacon, { selfFp, selfInstance, trusted } = {}) {
493
534
  // own packet carries the instance we are running and another deck's cannot.
494
535
  // Told apart here rather than healed here — this function decides, and
495
536
  // taking a new key is the engine's to do.
496
- return selfInstance && beacon.instance !== selfInstance ? "id-clash" : "self";
537
+ //
538
+ // AND ONE MACHINE'S OWN TWIN IS NOT A CLASH, which is the whole of a defect
539
+ // reported as "Fiodor pressed yes ten times and it keeps asking". Several
540
+ // decks on one computer share a config directory, so they start with one
541
+ // key; each read the others' beacons as somebody wearing its name, each
542
+ // took a fresh key, each wrote that key to the file the others read — and
543
+ // the loop never settles. Every new key is a new deck to everybody else on
544
+ // the network, so one machine produced a fresh pairing request every few
545
+ // seconds, forever, and accepting one accomplished nothing because the deck
546
+ // that asked no longer existed by the time the answer arrived.
547
+ //
548
+ // Two processes on one computer sharing one key is not a problem to heal.
549
+ // They read one claude-swap store; there is nothing for either to send the
550
+ // other, they never dial each other, and to the rest of the network they
551
+ // are one deck — which is exactly what they are. The clash worth healing is
552
+ // the OTHER one: a `~/.claude` copied to a second machine, where two real
553
+ // decks would otherwise be permanently invisible to each other.
554
+ if (selfInstance && beacon.instance !== selfInstance) {
555
+ return selfHost && beacon.host === selfHost ? "self" : "id-clash";
556
+ }
557
+ return "self";
497
558
  }
498
559
  // A DECK WE HAVE BEEN TOLD TO TRUST, or one somebody may choose to. There is
499
560
  // no third answer any more: the group tag used to sort strangers from peers
@@ -581,10 +642,17 @@ export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8, mi
581
642
  .filter(p => p && typeof p.at === "number" && now - p.at <= presentMs)
582
643
  .filter(p => !own.has(p.addr))
583
644
  .sort((a, b) => b.at - a.at);
645
+ // ONE ROW PER MACHINE. `live` is newest first, so the row that survives is the
646
+ // one that spoke most recently — which is the process somebody is actually
647
+ // running, rather than a key its computer took and abandoned an hour ago.
648
+ //
649
+ // The machine's own id when the far deck sends one, and the old name@address
650
+ // pair when it does not: a deck older than the `h` field is still collapsed
651
+ // as well as it can be rather than not at all.
584
652
  const seen = new Set();
585
653
  const out = [];
586
654
  for (const p of live) {
587
- const key = `${p.name}@${p.addr}`;
655
+ const key = p.host ? `host:${p.host}` : `${p.name}@${p.addr}`;
588
656
  if (seen.has(key)) continue;
589
657
  seen.add(key);
590
658
  out.push(p);
@@ -614,6 +682,7 @@ export function notePeer(peers, beacon, addr, now) {
614
682
  addr,
615
683
  port: beacon.port,
616
684
  instance: beacon.instance,
685
+ host: beacon.host,
617
686
  firstSeen: prev?.firstSeen ?? now,
618
687
  lastSeen: now,
619
688
  // Kept across a name change so the panel can say "was Laptop-birou", which