ccdeck 3.23.3 → 3.23.5

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.
@@ -44,8 +44,8 @@
44
44
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
45
45
  })();
46
46
  </script>
47
- <script type="module" crossorigin src="/assets/index-gPPlJnr8.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-cRw55wBV.css">
47
+ <script type="module" crossorigin src="/assets/index-Cc7l7mTM.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-B9PNdg0Y.css">
49
49
  </head>
50
50
  <body>
51
51
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.23.3",
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.",
3
+ "version": "3.23.5",
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": {
7
7
  "ccdeck": "bin/agent-dag.js"
@@ -32,18 +32,28 @@
32
32
  },
33
33
  "keywords": [
34
34
  "claude-code",
35
+ "claude-code-dashboard",
35
36
  "claude",
36
37
  "anthropic",
38
+ "codex",
39
+ "codex-cli",
40
+ "openai",
37
41
  "agents",
42
+ "ai-agents",
38
43
  "subagents",
39
- "visualization",
40
44
  "dashboard",
41
- "hooks",
45
+ "monitor",
46
+ "monitoring",
42
47
  "observability",
48
+ "usage",
49
+ "cost",
50
+ "tokens",
51
+ "quota",
52
+ "hooks",
43
53
  "dag",
44
- "deck",
45
- "graph",
46
- "live"
54
+ "visualization",
55
+ "cli",
56
+ "deck"
47
57
  ],
48
58
  "author": "Bargan Constantin",
49
59
  "license": "AGPL-3.0-only",
@@ -54,7 +64,7 @@
54
64
  "bugs": {
55
65
  "url": "https://github.com/BarganConstantin/ccdeck/issues"
56
66
  },
57
- "homepage": "https://github.com/BarganConstantin/ccdeck#readme",
67
+ "homepage": "https://ccdeck.dev",
58
68
  "devDependencies": {
59
69
  "@types/dagre": "^0.7.54",
60
70
  "@types/node": "^22.20.1",
@@ -39,7 +39,22 @@
39
39
  "the release whose version is in neither place. Any key opening with // is a",
40
40
  "note to the author and never a release."
41
41
  ],
42
- "//nothing-to-say": ["3.23.1", "3.23.2", "3.23.3"],
42
+ "//nothing-to-say": [
43
+ "3.23.1",
44
+ "3.23.2",
45
+ "3.23.3",
46
+ "3.23.4"
47
+ ],
48
+ "3.23.5": [
49
+ {
50
+ "title": "🧭 The accounts panel opens on the account you are on",
51
+ "body": "Its windows, its resets and how old the numbers are — whole, at the top of the column. Every other account stands behind one row that says how many of them a switch could actually reach; press it to see them, or rest on it to be shown their names and how much room each has left."
52
+ },
53
+ {
54
+ "title": "⚙️ Auto-switch moved in there with them",
55
+ "body": "It is behind that same row now, after the accounts it switches between, rather than at the foot of the panel. The row says `auto 90%` or `auto off` so you can tell what the policy is doing without opening it — and says so in amber when it is armed with nowhere left to go."
56
+ }
57
+ ],
43
58
  "3.23.0": [
44
59
  {
45
60
  "title": "🧭 The accounts column leads with accounts",
@@ -37,7 +37,7 @@ import { createPresence } from "./presence.mjs";
37
37
  import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
38
38
  import { createEngine, defaultName } from "./lan-engine.mjs";
39
39
  import { aboutThisDeck } from "./lan-about.mjs";
40
- import { PROBE_PS, UFW_CONF, UFW_DEFAULTS, isActive, localAliases, reachability, readProbe, readUfw } from "./lan-reach.mjs";
40
+ import { MAC_FW, PROBE_PS, UFW_CONF, UFW_DEFAULTS, isActive, localAliases, reachability, readMacProbe, readProbe, readUfw, silentInbound } from "./lan-reach.mjs";
41
41
  import { run } from "./exec.mjs";
42
42
  import { notify as osNotify } from "./browser-react.mjs";
43
43
  import { invokedName, renameNotice } from "./invoked-as.mjs";
@@ -3727,6 +3727,10 @@ async function handlePrefsWrite(req, res) {
3727
3727
  // so turning the switch off in the panel really does stop the sockets rather
3728
3728
  // than only changing what the panel says.
3729
3729
  await applyLanPrefs();
3730
+ // Somebody has just switched this on and is watching the panel for the answer
3731
+ // to one question: is anything going to turn up. Whatever the probe said
3732
+ // before is about a deck whose sockets were down — see forgetReach.
3733
+ if (body.lan?.enabled === true) forgetReach();
3730
3734
  return send(res, 200, prefsPayload());
3731
3735
  }
3732
3736
 
@@ -3914,6 +3918,9 @@ const lanEngine = createEngine({
3914
3918
  onPort: async port => {
3915
3919
  try { _prefs = await writePrefs({ lan: { port } }); }
3916
3920
  catch { /* the address field still works this session; next start re-pins */ }
3921
+ // The Linux fix lines name this number, so a verdict taken before the
3922
+ // listener had one is missing half of them — see forgetReach.
3923
+ forgetReach();
3917
3924
  },
3918
3925
  // A deck was accepted or unpaired. Written straight through, because the
3919
3926
  // trusted list is the whole of who this deck will talk to and a list that
@@ -4100,21 +4107,118 @@ async function probeLinux() {
4100
4107
  };
4101
4108
  }
4102
4109
 
4110
+ /**
4111
+ * Forget what the last probe said, so the next poll measures rather than
4112
+ * answering out of the five-minute cache.
4113
+ *
4114
+ * TWO MOMENTS EARN IT, and both are moments where the held answer is about a
4115
+ * machine in a different state from the one being asked about.
4116
+ *
4117
+ * A SWITCH-ON is the one somebody is watching. The panel starts polling every
4118
+ * five seconds the instant it goes on, and without this the first several
4119
+ * minutes of that are a verdict taken while the sockets were down — which is
4120
+ * the exact question they turned it on to have answered. Five minutes is the
4121
+ * right staleness for a firewall nobody is touching and the wrong one for the
4122
+ * press that starts the feature.
4123
+ *
4124
+ * THE SYNC PORT LANDING changes what the answer CONTAINS on Linux: the fix
4125
+ * lines name a TCP port (see linuxFixSteps), and a verdict taken before the
4126
+ * listener had one offers the discovery line alone — half an answer, pinned for
4127
+ * five minutes, on the machine that most needs the other half.
4128
+ */
4129
+ function forgetReach() { reachAt = 0; }
4130
+
4131
+ /**
4132
+ * What the macOS application firewall says about this deck.
4133
+ *
4134
+ * Three reads, none of them privileged — measured on macOS 26.6, where all of
4135
+ * them answer an ordinary user with exit 0 and no password. See the macos block
4136
+ * in lan-reach.mjs for why this platform can be asked the exact question about
4137
+ * its own binary while Linux has to reason from a default policy.
4138
+ *
4139
+ * A call that fails comes back as an empty string rather than as a throw, which
4140
+ * readMacProbe turns into a null field and macReach turns into silence: a Mac
4141
+ * that will not answer is a Mac this says nothing about.
4142
+ */
4143
+ async function probeMac() {
4144
+ const ask = args => run(MAC_FW, args, { timeout: 5_000 }).then(r => (r?.ok ? r.stdout : "")).catch(() => "");
4145
+ const [globalState, blockAll, app] = await Promise.all([
4146
+ ask(["--getglobalstate"]),
4147
+ ask(["--getblockall"]),
4148
+ // The binary the LISTENER runs as, which is this process's own executable —
4149
+ // the application firewall's rules are about a program, never about a port.
4150
+ ask(["--getappblocked", process.execPath]),
4151
+ ]);
4152
+ return readMacProbe({ global: globalState, blockAll, app });
4153
+ }
4154
+
4155
+ /** How recently a beacon has to have arrived to count as a machine this deck
4156
+ * can hear. Three announce intervals, which is the same window the panel calls
4157
+ * `now` — one dropped broadcast must not retract the claim. */
4158
+ const HEARD_MS = 90_000;
4159
+
4160
+ /**
4161
+ * The verdict for a machine no probe could speak about — see silentInbound.
4162
+ *
4163
+ * BOTH LISTS ARE COUNTED. `strangers` is already one row per machine, and a
4164
+ * deck LEAVES it for `peers` the moment somebody pairs with it — so counting
4165
+ * strangers alone would make a deck that has paired with everything it can hear
4166
+ * look like a deck that hears nobody, which is the one state this must not
4167
+ * confuse with being blocked.
4168
+ */
4169
+ function measuredReach() {
4170
+ const st = lanEngine.status();
4171
+ const at = Date.now();
4172
+ const fresh = p => typeof p?.lastSeen === "number" && at - p.lastSeen < HEARD_MS;
4173
+ const heard = (st.strangers?.length ?? 0) + (st.peers ?? []).filter(fresh).length;
4174
+ return silentInbound({ heard, listeningSince: st.listeningSince, inbound: st.inboundAt, now: at });
4175
+ }
4176
+
4103
4177
  function refreshReach() {
4104
- if (reachBusy || (process.platform !== "win32" && process.platform !== "linux")) return;
4178
+ if (reachBusy) return;
4105
4179
  if (reachAt && Date.now() - reachAt < REACH_MS) return;
4106
4180
  reachBusy = true;
4107
4181
  if (process.platform === "linux") {
4108
4182
  probeLinux().then(linux => {
4109
- reachSaid = reachability({ platform: "linux", linux, inbound: lanEngine.status().inboundAt });
4183
+ // A read of the real configuration beats an inference from silence, so
4184
+ // the measurement only speaks where the probe had no opinion at all.
4185
+ reachSaid = reachability({ platform: "linux", linux, inbound: lanEngine.status().inboundAt })
4186
+ ?? measuredReach();
4187
+ }).catch(() => {
4188
+ reachSaid = measuredReach();
4189
+ }).finally(() => {
4190
+ reachAt = Date.now();
4191
+ reachBusy = false;
4192
+ });
4193
+ return;
4194
+ }
4195
+ if (process.platform === "darwin") {
4196
+ probeMac().then(mac => {
4197
+ reachSaid = reachability({
4198
+ platform: "darwin",
4199
+ mac,
4200
+ // The same path the probe asked about, so the lines somebody pastes name
4201
+ // the binary the verdict was taken on.
4202
+ exePath: process.execPath,
4203
+ inbound: lanEngine.status().inboundAt,
4204
+ }) ?? measuredReach();
4110
4205
  }).catch(() => {
4111
- reachSaid = null;
4206
+ reachSaid = measuredReach();
4112
4207
  }).finally(() => {
4113
4208
  reachAt = Date.now();
4114
4209
  reachBusy = false;
4115
4210
  });
4116
4211
  return;
4117
4212
  }
4213
+ // EVERY OTHER PLATFORM, which used to return before reaching any of this and
4214
+ // therefore said nothing forever. FreeBSD, an unrecognised Linux, anything
4215
+ // node runs on: no probe to run, and the measurement needs none.
4216
+ if (process.platform !== "win32") {
4217
+ reachSaid = measuredReach();
4218
+ reachAt = Date.now();
4219
+ reachBusy = false;
4220
+ return;
4221
+ }
4118
4222
  run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PROBE_PS], {
4119
4223
  // The program path goes through the environment rather than into the
4120
4224
  // script, so a path with a quote in it cannot close the string it sits in.
@@ -4127,12 +4231,12 @@ function refreshReach() {
4127
4231
  aliases: localAliases(networkInterfaces()),
4128
4232
  exePath: process.execPath,
4129
4233
  inbound: lanEngine.status().inboundAt,
4130
- });
4234
+ }) ?? measuredReach();
4131
4235
  }).catch(() => {
4132
- // A probe that did not run says nothing, which is the same answer as a
4133
- // machine this cannot speak about. It is never an error the panel shows:
4134
- // nobody asked for it.
4135
- reachSaid = null;
4236
+ // A probe that did not run is exactly the machine silentInbound was written
4237
+ // for: nothing can be asked, so what is left is what was measured. It is
4238
+ // never an error the panel shows — nobody asked for it.
4239
+ reachSaid = measuredReach();
4136
4240
  }).finally(() => {
4137
4241
  reachAt = Date.now();
4138
4242
  reachBusy = false;
@@ -337,6 +337,17 @@ export function createEngine({
337
337
  */
338
338
  let inboundAt = null;
339
339
 
340
+ /** When this deck's listener came up, or null while it is down.
341
+ *
342
+ * THE OTHER HALF OF `inboundAt`. On its own, "nothing has ever connected in"
343
+ * says nothing: a deck that started four seconds ago has the same null as one
344
+ * that has been listening all afternoon while the network talked around it.
345
+ * What makes the silence evidence is how long it has gone on for, and that is
346
+ * a number only the engine holds. See silentInbound in lan-reach.mjs, which
347
+ * is the one verdict in this feature that works on a platform nothing can be
348
+ * asked about. */
349
+ let listeningSince = null;
350
+
340
351
  /** This deck's accounts in the shape the rules want. Read through the same
341
352
  * function the panel uses, so a row can never be alive here and dead there. */
342
353
  const localAccounts = async () => {
@@ -838,6 +849,9 @@ export function createEngine({
838
849
  throw err;
839
850
  }
840
851
  stalled = null;
852
+ // From here the socket is accepting, so this is the moment the silence
853
+ // starts being about the network rather than about a deck still starting.
854
+ listeningSince = now();
841
855
  if (port !== cfg.port) onPort?.(port);
842
856
  beacon = createBeacon({
843
857
  port, name: cfg.name, fp: identity.fp,
@@ -1211,6 +1225,8 @@ export function createEngine({
1211
1225
  // on a deck nobody has dialled yet, which is not the same as blocked
1212
1226
  // and is drawn as neither — see inboundAt and lan-reach.mjs.
1213
1227
  inboundAt,
1228
+ // How long that null has been true for — see listeningSince.
1229
+ listeningSince,
1214
1230
  addrs: beacon ? localAddresses() : [],
1215
1231
  shared: [...cfg.shared],
1216
1232
  // The token this deck is offering, if any. Drawn as the one thing to do
@@ -1323,6 +1339,9 @@ export function createEngine({
1323
1339
  server?.stop();
1324
1340
  beacon = null;
1325
1341
  server = null;
1342
+ // Nothing is listening, so nothing is being silent AT anybody. Leaving
1343
+ // this set would have the next start measure its quiet from the last one.
1344
+ listeningSince = null;
1326
1345
  },
1327
1346
  };
1328
1347
  }
@@ -296,30 +296,152 @@ export function linuxReach({ ufw = null, firewalld = false, inbound = null, sync
296
296
  };
297
297
  }
298
298
 
299
+ // ── macos ───────────────────────────────────────────────────────────────────
300
+ //
301
+ // THE REVERSAL. This module used to say macOS was deliberately silent: the
302
+ // firewall ships off, and when it is on it asks the person at the keyboard the
303
+ // first time a program listens — so the answer was either "nothing is in the
304
+ // way" or "somebody was shown a dialog and pressed a button".
305
+ //
306
+ // The second half of that is what gave way. A deck started from a terminal, or
307
+ // by npx, or by a launch agent, is not always a program somebody is sitting in
308
+ // front of when the question is asked, and a dialog nobody answers is a denial
309
+ // that looks exactly like an empty list. And the answer IS readable. Measured
310
+ // on macOS 26.6, as an ordinary user, no password and exit 0 on all four:
311
+ // `--getglobalstate`, `--getblockall`, `--getstealthmode`, and
312
+ // `--getappblocked <path>`.
313
+ //
314
+ // WHICH MAKES THIS THE BEST-INFORMED OF THE THREE PLATFORMS, not the worst.
315
+ // ufw will not show its rules to anybody but root, so the Linux verdict reasons
316
+ // from a default policy and says out loud that it could not check. This one can
317
+ // ask the exact question — is an incoming connection to THIS binary permitted —
318
+ // about the binary the listener actually runs as. So there is no `unsure` here,
319
+ // because there is nothing it could not look at.
320
+ //
321
+ // WHAT IS NOT EVIDENCE, and the trap this nearly walked into: with the firewall
322
+ // OFF, `--getappblocked` answers "is permitted" for every path handed to it,
323
+ // including one that does not exist. Measured, and it is the reason the order
324
+ // in macReach is load-bearing rather than tidy — the per-app answer means
325
+ // nothing at all until the global state says the firewall is on.
326
+ //
327
+ // STEALTH MODE IS READ AND NOT ACTED ON. It drops unsolicited probes to ports
328
+ // nothing is listening on, which is not this: a deck that is listening and
329
+ // allowed still answers. Blaming it would send somebody to change a setting
330
+ // that was never in the way.
331
+
332
+ /** The application firewall's own tool. Not on PATH, and the full path is what
333
+ * goes in the lines somebody pastes, so it is written once. */
334
+ export const MAC_FW = "/usr/libexec/ApplicationFirewall/socketfilterfw";
335
+
336
+ /**
337
+ * What the three reads said, as a verdict-shaped thing.
338
+ *
339
+ * Every field can come back null, which is this file's word for "the machine
340
+ * did not answer that" — a locked-down build, a future macOS that renamed the
341
+ * flag, a tool that is not there at all. Null travels to macReach and comes out
342
+ * as silence rather than as a guess.
343
+ */
344
+ export function readMacProbe({ global: globalState, blockAll, app } = {}) {
345
+ // "Firewall is disabled. (State = 0)" / "Firewall is enabled. (State = 1)".
346
+ // The number is read rather than the adjective: it is the stable half of the
347
+ // sentence, and 2 is a third state that says enabled in words.
348
+ const state = /State\s*=\s*(\d+)/i.exec(String(globalState ?? ""))?.[1];
349
+ return {
350
+ on: state == null ? null : Number(state) > 0,
351
+ // "Firewall has block all state set to enabled." — the setting that turns
352
+ // every allowance off at once, including one this deck already has.
353
+ blockAll: /block all state set to enabled/i.test(String(blockAll ?? "")),
354
+ // "Incoming connection to <path> is permitted." / "... is blocked."
355
+ appBlocked: /is blocked/i.test(String(app ?? "")) ? true
356
+ : /is permitted/i.test(String(app ?? "")) ? false
357
+ : null,
358
+ };
359
+ }
360
+
361
+ /**
362
+ * The lines that open the path, as text.
363
+ *
364
+ * SCOPED TO THE PROGRAM, like the Windows rule and unlike the Linux ones: the
365
+ * application firewall has no notion of a port, which is what makes it the
366
+ * right shape here — the sync listener's port is a different number after some
367
+ * restarts, and a rule about the binary survives that.
368
+ *
369
+ * `--add` before `--unblockapp` because a binary the firewall has never heard
370
+ * of cannot be unblocked; adding one that is already listed changes nothing,
371
+ * so the pair is safe to paste twice.
372
+ */
373
+ export function macFixSteps({ exePath, blockAll = false } = {}) {
374
+ const steps = [];
375
+ // First, because while it is on nothing else in this list has any effect.
376
+ if (blockAll) steps.push(`sudo ${MAC_FW} --setblockall off`);
377
+ steps.push(`sudo ${MAC_FW} --add "${exePath}"`);
378
+ steps.push(`sudo ${MAC_FW} --unblockapp "${exePath}"`);
379
+ return steps;
380
+ }
381
+
382
+ /**
383
+ * Whether other decks can reach this Mac, from what the application firewall
384
+ * says about this deck's own binary.
385
+ *
386
+ * Null for anything it could not read, and for the ordinary machine whose
387
+ * firewall is simply off — see the block above for why `appBlocked` is not
388
+ * consulted until `on` is true.
389
+ */
390
+ export function macReach({ probe = null, exePath = "", inbound = null } = {}) {
391
+ // MEASURED BEATS READ, here as on the other two: a connection from another
392
+ // machine has arrived, so the path is open whatever the settings say.
393
+ if (inbound) return { blocked: false, why: "inbound seen", category: "", alias: "" };
394
+ if (!probe || probe.on == null) return null;
395
+ if (!probe.on) return { blocked: false, why: "firewall off", category: "", alias: "" };
396
+ // Both halves of the sentence, because the asymmetry is the confusing part:
397
+ // the other deck may already show this machine's name, which reads as a
398
+ // working connection and is not one.
399
+ const heard = " They can still hear it — that is why one of them may already show this machine.";
400
+ if (probe.blockAll) {
401
+ return {
402
+ blocked: true,
403
+ why: "block all incoming",
404
+ category: "",
405
+ alias: "",
406
+ shell: "sh",
407
+ text: "macOS is set to block all incoming connections, so other decks cannot reach this one." + heard,
408
+ steps: macFixSteps({ exePath, blockAll: true }),
409
+ };
410
+ }
411
+ if (probe.appBlocked === true) {
412
+ return {
413
+ blocked: true,
414
+ why: "app blocked",
415
+ category: "",
416
+ alias: "",
417
+ shell: "sh",
418
+ text: "The macOS firewall is refusing incoming connections to this deck, so other decks cannot reach it." + heard,
419
+ steps: macFixSteps({ exePath }),
420
+ };
421
+ }
422
+ if (probe.appBlocked === false) return { blocked: false, why: "app permitted", category: "", alias: "" };
423
+ return null;
424
+ }
425
+
299
426
  /**
300
427
  * Whether other decks can reach this one, and what to do when they cannot.
301
428
  *
302
429
  * `null` means "no opinion", and it is the answer for every machine this cannot
303
- * speak about: anything that is neither Windows nor Linux, and any probe that
304
- * did not come back. Silence rather than a hedge — a panel line that says
305
- * "possibly" about a thing it did not measure is worse than no line.
306
- *
307
- * macOS is deliberately among the silent ones. Its firewall is off by default
308
- * and, when on, asks the person at the keyboard the first time a program
309
- * listens — so the answer is either "nothing is in the way" or "somebody was
310
- * shown a dialog and pressed a button", and neither is a thing to instruct
311
- * anybody about.
430
+ * speak about: anything that is none of the three platforms below, and any
431
+ * probe that did not come back. Silence rather than a hedge — a panel line that
432
+ * says "possibly" about a thing it did not measure is worse than no line.
312
433
  *
313
434
  * `blocked: false` is a real finding and is worth returning: it lets the panel
314
435
  * stop blaming the firewall for an empty list and say the other thing instead,
315
436
  * which is that nobody else is running.
316
437
  */
317
- export function reachability({ platform, probe, aliases = [], exePath = "", inbound = null, linux = null } = {}) {
318
- // Two platforms, two entirely different reads — see the linux block above for
319
- // why that one cannot be written in this one's shape. `inbound` is the one
320
- // input both of them share: it is a measurement rather than a read, so it
321
- // outranks whatever either platform's configuration says.
438
+ export function reachability({ platform, probe, aliases = [], exePath = "", inbound = null, linux = null, mac = null } = {}) {
439
+ // Three platforms, three entirely different reads — see each block above for
440
+ // why none of them can be written in another's shape. `inbound` is the one
441
+ // input all of them share: it is a measurement rather than a read, so it
442
+ // outranks whatever any platform's configuration says.
322
443
  if (platform === "linux") return linux ? linuxReach({ ...linux, inbound }) : null;
444
+ if (platform === "darwin") return mac ? macReach({ probe: mac, exePath, inbound }) : null;
323
445
  if (platform !== "win32") return null;
324
446
  if (!probe) return null;
325
447
  // The broadcast route first, then the interfaces the deck holds an address
@@ -378,6 +500,63 @@ export function reachability({ platform, probe, aliases = [], exePath = "", inbo
378
500
  * the first try if the invite is minted at the other end and pasted here. Same
379
501
  * two controls, opposite order, and only one of the orders works.
380
502
  */
503
+ // ── the machine nothing can be asked about ──────────────────────────────────
504
+ //
505
+ // Every block above depends on the operating system answering a question.
506
+ // FreeBSD has nobody to ask; a Linux box running plain nftables has no ufw.conf
507
+ // to read; a Windows probe comes back empty for a dozen policy reasons; a
508
+ // hardened Mac can refuse the tool. All of them arrive at `reachability` as
509
+ // null — which is honest, and no use at all to somebody watching an empty list.
510
+ //
511
+ // WHAT IS MEASURABLE ON ANY OF THEM. A beacon ARRIVING proves this machine
512
+ // hears the network. `inboundAt` still null proves nothing has ever connected
513
+ // in. Those two facts together are the exact shape of a blocked inbound path,
514
+ // and neither of them asks the OS anything.
515
+ //
516
+ // SO IT IS A LAST RESORT, NOT A SECOND OPINION. It speaks only where a platform
517
+ // verdict said nothing: a read of the actual configuration beats an inference
518
+ // from silence every time, including when the read says the firewall is off and
519
+ // the silence has some other cause this cannot name.
520
+
521
+ /** How long this deck has to have been listening — hearing other machines and
522
+ * receiving nothing — before the silence is worth reporting.
523
+ *
524
+ * Deliberately many times over the interval anything would have dialled on: a
525
+ * paired deck runs a round every SYNC_MS (60s) and a stranger that hears this
526
+ * one asks within ASKING_MS (8s), autoAsk being on by default. Five minutes is
527
+ * therefore dozens of missed chances rather than one — and the cost of getting
528
+ * this wrong is telling somebody their network is broken when it is quiet. */
529
+ export const QUIET_MS = 5 * 60_000;
530
+
531
+ /**
532
+ * The verdict for a machine this file cannot read: measured, never read.
533
+ *
534
+ * WHAT IT REFUSES TO SAY. It names no firewall and offers no command, because
535
+ * it looked at no configuration and would be guessing at both. Everything it
536
+ * has is in the one sentence it returns, and the ways out the panel draws under
537
+ * it need no rule on this machine at all.
538
+ */
539
+ export function silentInbound({ heard = 0, listeningSince = null, inbound = null, now = Date.now() } = {}) {
540
+ // Something got in, so the path is open — the same measurement that outranks
541
+ // a configuration read everywhere else in this file.
542
+ if (inbound) return null;
543
+ // Nobody is out there. "Nothing has arrived" and "nothing can arrive" are the
544
+ // two facts this whole module exists to tell apart, and with no beacon in
545
+ // hand this one is the first.
546
+ if (!heard) return null;
547
+ if (!listeningSince) return null;
548
+ const listening = now - listeningSince;
549
+ if (listening < QUIET_MS) return null;
550
+ const mins = Math.round(listening / 60_000);
551
+ return {
552
+ blocked: true,
553
+ why: "heard them, nothing got in",
554
+ category: "",
555
+ alias: "",
556
+ text: `This deck hears ${heard === 1 ? "another deck" : `${heard} other decks`} on the network and, in the ${mins} minutes it has been listening, nothing has ever connected to it — which is what a blocked inbound path looks like from this side. This machine cannot be asked which firewall it runs, so there is no line to paste.`,
557
+ };
558
+ }
559
+
381
560
  export function workaround(blocked) {
382
561
  if (!blocked) return null;
383
562
  return {