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.
- package/dist/web/assets/{index-Dp5_CD7C.css → index-BeMPJP5g.css} +1 -1
- package/dist/web/assets/index-gg-y6G2I.js +260 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/lan-engine.mjs +52 -2
- package/src/server/lan-socket.mjs +14 -4
- package/src/server/lan-sync.mjs +74 -5
- package/dist/web/assets/index-DYh8wtpn.js +0 -260
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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?.({
|
|
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
|
}
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|