ccdeck 3.29.11 → 3.30.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.
- package/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- 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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
+
}
|