ccdeck 3.39.1 → 3.39.4

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.
@@ -66,8 +66,8 @@
66
66
  document.documentElement.setAttribute("data-color-scheme", lightThemes.indexOf(theme) >= 0 ? "light" : "dark");
67
67
  })();
68
68
  </script>
69
- <script type="module" crossorigin src="/assets/index-DGMP4S3t.js"></script>
70
- <link rel="stylesheet" crossorigin href="/assets/index-WHHCRAB3.css">
69
+ <script type="module" crossorigin src="/assets/index-CxIdVCPz.js"></script>
70
+ <link rel="stylesheet" crossorigin href="/assets/index-TDFtmv7q.css">
71
71
  </head>
72
72
  <body>
73
73
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.39.1",
3
+ "version": "3.39.4",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -73,7 +73,18 @@
73
73
  "3.38.0",
74
74
  "3.38.1",
75
75
  "3.38.2",
76
- "3.38.6"
76
+ "3.38.6",
77
+ "3.39.4"
78
+ ],
79
+ "3.39.3": [
80
+ {
81
+ "title": "A cleaner desktop window",
82
+ "body": "The desktop app now has one compact toolbar that follows your theme, with native window controls and an application menu. Wider side rails give icons more room; the Local network footer and unread badges are more compact."
83
+ },
84
+ {
85
+ "title": "Clearer account usage and Telemetry Radar",
86
+ "body": "Account usage shows reset times together with capacity. Telemetry Radar has a clearer monitoring workspace and keeps history for up to 24 hours. Radar remains Beta."
87
+ }
77
88
  ],
78
89
  "3.39.1": [
79
90
  {
@@ -116,7 +127,7 @@
116
127
  "3.38.4": [
117
128
  {
118
129
  "title": "🔔 A notification when Auto-switch changes your account",
119
- "body": "When Auto-switch moves Claude Code to another account, ccdeck now tells you once, with where it went and why, for example \"Switched to work \u00b7 personal@example.com reached 90%\". You can turn it off in the sound settings, where two more wait, off until you turn them on: one when a quota window of the Claude or Codex account you are on reaches 90% and again at 100%, and one when a window that had reached 90% is available again. Each is said once per window and a restart does not repeat it. With either quota notification on, the deck reads quota about every two minutes even while no page is open."
130
+ "body": "When Auto-switch moves Claude Code to another account, ccdeck now tells you once, with where it went and why, for example \"Switched to work · personal@example.com reached 90%\". You can turn it off in the sound settings, where two more wait, off until you turn them on: one when a quota window of the Claude or Codex account you are on reaches 90% and again at 100%, and one when a window that had reached 90% is available again. Each is said once per window and a restart does not repeat it. With either quota notification on, the deck reads quota about every two minutes even while no page is open."
120
131
  }
121
132
  ],
122
133
  "3.38.3": [
@@ -22,7 +22,7 @@ function cleanDesktopUpdate(value) {
22
22
  ? value.version.trim().slice(0, 80)
23
23
  : null;
24
24
  if (value.status === "ready" && !version) return null;
25
- return { status: value.status, version };
25
+ return { status: value.status, version, ...(value.canCheck === true ? { canCheck: true } : {}) };
26
26
  }
27
27
 
28
28
  function broadcastDesktopUpdate() {
@@ -70,6 +70,19 @@ async function handleDesktopUpdateRequest(req, res, event) {
70
70
  send(res, 202, { ok: true });
71
71
  }
72
72
 
73
+ // Checking is routed to the same native updater as the tray menu. The existing
74
+ // mutation gate protects the request; only authenticated tray subscribers receive it.
75
+ let lastCheckAt = 0;
76
+ export function handleDesktopUpdateCheck(_req, res) {
77
+ if (trayClients.size === 0) return send(res, 409, { ok: false, reason: 'app_disconnected' });
78
+ if (!desktopUpdateState.canCheck) return send(res, 409, { ok: false, reason: 'app_update_required' });
79
+ if (Date.now() - lastCheckAt >= 1000) {
80
+ lastCheckAt = Date.now();
81
+ for (const client of trayClients) writeSse(client, 'event: desktop-update-check\ndata: {}\n\n');
82
+ }
83
+ send(res, 202, { ok: true });
84
+ }
85
+
73
86
  // The four routes index.mjs dispatches to these. Listed rather than marked at
74
87
  // each declaration, so the declarations read as they did where they came from.
75
88
  export { handleDesktopUpdateRead, handleDesktopUpdateReport, handleDesktopUpdateRequest };
@@ -20,6 +20,7 @@ export { challengeDeck, challengeProof, isProcessAlive };
20
20
  // The gates in front of the route table — see src/server/request-gates.mjs,
21
21
  // which also holds the per-process token the strictest of them checks.
22
22
  import { GUARDED_READS, OPEN_MUTATIONS, isAuthorizedDataRead, isAuthorizedMutation, isTrustedMutation, isTrustedRead } from "./request-gates.mjs";
23
+ import { trafficCapture } from "./traffic-radar-capture.mjs";
23
24
  import { trafficRadar } from "./traffic-radar.mjs";
24
25
  import { handleTrafficCapture, isAuthorizedTrafficIngest } from "./traffic-radar-routes.mjs";
25
26
  // How much the event ring may hold and what one event is charged against it —
@@ -71,7 +72,7 @@ import { handleFeature, noteRouteFeature } from "./feature-use.mjs";
71
72
  export { HARD_TRACKED_SESSIONS } from "./session-tracking.mjs";
72
73
  // The desktop app's update as its window sees it — the state the app reports
73
74
  // and the window's answers relayed back to it. See desktop-update-routes.mjs.
74
- import { handleDesktopUpdateRead, handleDesktopUpdateReport, handleDesktopUpdateRequest } from "./desktop-update-routes.mjs";
75
+ import { handleDesktopUpdateCheck, handleDesktopUpdateRead, handleDesktopUpdateReport, handleDesktopUpdateRequest } from "./desktop-update-routes.mjs";
75
76
  // The one spelling of `--workspace`, and of a rollout's cwd — see
76
77
  // canonical-path.mjs. Both were exported from this file before they moved,
77
78
  // and still are.
@@ -308,6 +309,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
308
309
  if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
309
310
  if (req.method === "GET" && url.pathname === "/api/desktop-update") return handleDesktopUpdateRead(req, res);
310
311
  if (req.method === "POST" && url.pathname === "/api/desktop-update") return guard(handleDesktopUpdateReport(req, res), res);
312
+ if (req.method === "POST" && url.pathname === "/api/desktop-update/check") return guard(handleDesktopUpdateCheck(req, res), res);
311
313
  if (req.method === "POST" && url.pathname === "/api/desktop-update/restart") return guard(handleDesktopUpdateRequest(req, res, "desktop-update-restart"), res);
312
314
  if (req.method === "POST" && url.pathname === "/api/desktop-update/seen") return guard(handleDesktopUpdateRequest(req, res, "desktop-update-seen"), res);
313
315
  if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
@@ -497,6 +499,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
497
499
  // and that will differ on the next boot. Every test in this suite boots
498
500
  // that way. Comparing the candidate would hand those decks a manifest.
499
501
  boundPort = server.address()?.port ?? candidate;
502
+ void trafficCapture.resume(boundPort).catch(() => {});
503
+ server.once("close", () => trafficCapture.dispose());
500
504
  // Codex has no working hooks on Windows — tail its rollout files instead.
501
505
  if (codex) startCodexWatcher(workspace);
502
506
  // What a session is producing between its tool calls. Claude only — it
@@ -243,7 +243,11 @@ export function createBeacon({
243
243
  // broadcast is filtered, and it is what Syncthing sends — and every
244
244
  // interface's own directed broadcast goes out beside it. A duplicate packet
245
245
  // costs one datagram; a missing one costs the whole feature.
246
- const plan = broadcastPlan(ifaces());
246
+ // Adapter enumeration may fail transiently on Windows. The limited
247
+ // broadcast still works without an interface map; retry next announce.
248
+ let faces;
249
+ try { faces = ifaces(); } catch { faces = {}; }
250
+ const plan = broadcastPlan(faces);
247
251
  // Asked behind the send, and read from what was last answered: the first
248
252
  // beacon after a start goes as it always did, and the ones after it know.
249
253
  void routes?.want?.(plan.map(p => p.to));
@@ -119,7 +119,12 @@ export function defaultName() {
119
119
  * `internal` is node's word for loopback, and a link-local 169.254 address is a
120
120
  * machine whose DHCP failed — reachable by nobody worth telling about.
121
121
  */
122
- export function localAddresses(faces = networkInterfaces()) {
122
+ export function localAddresses(faces) {
123
+ if (faces === undefined) {
124
+ // Windows can temporarily fail to enumerate adapters. Keep status and
125
+ // invite reads usable, and ask the OS again on the next read.
126
+ try { faces = networkInterfaces(); } catch { return []; }
127
+ }
123
128
  const out = [];
124
129
  for (const list of Object.values(faces ?? {})) {
125
130
  for (const n of list ?? []) {
@@ -1,24 +1,21 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
- import { execFile } from "node:child_process";
3
2
  import { isIP } from "node:net";
4
3
  import { fileURLToPath } from "node:url";
4
+ import { captureInterface, captureInterfaceValid, captureTool, CaptureSetupError } from './traffic-radar-platform.mjs';
5
+ import { monitorDestination, capturePreferences } from "./traffic-radar-monitor.mjs";
5
6
  import { createWireCapture } from "./traffic-radar-wire.mjs";
6
7
 
7
- const WINDOW = 300_000;
8
- const DURATION = 600_000;
9
- const MAX_CHARS = 4_000_000;
8
+ const WINDOW = 24 * 60 * 60 * 1000;
9
+ const MAX_EXPORTS = 2000;
10
+
11
+ const MAX_CHARS = 32 * 1024 * 1024;
10
12
  const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
13
+ const psQuote = value => `'${String(value).replaceAll("'", "''")}'`;
11
14
  export function captureDestination(value) {
12
15
  const match = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/.exec(String(value));
13
16
  if (!match || isIP(match[1]) !== 4 || +match[2] < 1 || +match[2] > 65535) throw new Error('Choose an IPv4 address and TCP port.');
14
17
  return { host: match[1], port: +match[2] };
15
18
  }
16
- async function networkInterface(host) {
17
- return new Promise(resolve => execFile('/sbin/route', ['-n', 'get', host], { timeout: 2000, maxBuffer: 16_384 }, (error, stdout) => {
18
- const name = !error && /interface:\s+([a-zA-Z0-9_.:-]{1,32})/.exec(stdout)?.[1];
19
- resolve(name || null);
20
- }));
21
- }
22
19
  export function exportSummary(payload, signal) {
23
20
  const group = signal === 'traces' ? 'Spans' : signal === 'logs' ? 'Logs' : 'Metrics';
24
21
  const item = signal === 'traces' ? 'spans' : signal === 'logs' ? 'logRecords' : 'metrics';
@@ -45,60 +42,93 @@ export function exportSummary(payload, signal) {
45
42
  }
46
43
  return { count: records.length, name: String(records[0]?.eventName || records[0]?.name || signal).slice(0, 200), content: [...fields], sessionIds: [...sessionIds] };
47
44
  }
48
- export function createTrafficCapture({ now = Date.now, platform = process.platform, findInterface = networkInterface, wire = createWireCapture } = {}) {
49
- let session = null, parsers = [], timer = null, nextId = 1, chars = 0;
45
+ export function createTrafficCapture({ now = Date.now, platform = process.platform, findInterface = host => captureInterface(host, platform), findTool = () => captureTool(platform), wire = createWireCapture, execPath = process.execPath, electron = !!process.versions.electron, managed = false, preferences = null, monitor = monitorDestination } = {}) {
46
+ let session = null, parsers = [], timer = null, nextId = 1, chars = 0, evictedExports = 0;
50
47
  const events = new Map();
51
48
  let observations = [];
49
+ let monitors = [], localPort = null, generation = 0;
50
+ const persist = enabled => preferences && localPort ? preferences.write(localPort, { enabled, destinations: session?.sources.map(s => s.destination) ?? [] }) : Promise.resolve();
52
51
  function prune() {
53
52
  observations = observations.filter(entry => now() - entry.observedAt <= WINDOW);
54
53
  for (const [id, event] of events) if (now() - event.observedAt > WINDOW) { events.delete(id); chars -= event.chars; }
55
- if (session?.token && now() >= session.expiresAt) stop('expired');
56
- else if (session?.token && session.sources.some(s => s.lastInputAt !== null) &&
57
- session.sources.filter(s => s.lastInputAt !== null).every(s => now() - s.lastInputAt > 5000)) stop('interrupted');
54
+ if (session?.token && session.sources.some(s => s.lastInputAt !== null) &&
55
+ session.sources.filter(s => s.lastInputAt !== null).every(s => now() - s.lastInputAt > 5000)) session.state = 'interrupted';
58
56
  if (!session?.token && !events.size && !observations.length && timer) { clearInterval(timer); timer = null; }
59
57
  }
60
- function stop(state = 'stopped') {
58
+ function stop(state = 'stopped', save = true) {
59
+ generation++;
60
+ for (const cancel of monitors) cancel(); monitors = [];
61
+ const saved = save ? persist(false) : Promise.resolve();
61
62
  if (session) { session.state = state; session.token = null; for (const source of session.sources) source.command = null; }
62
63
  for (const parser of parsers) parser.close(); parsers = [];
64
+ return saved;
63
65
  }
64
66
  function addExport(value) {
65
67
  const text = JSON.stringify(value.payload);
66
- const cost = text.length + value.protobufBase64.length;
68
+ const cost = Buffer.byteLength(text, 'utf8') + Buffer.byteLength(value.protobufBase64, 'utf8');
67
69
  if (cost > 1_000_000) { session.issues.payload_limit = (session.issues.payload_limit ?? 0) + 1; return null; }
68
70
  const id = nextId++;
69
- while (events.size >= 100 || (chars + cost > MAX_CHARS && events.size)) {
71
+ while (events.size >= MAX_EXPORTS || (chars + cost > MAX_CHARS && events.size)) {
70
72
  const first = events.keys().next().value; chars -= events.get(first).chars; events.delete(first);
73
+ evictedExports++;
71
74
  }
72
75
  events.set(id, { ...value, id, observedAt: now(), chars: cost, ...exportSummary(value.payload, value.signal), outcome: 'unconfirmed', response: null });
73
76
  chars += cost;
74
77
  return id;
75
78
  }
76
- return {
77
- async prepare(destination, localPort) {
78
- if (platform !== 'darwin') throw new Error('Passive capture setup is available on macOS.');
79
+ const api = {
80
+ async prepare(destination, requestedPort) {
81
+ const operation = ++generation;
82
+ if (!['darwin', 'linux', 'win32'].includes(platform)) throw new CaptureSetupError('Live capture is available on macOS, Linux and Windows.');
79
83
  const targets = [...new Set((Array.isArray(destination) ? destination : [destination]).map(String))];
80
84
  if (!targets.length || targets.length > 8) throw new Error('Choose between one and eight destinations.');
81
85
  const addresses = targets.map(captureDestination);
82
- if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error('Local ccdeck port unavailable.');
86
+ if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) throw new Error('Local ccdeck port unavailable.');
87
+ const tool = await findTool();
83
88
  const names = await Promise.all(addresses.map(({ host }) => findInterface(host)));
84
- if (names.some(name => !name || !/^[a-zA-Z0-9_.:-]{1,32}$/.test(name))) throw new Error('Network interface unavailable. Check the destination and retry.');
85
- stop(); events.clear(); observations = []; chars = 0;
89
+ if (names.some(name => !captureInterfaceValid(name, platform))) throw new Error('Network interface unavailable. Check the destination and retry.');
90
+ if (operation !== generation) throw new CaptureSetupError("Monitoring setup was cancelled.");
91
+ stop("stopped", false); const activeGeneration = generation; localPort = requestedPort;
92
+ prune();
86
93
  const token = randomBytes(32).toString('hex');
87
94
  const helper = fileURLToPath(new URL('./traffic-radar-helper.mjs', import.meta.url));
88
95
  const sources = addresses.map(({ host, port }, index) => {
89
96
  const filter = `host ${host} and tcp port ${port}`;
90
- const command = `sudo /usr/sbin/tcpdump -i ${quote(names[index])} -nn -U -s 0 -w - ${quote(filter)} | ${quote(process.execPath)} ${quote(helper)} ${quote(`http://127.0.0.1:${localPort}`)} ${quote(token)} ${quote(index)}`;
97
+ let command = platform === 'win32'
98
+ ? `& ${psQuote(execPath)} ${psQuote(helper)} --capture ${psQuote(tool)} ${psQuote(names[index])} ${psQuote(host)} ${port} ${psQuote(`http://127.0.0.1:${localPort}`)} ${psQuote(token)} ${index}`
99
+ : `sudo ${quote(tool)} -i ${quote(names[index])} -nn -U -s 0 -w - ${quote(filter)} | ${quote(execPath)} ${quote(helper)} ${quote(`http://127.0.0.1:${localPort}`)} ${quote(token)} ${quote(index)}`;
100
+ if (electron) command = platform === 'win32'
101
+ ? `& { $previous=$env:ELECTRON_RUN_AS_NODE; try { $env:ELECTRON_RUN_AS_NODE='1'; ${command} } finally { $env:ELECTRON_RUN_AS_NODE=$previous } }`
102
+ : command.replace(`| ${quote(execPath)}`, `| env ELECTRON_RUN_AS_NODE=1 ${quote(execPath)}`);
91
103
  return { destination: `${host}:${port}`, interface: names[index], command, lastInputAt: null, bytes: 0 };
92
104
  });
93
105
  session = { id: randomBytes(8).toString('hex'), state: 'awaiting', destination: sources[0].destination, interface: sources[0].interface,
94
- sources, startedAt: now(), expiresAt: now() + DURATION, lastInputAt: null, token, bytes: 0, issues: {} };
95
- parsers = addresses.map(({ host, port }) => wire({ host, port, onExport: addExport, onResponse: (id, response) => {
106
+ sources, startedAt: now(), expiresAt: null, lastInputAt: null, token, bytes: 0, issues: {} };
107
+ const parser = ({ host, port }) => wire({ host, port, onExport: addExport, onResponse: (id, response) => {
96
108
  const event = events.get(id);
97
109
  if (event) { event.response = response; event.outcome = response.outcome; }
98
- }, onObservation: value => { observations.unshift({ id: nextId++, ...value, observedAt: now() }); observations = observations.slice(0, 100); }, onIssue: code => { session.issues[code] = (session.issues[code] ?? 0) + 1; } }));
110
+ }, onObservation: value => { observations.unshift({ id: nextId++, ...value, observedAt: now() }); observations = observations.slice(0, MAX_EXPORTS); }, onIssue: code => { session.issues[code] = (session.issues[code] ?? 0) + 1; } });
111
+ parsers = addresses.map(parser);
112
+ await persist(true);
113
+ if (activeGeneration !== generation) throw new CaptureSetupError("Monitoring setup was cancelled.");
114
+ if (managed) monitors = addresses.map(({ host, port }, index) => monitor({ platform, tool, host, port, resolveInterface: findInterface,
115
+ reset: iface => { parsers[index]?.close(); parsers[index] = parser({ host, port }); session.sources[index].interface = iface; },
116
+ ingest: bytes => api.ingest(token, bytes, index),
117
+ status: error => { const source = session.sources[index]; source.error = error; if (error) { source.lastInputAt = null; session.state = 'interrupted'; } },
118
+ }));
99
119
  if (!timer) { timer = setInterval(prune, 1000); timer.unref?.(); }
100
120
  return { command: sources[0].command, commands: sources.map(({ command, destination }) => ({ command, destination })), expiresAt: session.expiresAt, sessionId: session.id };
101
121
  },
122
+ async resume(port) {
123
+ const operation = generation;
124
+ localPort = port;
125
+ const saved = await preferences?.read(port);
126
+ if (operation !== generation) return;
127
+ if (saved?.enabled && Array.isArray(saved.destinations)) {
128
+ try { await api.prepare(saved.destinations, port); }
129
+ catch { session = { state: "error", sources: [], issues: {}, token: null }; }
130
+ }
131
+ },
102
132
  accepts(token) {
103
133
  prune();
104
134
  return typeof token === 'string' && /^[a-f0-9]{64}$/.test(token) && !!session?.token
@@ -111,26 +141,28 @@ export function createTrafficCapture({ now = Date.now, platform = process.platfo
111
141
  session.sources[source].lastInputAt = now();
112
142
  session.sources[source].bytes += chunk.length;
113
143
  session.lastInputAt = now();
114
- if (chunk.length) session.state = 'capturing';
115
- else if (session.state === 'awaiting') session.state = 'receiving';
144
+ if (chunk.length && (!managed || session.sources[source].bytes > 24)) session.state = 'capturing';
145
+ else if (['awaiting', 'interrupted'].includes(session.state)) session.state = 'receiving';
116
146
  session.bytes += chunk.length;
117
- try { parsers[source].feed(chunk); } catch { stop('error'); return false; }
147
+ try { parsers[source].feed(chunk); } catch { void stop('error').catch(() => {}); return false; }
118
148
  return true;
119
149
  },
120
150
  read() {
121
151
  prune();
122
- return { ok: true, sessionId: session?.id ?? null, state: session?.state ?? 'idle', destination: session?.destination ?? null, interface: session?.interface ?? null,
152
+ return { ok: true, managed, enabled: !!session?.token, platform, backend: platform === 'win32' ? 'dumpcap' : 'tcpdump', shell: platform === 'win32' ? 'PowerShell' : 'Terminal', sessionId: session?.id ?? null, state: session?.state ?? 'idle', destination: session?.destination ?? null, interface: session?.interface ?? null,
123
153
  startedAt: session?.startedAt ?? null, expiresAt: session?.expiresAt ?? null, lastInputAt: session?.lastInputAt ?? null,
124
154
  sources: session?.sources.map(({ command, ...source }) => ({ ...source, active: source.lastInputAt !== null && now() - source.lastInputAt <= 5000 && !!session.token })) ?? [],
125
155
  observations: [...observations],
156
+ retention: { windowMs: WINDOW, maxExports: MAX_EXPORTS, payloadBudgetBytes: MAX_CHARS, evictedExports },
126
157
  bytes: session?.bytes ?? 0, issues: { ...session?.issues },
127
158
  events: [...events.values()].reverse().map(({ payload, protobufBase64, chars, flow, ...event }) => event),
128
159
  };
129
160
  },
130
161
  detail(id) { prune(); const event = events.get(id); return event ? { payload: event.payload, protobufBase64: event.protobufBase64 } : null; },
131
162
  stop,
132
- clear() { events.clear(); observations = []; chars = 0; },
133
- dispose() { stop(); events.clear(); observations = []; if (timer) clearInterval(timer); timer = null; },
163
+ clear() { events.clear(); observations = []; chars = 0; evictedExports = 0; },
164
+ dispose() { stop("stopped", false); events.clear(); observations = []; if (timer) clearInterval(timer); timer = null; },
134
165
  };
166
+ return api;
135
167
  }
136
- export const trafficCapture = createTrafficCapture();
168
+ export const trafficCapture = createTrafficCapture({ managed: true, preferences: capturePreferences() });
@@ -1,6 +1,6 @@
1
1
  import { open } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { posix, win32 } from "node:path";
4
4
  import { claudeConfigDir } from "./claude-dir.mjs";
5
5
 
6
6
  const BOOLEAN_KEYS = [
@@ -68,12 +68,15 @@ async function readBoundedJson(path) {
68
68
  } finally { await file.close(); }
69
69
  }
70
70
 
71
- export async function readRadarConfig({ home = homedir(), env = process.env, readJson = readBoundedJson } = {}) {
72
- const dir = claudeConfigDir(env, home);
71
+ export async function readRadarConfig({ home = homedir(), env = process.env, platform = process.platform, readJson = readBoundedJson } = {}) {
72
+ const pathJoin = platform === 'win32' ? win32.join : posix.join;
73
+ const dir = claudeConfigDir(env, home, platform);
74
+ const managed = platform === 'win32' ? pathJoin(env.ProgramFiles || 'C:\\Program Files', 'ClaudeCode', 'managed-settings.json')
75
+ : platform === 'linux' ? '/etc/claude-code/managed-settings.json' : '/Library/Application Support/ClaudeCode/managed-settings.json';
73
76
  const files = [
74
- ["User settings", join(dir, "settings.json")],
75
- ["Cached organization settings", join(dir, "remote-settings.json")],
76
- ["Managed settings", "/Library/Application Support/ClaudeCode/managed-settings.json"],
77
+ ["User settings", pathJoin(dir, "settings.json")],
78
+ ["Cached organization settings", pathJoin(dir, "remote-settings.json")],
79
+ ["Managed settings", managed],
77
80
  ];
78
81
  const sources = await Promise.all(files.map(async ([name, path]) => {
79
82
  try {
@@ -1,3 +1,6 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { captureDestination } from './traffic-radar-capture.mjs';
3
+ import { captureInterfaceValid } from './traffic-radar-platform.mjs';
1
4
  import { pathToFileURL } from "node:url";
2
5
 
3
6
  export async function streamCapture({ base, token, source = 0, input = process.stdin, send = fetch, report = message => process.stderr.write(message + '\n') }) {
@@ -20,8 +23,7 @@ export async function streamCapture({ base, token, source = 0, input = process.s
20
23
  return sequence;
21
24
  };
22
25
  const heartbeat = setInterval(() => { if (!stopped) void post(Buffer.alloc(0)); }, 1000);
23
- const deadline = setTimeout(finish, 600_000);
24
- report('Telemetry Radar: sending capture bytes only to local ccdeck; no file is written. Press Ctrl+C to stop tcpdump.');
26
+ report('Telemetry Radar: sending capture bytes only to local ccdeck; no file is written. Press Ctrl+C to stop capture.');
25
27
  try {
26
28
  for await (const chunk of input) {
27
29
  for (let offset = 0; offset < chunk.length && !stopped; offset += 65_536) await post(chunk.subarray(offset, offset + 65_536));
@@ -31,13 +33,35 @@ export async function streamCapture({ base, token, source = 0, input = process.s
31
33
  } catch {
32
34
  if (!stopped) report('Capture input interrupted.');
33
35
  } finally {
34
- stopped = true; clearInterval(heartbeat); clearTimeout(deadline);
35
- report('Telemetry Radar: local receiver stopped. Press Ctrl+C if tcpdump is still running.');
36
+ stopped = true; clearInterval(heartbeat);
37
+ report('Telemetry Radar: local receiver stopped. Press Ctrl+C if the capture tool is still running.');
36
38
  }
37
39
  }
38
40
 
41
+ // PowerShell 5 pipelines convert binary output to text. Stream dumpcap directly in Node.
42
+ export async function runCaptureTool({ tool, interface: iface, host, port, base, token, source = 0, launch = spawn, send = fetch, report = message => process.stderr.write(message + '\n') }) {
43
+ captureDestination(`${host}:${port}`);
44
+ if (!captureInterfaceValid(iface, 'win32') || !tool || /[\r\n]/.test(tool)) throw new Error('Invalid capture tool or interface.');
45
+ // Validate before launching a child, not after it has started capturing.
46
+ const url = new URL(base);
47
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.pathname !== '/' || url.search || url.hash || url.username || url.password || !/^[a-f0-9]{64}$/.test(token) || !Number.isInteger(source) || source < 0 || source > 7) throw new Error('Invalid local capture destination.');
48
+ const child = launch(tool, ['-i', iface, '-F', 'pcap', '-s', '0', '-f', `host ${host} and tcp port ${port}`, '-w', '-'], { stdio: ['ignore', 'pipe', 'inherit'], windowsHide: true, shell: false });
49
+ let failure = null;
50
+ child.on('error', error => { failure = error; child.stdout.destroy(error); });
51
+ child.on('exit', code => { if (code) failure = new Error('Capture tool failed. Check Npcap and capture permissions.'); });
52
+ const stop = () => { child.kill(); child.stdout.destroy(); };
53
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
54
+ try {
55
+ await streamCapture({ base, token, source, input: child.stdout, send, report });
56
+ if (failure) throw failure;
57
+ } finally {
58
+ child.kill(); process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
59
+ }
60
+ }
39
61
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
40
- streamCapture({ base: process.argv[2], token: process.argv[3], source: Number(process.argv[4] ?? 0) }).catch(() => {
41
- process.stderr.write('Could not start the local capture helper.\n'); process.exitCode = 1;
42
- });
62
+ const args = process.argv.slice(2);
63
+ const job = args[0] === '--capture'
64
+ ? runCaptureTool({ tool: args[1], interface: args[2], host: args[3], port: Number(args[4]), base: args[5], token: args[6], source: Number(args[7] ?? 0) })
65
+ : streamCapture({ base: args[0], token: args[1], source: Number(args[2] ?? 0) });
66
+ job.catch(() => { process.stderr.write('Could not start capture. Check that the capture tool is installed and its permissions allow this user.\n'); process.exitCode = 1; });
43
67
  }
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile, mkdir, writeFile, chmod } from 'node:fs/promises';
3
+ import { renameWithRetry } from './atomic-write.mjs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+
7
+ // Only destinations and the opt-in survive a restart. Tokens and packets never do.
8
+ export function capturePreferences(directory = join(homedir(), '.agents-deck')) {
9
+ let writes = Promise.resolve();
10
+ const file = port => join(directory, `radar-monitor-${port}.json`);
11
+ return {
12
+ async read(port) { try { return JSON.parse(await readFile(file(port), 'utf8')); } catch { return null; } },
13
+ write(port, value) {
14
+ writes = writes.catch(() => {}).then(async () => {
15
+ await mkdir(directory, { recursive: true });
16
+ const target = file(port), temp = target + `.${process.pid}.tmp`;
17
+ await writeFile(temp, JSON.stringify(value), { mode: 0o600 });
18
+ await chmod(temp, 0o600); await renameWithRetry(temp, target);
19
+ });
20
+ return writes;
21
+ },
22
+ };
23
+ }
24
+
25
+ const activeMonitors = new Set();
26
+ const stopAtExit = () => { for (const stop of activeMonitors) stop(); };
27
+
28
+ // A child per destination keeps pcap headers and TCP decoders independent.
29
+ // Retry after a crash or adapter change; an explicit stop cancels every retry.
30
+ export function monitorDestination({ platform, tool, host, port, resolveInterface, reset, ingest, status, launch = spawn, retryMs = 3000 }) {
31
+ let stopped = false, child = null, retry = null, heartbeat = null, ready = false;
32
+ const stopHeartbeat = () => { clearInterval(heartbeat); heartbeat = null; };
33
+ const retryLater = () => { if (!stopped) { retry = setTimeout(start, retryMs); retry.unref?.(); } };
34
+ async function start() {
35
+ if (stopped) return;
36
+ try {
37
+ const iface = await resolveInterface(host);
38
+ if (stopped) return;
39
+ if (!iface) throw new Error('interface');
40
+ reset(iface); ready = false;
41
+ const args = platform === 'win32'
42
+ ? ['-i', iface, '-F', 'pcap', '-s', '0', '-f', `host ${host} and tcp port ${port}`, '-w', '-']
43
+ : ['-i', iface, '-nn', '-U', '-s', '0', '-w', '-', `host ${host} and tcp port ${port}`];
44
+ child = launch(tool, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
45
+ const capturing = child;
46
+ let ended = false, error = '', stderr = '';
47
+ const listening = () => {
48
+ if (ready || stopped || ended) return;
49
+ ready = true; status(null); ingest(Buffer.alloc(0));
50
+ heartbeat = setInterval(() => { if (!stopped) ingest(Buffer.alloc(0)); }, 1000); heartbeat.unref?.();
51
+ };
52
+ const end = () => {
53
+ if (ended) return; ended = true; stopHeartbeat(); capturing.stdout.destroy(); capturing.kill();
54
+ if (!stopped) { status(error || 'Capture disconnected. Retrying automatically.'); retryLater(); }
55
+ };
56
+ child.stderr.on('data', bytes => {
57
+ stderr = (stderr + bytes.toString()).slice(-4096);
58
+ if (/permission|access.*denied|not permitted|administrator/i.test(stderr)) error = 'Packet capture permission is required.';
59
+ if (/listening on|Capturing on/i.test(stderr)) listening();
60
+ });
61
+ child.stdout.on('data', bytes => {
62
+ if (stopped) return;
63
+ listening();
64
+ for (let i = 0; i < bytes.length; i += 65536) ingest(bytes.subarray(i, i + 65536));
65
+ });
66
+ child.stdout.on('error', end);
67
+ child.once('error', reason => { if (['EACCES', 'EPERM'].includes(reason.code)) error = 'Packet capture permission is required.'; end(); }); child.once('exit', end);
68
+ } catch { if (!stopped) { status('Capture interface unavailable. Retrying automatically.'); retryLater(); } }
69
+ }
70
+ const stop = () => {
71
+ stopped = true; clearTimeout(retry); stopHeartbeat(); child?.stdout.destroy(); child?.kill();
72
+ activeMonitors.delete(stop); if (!activeMonitors.size) process.removeListener("exit", stopAtExit);
73
+ };
74
+ if (!activeMonitors.size) process.once("exit", stopAtExit);
75
+ activeMonitors.add(stop); void start();
76
+ return stop;
77
+ }
@@ -0,0 +1,80 @@
1
+ // OS-specific inspection stays here; the Radar history and decoder share one model.
2
+ import { execFile } from 'node:child_process';
3
+ import { readdir, readFile, readlink } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { win32 } from 'node:path';
6
+
7
+ export function systemCommand(file, args) {
8
+ return new Promise(resolve => execFile(file, args, { timeout: 8000, maxBuffer: 262144, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {
9
+ if (!error || (file === '/usr/sbin/lsof' && error.code === 1 && !stdout && !stderr)) resolve(stdout);
10
+ else resolve(null);
11
+ }));
12
+ }
13
+ export const powershell = script => ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop'; " + script];
14
+ export const WINDOWS_PROCESSES = "Get-Process | Where-Object { $_.ProcessName -in @('claude','claude-native') } | ForEach-Object { '{0} {1}' -f $_.Id,$_.ProcessName }";
15
+ export function windowsSockets(pids) {
16
+ return `$ids=@(${pids.join(',')}); Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Established' -and $_.OwningProcess -in $ids } | ForEach-Object { $peer=$_.RemoteAddress; if($peer.Contains(':')){$peer='['+$peer+']'}; 'p'+$_.OwningProcess; 'nlocal->'+$peer+':'+$_.RemotePort }`;
17
+ }
18
+
19
+ // /proc avoids an ss/lsof dependency and reads socket ownership only for selected Claude PIDs.
20
+ export async function linuxSockets(pids, { read = readFile, list = readdir, link = readlink } = {}) {
21
+ const sockets = [], cwd = [];
22
+ let unavailable = false;
23
+ await Promise.all(pids.map(async pid => {
24
+ try {
25
+ const descriptors = await list(`/proc/${pid}/fd`);
26
+ const owned = new Set((await Promise.all(descriptors.map(fd => link(`/proc/${pid}/fd/${fd}`).catch(() => ''))))
27
+ .map(value => /^socket:\[(\d+)\]$/.exec(value)?.[1]).filter(Boolean));
28
+ for (const family of ['tcp', 'tcp6']) {
29
+ const table = await read(`/proc/${pid}/net/${family}`, 'utf8');
30
+ for (const row of table.trim().split('\n').slice(1)) {
31
+ const fields = row.trim().split(/\s+/);
32
+ if (fields[3] !== '01' || !owned.has(fields[9])) continue;
33
+ const [hex, port] = fields[2].split(':');
34
+ const bytes = Buffer.from(hex, 'hex');
35
+ for (let i = 0; i < bytes.length; i += 4) bytes.subarray(i, i + 4).reverse();
36
+ const address = family === 'tcp' ? [...bytes].join('.') : '[' + Array.from({ length: 8 }, (_, i) => bytes.readUInt16BE(i * 2).toString(16)).join(':') + ']';
37
+ sockets.push(`p${pid}\nnlocal->${address}:${parseInt(port, 16)}`);
38
+ }
39
+ }
40
+ const path = await link(`/proc/${pid}/cwd`).catch(() => null);
41
+ if (path) cwd.push(`p${pid}\nn${path}`);
42
+ } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ESRCH') unavailable = true; }
43
+ }));
44
+ return { sockets: unavailable ? null : sockets.join('\n'), cwd: cwd.join('\n') };
45
+ }
46
+ export function captureInterfaceValid(name, platform) {
47
+ return typeof name === 'string' && (platform === 'win32'
48
+ ? /^\\Device\\NPF_(?:Loopback|\{[a-fA-F0-9-]{36}\})$/.test(name)
49
+ : /^[a-zA-Z0-9_.:-]{1,32}$/.test(name));
50
+ }
51
+ export async function captureInterface(host, platform, run = systemCommand) {
52
+ if (platform === 'linux') return 'any'; // tcpdump's cooked capture includes loopback and every routed adapter.
53
+ if (platform === 'darwin') {
54
+ const text = await run('/sbin/route', ['-n', 'get', host]);
55
+ return /interface:\s+([a-zA-Z0-9_.:-]{1,32})/.exec(text ?? '')?.[1] ?? null;
56
+ }
57
+ if (platform === 'win32') {
58
+ if (host.startsWith('127.')) return '\\Device\\NPF_Loopback';
59
+ const text = await run('powershell.exe', powershell(`if(Get-NetIPAddress -IPAddress '${host}' -ErrorAction SilentlyContinue) { '\\Device\\NPF_Loopback'; exit }; $route=Find-NetRoute -RemoteIPAddress '${host}'; $index=($route | Select-Object -First 1).InterfaceIndex; $adapter=Get-NetAdapter -IncludeHidden | Where-Object { $_.ifIndex -eq $index } | Select-Object -First 1; '\\Device\\NPF_{'+$adapter.InterfaceGuid.ToString().Trim('{}')+'}'`));
60
+ return text?.trim() ?? null;
61
+ }
62
+ return null;
63
+ }
64
+ export function dumpcapPath(env = process.env, exists = existsSync) {
65
+ for (const root of [env.ProgramW6432, env.ProgramFiles, env['ProgramFiles(x86)'], 'C:\\Program Files']) {
66
+ if (!root) continue;
67
+ const file = win32.join(root, 'Wireshark', 'dumpcap.exe');
68
+ if (exists(file)) return file;
69
+ }
70
+ return 'dumpcap.exe'; // Custom installs may expose it on PATH.
71
+ }
72
+ export class CaptureSetupError extends Error {}
73
+ export async function captureTool(platform, run = systemCommand) {
74
+ const file = platform === 'win32' ? dumpcapPath() : platform === 'darwin' ? '/usr/sbin/tcpdump' : 'tcpdump';
75
+ if (await run(file, [platform === 'win32' ? '-v' : '--version']) === null)
76
+ throw new CaptureSetupError(platform === 'win32' ? 'Install Wireshark with Npcap to capture traffic, then retry. Configuration and file inspection work without it.' : 'Install tcpdump to capture traffic, then retry. Configuration and file inspection work without it.');
77
+ if (platform === 'win32' && await run(file, ['-D']) === null)
78
+ throw new CaptureSetupError('Npcap interfaces are unavailable. Check that Npcap is installed and its capture permissions allow this user.');
79
+ return file;
80
+ }
@@ -1,4 +1,5 @@
1
1
  import { trafficCapture } from "./traffic-radar-capture.mjs";
2
+ import { CaptureSetupError } from './traffic-radar-platform.mjs';
2
3
  const BASE = '/api/system/traffic-radar';
3
4
  const loopback = req => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress);
4
5
  export function isAuthorizedTrafficIngest(req, url) {
@@ -33,8 +34,8 @@ export async function handleTrafficCapture(req, res, url, capture = trafficCaptu
33
34
  const data = JSON.parse((await body(req, 4096)).toString());
34
35
  return reply(res, 200, await capture.prepare(data.destination, req.socket.localPort));
35
36
  }
36
- if (req.method === 'POST' && action === '/capture/stop') { capture.stop(); return reply(res, 200, { ok: true }); }
37
+ if (req.method === 'POST' && action === '/capture/stop') { await capture.stop(); return reply(res, 200, { ok: true }); }
37
38
  if (req.method === 'POST' && action === '/capture/clear') { capture.clear(); return reply(res, 200, { ok: true }); }
38
39
  return reply(res, 404, { error: 'Unknown capture action.' });
39
- } catch { return reply(res, 400, { error: 'Could not complete capture setup. Use a valid IPv4 destination and check its network route.' }); }
40
+ } catch (error) { return reply(res, 400, { error: error instanceof CaptureSetupError ? error.message : 'Could not complete capture setup. Use a valid IPv4 destination and check its network route.' }); }
40
41
  }