ccdeck 3.29.11 → 3.31.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.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,134 @@
1
+ // The canvas's music: whether Claude FM and the other stations are live, and
2
+ // which channel and video a custom station's link resolves to.
3
+ //
4
+ // These lived in src/server/index.mjs in two places — the four radio stations
5
+ // near the top, beside the ring's accounting, and Claude FM, custom stations
6
+ // and Lofi Girl among the usage routes — and they are one feature behind one
7
+ // switch. Every handler answers AGENTS_DECK_NO_MUSIC=1 before it imports
8
+ // anything, which is how a deck told so never contacts YouTube, and every
9
+ // station module is imported lazily by the package-root URL PINNED_MODULES
10
+ // loads, so nothing leaves the machine until a page asks.
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ import { send } from "./http-io.mjs";
14
+
15
+ // Resolved the way pinned-build.mjs resolves it, from a file in the same
16
+ // directory, so every lazy import below is the URL the pin has already evaluated.
17
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
18
+
19
+ /**
20
+ * Whether Claude FM is broadcasting — the one question the canvas's music
21
+ * control needs answered before it draws itself.
22
+ *
23
+ * NO REQUEST LEAVES THIS MACHINE UNTIL A PAGE ASKS FOR IT. There is no boot
24
+ * probe and no timer behind this route: a deck nobody has opened calls
25
+ * youtube.com zero times, and the first canvas to mount answers every other one
26
+ * for the next ten minutes out of claude-fm.mjs's cache.
27
+ *
28
+ * Two environment variables, in the shape the notification and LAN switches
29
+ * already use (deck-prefs.mjs names both):
30
+ *
31
+ * AGENTS_DECK_NO_MUSIC=1 this deck never contacts YouTube at all. The
32
+ * answer is a plain no and the canvas draws
33
+ * nothing, which is the same thing it does when
34
+ * the channel is off air — so the off switch
35
+ * needs no second code path to test.
36
+ * AGENTS_DECK_FM_CHANNEL=UC... play a different channel's live stream. The
37
+ * built-in one is a channel id rather than a
38
+ * video id precisely so it does not go stale,
39
+ * but a channel can be renamed, retired or
40
+ * handed over, and a deck that can be pointed
41
+ * elsewhere in one line does not need a release
42
+ * to keep working. Anything that is not a
43
+ * well-formed channel id is ignored rather than
44
+ * fetched.
45
+ */
46
+ export async function handleClaudeFm(req, res) {
47
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
48
+ return send(res, 200, { ok: true, live: false, off: true });
49
+ }
50
+ const { fetchClaudeFm } = await import(
51
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-fm.mjs")).href
52
+ );
53
+ const url = new URL(req.url, "http://localhost");
54
+ const answer = await fetchClaudeFm({
55
+ force: url.searchParams.get("refresh") === "1",
56
+ channel: process.env.AGENTS_DECK_FM_CHANNEL,
57
+ });
58
+ send(res, 200, answer);
59
+ }
60
+
61
+ /**
62
+ * A custom station's YouTube link, resolved to the channel and video the
63
+ * embed plays (#1208). fm-station.mjs rebuilds the request from the parsed
64
+ * link rather than fetching it as given, and caches the answer.
65
+ *
66
+ * AGENTS_DECK_NO_MUSIC is the promise that this deck never contacts YouTube,
67
+ * and a station somebody added is not an exception to it: the answer is the
68
+ * same plain no /api/claude-fm gives, and the canvas draws nothing for it.
69
+ */
70
+ export async function handleFmStation(req, res) {
71
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
72
+ return send(res, 200, { ok: false, off: true });
73
+ }
74
+ const url = new URL(req.url, "http://localhost");
75
+ const stationUrl = url.searchParams.get("url") ?? "";
76
+ const { parseYouTubeStationUrl, resolveYouTubeStation } = await import(
77
+ pathToFileURL(join(PKG_ROOT, "src/server/fm-station.mjs")).href
78
+ );
79
+ if (!parseYouTubeStationUrl(stationUrl)) {
80
+ return send(res, 400, { ok: false, error: "unsupported_url" });
81
+ }
82
+ const answer = await resolveYouTubeStation(stationUrl);
83
+ send(res, answer.ok ? 200 : 404, answer);
84
+ }
85
+
86
+ export async function handleLofiGirl(req, res) {
87
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
88
+ return send(res, 200, { ok: true, live: false, off: true });
89
+ }
90
+ const url = new URL(req.url, "http://localhost");
91
+ const station = url.searchParams.get("station");
92
+ if (!["relax", "game", "vibe", "sleep"].includes(station)) {
93
+ return send(res, 400, { ok: false, error: "unknown Lofi Girl station" });
94
+ }
95
+ const { fetchLofiStations } = await import(
96
+ pathToFileURL(join(PKG_ROOT, "src/server/lofi-girl.mjs")).href
97
+ );
98
+ const stations = await fetchLofiStations();
99
+ const answer = stations[station];
100
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "station is not live" });
101
+ }
102
+
103
+ export async function handleLiveRadioMix(req, res) {
104
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
105
+ return send(res, 200, { ok: true, live: false, off: true });
106
+ }
107
+
108
+ const { fetchLiveRadioMix } = await import(
109
+ pathToFileURL(join(PKG_ROOT, "src/server/live-radio-mix.mjs")).href
110
+ );
111
+ const answer = await fetchLiveRadioMix();
112
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Radio Mix is not live" });
113
+ }
114
+
115
+ export async function handleBestOfNostalgia(req, res) {
116
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
117
+ const { fetchBestOfNostalgia } = await import(pathToFileURL(join(PKG_ROOT, "src/server/best-of-nostalgia.mjs")).href);
118
+ const answer = await fetchBestOfNostalgia();
119
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Best of Nostalgia is not live" });
120
+ }
121
+
122
+ export async function handleGoodLifeRadio(req, res) {
123
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
124
+ const { fetchGoodLifeRadio } = await import(pathToFileURL(join(PKG_ROOT, "src/server/good-life-radio.mjs")).href);
125
+ const answer = await fetchGoodLifeRadio();
126
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "The Good Life Radio is not live" });
127
+ }
128
+
129
+ export async function handleCafeMusicBgm(req, res) {
130
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
131
+ const { fetchCafeMusicBgm } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cafe-music-bgm.mjs")).href);
132
+ const answer = await fetchCafeMusicBgm();
133
+ send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Cafe Music BGM is not live" });
134
+ }
@@ -1,9 +1,11 @@
1
1
  // The network section's readings, as pure functions of what the platform prints.
2
2
  //
3
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.
4
+ // a Mac and a Windows one from either — the same reason every parser the
5
+ // Machine panel reads with (process-list.mjs, thermal-metrics.mjs,
6
+ // memory-metrics.mjs) is exported and pure. The sampler that runs the commands
7
+ // and times the connection lives in network-sampler.mjs, and the history it
8
+ // records into in metrics-history.mjs; this file only reads.
7
9
  //
8
10
  // THREE READINGS, AND WHY THESE THREE (#network). The first build of this idea
9
11
  // was a speed test, and a speed test is the wrong instrument twice over: it
@@ -0,0 +1,256 @@
1
+ // The Machine panel's network section: the sampler that reads the counters,
2
+ // times the connection to Claude and notes which way the traffic to it goes.
3
+ //
4
+ // Moved out of system-metrics.mjs unchanged. How each platform's answer is
5
+ // parsed is network-metrics.mjs, which stays pure, and the minute ring these
6
+ // readings are recorded into is metrics-history.mjs. system-metrics.mjs starts
7
+ // and stops this section (startNetworkTimers, stopNetwork), tells it when the
8
+ // panel polls (notePanelPoll), puts networkSnapshot on /api/system and
9
+ // routeChangesSince on the network chart, and re-exports probeNetwork, which is
10
+ // where the suite reaches it.
11
+ import { lookup } from "node:dns/promises";
12
+ import { access, readdir, readFile } from "node:fs/promises";
13
+ import net from "node:net";
14
+ import { hasClaudeInstalled } from "./claude-dir.mjs";
15
+ import { record } from "./metrics-history.mjs";
16
+ import { run } from "./metrics-run.mjs";
17
+ import {
18
+ classifyRoute, parseNetstatE, parseNetstatIbn, parseProcNetDev, rateBetween,
19
+ routeIfaceDarwin, routeIfaceLinux, routeLabel, tailscaleExitFromStatus,
20
+ } from "./network-metrics.mjs";
21
+
22
+ // ── the network ────────────────────────────────────────────────────────────
23
+ //
24
+ // Three readings, taken at two very different costs (network-metrics.mjs says
25
+ // why these three). THROUGHPUT is two counters the kernel already keeps, so it
26
+ // rides its own five-second timer for the life of the process like CPU does,
27
+ // and its history is as complete as every other section's. LATENCY and ROUTE
28
+ // leave the machine or spawn a process, so they are taken only while somebody
29
+ // is looking: the panel is the only thing that polls /api/system, and a probe
30
+ // runs only within NET_ASKED_MS of that poll. A deck left open in a background
31
+ // tab all day makes no connection it was not asked for — the rule Claude FM's
32
+ // probe already follows ("makes no request until a page asks for one").
33
+
34
+ /** Counters on Linux are a file read; on macOS and Windows one short command.
35
+ * Five seconds is often enough to catch a burst and rare enough that the
36
+ * command costs nothing anybody could measure. */
37
+ const NET_INTERVAL_MS = 5_000;
38
+ /** How often the far end is asked, while the panel is open. */
39
+ const NET_PROBE_MS = 30_000;
40
+ /** How recently a poll must have come for the probe to run at all. */
41
+ const NET_ASKED_MS = 20_000;
42
+ /** A probe older than this is from before the panel was last closed, and is
43
+ * not reported as the present. */
44
+ const NET_STALE_MS = NET_PROBE_MS * 2 + 5_000;
45
+ /** What "how far is Claude" is measured against: the host every request Claude
46
+ * Code makes goes to, and the one quota.mjs already reads from. */
47
+ const API_HOST = "api.anthropic.com";
48
+ const API_PORT = 443;
49
+ const CONNECT_TIMEOUT_MS = 4_000;
50
+ /** Where the route is asked for when there is no API address to ask about. A
51
+ * routing-table lookup sends no packet, so this contacts nobody. */
52
+ const ROUTE_FALLBACK = "1.1.1.1";
53
+ /** Route changes kept for the history's markers. A path changes a handful of
54
+ * times a day; forty is several days of it. */
55
+ const ROUTE_LOG = 40;
56
+
57
+ let netTimer = null;
58
+ let netInFlight = false;
59
+ let prevNet = null;
60
+ let netRate = null;
61
+ let physical = null;
62
+ let physicalAt = 0;
63
+ let probeTimer = null;
64
+ /** Whether this process may reach out at all. Off unless the server says so:
65
+ * the suite starts this module in dozens of cases, and none of them should
66
+ * open a connection to Anthropic or spawn `ip` and `tailscale` to do it. */
67
+ let probeEnabled = false;
68
+ let probeInFlight = false;
69
+ let probeAt = 0;
70
+ let askedAt = 0;
71
+ /** Undefined until measured, null when the host could not be reached. */
72
+ let apiMs;
73
+ let route = null;
74
+ const routeLog = [];
75
+
76
+ /** Interfaces that are hardware, by the one test the kernel offers: a
77
+ * `device` link under /sys/class/net. Rechecked each minute, because a dock or
78
+ * a phone tethered over USB is a new wire. */
79
+ async function physicalInterfaces(root = "/sys/class/net") {
80
+ try {
81
+ const names = await readdir(root);
82
+ const real = await Promise.all(names.map(n => access(`${root}/${n}/device`).then(() => n, () => null)));
83
+ return new Set(real.filter(Boolean));
84
+ } catch { return null; }
85
+ }
86
+
87
+ async function readNetCounters(platform = process.platform) {
88
+ if (platform === "linux") {
89
+ if (!physical || Date.now() - physicalAt > 60_000) {
90
+ physical = await physicalInterfaces();
91
+ physicalAt = Date.now();
92
+ }
93
+ try {
94
+ const include = physical?.size ? name => physical.has(name) : name => name !== "lo";
95
+ return parseProcNetDev(await readFile("/proc/net/dev", "utf8"), include);
96
+ } catch { return null; }
97
+ }
98
+ if (platform === "darwin") return parseNetstatIbn(await run("netstat", ["-ibn"]));
99
+ if (platform === "win32") return parseNetstatE(await run("netstat", ["-e"]));
100
+ return null;
101
+ }
102
+
103
+ export async function sampleNetwork() {
104
+ if (netInFlight) return;
105
+ netInFlight = true;
106
+ try {
107
+ const counters = await readNetCounters();
108
+ if (!counters) return;
109
+ const next = { rx: counters.rx, tx: counters.tx, at: Date.now() };
110
+ const rate = rateBetween(prevNet, next);
111
+ prevNet = next;
112
+ if (!rate) return;
113
+ netRate = rate;
114
+ record("net:down", rate.down);
115
+ record("net:up", rate.up);
116
+ } finally { netInFlight = false; }
117
+ }
118
+
119
+ /** Milliseconds for a TCP handshake with `address`, or null. The connection
120
+ * is closed the moment it opens: nothing is sent, so this is a round trip and
121
+ * nothing else — no TLS, no request, nothing the far end has to answer. */
122
+ function connectMs(address, port = API_PORT, timeoutMs = CONNECT_TIMEOUT_MS) {
123
+ return new Promise(resolve => {
124
+ const t0 = performance.now();
125
+ let done = false;
126
+ const socket = net.connect({ host: address, port });
127
+ const finish = ms => {
128
+ if (done) return;
129
+ done = true;
130
+ socket.destroy();
131
+ resolve(ms);
132
+ };
133
+ socket.setTimeout(timeoutMs, () => finish(null));
134
+ socket.once("connect", () => finish(Math.round(performance.now() - t0)));
135
+ socket.once("error", () => finish(null));
136
+ });
137
+ }
138
+
139
+ /** Which way traffic to `address` leaves, and — for a Tailscale exit node —
140
+ * through which machine and whether through a relay. */
141
+ async function readRoute(address, platform, runner) {
142
+ let iface = null;
143
+ if (platform === "linux") iface = routeIfaceLinux(await runner("ip", ["-o", "route", "get", address]));
144
+ else if (platform === "darwin") iface = routeIfaceDarwin(await runner("route", ["-n", "get", address]));
145
+ const kind = classifyRoute(iface);
146
+ if (!kind || kind.kind === "direct") return kind;
147
+ // Tailscale on a Mac is a utun like every other VPN there, so the question is
148
+ // asked of any tunnel rather than only of one named tailscale0. A machine
149
+ // without the CLI answers null and keeps the generic label.
150
+ const exit = tailscaleExitFromStatus(await runner("tailscale", ["status", "--json"], 3_000));
151
+ if (exit) return { kind: "tailscale-exit", iface, ...exit };
152
+ return kind;
153
+ }
154
+
155
+ /**
156
+ * One probe: how far the API is and which way the traffic to it goes.
157
+ *
158
+ * Guarded twice, because a GET can start it (see notePanelPoll) and a read's
159
+ * cost must have a ceiling (#544): never two at once, and never more often than
160
+ * half the probe interval however many tabs poll. `deps` is the seam the tests
161
+ * use — nothing else here can be made to answer "unreachable" on demand.
162
+ */
163
+ export async function probeNetwork(deps = {}) {
164
+ const now = deps.now ?? Date.now;
165
+ if (!probeEnabled && !deps.force) return;
166
+ if (probeInFlight || now() - probeAt < NET_PROBE_MS / 2) return;
167
+ if (!deps.force && now() - askedAt > NET_ASKED_MS) return;
168
+ probeInFlight = true;
169
+ probeAt = now();
170
+ try {
171
+ const platform = deps.platform ?? process.platform;
172
+ const runner = deps.run ?? run;
173
+ const measureApi = (deps.hasClaude ?? hasClaudeInstalled)();
174
+ let address = null;
175
+ if (measureApi) {
176
+ try { address = (await (deps.lookup ?? lookup)(API_HOST)).address; } catch { address = null; }
177
+ apiMs = address ? await (deps.connect ?? connectMs)(address) : null;
178
+ if (apiMs != null) record("net:api", apiMs);
179
+ }
180
+ const next = await readRoute(address ?? ROUTE_FALLBACK, platform, runner);
181
+ if (next) {
182
+ const label = routeLabel(next);
183
+ const prev = routeLog[routeLog.length - 1]?.label ?? null;
184
+ if (prev !== label) {
185
+ // `from` null is the first route this process saw — the state when it
186
+ // started looking, not a change, and the chart draws no mark for it.
187
+ routeLog.push({ t: now(), label, from: prev });
188
+ while (routeLog.length > ROUTE_LOG) routeLog.shift();
189
+ }
190
+ }
191
+ route = next;
192
+ } finally { probeInFlight = false; }
193
+ }
194
+
195
+ /** What the panel shows for the network, or null before anything is known. */
196
+ export function networkSnapshot(nowMs = Date.now()) {
197
+ const fresh = nowMs - probeAt <= NET_STALE_MS;
198
+ const api = fresh && apiMs !== undefined ? { host: API_HOST, ms: apiMs } : null;
199
+ // `to` says what the route was asked about: the API itself when Claude Code
200
+ // is here, the open internet otherwise — a split tunnel can send one and not
201
+ // the other, and the panel should not claim more than was measured.
202
+ const via = fresh && route && route.kind !== "direct"
203
+ ? { ...route, label: routeLabel(route), to: apiMs !== undefined ? "claude" : "internet" }
204
+ : null;
205
+ if (!netRate && !api && !via) return null;
206
+ return { down: netRate?.down ?? null, up: netRate?.up ?? null, api, route: via };
207
+ }
208
+
209
+ /** The route changes the history still covers, as the chart marks them. */
210
+ export function routeChangesSince(since) {
211
+ return routeLog.filter(c => c.t >= since).map(c => ({ t: c.t, label: c.label, from: c.from }));
212
+ }
213
+
214
+ /**
215
+ * A poll of /api/system, which is how this section knows somebody is looking.
216
+ *
217
+ * The panel is the only poller, so a poll is the signal somebody is looking.
218
+ * The first one after the panel was closed asks at once rather than waiting
219
+ * out the probe timer, so the latency is on screen by the second poll; the
220
+ * guards inside probeNetwork are what keep a busy poller from paying twice.
221
+ */
222
+ export function notePanelPoll() {
223
+ const idle = Date.now() - askedAt > NET_ASKED_MS;
224
+ askedAt = Date.now();
225
+ if (idle && probeEnabled) void probeNetwork();
226
+ }
227
+
228
+ /** The section's two timers: the counters for the life of the process, and the
229
+ * probe only when `probe` — the server's permission to leave the machine —
230
+ * says it may. See startSystemMetrics in system-metrics.mjs. */
231
+ export function startNetworkTimers(probe) {
232
+ probeEnabled = probe;
233
+ netTimer = setInterval(sampleNetwork, NET_INTERVAL_MS);
234
+ if (probeEnabled) probeTimer = setInterval(() => { probeNetwork(); }, NET_PROBE_MS);
235
+ netTimer.unref?.();
236
+ probeTimer?.unref?.();
237
+ }
238
+
239
+ /** Stop both timers and forget every reading this section holds — its half of
240
+ * stopSystemMetrics in system-metrics.mjs, and there for the same reason
241
+ * (#798). */
242
+ export function stopNetwork() {
243
+ if (netTimer) clearInterval(netTimer);
244
+ if (probeTimer) clearInterval(probeTimer);
245
+ netTimer = probeTimer = null;
246
+ probeEnabled = false;
247
+ prevNet = null;
248
+ netRate = null;
249
+ physical = null;
250
+ physicalAt = 0;
251
+ probeAt = 0;
252
+ askedAt = 0;
253
+ apiMs = undefined;
254
+ route = null;
255
+ routeLog.length = 0;
256
+ }