ccdeck 3.27.6 → 3.28.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.
@@ -0,0 +1,17 @@
1
+ {
2
+ "id": "/",
3
+ "name": "ccdeck",
4
+ "short_name": "ccdeck",
5
+ "description": "Live deck of Claude Code and Codex agents — tool calls, token spend and every subagent on one canvas.",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#0b0c10",
10
+ "theme_color": "#14161b",
11
+ "icons": [
12
+ { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
13
+ { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
14
+ { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
15
+ { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
16
+ ]
17
+ }
package/hook/hook.js CHANGED
@@ -688,7 +688,13 @@ function forgetIfAbandoned(d, done) {
688
688
  */
689
689
  function post(d, body, persists, done) {
690
690
  let settled = false;
691
- const finish = (ok, why = null) => { if (settled) return; settled = true; done(ok, why); };
691
+ let sendDeadline = null;
692
+ const finish = (ok, why = null) => {
693
+ if (settled) return;
694
+ settled = true;
695
+ clearTimeout(sendDeadline);
696
+ done(ok, why);
697
+ };
692
698
  // Whether the kernel has taken the whole body, and whether it was this
693
699
  // process's deadline rather than the writer that ended the exchange — the
694
700
  // two facts the last paragraph above sorts an error by.
@@ -707,7 +713,6 @@ function post(d, body, persists, done) {
707
713
  res.resume();
708
714
  finish(res.statusCode >= 200 && res.statusCode < 300);
709
715
  });
710
- req.on("finish", () => { sent = true; });
711
716
  // `destroy()` on a timeout makes 'error' fire with ECONNRESET — the status
712
717
  // line has not arrived, or it would have settled this already — so `timedOut`
713
718
  // is what tells this process giving up apart from the writer hanging up.
@@ -730,9 +735,29 @@ function post(d, body, persists, done) {
730
735
  // written the line — was never asked. The snapshot is the rule that paragraph
731
736
  // states, taken at the only moment it is still true.
732
737
  let sentByDeadline = false;
738
+ const expire = () => {
739
+ if (settled) return;
740
+ timedOut = true;
741
+ sentByDeadline = sent;
742
+ req.destroy();
743
+ };
733
744
  req.on("error", () => finish(false, timedOut && sentByDeadline ? "deadline" : null));
734
- req.on("timeout", () => { timedOut = true; sentByDeadline = sent; req.destroy(); });
745
+ req.on("timeout", expire);
746
+ req.on("finish", () => {
747
+ sent = true;
748
+ clearTimeout(sendDeadline);
749
+ sendDeadline = null;
750
+ });
735
751
  req.write(body);
752
+ // `http.request`'s timeout is an idle-socket timeout. While a large body is
753
+ // still being accepted into kernel buffers, those writes keep resetting it
754
+ // even if the peer application stopped reading. Bound the send phase itself
755
+ // so that a wedged writer can still hand the log to the next deck inside the
756
+ // hook's process cap. Once `finish` says the kernel took the whole body, this
757
+ // timer is cleared and the existing idle timeout remains the only deadline
758
+ // on a slow answer, preserving #1133's no-duplicate rule. (#1195)
759
+ sendDeadline = setTimeout(expire, POST_TIMEOUT_MS);
760
+ sendDeadline.unref?.();
736
761
  req.end();
737
762
  }
738
763
 
@@ -899,6 +924,32 @@ function discoverTargets(cwd, cb) {
899
924
  normPathAsync(cwd, scan);
900
925
  }
901
926
 
927
+ // JSON.parse can accept nesting that older supported Node releases cannot
928
+ // stringify again. Count structural nesting directly in the original text so
929
+ // the hook never depends on a runtime-specific recursion limit. Strings are
930
+ // skipped with their escapes, so braces inside tool output do not count.
931
+ function exceedsJsonDepth(text, limit = 1000) {
932
+ let depth = 0;
933
+ let quoted = false;
934
+ let escaped = false;
935
+ for (let i = 0; i < text.length; i++) {
936
+ const c = text[i];
937
+ if (quoted) {
938
+ if (escaped) escaped = false;
939
+ else if (c === "\\") escaped = true;
940
+ else if (c === '"') quoted = false;
941
+ continue;
942
+ }
943
+ if (c === '"') { quoted = true; continue; }
944
+ if (c === "{" || c === "[") {
945
+ if (++depth > limit) return true;
946
+ } else if (c === "}" || c === "]") {
947
+ depth--;
948
+ }
949
+ }
950
+ return false;
951
+ }
952
+
902
953
  function main() {
903
954
  // Hard cap so a stuck server can never wedge the host CLI: this process ends
904
955
  // itself rather than being killed. CAP_MS says what the budget is; this line
@@ -958,6 +1009,7 @@ function main() {
958
1009
  process.stdin.on("end", () => {
959
1010
  let parsed;
960
1011
  try { parsed = JSON.parse(input); } catch { return process.exit(0); }
1012
+ const tooDeepToSerialize = exceedsJsonDepth(input);
961
1013
  const cwd = parsed && parsed.cwd;
962
1014
  if (!cwd) return process.exit(0);
963
1015
 
@@ -1008,7 +1060,26 @@ function main() {
1008
1060
  // Read or a Grep result arrives whole in `tool_response` and is routinely
1009
1061
  // megabytes.
1010
1062
  if (parsed && typeof parsed === "object") parsed.cwd = resolvedCwd;
1011
- const taggedInput = JSON.stringify(parsed);
1063
+ let taggedInput;
1064
+ try {
1065
+ if (tooDeepToSerialize) throw new RangeError("JSON nesting exceeds safe serialization depth");
1066
+ taggedInput = JSON.stringify(parsed);
1067
+ } catch {
1068
+ // JSON.parse accepts nesting much deeper than JSON.stringify can write
1069
+ // back out. Keep the event instead of losing it silently: all scalar
1070
+ // top-level metadata is safe to copy without walking the deep value,
1071
+ // which preserves the event/session/tool identity, canonical cwd and
1072
+ // provider while making the loss explicit to the deck. (#1180)
1073
+ const shallow = {};
1074
+ for (const [key, value] of Object.entries(parsed)) {
1075
+ if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
1076
+ shallow[key] = value;
1077
+ }
1078
+ }
1079
+ shallow.ccdeck_truncated = true;
1080
+ shallow.ccdeck_truncation_reason = "serialization-depth";
1081
+ taggedInput = JSON.stringify(shallow);
1082
+ }
1012
1083
 
1013
1084
  // See proveTargets for what the other order cost. A record whose pid is
1014
1085
  // merely alive has established nothing: it may be a deck that died and had
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.27.6",
3
+ "version": "3.28.0",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, 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": {
@@ -49,7 +49,9 @@
49
49
  "3.26.3",
50
50
  "3.26.4",
51
51
  "3.26.5",
52
- "3.27.6"
52
+ "3.27.6",
53
+ "3.27.7",
54
+ "3.28.0"
53
55
  ],
54
56
  "3.27.0": [
55
57
  {
@@ -0,0 +1,55 @@
1
+ // Whether this deck may be installed as an application, and why it usually may.
2
+ //
3
+ // WHAT THE MANIFEST BUYS. With `dist/web/manifest.webmanifest` served, Chrome
4
+ // and Edge put their own install control in the address bar and the deck gets a
5
+ // real icon: a Dock tile on macOS, a taskbar and Start entry on Windows, a
6
+ // `.desktop` file in the application launcher on Linux — which is the one that
7
+ // matters most, because a launcher entry works on Hyprland and Sway, where a
8
+ // system tray, the other way people ask for this, does not exist at all.
9
+ // Safari's File ▸ Add to Dock needs no manifest but honours the name and icons
10
+ // in one. None of that is UI this deck has to draw, nag about, or maintain.
11
+ //
12
+ // WHY IT IS NOT ALWAYS OFFERED, which is the whole of this file. An installed
13
+ // app is pinned to an ORIGIN, and an origin includes the port. So the icon is a
14
+ // promise about a number, and there is exactly one case where this deck cannot
15
+ // keep it: the random fallback.
16
+ //
17
+ // bin/deck.js asks for a port — 4317, or whatever `--port` said — and on a
18
+ // refused bind takes a RANDOM one out of 4318–4400 instead. That fallback is
19
+ // right and stays: 4317 is also the standard OTLP collector port, and Windows
20
+ // reserves contiguous TCP blocks for Hyper-V, WSL2 and Docker, so a deck that
21
+ // comes up on 4322 beats a deck that refuses to come up. But it draws a new
22
+ // number on every start. An app installed against one of those would be a
23
+ // broken shortcut by the next boot, and — because Chrome keys an installed app
24
+ // by origin — a SECOND icon would appear beside it, then a third. Somebody on a
25
+ // machine where 4317 is permanently taken would collect one dead tile per
26
+ // launch, and every one of them would look like the deck's fault.
27
+ //
28
+ // So the rule is not "is this 4317". It is "is this the port that was ASKED
29
+ // for", which is true for the default, true for an explicit `--port 4500` that
30
+ // bound, and false only when a bind was refused and a number was invented. A
31
+ // user who pins a port keeps their icon; a user who cannot have one never gets
32
+ // offered a shortcut that will not work tomorrow.
33
+ //
34
+ // The deck says nothing about any of this. An install control that is simply
35
+ // absent reads as a browser that has nothing to offer; a control that appeared
36
+ // and then explained itself would be a paragraph about port allocation in a
37
+ // place nobody asked a question.
38
+
39
+ /** The one path this is all about. Served out of dist/web like any other file. */
40
+ export const MANIFEST_PATH = "/manifest.webmanifest";
41
+
42
+ /**
43
+ * May this deck be installed as an application?
44
+ *
45
+ * @param {object} [o]
46
+ * @param {number|null} [o.asked] The port the launcher wanted.
47
+ * @param {number|null} [o.bound] The port it actually got.
48
+ * @returns {boolean} False when the port was invented rather than chosen.
49
+ */
50
+ export function offerManifest({ asked = null, bound = null } = {}) {
51
+ // Not yet listening, or a caller that never said — neither is a port anybody
52
+ // chose, and a manifest is the wrong thing to guess about.
53
+ if (!Number.isInteger(asked) || !Number.isInteger(bound)) return false;
54
+ return asked === bound;
55
+ }
@@ -72,8 +72,9 @@ export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.j
72
72
  */
73
73
  export const DEFAULTS = Object.freeze({
74
74
  notifications: false,
75
- // The web tour is a deck-level marker, not browser-profile state. Electron
76
- // may serve the same deck from a different loopback port after an update.
75
+ // The Electron window can open on a new localhost port after an update.
76
+ // Its tour marker must outlive that origin; browser tabs still use their
77
+ // own localStorage marker and never read this preference.
77
78
  tourSeen: false,
78
79
  // Whether the deck may update itself: restart into code already on disk once
79
80
  // it is idle, and — while nobody is looking — install a newer release and
@@ -86,7 +87,7 @@ export const DEFAULTS = Object.freeze({
86
87
  // write below names a mode. AGENTS_DECK_NO_LAN=1 keeps a deck off the network
87
88
  // whatever this file says — see lanEnabled.
88
89
  lan: Object.freeze({
89
- enabled: true, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0,
90
+ enabled: true, name: "", secret: "", shared: [], manual: [], trusted: [], unpaired: [], port: 0,
90
91
  // WHO PAIRS WITH WHOM, WITHOUT ANYBODY PRESSING ANYTHING. Asking is on, so
91
92
  // two decks on one network find each other and send each other a request —
92
93
  // which is what a person with three of their own machines wants and had to
@@ -178,8 +179,8 @@ function aliasesFrom(raw) {
178
179
  }
179
180
 
180
181
  /** One LAN section, coerced. Unknown keys dropped like everything else here,
181
- * and the two lists forced to arrays of strings — they arrive from a page and
182
- * are then compared against account keys and dialled as addresses. */
182
+ * and string lists forced to arrays of strings before anything compares them
183
+ * with account keys, addresses or fingerprints. */
183
184
  function normaliseLan(raw) {
184
185
  const src = raw && typeof raw === "object" ? raw : {};
185
186
  const strings = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
@@ -206,6 +207,10 @@ function normaliseLan(raw) {
206
207
  // kept, and absent is drawn as "before" rather than guessed.
207
208
  ...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
208
209
  })),
210
+ // Fingerprints somebody explicitly unpaired. Kept separately from trusted
211
+ // because manual dial rows survive the action and must not silently rebuild
212
+ // trust on the next round or restart.
213
+ unpaired: strings(src.unpaired),
209
214
  aliases: aliasesFrom(src.aliases),
210
215
  // The port this deck listened on last time, so an address somebody typed on
211
216
  // the other machine still works after a restart. It asked the OS for a new
@@ -566,7 +571,10 @@ async function save(mutate, home, deps) {
566
571
  */
567
572
  export function publicPrefs(prefs) {
568
573
  const p = normalise(prefs);
569
- const { secret, trusted, ...lan } = p.lan;
574
+ // unpaired is engine-authored state too. No page draws or edits it; keeping
575
+ // it out also means a future whole-prefs form cannot replay a stale unpair
576
+ // list over a decision the engine made after the form loaded.
577
+ const { secret, trusted, unpaired: _unpaired, ...lan } = p.lan;
570
578
  return {
571
579
  ...p,
572
580
  lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
@@ -70,7 +70,13 @@ export function challengeProof(token, nonce) {
70
70
  export function challengeDeck(port, token) {
71
71
  return new Promise(resolve => {
72
72
  let settled = false;
73
- const finish = ok => { if (settled) return; settled = true; resolve(ok); };
73
+ let deadline;
74
+ const finish = ok => {
75
+ if (settled) return;
76
+ settled = true;
77
+ if (deadline) clearTimeout(deadline);
78
+ resolve(ok);
79
+ };
74
80
  const nonce = randomBytes(16).toString("hex");
75
81
  const want = challengeProof(token, nonce);
76
82
  const req = httpRequest({
@@ -98,6 +104,14 @@ export function challengeDeck(port, token) {
98
104
  });
99
105
  req.on("error", () => finish(false));
100
106
  req.on("timeout", () => req.destroy());
107
+ // `http.request`'s timeout is an idle timeout: a listener can keep this
108
+ // request alive forever by trickling bytes, and once a response has begun
109
+ // destroying the request does not reliably emit `error`. Bound the whole
110
+ // challenge instead, and settle before closing the socket. (#1179)
111
+ deadline = setTimeout(() => {
112
+ finish(false);
113
+ req.destroy();
114
+ }, DECK_CHALLENGE_TIMEOUT_MS);
101
115
  req.end();
102
116
  });
103
117
  }
@@ -45,6 +45,7 @@ import { MAC_FW, PROBE_PS, UFW_CONF, UFW_DEFAULTS, isActive, localAliases, reach
45
45
  import { run } from "./exec.mjs";
46
46
  import { notify as osNotify } from "./browser-react.mjs";
47
47
  import { invokedName, renameNotice } from "./invoked-as.mjs";
48
+ import { MANIFEST_PATH, offerManifest } from "./app-manifest.mjs";
48
49
  import { appendFailureStats, appendLogLine, appendsLanded, codexCwdInWorkspace, electWriters, emptyLog, flushAppends, foldsCase, writesCodexLog } from "./log-writer.mjs";
49
50
  import { historySnapshot, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
50
51
  import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
@@ -75,6 +76,11 @@ const MIME = {
75
76
  ".css": "text/css; charset=utf-8",
76
77
  ".json": "application/json; charset=utf-8",
77
78
  ".svg": "image/svg+xml",
79
+ // The type is the whole of how a browser recognises a manifest: served as
80
+ // application/octet-stream — which is what the fallback below hands anything
81
+ // unlisted — Chrome fetches it, declines to parse it, and offers no install,
82
+ // with nothing in the console that names the reason.
83
+ ".webmanifest": "application/manifest+json; charset=utf-8",
78
84
  ".png": "image/png",
79
85
  ".jpg": "image/jpeg",
80
86
  ".woff2": "font/woff2",
@@ -4015,6 +4021,13 @@ const lanEngine = createEngine({
4015
4021
  try { _prefs = await writePrefs({ lan: { trusted } }); }
4016
4022
  catch (err) { console.error(`${PRODUCT}: lan sync could not save the pairing:`, err?.message ?? err); }
4017
4023
  },
4024
+ // An explicit unpair must outlive the process too. Manual dial rows are kept
4025
+ // deliberately, so without this marker the next successful round could pin
4026
+ // the same fingerprint again without another press.
4027
+ onUnpaired: async unpaired => {
4028
+ try { _prefs = await writePrefs({ lan: { unpaired } }); }
4029
+ catch (err) { console.error(`${PRODUCT}: lan sync could not save the unpair decision:`, err?.message ?? err); }
4030
+ },
4018
4031
  // An address this deck must keep dialling: the far end of a pairing that
4019
4032
  // happened over an invite. Kept in prefs, because setPeers replaces the dial
4020
4033
  // list wholesale on every settings write and a row that lives only in memory
@@ -4046,7 +4059,7 @@ const lanEngine = createEngine({
4046
4059
  // one it is given, so this settles after one pass rather than looping.
4047
4060
  //
4048
4061
  // The key is handed over HERE rather than read back off `_prefs`, because
4049
- // `applyLanPrefs` no longer round-trips the three fields the engine
4062
+ // `applyLanPrefs` no longer round-trips the fields the engine
4050
4063
  // authors — see what it does and does not pass. This is the one caller
4051
4064
  // that legitimately changes one of them, and it is holding the new value.
4052
4065
  await applyLanPrefs({ secret });
@@ -4063,9 +4076,9 @@ const lanEngine = createEngine({
4063
4076
  /**
4064
4077
  * What prefs is entitled to tell the LAN engine, and what it is not.
4065
4078
  *
4066
- * THREE FIELDS ARE THE ENGINE'S OWN AND ARE LOADED ONCE. `trusted`, `secret`
4067
- * and `port` are written BY the engine, through `onTrust`, `onIdentity` and
4068
- * `onPort` — prefs is where they are kept between runs, not where they are
4079
+ * FOUR FIELDS ARE THE ENGINE'S OWN AND ARE LOADED ONCE. `trusted`, `unpaired`,
4080
+ * `secret` and `port` are written BY the engine, through `onTrust`,
4081
+ * `onUnpaired`, `onIdentity` and `onPort` — prefs is where they are kept between runs, not where they are
4069
4082
  * decided. Handing them back on every settings write round-trips the engine's
4070
4083
  * live state through a module-level copy of a file, and that copy is stale for
4071
4084
  * as long as one of those writes is queued.
@@ -4120,11 +4133,12 @@ export function lanApplyFields(prefs, { load = false, env = process.env } = {})
4120
4133
  ...page,
4121
4134
  secret: lan.secret || "",
4122
4135
  trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
4136
+ unpaired: Array.isArray(lan.unpaired) ? lan.unpaired : [],
4123
4137
  port: lan.port || 0,
4124
4138
  };
4125
4139
  }
4126
4140
 
4127
- /** Whether the engine has already been handed the three fields it authors.
4141
+ /** Whether the engine has already been handed the fields it authors.
4128
4142
  * Reset by `startServer`, because a boot is what reads them off the file. */
4129
4143
  let _lanLoaded = false;
4130
4144
 
@@ -7480,6 +7494,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7480
7494
  // background quota poll hit a network error. Answer the request instead.
7481
7495
  const guard = (p, res) => Promise.resolve(p).catch(err => sendInternalError(res, err));
7482
7496
 
7497
+ // Which of the eleven candidates below this deck ended up on, set the moment
7498
+ // one of them binds. Declared here rather than beside the loop that fills it
7499
+ // so it is above the route that reads it — see the manifest gate.
7500
+ let boundPort = null;
7501
+
7483
7502
  const route = (req, res) => {
7484
7503
  const url = requestUrl(req.url);
7485
7504
  // Unparseable request target. Nothing below can route it, and throwing here
@@ -7653,6 +7672,20 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7653
7672
  return send(res, 404, { error: "not found" });
7654
7673
  }
7655
7674
 
7675
+ // THE MANIFEST IS WITHDRAWN WHEN THE PORT WAS INVENTED, not served and
7676
+ // then apologised for. An installed app is pinned to an origin and an
7677
+ // origin includes the port, so offering one from a random fallback port
7678
+ // promises a shortcut that the next boot breaks — and leaves a dead tile
7679
+ // behind for every launch, because the browser keys an app by origin. See
7680
+ // app-manifest.mjs, which is where the rule and its reasoning live.
7681
+ //
7682
+ // A 404 rather than a route that answers `{}`: an unparseable manifest is
7683
+ // a console error on every page load, and "absent" is exactly what this
7684
+ // means.
7685
+ if (url.pathname === MANIFEST_PATH && !offerManifest({ asked: port, bound: boundPort })) {
7686
+ return send(res, 404, { error: "not found" });
7687
+ }
7688
+
7656
7689
  if (req.method === "GET") return serveStatic(req, res, url);
7657
7690
  send(res, 405, { error: "method not allowed" });
7658
7691
  };
@@ -7692,6 +7725,13 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7692
7725
  for (const candidate of candidates) {
7693
7726
  try {
7694
7727
  await tryListen(server, candidate, host);
7728
+ // What actually bound, read by the manifest gate above. `address()`
7729
+ // rather than `candidate`, and the difference is the whole point of the
7730
+ // gate: `port: 0` means "any ephemeral port", so the candidate and the
7731
+ // request agree at 0 while the deck is listening on a number nobody chose
7732
+ // and that will differ on the next boot. Every test in this suite boots
7733
+ // that way. Comparing the candidate would hand those decks a manifest.
7734
+ boundPort = server.address()?.port ?? candidate;
7695
7735
  // Codex has no working hooks on Windows — tail its rollout files instead.
7696
7736
  if (codex) startCodexWatcher(workspace);
7697
7737
  // What a session is producing between its tool calls. Claude only — it
@@ -213,7 +213,7 @@ export function ticksOnArrival(step, via) {
213
213
 
214
214
  export function createEngine({
215
215
  readAccounts, exportAccount, importAccount,
216
- onChange, onError, onIdentity, onPort, onTrust, onDial, onShared, now = Date.now,
216
+ onChange, onError, onIdentity, onPort, onTrust, onUnpaired, onDial, onShared, now = Date.now,
217
217
  /**
218
218
  * The UDP socket the beacon shouts through, injectable for the same reason
219
219
  * lan-socket exposes it — and for one more that only showed up in use.
@@ -262,7 +262,7 @@ export function createEngine({
262
262
  routes = null,
263
263
  } = {}) {
264
264
  let cfg = {
265
- enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
265
+ enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], unpaired: [], port: 0,
266
266
  autoAsk: true, autoAccept: true, aliases: {},
267
267
  // Tell paired decks which shared account this one is on — see currentFor.
268
268
  shareActive: true,
@@ -427,6 +427,21 @@ export function createEngine({
427
427
  * — and always is on a deck with no Tailscale reader. */
428
428
  const routeTo = addr => routeOf(tailnet?.snapshot?.() ?? null, addr);
429
429
 
430
+ /** A pairing somebody explicitly removed. Unlike `declined`, this survives a
431
+ * restart because the old dial row survives too; forgetting the decision
432
+ * would let that row silently recreate the pairing on the next round. */
433
+ const wasUnpaired = fp => Array.isArray(cfg.unpaired) && cfg.unpaired.includes(fp);
434
+ const markUnpaired = (fp, value) => {
435
+ const before = Array.isArray(cfg.unpaired) ? cfg.unpaired : [];
436
+ const next = value
437
+ ? (before.includes(fp) ? before : [...before, fp])
438
+ : before.filter(x => x !== fp);
439
+ if (next.length === before.length && next.every((x, i) => x === before[i])) return false;
440
+ cfg = { ...cfg, unpaired: next };
441
+ onUnpaired?.(next);
442
+ return true;
443
+ };
444
+
430
445
  /** The two permissions that answer for one route. */
431
446
  const asksOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
432
447
  const saysYesOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
@@ -618,7 +633,10 @@ export function createEngine({
618
633
  const via = route ? "tailscale" : "lan";
619
634
  const own = !!route?.own;
620
635
  pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
621
- if (saysYesOn(via) && (via === "lan" || own)) { engine?.accept(entry.fp); return; }
636
+ if (!wasUnpaired(entry.fp) && saysYesOn(via) && (via === "lan" || own)) {
637
+ engine?.accept(entry.fp, { byHand: false });
638
+ return;
639
+ }
622
640
  if (!had) onChange?.();
623
641
  };
624
642
 
@@ -740,7 +758,7 @@ export function createEngine({
740
758
  // A deck we DO have a pin for was checked before this line: connectToPeer
741
759
  // was given expectPub and refuses a different key at that address.
742
760
  if (!trustedPeer(cfg.trusted, conn.peerFp)) {
743
- if (!peer.typed) {
761
+ if (!peer.typed || wasUnpaired(conn.peerFp)) {
744
762
  // The same row the listener's own `onPending` draws, from the other
745
763
  // direction: this deck dialled rather than being dialled, and the
746
764
  // handshake it just finished is the same evidence either way — a real
@@ -996,11 +1014,11 @@ export function createEngine({
996
1014
  const ask = c => ({ lan: !!c.autoAsk, tailscale: !!c.tailscale && c.tailscaleAsk !== false });
997
1015
  const turnedOn = (f, via) => !f(was)[via] && f(cfg)[via];
998
1016
  const mayAnswer = p => (p.via === "tailscale" ? turnedOn(yes, "tailscale") && p.own : turnedOn(yes, "lan"));
999
- for (const [fp, p] of [...pending]) if (mayAnswer(p)) this.accept(fp);
1017
+ for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) this.accept(fp, { byHand: false });
1000
1018
  // The same for the other direction: switching `ask` on with four machines
1001
1019
  // already listed asks those four.
1002
1020
  const mayAsk = p => (p.via === "tailscale" ? turnedOn(ask, "tailscale") && p.own : turnedOn(ask, "lan"));
1003
- for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub && mayAsk(p)) this.accept(fp, { byHand: false });
1021
+ for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !wasUnpaired(fp) && !p.pub && mayAsk(p)) this.accept(fp, { byHand: false });
1004
1022
  // OFF MEANS THE TAILNET GOES QUIET HERE: nobody heard over it is offered,
1005
1023
  // and syncTailnet stops the reads. Decks already paired stay paired.
1006
1024
  if (was.tailscale && !cfg.tailscale) {
@@ -1037,6 +1055,7 @@ export function createEngine({
1037
1055
  onInviteUsed: entry => {
1038
1056
  const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
1039
1057
  cfg = { ...cfg, trusted: list };
1058
+ markUnpaired(entry.fp, false);
1040
1059
  invite = null;
1041
1060
  // AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
1042
1061
  // deck with no way to reach it: an inbound connection puts nothing in
@@ -1123,7 +1142,7 @@ export function createEngine({
1123
1142
  // Over the tailnet only a machine on this person's own account is
1124
1143
  // asked unprompted; any other is a row for somebody to decide on.
1125
1144
  const mayAsk = asksOn(entry.via) && (entry.via !== "tailscale" || own);
1126
- if (mayAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
1145
+ if (mayAsk && !had && !declined.has(entry.fp) && !wasUnpaired(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
1127
1146
  // Only a deck that is new to us is news. A beacon every thirty
1128
1147
  // seconds from one already on the list is not a reason to redraw.
1129
1148
  if (!had) onChange?.();
@@ -1229,6 +1248,7 @@ export function createEngine({
1229
1248
  fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
1230
1249
  });
1231
1250
  cfg = { ...cfg, trusted: list };
1251
+ markUnpaired(conn.peerFp, false);
1232
1252
  this.addPeer(at.addr, at.port);
1233
1253
  onDial?.(`${at.addr}:${at.port}`);
1234
1254
  learned.set(`${at.addr}:${at.port}`, { fp: conn.peerFp, name: conn.peerName || inv.name });
@@ -1262,6 +1282,7 @@ export function createEngine({
1262
1282
  const heard = strangers.get(fp) ?? null;
1263
1283
  const seen = asked ?? heard;
1264
1284
  if (!seen) return null;
1285
+ if (wasUnpaired(fp) && !byHand) return null;
1265
1286
  // TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
1266
1287
  //
1267
1288
  // A deck that ASKED finished a handshake, so it held the private half of
@@ -1276,6 +1297,7 @@ export function createEngine({
1276
1297
  // is the same two presses, in the other order.
1277
1298
  if (!seen.pub) {
1278
1299
  if (!seen.addr || !seen.port) return null;
1300
+ markUnpaired(fp, false);
1279
1301
  // `byHand` IS WHAT THE ROW WILL BE ALLOWED TO DO LATER. A press here is
1280
1302
  // a person naming a machine, so the row may be pinned on the round that
1281
1303
  // reaches it. `autoAsk` reaches this same line with byHand false — the
@@ -1288,6 +1310,7 @@ export function createEngine({
1288
1310
  }
1289
1311
  const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
1290
1312
  cfg = { ...cfg, trusted: list };
1313
+ markUnpaired(fp, false);
1291
1314
  pending.delete(fp);
1292
1315
  strangers.delete(fp);
1293
1316
  onTrust?.(list);
@@ -1338,6 +1361,7 @@ export function createEngine({
1338
1361
  const list = dropTrusted(cfg.trusted, fp);
1339
1362
  if (list.length === cfg.trusted.length) return false;
1340
1363
  cfg = { ...cfg, trusted: list };
1364
+ markUnpaired(fp, true);
1341
1365
  onTrust?.(list);
1342
1366
  onChange?.();
1343
1367
  return true;
@@ -132,9 +132,54 @@ export function mayAskNpm({ now, lastAskAt }) {
132
132
 
133
133
  // ── version comparison ───────────────────────────────────────────────────────
134
134
 
135
- /** True when `a` sorts before `b`. Numeric-segment compare — non-numeric
136
- * segments count as 0, missing segments pad with 0, so "1.30" < "1.30.1" and
137
- * "1.9.0" < "1.10.0".
135
+ /** Numeric-segment compare of one version's parts: -1, 0 or 1. Non-numeric
136
+ * segments count as 0 and missing segments pad with 0, so "1.30" sorts before
137
+ * "1.30.1" and "1.9.0" before "1.10.0". */
138
+ function cmpParts(x, y) {
139
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
140
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
141
+ if (d !== 0) return d < 0 ? -1 : 1;
142
+ }
143
+ return 0;
144
+ }
145
+
146
+ /** A version split at the FIRST `-`: the release on one side, the prerelease
147
+ * identifiers on the other, or null when there is no prerelease at all.
148
+ *
149
+ * `+` stays inside the release half rather than starting a third part, which
150
+ * keeps build metadata reading exactly as it read before — `1.0.0+build.7`
151
+ * segments to [1,0,0,0,7] and therefore sorts above `1.0.0`. That is inherited
152
+ * behaviour rather than a design, this repo has never published one, and a
153
+ * change to it belongs in its own issue. */
154
+ function versionParts(v) {
155
+ const nums = (s) => s.split(/[.+]/).map(n => parseInt(n, 10)).map(n => Number.isNaN(n) ? 0 : n);
156
+ const dash = v.indexOf("-");
157
+ if (dash === -1) return { release: nums(v), pre: null };
158
+ return { release: nums(v.slice(0, dash)), pre: nums(v.slice(dash + 1)) };
159
+ }
160
+
161
+ /** True when `a` sorts before `b`.
162
+ *
163
+ * A PRERELEASE SORTS BELOW THE RELEASE IT PRECEDES (#976), which is semver's
164
+ * rule and was not this function's. Splitting on `[.\-+]` and mapping
165
+ * non-numeric segments to 0 made `3.23.0-rc.1` segment to [3,23,0,0,1] — five
166
+ * numbers against the release's three, so it compared ABOVE `3.23.0` and the
167
+ * release compared below the candidate it was meant to supersede:
168
+ *
169
+ * isOlder("3.23.0-rc.1", "3.23.0") = false ← the release never looked newer
170
+ * isOlder("3.23.0", "3.23.0-rc.1") = true ← and the deck would go backwards
171
+ *
172
+ * What that costs is not a cosmetic ordering. `autoUpdate` defaults on, so a
173
+ * deck that has ended up on a prerelease — installed by hand by a tester, or
174
+ * by the dist-tag accident publish.yml now prevents — produces no upgrade
175
+ * notice when the real release ships, shows nothing in the banner, and has no
176
+ * way to come back off it from inside the product. A bad release can be
177
+ * followed by a good one; a fleet that cannot SEE the good one cannot be
178
+ * rescued by publishing it, which is why this half matters even with the
179
+ * dist-tag half in place.
180
+ *
181
+ * Ordinary numeric ordering is untouched — the release halves are compared
182
+ * exactly as before — and so is build metadata; see versionParts.
138
183
  *
139
184
  * cswap-install.mjs had this written out a second time, without the type guard
140
185
  * below, and imports it from here now (#374). The two bodies were identical:
@@ -143,16 +188,20 @@ export function mayAskNpm({ now, lastAskAt }) {
143
188
  * and threw a TypeError there. Nothing could reach that call with a non-string
144
189
  * (both arguments are behind `typeof v === "string"` checks at the one call
145
190
  * site), so this is the copy with a test behind it absorbing the one without,
146
- * not a bug fix. */
191
+ * not a bug fix. That sweep is now one-sided on prerelease pairs and
192
+ * duplicated-helpers.test.ts says which, rather than asserting an equality
193
+ * that has deliberately stopped holding. */
147
194
  export function isOlder(a, b) {
148
195
  if (typeof a !== "string" || typeof b !== "string") return false;
149
- const seg = (v) => v.split(/[.\-+]/).map(n => parseInt(n, 10)).map(n => Number.isNaN(n) ? 0 : n);
150
- const x = seg(a), y = seg(b);
151
- for (let i = 0; i < Math.max(x.length, y.length); i++) {
152
- const d = (x[i] ?? 0) - (y[i] ?? 0);
153
- if (d !== 0) return d < 0;
154
- }
155
- return false;
196
+ const x = versionParts(a), y = versionParts(b);
197
+ const release = cmpParts(x.release, y.release);
198
+ if (release !== 0) return release < 0;
199
+ // Same release. A version carrying a prerelease is below the plain one, and
200
+ // never the other way round; two prereleases of one release compare on their
201
+ // own identifiers, so rc.2 is above rc.1.
202
+ if (x.pre && !y.pre) return true;
203
+ if (!x.pre || !y.pre) return false;
204
+ return cmpParts(x.pre, y.pre) < 0;
156
205
  }
157
206
 
158
207
  // ── what is on disk ──────────────────────────────────────────────────────────
@@ -11,7 +11,7 @@
11
11
  import { brotliCompressSync, constants, gzipSync } from "node:zlib";
12
12
 
13
13
  /** The types worth compressing; images and fonts are compressed already. */
14
- const COMPRESSIBLE = new Set([".html", ".js", ".mjs", ".css", ".json", ".svg", ".map"]);
14
+ const COMPRESSIBLE = new Set([".html", ".js", ".mjs", ".css", ".json", ".svg", ".map", ".webmanifest"]);
15
15
 
16
16
  /** Vite's output under assets/: `<name>-<hash>.<ext>`, the hash eight or more
17
17
  * characters of base64url. */