ccdeck 3.24.1 → 3.26.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,66 @@
1
+ // Which program holds a UDP port, in the words the LAN panel prints.
2
+ //
3
+ // A deck that cannot bind the discovery port says so, and "another program"
4
+ // is a sentence nobody can act on. On the Mac where this was measured the
5
+ // program was Tailscale, and finding that out took netstat and an hour. The
6
+ // operating system will usually say, without elevation, and when it will not
7
+ // the sentence stays the one it was.
8
+ //
9
+ // Pure readers per platform, and one runner that picks the command; nothing
10
+ // here is ever a reason for the deck to fail.
11
+ import { run as runCommand } from "./exec.mjs";
12
+
13
+ /**
14
+ * macOS: `netstat -anv -p udp` lists every UDP socket with its owner as
15
+ * `name:pid` in the process column — readable by a plain user for sockets of
16
+ * any user, which is the reason this is the Mac's answer.
17
+ */
18
+ export function holderFromNetstat(text, port) {
19
+ const at = new RegExp(`[.:]${port}\\s`);
20
+ for (const line of String(text ?? "").split("\n")) {
21
+ if (!/^udp/.test(line) || !at.test(line.split(/\s+/).slice(0, 5).join(" ") + " ")) continue;
22
+ const owner = line.split(/\s+/).find(f => /^[A-Za-z][\w.-]*:\d+$/.test(f));
23
+ if (owner) return owner.replace(/:\d+$/, "");
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /** Linux: `ss -H -uanp 'sport = :PORT'` — `users:(("name",pid=…))`, shown
29
+ * only for this user's own processes; somebody else's is an unnamed row. */
30
+ export function holderFromSs(text) {
31
+ const m = /users:\(\("([^"]+)"/.exec(String(text ?? ""));
32
+ return m ? m[1] : null;
33
+ }
34
+
35
+ /** Windows: the owning process's name, one line, from the PowerShell below. */
36
+ export function holderFromPowershell(text) {
37
+ const name = String(text ?? "").split(/\r?\n/).map(l => l.trim()).find(Boolean);
38
+ return name && /^[\w .()-]{1,64}$/.test(name) ? name : null;
39
+ }
40
+
41
+ /** The name a person knows the program by. Tailscale is the one measured,
42
+ * and it goes by a different process name on each platform. */
43
+ export function friendlyHolder(name) {
44
+ if (!name) return null;
45
+ if (/tailscale/i.test(name)) return "Tailscale";
46
+ return name;
47
+ }
48
+
49
+ /** Who holds UDP `port` here, or null when the machine will not say. */
50
+ export async function portHolder(port, { platform = process.platform, run = runCommand } = {}) {
51
+ try {
52
+ if (platform === "darwin") {
53
+ const r = await run("netstat", ["-anv", "-p", "udp"], { timeout: 5_000 });
54
+ return friendlyHolder(r?.ok ? holderFromNetstat(r.stdout, port) : null);
55
+ }
56
+ if (platform === "win32") {
57
+ const ps = `Get-NetUDPEndpoint -LocalPort ${Number(port)} -ErrorAction SilentlyContinue | Select-Object -First 1 | ForEach-Object { (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }`;
58
+ const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 15_000 });
59
+ return friendlyHolder(r?.ok ? holderFromPowershell(r.stdout) : null);
60
+ }
61
+ const r = await run("ss", ["-H", "-uanp", `sport = :${Number(port)}`], { timeout: 5_000 });
62
+ return friendlyHolder(r?.ok ? holderFromSs(r.stdout) : null);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
@@ -0,0 +1,109 @@
1
+ // Which interface the machine would send a packet to an address by.
2
+ //
3
+ // A BEACON FOR THE LOCAL NETWORK MUST STAY ON IT. A laptop using a Tailscale
4
+ // exit node, or any VPN that takes the local network too, routes even a
5
+ // broadcast for its own Wi-Fi into the tunnel — and on the far end it comes out
6
+ // on somebody else's network: an office Mac that is the exit node for a home
7
+ // laptop put the laptop's beacons on the office LAN, where a colleague whose
8
+ // network uses the same addresses could see the name of a machine that is
9
+ // nothing to do with them. So before a beacon goes, the machine is asked where
10
+ // it would go, and a beacon that would leave through a tunnel does not leave.
11
+ //
12
+ // Asked of the operating system rather than reasoned from interface names,
13
+ // because only the routing table knows what a VPN did to it: `route get` on
14
+ // macOS, `ip route get` on Linux, Find-NetRoute on Windows. The answers are
15
+ // cached, since routes change when networks do and not every thirty seconds,
16
+ // and a lookup that fails or cannot be made answers nothing — the beacon then
17
+ // goes as it always did.
18
+ import { run as runCommand } from "./exec.mjs";
19
+
20
+ /** How long one answer is kept. Long enough that a Windows machine starts one
21
+ * PowerShell every couple of minutes rather than every beacon; short enough
22
+ * that turning an exit node on stops the beacons into it soon after. */
23
+ export const ROUTE_TTL_MS = 120_000;
24
+
25
+ /** `route -n get <ip>` on macOS: the `interface:` line. */
26
+ export function viaFromRouteGet(text) {
27
+ const m = /^\s*interface:\s*(\S+)/m.exec(String(text ?? ""));
28
+ return m ? m[1] : null;
29
+ }
30
+
31
+ /** `ip -o route get <ip>` on Linux: the device after `dev`. */
32
+ export function viaFromIpRoute(text) {
33
+ const m = /\bdev\s+(\S+)/.exec(String(text ?? ""));
34
+ return m ? m[1] : null;
35
+ }
36
+
37
+ /** The PowerShell below prints `address|alias` per line on Windows. */
38
+ export function viaFromFindNetRoute(text) {
39
+ const out = new Map();
40
+ for (const line of String(text ?? "").split(/\r?\n/)) {
41
+ const at = line.indexOf("|");
42
+ if (at <= 0) continue;
43
+ const alias = line.slice(at + 1).trim();
44
+ if (alias) out.set(line.slice(0, at).trim(), alias);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Whether an interface is a tunnel rather than a network a person is on.
51
+ *
52
+ * Used only for the limited broadcast, which belongs to no one interface: a
53
+ * directed broadcast is judged by whether it leaves by the interface whose
54
+ * subnet it is, which needs no names at all. These are the names tunnels go
55
+ * by on the three platforms — macOS numbers every VPN `utun`, and a Windows
56
+ * adapter is called whatever its VPN named it.
57
+ */
58
+ export function looksLikeTunnel(name) {
59
+ if (typeof name !== "string" || !name) return false;
60
+ return /^(utun|tun|tap|wg|ppp|ipsec|gpd|zt)\d*$/i.test(name)
61
+ || /tailscale|wireguard|nordlynx|openvpn|vpn/i.test(name);
62
+ }
63
+
64
+ const QUAD = /^\d{1,3}(\.\d{1,3}){3}$/;
65
+
66
+ /** Ask the machine, for every address in `targets`, which interface it would
67
+ * use. A Map of address to interface name; missing where it would not say. */
68
+ export async function lookupRoutes(targets, { platform = process.platform, run = runCommand } = {}) {
69
+ const list = targets.filter(t => typeof t === "string" && QUAD.test(t));
70
+ const out = new Map();
71
+ if (!list.length) return out;
72
+ if (platform === "win32") {
73
+ const quoted = list.map(t => `'${t}'`).join(",");
74
+ const ps = `foreach($t in @(${quoted})){ $r = Find-NetRoute -RemoteIPAddress $t -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceAlias } | Select-Object -First 1; $t + '|' + $r.InterfaceAlias }`;
75
+ const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 15_000 });
76
+ return r?.ok ? viaFromFindNetRoute(r.stdout) : out;
77
+ }
78
+ for (const t of list) {
79
+ const r = platform === "darwin"
80
+ ? await run("route", ["-n", "get", t], { timeout: 3_000 })
81
+ : await run("ip", ["-o", "route", "get", t], { timeout: 3_000 });
82
+ const via = r?.ok ? (platform === "darwin" ? viaFromRouteGet(r.stdout) : viaFromIpRoute(r.stdout)) : null;
83
+ if (via) out.set(t, via);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * The cache the beacon reads synchronously: `via(address)` is the last answer
90
+ * or undefined, and `want(addresses)` starts one lookup for whichever of them
91
+ * are missing or stale. Never more than one lookup at a time.
92
+ */
93
+ export function createRouteCheck({ platform = process.platform, run = runCommand, now = Date.now, ttlMs = ROUTE_TTL_MS } = {}) {
94
+ const cache = new Map();
95
+ let busy = null;
96
+ return {
97
+ via: to => cache.get(to)?.via,
98
+ want(targets) {
99
+ if (busy) return busy;
100
+ const stale = (targets ?? []).filter(t => { const c = cache.get(t); return !c || now() - c.at > ttlMs; });
101
+ if (!stale.length) return Promise.resolve();
102
+ busy = lookupRoutes(stale, { platform, run })
103
+ .then(found => { for (const t of stale) cache.set(t, { via: found.get(t) ?? null, at: now() }); })
104
+ .catch(() => { /* nothing learned; the beacon goes as it always did */ })
105
+ .finally(() => { busy = null; });
106
+ return busy;
107
+ },
108
+ };
109
+ }
@@ -22,6 +22,7 @@
22
22
  // it by name and only where it can actually work: a global install, on a
23
23
  // directory we can write, outside a git checkout and outside an npx cache.
24
24
  // Everywhere else this stays what it has always been — a printed command.
25
+ import { inApp } from "./app-host.mjs";
25
26
  import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
26
27
  import { spawn } from "node:child_process";
27
28
  import { homedir } from "node:os";
@@ -1085,7 +1086,9 @@ export function upgradeBlock(pkgRoot) {
1085
1086
  // npm -g rewrites the package directory and its parent (the global
1086
1087
  // node_modules), so both have to be ours to write.
1087
1088
  writable: dirWritable(target) && dirWritable(resolve(target, "..")),
1088
- optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1",
1089
+ // Inside the desktop app the app updates itself; an `npm i -g` here would
1090
+ // install a copy it never runs (app-host.mjs).
1091
+ optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1" || inApp(),
1089
1092
  // Whether the package that write would fetch is still a deck. Kept out of
1090
1093
  // the pure rule above, like every other input here, so the policy stays one
1091
1094
  // readable expression and this file owns the filesystem half of it.
@@ -1461,7 +1464,8 @@ export async function versionReport({ running, pkgRoot, name = PUBLISHED_NAME, n
1461
1464
  // isOlder decides that, not this.
1462
1465
  const skipRegistry =
1463
1466
  process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
1464
- process.env.AGENTS_DECK_NO_INSTALL === "1";
1467
+ process.env.AGENTS_DECK_NO_INSTALL === "1" ||
1468
+ inApp();
1465
1469
  const latest = skipRegistry ? null : await latestOnNpm(asked, now, force);
1466
1470
  const marker = skipRegistry ? null : readMarker(asked);
1467
1471
  const blocked = upgradeBlock(pkgRoot);
@@ -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
  };