agent-dag 3.16.0 → 3.17.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.
@@ -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-C7_uf54q.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-DPOZaaRr.css">
43
+ <script type="module" crossorigin src="/assets/index-CSpWSOAU.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-BwFExw1D.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.16.0",
3
+ "version": "3.17.0",
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,12 @@
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.17.0": [
35
+ {
36
+ "title": "🔔 Pairing is two presses",
37
+ "body": "When another deck asks to pair, the question now arrives **over the deck** rather than in a row three sections down a panel: its name, its address, the fingerprint to compare, **Accept** or **Decline**. Escape means later — the request stays in the panel until you answer it.\n\nThe rest is one list. Every deck you are paired with, every deck on the network, and every one you turned away, each a row with its state under the name and one verb at the end — and a deck that cannot be reached now reads differently from one that is fine. Saying no also reaches the other machine now, instead of leaving it waiting forever; you can take it back.\n\nThe dialog behind **setup** is down to the two things it is really about: what this deck is called, and which logins it offers."
38
+ }
39
+ ],
34
40
  "3.15.0": [
35
41
  {
36
42
  "title": "🔗 Decks on one network heal each other's dead logins",
@@ -7,7 +7,7 @@ import { createServer, request as httpRequest } from "node:http";
7
7
  import { readFile, stat, mkdir, open, truncate, readdir, unlink } from "node:fs/promises";
8
8
  import { existsSync, readFileSync, realpath as realpathCb, realpathSync } from "node:fs";
9
9
  import { extname, join, resolve, sep, dirname as pdirname } from "node:path";
10
- import { homedir } from "node:os";
10
+ import { homedir, networkInterfaces } from "node:os";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { dirname } from "node:path";
13
13
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
@@ -18,6 +18,8 @@ import { PRODUCT } from "./brand.mjs";
18
18
  import { createBlockNotifier } from "./block-notify.mjs";
19
19
  import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
20
20
  import { createEngine, defaultName } from "./lan-engine.mjs";
21
+ import { PROBE_PS, localAliases, reachability, readProbe } from "./lan-reach.mjs";
22
+ import { run } from "./exec.mjs";
21
23
  import { notify as osNotify } from "./browser-react.mjs";
22
24
  import { invokedName, renameNotice } from "./invoked-as.mjs";
23
25
  import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
@@ -3322,10 +3324,57 @@ async function applyLanPrefs() {
3322
3324
  }
3323
3325
  }
3324
3326
 
3327
+ // ── can other decks reach this one ──────────────────────────────────────────
3328
+ //
3329
+ // NEVER IN THE REQUEST. The panel polls this route, and the probe is a
3330
+ // PowerShell start-up — the better part of a second on a warm machine and
3331
+ // worse on a cold one. So the route answers with whatever the last probe said
3332
+ // and starts the next one behind it. The first poll after a deck starts
3333
+ // carries no verdict, which is correct rather than merely tolerable: at that
3334
+ // point the deck has not been listening long enough for an empty list to mean
3335
+ // anything either.
3336
+ //
3337
+ // STALE IS THE RIGHT DEFAULT HERE. What this measures — a network's category, a
3338
+ // firewall rule — changes when somebody changes it, which is a thing they do
3339
+ // while looking at the instructions this produced. Five minutes is far tighter
3340
+ // than that, and the panel's own reload picks up the change on the next poll.
3341
+ let reachSaid = null;
3342
+ let reachAt = 0;
3343
+ let reachBusy = false;
3344
+ const REACH_MS = 5 * 60_000;
3345
+
3346
+ function refreshReach() {
3347
+ if (reachBusy || process.platform !== "win32") return;
3348
+ if (reachAt && Date.now() - reachAt < REACH_MS) return;
3349
+ reachBusy = true;
3350
+ run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PROBE_PS], {
3351
+ // The program path goes through the environment rather than into the
3352
+ // script, so a path with a quote in it cannot close the string it sits in.
3353
+ env: { ...process.env, CCDECK_EXE: process.execPath },
3354
+ timeout: 25_000,
3355
+ }).then(r => {
3356
+ reachSaid = reachability({
3357
+ platform: process.platform,
3358
+ probe: r?.ok ? readProbe(r.stdout) : null,
3359
+ aliases: localAliases(networkInterfaces()),
3360
+ exePath: process.execPath,
3361
+ });
3362
+ }).catch(() => {
3363
+ // A probe that did not run says nothing, which is the same answer as a
3364
+ // machine this cannot speak about. It is never an error the panel shows:
3365
+ // nobody asked for it.
3366
+ reachSaid = null;
3367
+ }).finally(() => {
3368
+ reachAt = Date.now();
3369
+ reachBusy = false;
3370
+ });
3371
+ }
3372
+
3325
3373
  /** What the panel draws: the switch, this deck's own name and address, and who
3326
3374
  * else is in the group. No passphrase, for the reason prefsPayload gives. */
3327
3375
  function handleLanStatus(req, res) {
3328
- return send(res, 200, { ok: true, ...lanEngine.status() });
3376
+ refreshReach();
3377
+ return send(res, 200, { ok: true, ...lanEngine.status(), reach: reachSaid });
3329
3378
  }
3330
3379
 
3331
3380
  /**
@@ -3397,6 +3446,10 @@ async function handleLanPeer(req, res) {
3397
3446
  }
3398
3447
  case "dismiss":
3399
3448
  return send(res, 200, { ok: lanEngine.dismiss(fp), ...lanEngine.status() });
3449
+ // The undo for the one above. A deck that was told no stops asking; this is
3450
+ // how somebody who changed their mind lets it ask again.
3451
+ case "allow":
3452
+ return send(res, 200, { ok: lanEngine.allow(fp), ...lanEngine.status() });
3400
3453
  case "unpair":
3401
3454
  return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
3402
3455
  default:
@@ -121,12 +121,46 @@ export function createEngine({
121
121
  */
122
122
  const pending = new Map();
123
123
  const strangers = new Map();
124
+ /**
125
+ * Decks somebody here said no to.
126
+ *
127
+ * WITHOUT THIS, DECLINING DID NOTHING THAT LASTED. A deck that asks is a deck
128
+ * that keeps asking — it dials on its own timer, and every dial that finds no
129
+ * pin here becomes a fresh request. So `dismiss` took a row off a list that
130
+ * the next minute put back, and on the other machine the refusal was
131
+ * indistinguishable from a deck that had not been answered yet: both are the
132
+ * same `pending` refusal on the wire, and both drew "waiting for the other
133
+ * deck to accept this one" forever.
134
+ *
135
+ * A name kept here is therefore two answers at once. This deck stops asking
136
+ * its owner, and the deck that asked is TOLD — see refuse("declined") in
137
+ * lan-socket.mjs, which is the only way the far end can ever learn that the
138
+ * answer was no rather than not yet.
139
+ *
140
+ * In memory, like the two lists above, and reversible: `allow` takes a name
141
+ * out and the requests come back. Nothing about it is written down, because a
142
+ * refusal that outlives the process is a decision nobody can find to undo.
143
+ */
144
+ const declined = new Map();
124
145
  /** The invite this deck is offering, or null. One at a time: a deck showing
125
146
  * two tokens is a deck whose owner cannot say which one they sent. */
126
147
  let invite = null;
127
148
  /** What the last round did, for the panel. Not a log: one line per peer, most
128
149
  * recent only, because "what happened" is a question about now. */
129
150
  const lastRound = new Map();
151
+ /**
152
+ * Why this deck is not listening, when it is switched on and is not.
153
+ *
154
+ * A second deck on one machine takes the first one's port and the bind fails;
155
+ * the switch stays on, the beacon never starts, and the panel drew
156
+ * `starting…` for as long as the process lived. A state that cannot resolve
157
+ * and does not say why is the worst thing an instrument can show — the reader
158
+ * waits, and waiting is the one thing that never fixes it.
159
+ *
160
+ * Only the failures that stop the service reach this. A round that could not
161
+ * reach one peer is that peer's row, not the deck's.
162
+ */
163
+ let stalled = null;
130
164
 
131
165
  /** This deck's accounts in the shape the rules want. Read through the same
132
166
  * function the panel uses, so a row can never be alive here and dead there. */
@@ -361,13 +395,26 @@ export function createEngine({
361
395
  onTrust?.(list);
362
396
  onChange?.();
363
397
  },
398
+ // Asked before the request is drawn, so a deck that was told no is
399
+ // told no again rather than becoming a row somebody has to answer
400
+ // twice. The socket sends the reason; this only knows the name.
401
+ declined: fp => declined.has(fp),
364
402
  onPending: entry => {
365
403
  const had = pending.get(entry.fp);
366
404
  pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
367
405
  if (!had) onChange?.();
368
406
  },
369
407
  });
370
- const port = await server.start();
408
+ let port;
409
+ try {
410
+ port = await server.start();
411
+ } catch (err) {
412
+ // Kept, so the panel can say it. Rethrown, because the caller's own
413
+ // catch is what leaves the engine stopped rather than half-started.
414
+ stalled = err?.message ?? String(err);
415
+ throw err;
416
+ }
417
+ stalled = null;
371
418
  if (port !== cfg.port) onPort?.(port);
372
419
  beacon = createBeacon({
373
420
  port, name: cfg.name, fp: identity.fp,
@@ -533,7 +580,28 @@ export function createEngine({
533
580
  /** Say no, and stop being asked. The deck is dropped from both lists; if it
534
581
  * connects again it is a new request, because refusing is not a block. */
535
582
  dismiss(fp) {
583
+ // Whatever the row said, kept — the panel draws a declined deck by name
584
+ // and address, and after the delete below there is nowhere else to read
585
+ // them from.
586
+ const was = pending.get(fp) ?? strangers.get(fp) ?? null;
536
587
  const had = pending.delete(fp) || strangers.delete(fp);
588
+ if (had) {
589
+ declined.set(fp, {
590
+ fp,
591
+ name: was?.name ?? fp,
592
+ addr: was?.addr ?? "",
593
+ port: was?.port ?? 0,
594
+ at: now(),
595
+ });
596
+ onChange?.();
597
+ }
598
+ return had;
599
+ },
600
+ /** Change your mind. The name comes off the declined list and the next time
601
+ * that deck dials, it is a request again — which it will, on its own, so
602
+ * there is nothing else to press. */
603
+ allow(fp) {
604
+ const had = declined.delete(fp);
537
605
  if (had) onChange?.();
538
606
  return had;
539
607
  },
@@ -575,6 +643,9 @@ export function createEngine({
575
643
  return {
576
644
  enabled: !!cfg.enabled,
577
645
  running: !!beacon,
646
+ // Said only while it is true, and it is only ever true of a deck that
647
+ // is switched on and has no listener.
648
+ stalled: cfg.enabled && !beacon ? stalled : null,
578
649
  name: cfg.name,
579
650
  fp: identity?.fp ?? null,
580
651
  // The address and port a person on another subnet types into the other
@@ -597,9 +668,16 @@ export function createEngine({
597
668
  // per machine, newest first — see pairable, which is where the rule
598
669
  // that keeps this from becoming a wall of ghosts lives.
599
670
  strangers: (() => {
600
- const { shown, more } = pairable([...strangers.values()], now());
671
+ // A deck that was told no is not somebody to offer pairing with. It
672
+ // has its own row, with the one control that undoes the decision.
673
+ const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
674
+ const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
601
675
  return shown.map(p => ({ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more }));
602
676
  })(),
677
+ // Said no to, by somebody at this keyboard. Listed rather than merely
678
+ // silenced, because a refusal nobody can see is a refusal nobody can
679
+ // take back.
680
+ declined: [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
603
681
  peers: beacon ? (() => {
604
682
  // ONE DECK, ONE ROW, and it takes work because a deck can arrive here
605
683
  // twice by two different routes: heard on the network, and dialled at
@@ -657,6 +735,8 @@ export function createEngine({
657
735
  },
658
736
  stop() {
659
737
  if (timer) clearInterval(timer);
738
+ // A deliberate stop is not a fault, and the next start says its own.
739
+ if (!cfg.enabled) stalled = null;
660
740
  timer = null;
661
741
  beacon?.stop();
662
742
  server?.stop();
@@ -0,0 +1,256 @@
1
+ // Why another deck cannot reach this one — read off the machine, not guessed.
2
+ //
3
+ // THE FAILURE THIS EXISTS FOR. Two decks on one router, LAN sync on at both
4
+ // ends, and one of them shows an empty list forever. Reported as "he sees me
5
+ // and I see nobody", which is the shape that gives it away: Windows lets a
6
+ // process send without asking anybody, and drops unsolicited inbound unless a
7
+ // rule says otherwise. So the beacon leaves, the far deck hears it and draws a
8
+ // row — and the answer coming back is dropped at this machine's edge. Both
9
+ // halves of the feature look broken from here and neither is.
10
+ //
11
+ // WHY IT CANNOT BE FIXED FROM HERE, and this is settled rather than open.
12
+ // Creating a firewall rule needs elevation, and `relay-guard.mjs` already wrote
13
+ // down why no route in this server may raise a password dialog: `index.mjs`
14
+ // deliberately trusts a request carrying no Origin header so hook.js and curl
15
+ // keep working, on the reasoning that a process able to POST to loopback can
16
+ // already run anything as the user. That holds only while no route can do
17
+ // something the caller could not do for itself. One route that elevates hands
18
+ // every local process a way to make an authentication prompt appear wearing
19
+ // ccdeck's name, at a moment ccdeck chose. So this module reads, decides, and
20
+ // hands back a command as TEXT. It is the same shape and the same refusal.
21
+ //
22
+ // WHY IT IS WORTH SAYING ANYTHING AT ALL, given a manual address already makes
23
+ // the feature work without any of this: because "nothing here yet" and "this
24
+ // machine cannot be reached" are different facts, and an empty list says the
25
+ // first while meaning the second. A person reading it concludes the feature is
26
+ // broken, or that the other machine is off. Neither is true and neither is
27
+ // actionable.
28
+ //
29
+ // EVERYTHING BELOW IS A PURE FUNCTION. The probe is one PowerShell call that
30
+ // knows nothing and reports three lists; every judgement is here, where the
31
+ // suite can run it on any platform. That is the same division lan-sync.mjs and
32
+ // lan-socket.mjs already keep, for the same reason.
33
+
34
+ /** What the probe asks Windows for. Three reads, none of them privileged —
35
+ * measured: `Get-NetConnectionProfile`, `Get-NetFirewallProfile` and
36
+ * `Get-NetFirewallApplicationFilter` all answer for an ordinary user, while
37
+ * `Get-NetFirewallPortFilter` returns "Access is denied". So the rule this
38
+ * looks for is scoped to a PROGRAM rather than to a port, which is also the
39
+ * only kind that keeps working: the sync listener's port is ephemeral and is
40
+ * a different number after every restart. */
41
+ export const PROBE_PS = [
42
+ "$ErrorActionPreference='SilentlyContinue'",
43
+ "$nets = @(Get-NetConnectionProfile | ForEach-Object { [pscustomobject]@{ alias=$_.InterfaceAlias; category=[string]$_.NetworkCategory; v4=[string]$_.IPv4Connectivity } })",
44
+ "$profiles = @(Get-NetFirewallProfile | ForEach-Object { [pscustomobject]@{ name=[string]$_.Name; enabled=[bool]$_.Enabled } })",
45
+ "$rules = @(Get-NetFirewallApplicationFilter | Where-Object { $_.Program -eq $env:CCDECK_EXE } | ForEach-Object { $_ | Get-NetFirewallRule } | ForEach-Object { [pscustomobject]@{ direction=[string]$_.Direction; action=[string]$_.Action; enabled=[bool]$_.Enabled; profile=[string]$_.Profile } })",
46
+ // WHICH INTERFACE IS THE LAN, asked of the routing table rather than guessed
47
+ // from a name. The beacon goes to 255.255.255.255, so the interface that
48
+ // carries the broadcast IS the one this feature lives or dies on — and on a
49
+ // machine with Tailscale beside wifi the two have different categories, so
50
+ // picking the wrong one reports Private and clears a machine that is blocked.
51
+ "$bcast = [string](Find-NetRoute -RemoteIPAddress 255.255.255.255 | Select-Object -First 1 -ExpandProperty InterfaceAlias)",
52
+ "[pscustomobject]@{ nets=$nets; profiles=$profiles; rules=$rules; bcast=$bcast } | ConvertTo-Json -Depth 4 -Compress",
53
+ ].join("; ");
54
+
55
+ /**
56
+ * Read what the probe printed, or null.
57
+ *
58
+ * REFUSES RATHER THAN GUESSES. Every field here comes back from a shell on
59
+ * somebody else's machine, where a policy, a missing cmdlet or a locale can
60
+ * turn any of it into something this did not expect. A null answer means the
61
+ * panel says nothing, which is what it did before this module existed — the
62
+ * one outcome that cannot make anything worse.
63
+ *
64
+ * `ConvertTo-Json` collapses a one-element array to a bare object, which is
65
+ * the classic way a Windows probe passes its own tests and fails on the one
66
+ * machine that has exactly one network. Normalised here rather than worked
67
+ * around at each use.
68
+ */
69
+ export function readProbe(stdout) {
70
+ let raw = null;
71
+ try { raw = JSON.parse(String(stdout ?? "").trim()); }
72
+ catch { return null; }
73
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
74
+ const list = v => (Array.isArray(v) ? v : v == null ? [] : [v]).filter(x => x && typeof x === "object");
75
+ return {
76
+ nets: list(raw.nets).map(n => ({
77
+ alias: typeof n.alias === "string" ? n.alias : "",
78
+ category: typeof n.category === "string" ? n.category : "",
79
+ v4: typeof n.v4 === "string" ? n.v4 : "",
80
+ })).filter(n => n.alias),
81
+ profiles: list(raw.profiles).map(p => ({
82
+ name: typeof p.name === "string" ? p.name : "",
83
+ enabled: p.enabled === true,
84
+ })).filter(p => p.name),
85
+ rules: list(raw.rules).map(r => ({
86
+ direction: typeof r.direction === "string" ? r.direction : "",
87
+ action: typeof r.action === "string" ? r.action : "",
88
+ enabled: r.enabled === true,
89
+ profile: typeof r.profile === "string" ? r.profile : "",
90
+ })),
91
+ bcast: typeof raw.bcast === "string" ? raw.bcast.trim() : "",
92
+ };
93
+ }
94
+
95
+ /** The interfaces this machine holds a routable v4 address on, as the names
96
+ * Windows knows them by. `faces` is passed in rather than read, for the reason
97
+ * everything else here is pure. Measured on Windows 11 26200: node's key and
98
+ * `Get-NetConnectionProfile`'s `InterfaceAlias` are the same string, which is
99
+ * what makes matching them worth doing at all. */
100
+ export function localAliases(faces) {
101
+ const out = [];
102
+ for (const [name, list] of Object.entries(faces ?? {})) {
103
+ for (const n of list ?? []) {
104
+ if (n?.internal) continue;
105
+ if (n?.family !== "IPv4" && n?.family !== 4) continue;
106
+ if (typeof n.address !== "string" || n.address.startsWith("169.254.")) continue;
107
+ if (!out.includes(name)) out.push(name);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** A connection profile's category is spelled `DomainAuthenticated`; the
114
+ * firewall profile it answers to is spelled `Domain`. One mapping, in one
115
+ * place, because getting it wrong reads the wrong profile's `Enabled` and
116
+ * produces a confident wrong answer rather than a visible failure. */
117
+ export function profileFor(category) {
118
+ const c = String(category ?? "").trim().toLowerCase();
119
+ if (c === "public") return "Public";
120
+ if (c === "private") return "Private";
121
+ if (c === "domainauthenticated" || c === "domain") return "Domain";
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * The network this deck is actually on.
127
+ *
128
+ * A machine with Tailscale, a container bridge and wifi has several profiles
129
+ * and only one of them is the LAN somebody means.
130
+ *
131
+ * `aliases` IS IN PREFERENCE ORDER and is walked in that order rather than
132
+ * matched as a set. The caller puts the broadcast route's interface first,
133
+ * because that is the one the beacon actually leaves by; matching whichever
134
+ * happened to come first in the probe's own output is how the machine this was
135
+ * written on reports Tailscale's Private and clears a wifi that is Public.
136
+ */
137
+ export function lanNet(nets, aliases = []) {
138
+ const rows = Array.isArray(nets) ? nets : [];
139
+ for (const a of (Array.isArray(aliases) ? aliases : [])) {
140
+ const want = String(a ?? "").trim().toLowerCase();
141
+ if (!want) continue;
142
+ const hit = rows.find(n => n.alias.toLowerCase() === want);
143
+ if (hit) return hit;
144
+ }
145
+ return rows.find(n => n.v4 && n.v4 !== "Disconnected") ?? rows[0] ?? null;
146
+ }
147
+
148
+ /** Does an inbound allow rule for this program already cover that profile?
149
+ * A rule's `Profile` is a comma-joined list, and `Any` means all of them. */
150
+ export function ruleCovers(rules, profileName) {
151
+ const want = String(profileName ?? "").toLowerCase();
152
+ return (Array.isArray(rules) ? rules : []).some(r => {
153
+ if (!r.enabled) return false;
154
+ if (r.direction.toLowerCase() !== "inbound") return false;
155
+ if (r.action.toLowerCase() !== "allow") return false;
156
+ const on = r.profile.split(",").map(s => s.trim().toLowerCase());
157
+ return on.includes("any") || on.includes(want);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * The command that would change it — as text, for a person to paste into an
163
+ * elevated shell. Never run from here; see the header.
164
+ *
165
+ * ONE RULE, SCOPED TO THE PROGRAM. It covers the discovery socket and the sync
166
+ * listener at once, and it goes on covering the sync listener after a restart
167
+ * moves it — a rule written against today's port is a rule that works today.
168
+ *
169
+ * The category line is only offered when the network is Public, and it is
170
+ * offered rather than assumed: Public is the right setting for a café and the
171
+ * wrong one for the router at home, and this module cannot tell which one
172
+ * somebody is sitting in. The panel says so beside it.
173
+ */
174
+ export function fixSteps({ category, alias, exePath }) {
175
+ const steps = [];
176
+ if (profileFor(category) === "Public" && alias) {
177
+ steps.push(`Set-NetConnectionProfile -InterfaceAlias "${alias}" -NetworkCategory Private`);
178
+ }
179
+ steps.push(
180
+ "New-NetFirewallRule -DisplayName \"ccdeck (local network)\" -Direction Inbound"
181
+ + ` -Program "${exePath}" -Action Allow -Profile Private`,
182
+ );
183
+ return steps;
184
+ }
185
+
186
+ /**
187
+ * Whether other decks can reach this one, and what to do when they cannot.
188
+ *
189
+ * `null` means "no opinion", and it is the answer for every machine this cannot
190
+ * speak about: anything that is not Windows, and any probe that did not come
191
+ * back. Silence rather than a hedge — a panel line that says "possibly" about a
192
+ * thing it did not measure is worse than no line.
193
+ *
194
+ * `blocked: false` is a real finding and is worth returning: it lets the panel
195
+ * stop blaming the firewall for an empty list and say the other thing instead,
196
+ * which is that nobody else is running.
197
+ */
198
+ export function reachability({ platform, probe, aliases = [], exePath = "" } = {}) {
199
+ if (platform !== "win32") return null;
200
+ if (!probe) return null;
201
+ // The broadcast route first, then the interfaces the deck holds an address
202
+ // on. The routing table is the authority on where a beacon goes; the deck's
203
+ // own addresses are the fallback for a machine where that read came back
204
+ // empty, and the probe's first connected network is the fallback for that.
205
+ const net = lanNet(probe.nets, [probe.bcast, ...aliases]);
206
+ const category = net?.category ?? "";
207
+ const name = profileFor(category);
208
+ if (!name) return null;
209
+ const prof = probe.profiles.find(p => p.name.toLowerCase() === name.toLowerCase());
210
+ // A firewall that is off blocks nothing, and saying otherwise would send
211
+ // somebody to add a rule that changes nothing.
212
+ if (prof && !prof.enabled) return { blocked: false, why: "firewall off", category, alias: net?.alias ?? "" };
213
+ if (ruleCovers(probe.rules, name)) {
214
+ return { blocked: false, why: "rule present", category, alias: net?.alias ?? "" };
215
+ }
216
+ return {
217
+ blocked: true,
218
+ why: "no inbound rule",
219
+ category,
220
+ alias: net?.alias ?? "",
221
+ // The reason in the reader's terms, not the registry's. What they need to
222
+ // know is which half is broken, because the other half is what makes the
223
+ // workaround below obvious rather than magic.
224
+ text: name === "Public"
225
+ ? "This network is set to Public, and Windows drops what other decks send. They can still hear this deck — that is why one of them may already show it."
226
+ : "Windows has no inbound rule for this deck, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine.",
227
+ steps: fixSteps({ category, alias: net?.alias ?? "", exePath }),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * The way out that needs no rule at all, and the reason this module is not the
233
+ * whole answer.
234
+ *
235
+ * A round is one OUTBOUND connection — `roundWith` dials, both sides prove
236
+ * themselves over that socket, and the manifest and any transfer ride it home.
237
+ * Nothing in a round needs this machine to accept a connection. So a deck that
238
+ * cannot be reached can still do every part of this by dialling first, and the
239
+ * two controls that make it dial are an address somebody typed and an invite
240
+ * somebody else minted.
241
+ *
242
+ * WHICH WAY THE INVITE GOES IS THE WHOLE OF IT, and nothing said so before.
243
+ * An invite carries the addresses of the deck that MINTED it, and the deck that
244
+ * PASTES it dials them. So a blocked machine that mints an invite is asking to
245
+ * be called, which is the one thing it cannot receive; the same pair works on
246
+ * the first try if the invite is minted at the other end and pasted here. Same
247
+ * two controls, opposite order, and only one of the orders works.
248
+ */
249
+ export function workaround(blocked) {
250
+ if (!blocked) return null;
251
+ return {
252
+ dial: "This deck can still reach out, and one connection is all a round needs.",
253
+ invite: "Ask them to make the invite and paste it here — an invite is dialled by whoever pastes it, so it has to be pasted on this machine rather than made on it.",
254
+ address: "Or type their address below, and this deck will call them.",
255
+ };
256
+ }
@@ -253,6 +253,11 @@ export function createSyncServer({
253
253
  * is therefore a real deck rather than a port scan. The panel turns this
254
254
  * into a row with an accept on it. */
255
255
  onPending,
256
+ /** Was this deck already told no? A refusal has to be sent rather than
257
+ * merely held, or the far end reads "declined" and "not answered yet" as one
258
+ * silent wait — they are the same frame otherwise, and only one of them ever
259
+ * comes right by waiting. */
260
+ declined = () => false,
256
261
  /** The invite this deck is currently offering, or null. A caller that proves
257
262
  * it holds the code is somebody the owner handed a token to, so it is paired
258
263
  * on arrival rather than queued behind a press. */
@@ -394,6 +399,12 @@ export function createSyncServer({
394
399
  return;
395
400
  }
396
401
  }
402
+ // A DECK THIS ONE'S OWNER ALREADY ANSWERED, and the answer was no.
403
+ // It is not asked again here, and — the half a held refusal cannot
404
+ // do — the deck that asked is told, so its own panel can stop saying
405
+ // "waiting" about a question that has been answered.
406
+ if (declined(peerFp)) return refuse("declined");
407
+
397
408
  // A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
398
409
  // a port scan, and it told us a name and an address a person can
399
410
  // recognise. That is a row with an accept on it, and nothing else
@@ -525,6 +536,7 @@ export function connectToPeer({
525
536
  if (msg.t === "no") {
526
537
  return fail(new Error({
527
538
  pending: "waiting for the other deck to accept this one",
539
+ declined: "that deck said no",
528
540
  impostor: "that deck has this one pinned under a different key",
529
541
  "bad proof": "the other deck refused this one's proof",
530
542
  }[msg.why] ?? "the other deck refused this handshake"));
@@ -564,9 +564,22 @@ export function dropTrusted(trusted, fp) {
564
564
  * NEWEST FIRST, AND CAPPED. The deck somebody just started is the one they are
565
565
  * looking for; a list longer than a screen is a list nobody reads.
566
566
  */
567
- export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8 } = {}) {
567
+ export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8, mine = [] } = {}) {
568
+ // NOT THIS MACHINE. A deck's own beacon is filtered by fingerprint, which is
569
+ // right and is not enough: a second deck on the same computer is a different
570
+ // process with a different key, so it passes that check honestly and then
571
+ // shows up in the list under this machine's own hostname, at this machine's
572
+ // own address, offering to pair with itself.
573
+ //
574
+ // It was reported from a screenshot — "why myself appear here in list" — and
575
+ // the answer is that the address is the one thing that cannot lie: a beacon
576
+ // arriving FROM an address this machine holds came from this machine. Pairing
577
+ // with it would also buy nothing, because both decks read one claude-swap
578
+ // store and there is nothing for either to heal.
579
+ const own = new Set(Array.isArray(mine) ? mine : []);
568
580
  const live = [...(strangers ?? [])]
569
581
  .filter(p => p && typeof p.at === "number" && now - p.at <= presentMs)
582
+ .filter(p => !own.has(p.addr))
570
583
  .sort((a, b) => b.at - a.at);
571
584
  const seen = new Set();
572
585
  const out = [];