ccdeck 3.24.0 → 3.25.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.
@@ -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-DFezFf9U.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-DFTb6plM.css">
47
+ <script type="module" crossorigin src="/assets/index-BoR2dMqR.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-7a7ISatq.css">
49
49
  </head>
50
50
  <body>
51
51
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.24.0",
3
+ "version": "3.25.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": {
@@ -45,6 +45,30 @@
45
45
  "3.23.3",
46
46
  "3.23.4"
47
47
  ],
48
+ "3.25.0": [
49
+ {
50
+ "title": "🖱️ A click goes to the session, a double-click opens the details",
51
+ "body": "Clicking a card frames it with the rest of its session and leaves the detail panel shut. Double-click a card to open its details; `j`/`k`, the session list, Enter and `D` still open them too."
52
+ },
53
+ {
54
+ "title": "🔭 Cards stay readable when you zoom out",
55
+ "body": "At every zoom a card shows its name, its state and anything waiting on you, instead of shrinking to a coloured pill. Hover or focus a card to see it whole. Recap notes read at a distance too, and show the full recap on hover."
56
+ },
57
+ {
58
+ "title": "🌐 The Machine panel shows your network",
59
+ "body": "A new Network section shows download, upload and the round trip to Claude's API, and names the route when traffic goes through a VPN or a Tailscale exit node. Those checks run only while the panel is open. The panel is regrouped into CPU, Memory, Network and Thermal, with a flag at the top when something needs attention."
60
+ },
61
+ {
62
+ "title": "🎨 See each theme before you pick it",
63
+ "body": "The Appearance menu shows a small preview of the deck in Light and in Dark. Claude FM also keeps its volume when a notification chime plays, instead of dipping under it."
64
+ }
65
+ ],
66
+ "3.24.1": [
67
+ {
68
+ "title": "🎛️ Appearance is one small menu now",
69
+ "body": "The topbar's appearance control opens a compact menu where you can choose Light or Dark and show or hide the character. The character stays on by default, and your choice is remembered."
70
+ }
71
+ ],
48
72
  "3.24.0": [
49
73
  {
50
74
  "title": "🎧 Claude FM plays from the minimap",
@@ -7393,7 +7393,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7393
7393
  // An allowlist, not a pass-through: the parameter names a section of this
7394
7394
  // panel and nothing else, so an unknown one is a 400 rather than an empty
7395
7395
  // chart that looks like a machine with nothing to report.
7396
- if (!["thermal", "cores", "memory", "load"].includes(group)) {
7396
+ if (!["thermal", "cores", "memory", "load", "network"].includes(group)) {
7397
7397
  return send(res, 400, { ok: false, error: "unknown_group" });
7398
7398
  }
7399
7399
  return send(res, 200, historySnapshot(group));
@@ -7509,8 +7509,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7509
7509
  // reads `transcript_path`, which Codex hooks never send — and unref'd
7510
7510
  // like its neighbours.
7511
7511
  startOutputWatch();
7512
- // Both timers are unref'd, so this never holds the process open.
7513
- startSystemMetrics();
7512
+ // Every timer is unref'd, so this never holds the process open. `probe`
7513
+ // lets the network section time the API and read the route while the
7514
+ // Machine panel is open — the only caller allowed to reach out.
7515
+ startSystemMetrics({ probe: true });
7514
7516
  // LAN sync, from the prefs the import read — and only from here, so a
7515
7517
  // deck that had it on comes back with it on, and a launcher that only
7516
7518
  // asked the registry never binds a port it is about to walk away from.
@@ -0,0 +1,190 @@
1
+ // The network section's readings, as pure functions of what the platform prints.
2
+ //
3
+ // Everything here is text in, numbers out, so a Linux answer can be checked from
4
+ // a Mac and a Windows one from either — the same reason every parser in
5
+ // system-metrics.mjs is exported and pure. The sampler that runs the commands,
6
+ // times the connection and keeps the history lives there; this file only reads.
7
+ //
8
+ // THREE READINGS, AND WHY THESE THREE (#network). The first build of this idea
9
+ // was a speed test, and a speed test is the wrong instrument twice over: it
10
+ // fills the line for as long as it runs — which is exactly the stall a person
11
+ // opens this section to explain — and the maximum a line can carry is not what
12
+ // was wrong the day this was designed. That day the Wi-Fi measured 270 Mbit/s
13
+ // and Claude FM still went silent for twenty seconds at a time, because every
14
+ // packet was leaving through a Tailscale exit node on another continent's relay.
15
+ // So the section reports what is MOVING (throughput), how FAR Claude is
16
+ // (latency), and which WAY the traffic goes (the route) — the three things a
17
+ // person needs to tell "the network is slow" from "the network is fine and
18
+ // something else is".
19
+
20
+ /**
21
+ * Bytes received and sent across the interfaces that are wires, out of
22
+ * `/proc/net/dev`.
23
+ *
24
+ * `include` decides which interfaces count, and the sampler passes the set of
25
+ * physical ones (those with a `device` under /sys/class/net). Counting every
26
+ * interface would count a VPN's traffic twice — once inside the tunnel and once
27
+ * again, encrypted, on the Wi-Fi that carries it — and loopback's traffic,
28
+ * which never leaves the machine, would read as the deck downloading.
29
+ *
30
+ * Returns null when nothing matched, which is not the same as zero: a machine
31
+ * whose counters read zero moved nothing, and one this could not read did not
32
+ * say.
33
+ */
34
+ export function parseProcNetDev(text, include = name => name !== "lo") {
35
+ if (typeof text !== "string") return null;
36
+ let rx = 0;
37
+ let tx = 0;
38
+ const ifaces = [];
39
+ for (const line of text.split("\n")) {
40
+ const colon = line.indexOf(":");
41
+ if (colon < 0) continue;
42
+ const name = line.slice(0, colon).trim();
43
+ if (!name || name.includes("|") || !include(name)) continue;
44
+ const f = line.slice(colon + 1).trim().split(/\s+/).map(Number);
45
+ // Receive is the first eight columns and transmit the next eight; bytes
46
+ // lead each group.
47
+ if (f.length < 9 || !Number.isFinite(f[0]) || !Number.isFinite(f[8])) continue;
48
+ rx += f[0];
49
+ tx += f[8];
50
+ ifaces.push(name);
51
+ }
52
+ return ifaces.length ? { rx, tx, ifaces } : null;
53
+ }
54
+
55
+ /**
56
+ * The same two totals out of macOS's `netstat -ibn`.
57
+ *
58
+ * Only the `enN` rows, which are the Mac's Ethernet and Wi-Fi ports, and only
59
+ * their `<Link#N>` row — netstat prints one row per address family per
60
+ * interface, and summing all of them would count every byte two or three times.
61
+ * `utun` (every VPN, Tailscale included), `lo`, `awdl`, `llw`, `bridge` and the
62
+ * rest are left out for the reason the Linux reader leaves virtual interfaces
63
+ * out. Read from the END of the row, because the Address column is empty on
64
+ * some interfaces and a count from the front would shift by one.
65
+ */
66
+ export function parseNetstatIbn(text) {
67
+ if (typeof text !== "string") return null;
68
+ let rx = 0;
69
+ let tx = 0;
70
+ let seen = 0;
71
+ for (const line of text.split("\n")) {
72
+ const f = line.trim().split(/\s+/);
73
+ if (f.length < 8 || !/^en\d+$/.test(f[0]) || !f.some(c => c.startsWith("<Link#"))) continue;
74
+ // … Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll
75
+ const ibytes = Number(f[f.length - 5]);
76
+ const obytes = Number(f[f.length - 2]);
77
+ if (!Number.isFinite(ibytes) || !Number.isFinite(obytes)) continue;
78
+ rx += ibytes;
79
+ tx += obytes;
80
+ seen++;
81
+ }
82
+ return seen ? { rx, tx } : null;
83
+ }
84
+
85
+ /**
86
+ * The two totals out of Windows' `netstat -e`.
87
+ *
88
+ * Found by shape rather than by label: the first row that is a word followed by
89
+ * exactly two integers is the Bytes row, in every language Windows ships —
90
+ * "Bytes" is "Octets" on a French machine, and a parser that matched the word
91
+ * would read nothing there. The totals cover every adapter; Windows offers no
92
+ * cheaper per-adapter figure than a PowerShell start, which costs more than
93
+ * this whole section is worth every five seconds.
94
+ */
95
+ export function parseNetstatE(text) {
96
+ if (typeof text !== "string") return null;
97
+ for (const line of text.split(/\r?\n/)) {
98
+ const m = /^\s*\S[^\d]*?\s+(\d+)\s+(\d+)\s*$/.exec(line);
99
+ if (m) return { rx: Number(m[1]), tx: Number(m[2]) };
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Bytes per second between two readings, or null when the pair cannot say.
106
+ *
107
+ * A counter that went BACKWARDS is an adapter that was reset — unplugged,
108
+ * resumed from sleep, a driver reloaded — and the difference across a reset is
109
+ * not a rate, it is a large negative number. Too short a gap is refused for the
110
+ * opposite reason: two reads a few milliseconds apart turn one packet into a
111
+ * wild figure.
112
+ */
113
+ export function rateBetween(prev, next) {
114
+ if (!prev || !next) return null;
115
+ const seconds = (next.at - prev.at) / 1000;
116
+ if (!(seconds >= 0.5)) return null;
117
+ const drx = next.rx - prev.rx;
118
+ const dtx = next.tx - prev.tx;
119
+ if (drx < 0 || dtx < 0) return null;
120
+ return { down: Math.round(drx / seconds), up: Math.round(dtx / seconds) };
121
+ }
122
+
123
+ /** The interface Linux would send a packet for this address out of, from
124
+ * `ip -o route get <address>`. Asking sends nothing; it reads the routing
125
+ * table, including the policy tables a Tailscale exit node installs, which
126
+ * `/proc/net/route` does not show. */
127
+ export function routeIfaceLinux(text) {
128
+ if (typeof text !== "string") return null;
129
+ return /\bdev\s+(\S+)/.exec(text)?.[1] ?? null;
130
+ }
131
+
132
+ /** The same question on macOS, from `route -n get <address>`. */
133
+ export function routeIfaceDarwin(text) {
134
+ if (typeof text !== "string") return null;
135
+ return /^\s*interface:\s*(\S+)/m.exec(text)?.[1] ?? null;
136
+ }
137
+
138
+ /**
139
+ * What kind of path an interface name is.
140
+ *
141
+ * Direct is the ordinary case and draws nothing at all in the panel: only a
142
+ * route that is not the machine's own connection is worth a line. Names, not
143
+ * probes, because the kernel already named them — `tailscale0`, `wg0`,
144
+ * `nordlynx`, `utun4`, `tun0`, `ppp0`, `zt…` — and asking each VPN client
145
+ * whether it is running would cost a process per client per sample.
146
+ */
147
+ export function classifyRoute(iface) {
148
+ if (typeof iface !== "string" || !iface) return null;
149
+ if (/^tailscale\d*$/.test(iface)) return { kind: "tailscale", iface };
150
+ if (/^(wg\d|nordlynx|mullvad|proton|wgpia)/.test(iface)) return { kind: "vpn", iface, name: "WireGuard" };
151
+ if (/^zt/.test(iface)) return { kind: "vpn", iface, name: "ZeroTier" };
152
+ if (/^(utun|tun|tap|ppp|ipsec|gpd|cscotun|vpn)/.test(iface)) return { kind: "vpn", iface, name: null };
153
+ return { kind: "direct", iface };
154
+ }
155
+
156
+ /**
157
+ * The exit node carrying this machine's traffic, and whether it is reached
158
+ * directly or through one of Tailscale's relays, from `tailscale status --json`.
159
+ *
160
+ * The relay is the half that matters. A direct path to an exit node costs a
161
+ * hop; a relayed one sends every byte through a DERP server — `nue` is
162
+ * Nuremberg — and adds the round trip to wherever that is, twice. That is what
163
+ * made a live stream buffer every few minutes on the day this was written, on a
164
+ * line that was otherwise fine.
165
+ */
166
+ export function tailscaleExitFromStatus(json) {
167
+ let status = json;
168
+ if (typeof json === "string") {
169
+ try { status = JSON.parse(json); } catch { return null; }
170
+ }
171
+ if (!status || typeof status !== "object" || !status.ExitNodeStatus) return null;
172
+ const peer = Object.values(status.Peer ?? {}).find(p => p && p.ExitNode);
173
+ const node = peer?.HostName || peer?.DNSName?.split(".")[0] || null;
174
+ // CurAddr is set when the path is direct; an empty one with a Relay named is
175
+ // every packet going through that relay.
176
+ const relay = peer && !peer.CurAddr && peer.Relay ? String(peer.Relay) : null;
177
+ return { node, relay };
178
+ }
179
+
180
+ /** One line for a route, the same words the panel and the history use, so the
181
+ * two can never describe one path differently. */
182
+ export function routeLabel(route) {
183
+ if (!route) return null;
184
+ if (route.kind === "direct") return "direct";
185
+ if (route.kind === "tailscale-exit") {
186
+ return `through Tailscale exit node${route.node ? ` ${route.node}` : ""}${route.relay ? `, relayed via ${route.relay}` : ""}`;
187
+ }
188
+ if (route.kind === "tailscale") return "through Tailscale";
189
+ return `through ${route.name ? `${route.name} ` : "a "}VPN${route.iface ? ` (${route.iface})` : ""}`;
190
+ }
@@ -18,8 +18,15 @@
18
18
  // readout the loudest producer in the application. So this is a plain poll
19
19
  // endpoint, exactly like /api/quota and /api/codex-usage already are.
20
20
  import { spawn } from "node:child_process";
21
- import { readdir, readFile } from "node:fs/promises";
21
+ import { lookup } from "node:dns/promises";
22
+ import { access, readdir, readFile } from "node:fs/promises";
23
+ import net from "node:net";
22
24
  import os from "node:os";
25
+ import { hasClaudeInstalled } from "./claude-dir.mjs";
26
+ import {
27
+ classifyRoute, parseNetstatE, parseNetstatIbn, parseProcNetDev, rateBetween,
28
+ routeIfaceDarwin, routeIfaceLinux, routeLabel, tailscaleExitFromStatus,
29
+ } from "./network-metrics.mjs";
23
30
 
24
31
  /** CPU is the metric with spikes, so it is sampled often enough to catch one. */
25
32
  const CPU_INTERVAL_MS = 3_000;
@@ -1690,9 +1697,44 @@ function seriesFor(group) {
1690
1697
  }];
1691
1698
  }
1692
1699
 
1700
+ if (group === "network") {
1701
+ // Download and upload are two charts, not two lines on one: upload is
1702
+ // usually a hundredth of download, and on a shared scale it is a line along
1703
+ // the floor. Each gets a top fitted to its own peak. No bands: there is no
1704
+ // throughput that is too much, only more than usual, and a threshold here
1705
+ // would be a number this module made up.
1706
+ const down = at("net:down");
1707
+ const up = at("net:up");
1708
+ const api = at("net:api");
1709
+ const series = [
1710
+ // Floors of 100 KB/s and 100 ms, so a quiet line is drawn as quiet.
1711
+ { key: "net:down", label: "Download", unit: "B/s", top: niceTop(down, 100_000), warnAt: null, critAt: null, restsAtZero: false, points: down },
1712
+ { key: "net:up", label: "Upload", unit: "B/s", top: niceTop(up, 100_000), warnAt: null, critAt: null, restsAtZero: false, points: up },
1713
+ { key: "net:api", label: "Claude API latency", unit: "ms", top: niceTop(api, 100), warnAt: null, critAt: null, restsAtZero: false, points: api },
1714
+ ].filter(s => s.points.length);
1715
+ // The path the traffic took, as marks on the last chart — the question the
1716
+ // section exists for is "was it the VPN", and a line that jumps where a
1717
+ // mark sits answers it without a legend.
1718
+ const since = history.length ? history[0].m * BUCKET_MS : 0;
1719
+ const changes = routeLog.filter(c => c.t >= since).map(c => ({ t: c.t, label: c.label, from: c.from }));
1720
+ if (series.length && changes.length) series[series.length - 1].changes = changes;
1721
+ return series;
1722
+ }
1723
+
1693
1724
  return [];
1694
1725
  }
1695
1726
 
1727
+ /** A scale top a person would choose — 1, 2 or 5 times a power of ten — just
1728
+ * above the peak, and never below `floor`, so an idle line is not drawn as a
1729
+ * dramatic climb. */
1730
+ function niceTop(points, floor) {
1731
+ const peak = points.reduce((a, p) => Math.max(a, p.v), 0);
1732
+ const want = Math.max(floor, peak * 1.15);
1733
+ const pow = 10 ** Math.floor(Math.log10(want));
1734
+ for (const m of [1, 2, 5, 10]) if (m * pow >= want) return m * pow;
1735
+ return want;
1736
+ }
1737
+
1696
1738
  /**
1697
1739
  * Whether this machine has been held back AT ALL since the deck started, and
1698
1740
  * when it last was.
@@ -1854,23 +1896,219 @@ function sampleCpu() {
1854
1896
  if (process.platform !== "win32") record("load:1m", Math.round(load[0] * 100) / 100);
1855
1897
  }
1856
1898
 
1899
+ // ── the network ────────────────────────────────────────────────────────────
1900
+ //
1901
+ // Three readings, taken at two very different costs (network-metrics.mjs says
1902
+ // why these three). THROUGHPUT is two counters the kernel already keeps, so it
1903
+ // rides its own five-second timer for the life of the process like CPU does,
1904
+ // and its history is as complete as every other section's. LATENCY and ROUTE
1905
+ // leave the machine or spawn a process, so they are taken only while somebody
1906
+ // is looking: the panel is the only thing that polls /api/system, and a probe
1907
+ // runs only within NET_ASKED_MS of that poll. A deck left open in a background
1908
+ // tab all day makes no connection it was not asked for — the rule Claude FM's
1909
+ // probe already follows ("makes no request until a page asks for one").
1910
+
1911
+ /** Counters on Linux are a file read; on macOS and Windows one short command.
1912
+ * Five seconds is often enough to catch a burst and rare enough that the
1913
+ * command costs nothing anybody could measure. */
1914
+ const NET_INTERVAL_MS = 5_000;
1915
+ /** How often the far end is asked, while the panel is open. */
1916
+ const NET_PROBE_MS = 30_000;
1917
+ /** How recently a poll must have come for the probe to run at all. */
1918
+ const NET_ASKED_MS = 20_000;
1919
+ /** A probe older than this is from before the panel was last closed, and is
1920
+ * not reported as the present. */
1921
+ const NET_STALE_MS = NET_PROBE_MS * 2 + 5_000;
1922
+ /** What "how far is Claude" is measured against: the host every request Claude
1923
+ * Code makes goes to, and the one quota.mjs already reads from. */
1924
+ const API_HOST = "api.anthropic.com";
1925
+ const API_PORT = 443;
1926
+ const CONNECT_TIMEOUT_MS = 4_000;
1927
+ /** Where the route is asked for when there is no API address to ask about. A
1928
+ * routing-table lookup sends no packet, so this contacts nobody. */
1929
+ const ROUTE_FALLBACK = "1.1.1.1";
1930
+ /** Route changes kept for the history's markers. A path changes a handful of
1931
+ * times a day; forty is several days of it. */
1932
+ const ROUTE_LOG = 40;
1933
+
1934
+ let netTimer = null;
1935
+ let netInFlight = false;
1936
+ let prevNet = null;
1937
+ let netRate = null;
1938
+ let physical = null;
1939
+ let physicalAt = 0;
1940
+ let probeTimer = null;
1941
+ /** Whether this process may reach out at all. Off unless the server says so:
1942
+ * the suite starts this module in dozens of cases, and none of them should
1943
+ * open a connection to Anthropic or spawn `ip` and `tailscale` to do it. */
1944
+ let probeEnabled = false;
1945
+ let probeInFlight = false;
1946
+ let probeAt = 0;
1947
+ let askedAt = 0;
1948
+ /** Undefined until measured, null when the host could not be reached. */
1949
+ let apiMs;
1950
+ let route = null;
1951
+ const routeLog = [];
1952
+
1953
+ /** Interfaces that are hardware, by the one test the kernel offers: a
1954
+ * `device` link under /sys/class/net. Rechecked each minute, because a dock or
1955
+ * a phone tethered over USB is a new wire. */
1956
+ async function physicalInterfaces(root = "/sys/class/net") {
1957
+ try {
1958
+ const names = await readdir(root);
1959
+ const real = await Promise.all(names.map(n => access(`${root}/${n}/device`).then(() => n, () => null)));
1960
+ return new Set(real.filter(Boolean));
1961
+ } catch { return null; }
1962
+ }
1963
+
1964
+ async function readNetCounters(platform = process.platform) {
1965
+ if (platform === "linux") {
1966
+ if (!physical || Date.now() - physicalAt > 60_000) {
1967
+ physical = await physicalInterfaces();
1968
+ physicalAt = Date.now();
1969
+ }
1970
+ try {
1971
+ const include = physical?.size ? name => physical.has(name) : name => name !== "lo";
1972
+ return parseProcNetDev(await readFile("/proc/net/dev", "utf8"), include);
1973
+ } catch { return null; }
1974
+ }
1975
+ if (platform === "darwin") return parseNetstatIbn(await run("netstat", ["-ibn"]));
1976
+ if (platform === "win32") return parseNetstatE(await run("netstat", ["-e"]));
1977
+ return null;
1978
+ }
1979
+
1980
+ async function sampleNetwork() {
1981
+ if (netInFlight) return;
1982
+ netInFlight = true;
1983
+ try {
1984
+ const counters = await readNetCounters();
1985
+ if (!counters) return;
1986
+ const next = { rx: counters.rx, tx: counters.tx, at: Date.now() };
1987
+ const rate = rateBetween(prevNet, next);
1988
+ prevNet = next;
1989
+ if (!rate) return;
1990
+ netRate = rate;
1991
+ record("net:down", rate.down);
1992
+ record("net:up", rate.up);
1993
+ } finally { netInFlight = false; }
1994
+ }
1995
+
1996
+ /** Milliseconds for a TCP handshake with `address`, or null. The connection
1997
+ * is closed the moment it opens: nothing is sent, so this is a round trip and
1998
+ * nothing else — no TLS, no request, nothing the far end has to answer. */
1999
+ function connectMs(address, port = API_PORT, timeoutMs = CONNECT_TIMEOUT_MS) {
2000
+ return new Promise(resolve => {
2001
+ const t0 = performance.now();
2002
+ let done = false;
2003
+ const socket = net.connect({ host: address, port });
2004
+ const finish = ms => {
2005
+ if (done) return;
2006
+ done = true;
2007
+ socket.destroy();
2008
+ resolve(ms);
2009
+ };
2010
+ socket.setTimeout(timeoutMs, () => finish(null));
2011
+ socket.once("connect", () => finish(Math.round(performance.now() - t0)));
2012
+ socket.once("error", () => finish(null));
2013
+ });
2014
+ }
2015
+
2016
+ /** Which way traffic to `address` leaves, and — for a Tailscale exit node —
2017
+ * through which machine and whether through a relay. */
2018
+ async function readRoute(address, platform, runner) {
2019
+ let iface = null;
2020
+ if (platform === "linux") iface = routeIfaceLinux(await runner("ip", ["-o", "route", "get", address]));
2021
+ else if (platform === "darwin") iface = routeIfaceDarwin(await runner("route", ["-n", "get", address]));
2022
+ const kind = classifyRoute(iface);
2023
+ if (!kind || kind.kind === "direct") return kind;
2024
+ // Tailscale on a Mac is a utun like every other VPN there, so the question is
2025
+ // asked of any tunnel rather than only of one named tailscale0. A machine
2026
+ // without the CLI answers null and keeps the generic label.
2027
+ const exit = tailscaleExitFromStatus(await runner("tailscale", ["status", "--json"], 3_000));
2028
+ if (exit) return { kind: "tailscale-exit", iface, ...exit };
2029
+ return kind;
2030
+ }
2031
+
2032
+ /**
2033
+ * One probe: how far the API is and which way the traffic to it goes.
2034
+ *
2035
+ * Guarded twice, because a GET can start it (see systemSnapshot) and a read's
2036
+ * cost must have a ceiling (#544): never two at once, and never more often than
2037
+ * half the probe interval however many tabs poll. `deps` is the seam the tests
2038
+ * use — nothing else here can be made to answer "unreachable" on demand.
2039
+ */
2040
+ export async function probeNetwork(deps = {}) {
2041
+ const now = deps.now ?? Date.now;
2042
+ if (!probeEnabled && !deps.force) return;
2043
+ if (probeInFlight || now() - probeAt < NET_PROBE_MS / 2) return;
2044
+ if (!deps.force && now() - askedAt > NET_ASKED_MS) return;
2045
+ probeInFlight = true;
2046
+ probeAt = now();
2047
+ try {
2048
+ const platform = deps.platform ?? process.platform;
2049
+ const runner = deps.run ?? run;
2050
+ const measureApi = (deps.hasClaude ?? hasClaudeInstalled)();
2051
+ let address = null;
2052
+ if (measureApi) {
2053
+ try { address = (await (deps.lookup ?? lookup)(API_HOST)).address; } catch { address = null; }
2054
+ apiMs = address ? await (deps.connect ?? connectMs)(address) : null;
2055
+ if (apiMs != null) record("net:api", apiMs);
2056
+ }
2057
+ const next = await readRoute(address ?? ROUTE_FALLBACK, platform, runner);
2058
+ if (next) {
2059
+ const label = routeLabel(next);
2060
+ const prev = routeLog[routeLog.length - 1]?.label ?? null;
2061
+ if (prev !== label) {
2062
+ // `from` null is the first route this process saw — the state when it
2063
+ // started looking, not a change, and the chart draws no mark for it.
2064
+ routeLog.push({ t: now(), label, from: prev });
2065
+ while (routeLog.length > ROUTE_LOG) routeLog.shift();
2066
+ }
2067
+ }
2068
+ route = next;
2069
+ } finally { probeInFlight = false; }
2070
+ }
2071
+
2072
+ /** What the panel shows for the network, or null before anything is known. */
2073
+ function networkSnapshot(nowMs = Date.now()) {
2074
+ const fresh = nowMs - probeAt <= NET_STALE_MS;
2075
+ const api = fresh && apiMs !== undefined ? { host: API_HOST, ms: apiMs } : null;
2076
+ // `to` says what the route was asked about: the API itself when Claude Code
2077
+ // is here, the open internet otherwise — a split tunnel can send one and not
2078
+ // the other, and the panel should not claim more than was measured.
2079
+ const via = fresh && route && route.kind !== "direct"
2080
+ ? { ...route, label: routeLabel(route), to: apiMs !== undefined ? "claude" : "internet" }
2081
+ : null;
2082
+ if (!netRate && !api && !via) return null;
2083
+ return { down: netRate?.down ?? null, up: netRate?.up ?? null, api, route: via };
2084
+ }
2085
+
1857
2086
  /**
1858
- * Begin sampling. Idempotent, and both timers are unref'd so this can never be
2087
+ * Begin sampling. Idempotent, and every timer is unref'd so this can never be
1859
2088
  * the reason the process stays alive.
2089
+ *
2090
+ * `probe` is the network section's permission to leave the machine — latency to
2091
+ * the API and the route there. The server passes it; nothing else should.
1860
2092
  */
1861
- export function startSystemMetrics() {
2093
+ export function startSystemMetrics({ probe = false } = {}) {
1862
2094
  if (cpuTimer) return;
2095
+ probeEnabled = probe;
1863
2096
  prevTicks = readTicks(); // baseline, so the first tick has a delta
1864
2097
  prevCoreTicks = readCoreTicks();
1865
2098
  sampleMemory();
1866
2099
  historySince = Date.now();
1867
2100
  sampleThermal();
2101
+ sampleNetwork();
1868
2102
  cpuTimer = setInterval(sampleCpu, CPU_INTERVAL_MS);
1869
2103
  memTimer = setInterval(sampleMemory, MEM_INTERVAL_MS);
1870
2104
  thermalTimer = setInterval(sampleThermal, THERMAL_INTERVAL_MS);
2105
+ netTimer = setInterval(sampleNetwork, NET_INTERVAL_MS);
2106
+ if (probeEnabled) probeTimer = setInterval(() => { probeNetwork(); }, NET_PROBE_MS);
1871
2107
  cpuTimer.unref?.();
1872
2108
  memTimer.unref?.();
1873
2109
  thermalTimer.unref?.();
2110
+ netTimer.unref?.();
2111
+ probeTimer?.unref?.();
1874
2112
  }
1875
2113
 
1876
2114
  /**
@@ -1894,7 +2132,19 @@ export function stopSystemMetrics() {
1894
2132
  if (cpuTimer) clearInterval(cpuTimer);
1895
2133
  if (memTimer) clearInterval(memTimer);
1896
2134
  if (thermalTimer) clearInterval(thermalTimer);
1897
- cpuTimer = memTimer = thermalTimer = null;
2135
+ if (netTimer) clearInterval(netTimer);
2136
+ if (probeTimer) clearInterval(probeTimer);
2137
+ cpuTimer = memTimer = thermalTimer = netTimer = probeTimer = null;
2138
+ probeEnabled = false;
2139
+ prevNet = null;
2140
+ netRate = null;
2141
+ physical = null;
2142
+ physicalAt = 0;
2143
+ probeAt = 0;
2144
+ askedAt = 0;
2145
+ apiMs = undefined;
2146
+ route = null;
2147
+ routeLog.length = 0;
1898
2148
  thermal = null;
1899
2149
  thermalMisses = 0;
1900
2150
  thermalEverAnswered = false;
@@ -1924,6 +2174,13 @@ export function stopSystemMetrics() {
1924
2174
  */
1925
2175
  export function systemSnapshot() {
1926
2176
  const cpu = cpuHistory.length ? cpuHistory[cpuHistory.length - 1] : null;
2177
+ // The panel is the only poller, so a poll is the signal somebody is looking.
2178
+ // The first one after the panel was closed asks at once rather than waiting
2179
+ // out the probe timer, so the latency is on screen by the second poll; the
2180
+ // guards inside probeNetwork are what keep a busy poller from paying twice.
2181
+ const idle = Date.now() - askedAt > NET_ASKED_MS;
2182
+ askedAt = Date.now();
2183
+ if (idle && probeEnabled) void probeNetwork();
1927
2184
  const load = os.loadavg();
1928
2185
  // Platform alone, for the reason spelled out beside the `load:1m` record in
1929
2186
  // sampleCpu: Node's own contract makes the platform test sufficient, and the
@@ -1945,6 +2202,7 @@ export function systemSnapshot() {
1945
2202
  uptimeSec: Math.round(os.uptime()),
1946
2203
  platform: process.platform,
1947
2204
  loadavg: hasLoad ? load.map(n => Math.round(n * 100) / 100) : null,
2205
+ network: networkSnapshot(),
1948
2206
  intervalMs: CPU_INTERVAL_MS,
1949
2207
  sampledAt: Date.now(),
1950
2208
  };