ccdeck 3.32.2 → 3.33.1

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 (73) hide show
  1. package/README.md +14 -12
  2. package/bin/agent-dag.js +6 -0
  3. package/bin/cli/help.js +2 -0
  4. package/bin/cli/one-shot.js +32 -13
  5. package/bin/cli/second-start.js +1 -0
  6. package/bin/cli/startup.js +34 -0
  7. package/bin/cli/uninstall.js +47 -2
  8. package/bin/deck.js +12 -1
  9. package/dist/web/assets/BrowserWatchModal-BTNOQdp5.js +1 -0
  10. package/dist/web/assets/UsageHistoryModal-CIDpgHTW.js +1 -0
  11. package/dist/web/assets/index-BIANlQ9A.css +1 -0
  12. package/dist/web/assets/index-D7ddxIpC.js +299 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +36 -1
  16. package/src/server/account-projects.mjs +11 -2
  17. package/src/server/args.mjs +12 -2
  18. package/src/server/auto-update.mjs +14 -1
  19. package/src/server/best-of-nostalgia.mjs +2 -1
  20. package/src/server/block-notify.mjs +114 -11
  21. package/src/server/browser-history.mjs +81 -4
  22. package/src/server/browser-presence.mjs +98 -2
  23. package/src/server/browser-react.mjs +39 -4
  24. package/src/server/browser-watch-seen.mjs +35 -1
  25. package/src/server/browser-watch.mjs +105 -18
  26. package/src/server/cafe-music-bgm.mjs +2 -1
  27. package/src/server/ccusage-install.mjs +8 -4
  28. package/src/server/ccusage.mjs +3 -1
  29. package/src/server/claude-accounts.mjs +38 -16
  30. package/src/server/codex-dir.mjs +8 -6
  31. package/src/server/codex-quota.mjs +4 -2
  32. package/src/server/codex-usage.mjs +93 -38
  33. package/src/server/codex-watch.mjs +150 -18
  34. package/src/server/cswap-auto-loop.mjs +15 -2
  35. package/src/server/cswap-auto-readers.mjs +10 -4
  36. package/src/server/cswap-auto.mjs +6 -6
  37. package/src/server/cswap-install.mjs +118 -3
  38. package/src/server/deck-prefs.mjs +4 -0
  39. package/src/server/event-pipeline.mjs +3 -0
  40. package/src/server/event-ring.mjs +2 -2
  41. package/src/server/good-life-radio.mjs +2 -1
  42. package/src/server/index.mjs +3 -2
  43. package/src/server/installer.mjs +62 -12
  44. package/src/server/lan-beacon.mjs +6 -4
  45. package/src/server/lan-call.mjs +4 -0
  46. package/src/server/lan-dials.mjs +10 -1
  47. package/src/server/lan-engine.mjs +221 -44
  48. package/src/server/lan-requests.mjs +88 -5
  49. package/src/server/lan-round-record.mjs +21 -4
  50. package/src/server/lan-socket.mjs +105 -15
  51. package/src/server/lan-sync.mjs +18 -4
  52. package/src/server/lifecycle.mjs +21 -2
  53. package/src/server/live-radio-mix.mjs +2 -1
  54. package/src/server/log-replay.mjs +55 -24
  55. package/src/server/login-service.mjs +38 -3
  56. package/src/server/metrics-run.mjs +5 -3
  57. package/src/server/network-sampler.mjs +25 -2
  58. package/src/server/npm-upgrade.mjs +8 -1
  59. package/src/server/npx.mjs +32 -5
  60. package/src/server/process-list.mjs +103 -13
  61. package/src/server/purge-key.mjs +52 -24
  62. package/src/server/request-gates.mjs +47 -14
  63. package/src/server/retire-sound-hook.mjs +56 -9
  64. package/src/server/ring-bounds.mjs +10 -0
  65. package/src/server/running-deck.mjs +72 -2
  66. package/src/server/session-enrichment.mjs +40 -8
  67. package/src/server/stop-deck.mjs +13 -4
  68. package/src/server/system-metrics.mjs +2 -2
  69. package/src/server/transcript-scan.mjs +27 -6
  70. package/dist/web/assets/BrowserWatchModal-bx_k4o-J.js +0 -1
  71. package/dist/web/assets/UsageHistoryModal-Do8S0UPG.js +0 -1
  72. package/dist/web/assets/index-DKn7nc15.css +0 -1
  73. package/dist/web/assets/index-hGO2DTdi.js +0 -299
@@ -20,7 +20,7 @@ import { bootstrapUv, existingBootstrappedUv } from "./uv-bootstrap.mjs";
20
20
  // Where installers leave claude-swap and which one owns this machine's copy —
21
21
  // pure layout, with the platform a parameter, so it lives on its own.
22
22
  import { PKG, cswapCandidates, cswapOwner } from "./cswap-layout.mjs";
23
- import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
23
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
24
24
  import { join } from "node:path";
25
25
  import { homedir } from "node:os";
26
26
 
@@ -92,6 +92,98 @@ function sameVersion(a, b) { return !isOlder(a, b) && !isOlder(b, a); }
92
92
 
93
93
  let _bin = null;
94
94
 
95
+ // ── a copy this deck refused ─────────────────────────────────────────────────
96
+ //
97
+ // #1799. installAndConfirm refuses a claude-swap whose version is not the one it
98
+ // asked for, and the refusal used to exist only in the value it returned. The
99
+ // copy was still on disk, cswapBin memoized it the moment it answered
100
+ // `--version`, and everything after the boot row drove it anyway: the roster
101
+ // called it installed-and-empty and offered Add account, `/api/cswap-auto` ran
102
+ // `cswap config` with it, and the NEXT launch could not tell it from a copy the
103
+ // user installed themselves — which ensureCswap deliberately accepts whatever
104
+ // its number — so it read "accounts panel enabled" and was seeded with
105
+ // `cswap add`.
106
+ //
107
+ // So a refusal is written down, next to the update marker, and kept until
108
+ // something changes: while a copy answering the refused version is what
109
+ // resolves, cswapBin hands out REFUSED_BIN instead of it and cswapVersion
110
+ // answers null. It is matched on the VERSION, not on the spelling: the same
111
+ // file answers as `cswap` once ~/.local/bin is on PATH and by its absolute path
112
+ // before, and telling those apart would need a PATH walk to be right in both
113
+ // directions. A copy answering any other version clears the record — that is a
114
+ // different copy, most likely one the user installed, and it is left alone the
115
+ // way any other is — and so does an install that lands an acceptable one.
116
+ // AGENTS_DECK_CSWAP wins over all of it, as it wins over every other lookup
117
+ // here: someone who names a binary has chosen it.
118
+ const REFUSED_RECORD = join(homedir(), ".agents-deck", "cswap-refused.json");
119
+
120
+ /**
121
+ * What cswapBin answers while the refusal holds: a path under the deck's own
122
+ * state directory that nothing creates, with an extension so that Windows spawns
123
+ * it as given rather than trying `.cmd` spellings through cmd.exe. Every caller
124
+ * already handles a cswap that is not there, and this is one — so a mutation
125
+ * that reaches it (an Add pressed from a panel drawn before the refusal) fails
126
+ * the way a missing tool fails, and runs nothing.
127
+ */
128
+ const REFUSED_BIN = join(homedir(), ".agents-deck", "cswap-refused", "cswap.refused");
129
+
130
+ /** The refusal in effect for THIS process, `{ path, version, want, via }` — set
131
+ * when an install is refused, or when cswapBin resolves a copy the record
132
+ * names. */
133
+ let _refused = null;
134
+
135
+ function readRefusal() {
136
+ try {
137
+ const r = JSON.parse(readFileSync(REFUSED_RECORD, "utf8"));
138
+ return r && typeof r.version === "string" ? r : null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function clearRefusal() {
145
+ try { rmSync(REFUSED_RECORD, { force: true }); } catch { /* best-effort */ }
146
+ }
147
+
148
+ /** Refuse this copy from now on, in this process and in the next. */
149
+ function refuse(entry) {
150
+ _refused = entry;
151
+ _bin = null;
152
+ _probe = null;
153
+ try {
154
+ mkdirSync(join(homedir(), ".agents-deck"), { recursive: true });
155
+ writeFileSync(REFUSED_RECORD, JSON.stringify({ ...entry, at: new Date().toISOString() }, null, 2) + "\n");
156
+ } catch { /* the in-process refusal still holds; a note on disk is the next launch's */ }
157
+ }
158
+
159
+ /**
160
+ * The refusal cswapBin's resolution found, or null — `{ path, version, want,
161
+ * via }`. As of the last cswapBin(), so a caller that has just awaited one can
162
+ * ask this without another hop; one that has not wants cswapRefusal.
163
+ */
164
+ export function cswapRefused() {
165
+ return process.env.AGENTS_DECK_CSWAP ? null : _refused;
166
+ }
167
+
168
+ /**
169
+ * The refusal this deck is keeping, or null, for a caller that may be asking
170
+ * before anything has resolved the binary: the roster and the first-run seed.
171
+ * Resolves it first only when there is a record to match and nothing has been
172
+ * resolved yet, so a process pays one `--version` for the question and a
173
+ * machine with no record pays a missing file.
174
+ */
175
+ export async function cswapRefusal() {
176
+ if (process.env.AGENTS_DECK_CSWAP) return null;
177
+ if (!_refused && !_bin && existsSync(REFUSED_RECORD)) await cswapBin();
178
+ return _refused;
179
+ }
180
+
181
+ /** ensureCswap's answer for a refused copy: the install-time row's shape, so
182
+ * the boot says the same thing on every launch the refusal holds. */
183
+ function refusedState(r) {
184
+ return { state: "unavailable", reason: "unexpected_version", version: r.version, want: r.want ?? RANGE_SPEC, via: r.via ?? null };
185
+ }
186
+
95
187
  /**
96
188
  * What the probe that resolved `_bin` printed, and when.
97
189
  *
@@ -136,9 +228,19 @@ export async function cswapBin() {
136
228
  // debugging a bad resolution needs it to take effect immediately.
137
229
  if (process.env.AGENTS_DECK_CSWAP) return process.env.AGENTS_DECK_CSWAP;
138
230
  if (_bin) return _bin;
231
+ if (_refused) return REFUSED_BIN;
139
232
 
140
233
  const take = (spelling, r) => {
141
- _probe = { version: versionIn(r), at: Date.now() };
234
+ const version = versionIn(r);
235
+ // The copy the deck refused, still answering: decline it (#1799). Any
236
+ // other version is another copy, and the record goes.
237
+ const refused = readRefusal();
238
+ if (refused && refused.version === version) {
239
+ _refused = refused;
240
+ return REFUSED_BIN;
241
+ }
242
+ if (refused) clearRefusal();
243
+ _probe = { version, at: Date.now() };
142
244
  return (_bin = spelling);
143
245
  };
144
246
 
@@ -173,11 +275,12 @@ export async function cswapBin() {
173
275
  * ensureCswap below, whose return value says nothing about which binary the
174
276
  * following twenty account operations will be sent to. See cswap-bin-memo.test.ts.
175
277
  */
176
- export function resetCswapBin() { _bin = null; _probe = null; }
278
+ export function resetCswapBin() { _bin = null; _probe = null; _refused = null; }
177
279
 
178
280
  /** Installed version string, or null when cswap cannot be found. */
179
281
  export async function cswapVersion() {
180
282
  const bin = await cswapBin();
283
+ if (bin === REFUSED_BIN) return null; // declined, not asked — see _refused
181
284
  // The call above may have just asked this very question — see _probe. Nothing
182
285
  // is remembered past PROBE_FRESH_MS, so this is the second half of one
183
286
  // lookup rather than a cache of the answer.
@@ -600,10 +703,17 @@ export async function ensureCswap({ onInstalling = null } = {}) {
600
703
 
601
704
  if (process.env.AGENTS_DECK_NO_INSTALL === "1") {
602
705
  const version = await cswapVersion();
706
+ if (cswapRefused()) return refusedState(cswapRefused());
603
707
  return version ? { state: "present", version } : { state: "skipped" };
604
708
  }
605
709
 
606
710
  const existing = await cswapVersion();
711
+ // STILL THE COPY IT REFUSED (#1799). Asked before the existing-install branch,
712
+ // which accepts any version on purpose and would call this one present, and
713
+ // before the install below, which would fetch the same answer from the same
714
+ // index on every launch. The boot row repeats the refusal instead, and nothing
715
+ // is seeded.
716
+ if (cswapRefused()) return refusedState(cswapRefused());
607
717
  if (existing) {
608
718
  // Installed — the only question left is whether it's stale. One PyPI
609
719
  // request a day, and the upgrade itself never blocks startup.
@@ -650,6 +760,9 @@ async function installAndConfirm() {
650
760
  // found nothing — see resetCswapBin — but a lookup that DID resolve, to a copy
651
761
  // whose `--version` then failed, is exactly the case that got us here.
652
762
  resetCswapBin();
763
+ // And whatever was refused before: what arrived is judged below, afresh, and
764
+ // an acceptable version is the end of the refusal.
765
+ clearRefusal();
653
766
 
654
767
  // Freshly installed tools land in ~/.local/bin, which may not be on the PATH
655
768
  // of the shell that launched us — cswapBin looks there directly, so this
@@ -675,6 +788,8 @@ async function installAndConfirm() {
675
788
  if (version !== "installed") {
676
789
  const wrong = result.want ? !sameVersion(version, result.want) : !isAcceptableVersion(version);
677
790
  if (wrong) {
791
+ // Kept, not only reported (#1799): see REFUSED_RECORD.
792
+ refuse({ path: _bin, version, want: result.want ?? RANGE_SPEC, via: result.via });
678
793
  return { state: "unavailable", reason: "unexpected_version", version, want: result.want ?? RANGE_SPEC, via: result.via };
679
794
  }
680
795
  }
@@ -212,6 +212,10 @@ function normaliseLan(raw) {
212
212
  // When somebody here said yes. Absent on every pin made before this was
213
213
  // kept, and absent is drawn as "before" rather than guessed.
214
214
  ...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
215
+ // Said yes to by the accept switch rather than by a person — see
216
+ // addTrusted in lan-sync.mjs. Kept, or a restart would turn every
217
+ // pairing the switch made into one somebody chose. Only a real `true`.
218
+ ...(t.auto === true ? { auto: true } : {}),
215
219
  })),
216
220
  // Fingerprints somebody explicitly unpaired. Kept separately from trusted
217
221
  // because manual dial rows survive the action and must not silently rebuild
@@ -72,6 +72,9 @@ const blockNotifier = createBlockNotifier({
72
72
  // menu while the deck is running, and a mute that waited for a restart would
73
73
  // not be a mute. The env var still wins inside `notificationsOn`.
74
74
  enabled: () => notificationsOn(heldPrefs.current()),
75
+ // Asked again when the burst cap's window reopens, for the one notice that
76
+ // stands for what it held back — see BURST_MAX.
77
+ pages: () => pageCount(),
75
78
  onError: err => console.error(`${PRODUCT}: could not raise a desktop notification:`, err?.message ?? err),
76
79
  });
77
80
 
@@ -8,7 +8,7 @@
8
8
  // envelope and evicts behind it, clearEventBuffer empties all three, and the
9
9
  // readers get a copy or an answer rather than the array. The bodies are
10
10
  // unchanged.
11
- import { ENVELOPE_CHARS, LAST_VALUE_WINS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, payloadChars } from "./ring-bounds.mjs";
11
+ import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, payloadChars } from "./ring-bounds.mjs";
12
12
 
13
13
  // ─── The event ring buffer ─────────────────────────────────────────────────
14
14
  // Its two bounds, MAX_BUFFER and MAX_BUFFER_CHARS, and the charge an event is
@@ -148,7 +148,7 @@ export function admitEvent(raw, source, receivedAt) {
148
148
  // Decided once, here, beside the charge and for the same reason: eviction
149
149
  // must never have to look at a payload again to know what it is giving
150
150
  // back. See LAST_VALUE_WINS.
151
- [ENRICHMENT]: raw != null && typeof raw === "object" && LAST_VALUE_WINS.has(raw.hook_event_name),
151
+ [ENRICHMENT]: isEnrichment(raw),
152
152
  };
153
153
  events.push(evt);
154
154
  bufferedChars += evt[CHARS];
@@ -26,7 +26,8 @@ function currentLiveVideo(html) {
26
26
 
27
27
  export async function fetchGoodLifeRadio({ fetchImpl } = {}) {
28
28
  const now = Date.now();
29
- if (cache && now - cacheAt < cacheTtl) return cache;
29
+ // The stamp, not the value: a miss is remembered too, as null.
30
+ if (cacheAt && now - cacheAt < cacheTtl) return cache;
30
31
  if (inflight) return inflight;
31
32
  const get = fetchImpl ?? fetch;
32
33
  inflight = (async () => {
@@ -136,7 +136,7 @@ import { handleLanInvite, handleLanPeer, handleLanStatus, handleLanSync } from "
136
136
  // GET and POST /api/prefs — see prefs-routes.mjs.
137
137
  import { handlePrefsGiveBack, handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
138
138
  import { MANIFEST_PATH, offerManifest } from "./app-manifest.mjs";
139
- import { historySnapshot, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
139
+ import { historySnapshot, processesReply, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
140
140
  // How every route reads a body and answers, and the answer for one that threw
141
141
  // — see http-io.mjs. sendInternalError was exported from this file before it
142
142
  // moved, and still is.
@@ -238,6 +238,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
238
238
  host: req.headers.host,
239
239
  secFetchSite: req.headers["sec-fetch-site"],
240
240
  referer: req.headers.referer,
241
+ token: req.headers["x-ccdeck-token"],
241
242
  })) {
242
243
  return send(res, 403, { error: "cross-site request blocked" });
243
244
  }
@@ -309,7 +310,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
309
310
  // draws four columns and needs none of it, so on a deck where the modal
310
311
  // is never opened the argv is never read at all.
311
312
  const detail = url.searchParams.get("detail") === "1";
312
- return guard(readProcesses(process.platform, detail).then(r => send(res, 200, { ok: true, ...r })), res);
313
+ return guard(readProcesses(process.platform, detail).then(r => send(res, 200, processesReply(r))), res);
313
314
  }
314
315
  // A day of minute buckets, which is far too much to ride along on
315
316
  // /api/system's three-second poll for a chart that is usually closed. Its
@@ -106,21 +106,68 @@ export function hookCommand(installedHookPath, provider, node = process.execPath
106
106
  return `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
107
107
  }
108
108
 
109
- function isOurEntry(g) {
110
- if (!g || typeof g !== "object") return false;
109
+ function isMarked(g) {
111
110
  if (g[MARK_KEY] === true) return true;
112
111
  for (const k of LEGACY_MARKS) if (g[k] === true) return true;
113
- const cmds = Array.isArray(g.hooks) ? g.hooks : [];
114
- for (const h of cmds) {
115
- const c = typeof h?.command === "string" ? h.command : "";
116
- for (const dir of LEGACY_DIRS) {
117
- if (c.includes(`.claude/${dir}/hook.js`) || c.includes(`.claude\\${dir}\\hook.js`)) return true;
118
- if (c.includes(`.codex/${dir}/hook.js`) || c.includes(`.codex\\${dir}\\hook.js`)) return true;
119
- }
112
+ return false;
113
+ }
114
+
115
+ /**
116
+ * Whether one command is our forwarder. In a group we marked, any path ending
117
+ * in one of our directories' hook.js — the config dir may be relocated and so
118
+ * not be called `.claude` at all. Anywhere else only the `.claude`/`.codex`
119
+ * spelling, which is what an entry from before the mark, or one written by
120
+ * hand, looks like.
121
+ */
122
+ function isOurCommand(h, marked) {
123
+ const c = typeof h?.command === "string" ? h.command : "";
124
+ for (const dir of LEGACY_DIRS) {
125
+ if (c.includes(`.claude/${dir}/hook.js`) || c.includes(`.claude\\${dir}\\hook.js`)) return true;
126
+ if (c.includes(`.codex/${dir}/hook.js`) || c.includes(`.codex\\${dir}\\hook.js`)) return true;
127
+ if (marked && (c.includes(`/${dir}/hook.js`) || c.includes(`\\${dir}\\hook.js`))) return true;
120
128
  }
121
129
  return false;
122
130
  }
123
131
 
132
+ /** What withoutOurCommands answers for a group with nothing left in it. */
133
+ const DROP = Symbol("drop");
134
+
135
+ /**
136
+ * One group with our forwarders taken out of it: `g` itself when nothing in it
137
+ * was ours, DROP when nothing is left, and otherwise a copy holding the rest
138
+ * with its matcher and without our marks.
139
+ *
140
+ * PER COMMAND, NOT PER GROUP (#1734). This used to decide for the whole group —
141
+ * marked, or one command naming our hook.js — and drop all of it. But the group
142
+ * we write has no matcher, and Claude Code's own hook editor files a new
143
+ * matcher-less hook into the first group for that event whose matcher is
144
+ * missing or empty, which on a machine with no hook of the user's own for that
145
+ * event is ours. The next start then rewrote settings.json without the user's
146
+ * sound or PreToolUse guard, and said nothing. Only our forwarder is ours.
147
+ *
148
+ * A marked group whose `hooks` is not a list is dropped, as it always was; an
149
+ * unmarked one is left exactly where it is, as it always was.
150
+ */
151
+ function withoutOurCommands(g) {
152
+ if (!g || typeof g !== "object") return g;
153
+ const marked = isMarked(g);
154
+ if (!Array.isArray(g.hooks)) return marked ? DROP : g;
155
+ const kept = g.hooks.filter(h => !isOurCommand(h, marked));
156
+ if (!marked && kept.length === g.hooks.length) return g;
157
+ if (kept.length === 0) return DROP;
158
+ const rest = {};
159
+ for (const [k, v] of Object.entries(g)) {
160
+ if (k === MARK_KEY || LEGACY_MARKS.includes(k)) continue;
161
+ rest[k] = k === "hooks" ? kept : v;
162
+ }
163
+ return rest;
164
+ }
165
+
166
+ /** Whether a group holds anything of ours — a forwarder, or our mark. */
167
+ function isOurEntry(g) {
168
+ return withoutOurCommands(g) !== g;
169
+ }
170
+
124
171
  async function ensureDir(p) {
125
172
  if (!existsSync(p)) await mkdir(p, { recursive: true });
126
173
  }
@@ -207,7 +254,7 @@ function buildHookEntry(command) {
207
254
 
208
255
  function dedupeOurEntries(group) {
209
256
  if (!Array.isArray(group)) return [];
210
- return group.filter(g => !isOurEntry(g));
257
+ return group.map(withoutOurCommands).filter(g => g !== DROP);
211
258
  }
212
259
 
213
260
  /**
@@ -422,8 +469,11 @@ export async function uninstallHooks({ provider = "claude", beforeWrite = null }
422
469
  if (!current?.hooks) return { ok: true, changed: false, provider, settingsPath: cfg.settingsPath };
423
470
  let changed = false;
424
471
  for (const evt of Object.keys(current.hooks)) {
425
- const cleaned = dedupeOurEntries(current.hooks[evt]);
426
- if (cleaned.length !== (current.hooks[evt]?.length ?? 0)) changed = true;
472
+ const was = current.hooks[evt];
473
+ const cleaned = dedupeOurEntries(was);
474
+ // A count alone no longer sees every change: a group that loses our
475
+ // forwarder but keeps the user's own hooks is still there, as a new object.
476
+ if (cleaned.length !== (was?.length ?? 0) || cleaned.some((g, i) => g !== was[i])) changed = true;
427
477
  if (cleaned.length === 0) delete current.hooks[evt];
428
478
  else current.hooks[evt] = cleaned;
429
479
  }
@@ -305,10 +305,12 @@ export function createBeacon({
305
305
  announce(via === "tailscale" ? [rinfo.address] : []);
306
306
  }
307
307
  if (verdict !== "peer") {
308
- // Another deck is using this one's key — see beaconVerdict. Reported
309
- // rather than fixed here: this file carries packets, and choosing a new
310
- // identity for the deck belongs to whoever stores it.
311
- if (verdict === "id-clash") onIdClash?.();
308
+ // Another deck may be using this one's key — see beaconVerdict. Reported
309
+ // with where the beacon says that deck listens, rather than acted on
310
+ // here: this file carries packets, and finding out whether anything
311
+ // there holds the key, and choosing a new identity if it does, belong to
312
+ // whoever stores it.
313
+ if (verdict === "id-clash") onIdClash?.({ addr: rinfo.address, port: beacon.port });
312
314
  // A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
313
315
  // dropped: it is a name and an address on the same network, which is a
314
316
  // row somebody can accept. Nothing is asked of it and nothing is
@@ -200,6 +200,10 @@ export function connectToPeer({
200
200
  resolve({
201
201
  sock, key, sealed: !!chan,
202
202
  peerFp: theirFp, peerPub: theirPub, peerName: cleanName(msg.name, ""),
203
+ // The challenge the far end made for this connection, so a deck that
204
+ // reached its own key can ask its own listener whether it made it —
205
+ // see `issued` in lan-socket.mjs.
206
+ peerChallenge: theirChallenge,
203
207
  send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
204
208
  /** What a frame from the other end says — or null on a sealed
205
209
  * connection when it does not open, which ends the connection: every
@@ -137,9 +137,18 @@ export function createDials() {
137
137
  rowAnswering: fp => [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp),
138
138
 
139
139
  /** A row put on for a paired deck that called in from it, on trial until a
140
- * round proves the address answers — see calledBack. */
140
+ * round proves the address answers — see calledBack.
141
+ *
142
+ * NEVER A ROW SOMEBODY TYPED (#1741). learnCaller asks for a trial
143
+ * whenever `add` says yes, and `add` says yes for an address already on
144
+ * the list and keeps a typed row typed — so a deck calling in from the
145
+ * exact address somebody typed, before a round here had reached it, put
146
+ * that row on trial and the next failed round took it away. It is not
147
+ * the deck's to take away (#1674); who called from there is still worth
148
+ * knowing, so the row reads as that deck. */
141
149
  trial(at, met) {
142
150
  learned.set(at, met);
151
+ if (manual.get(at)?.typed) return;
143
152
  calledBack.add(at);
144
153
  },
145
154
  /** A round reached `host:port` and `met` is who answered there. A