ccdeck 3.38.5 → 3.39.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.
@@ -61,11 +61,11 @@
61
61
  // embed without matchMedia must not stop the parse either.
62
62
  try { light = window.matchMedia("(prefers-color-scheme: light)").matches; } catch (e) {}
63
63
  document.documentElement.setAttribute("data-theme",
64
- stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
64
+ stored === "light" || stored === "dark" || stored === "rider-black" || stored === "vscode-black" ? stored : light ? "light" : "dark");
65
65
  })();
66
66
  </script>
67
- <script type="module" crossorigin src="/assets/index-Bg99W1Kp.js"></script>
68
- <link rel="stylesheet" crossorigin href="/assets/index-BIkuTs0Y.css">
67
+ <script type="module" crossorigin src="/assets/index-DUlU5kmL.js"></script>
68
+ <link rel="stylesheet" crossorigin href="/assets/index-BQRqT36t.css">
69
69
  </head>
70
70
  <body>
71
71
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.38.5",
3
+ "version": "3.39.0",
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": {
@@ -21,10 +21,10 @@
21
21
  "dev:web": "vite",
22
22
  "dev:server": "node src/server/index.mjs",
23
23
  "start": "node bin/agent-dag.js",
24
- "build": "vite build",
24
+ "build": "node scripts/build-radar-codec.mjs && vite build",
25
25
  "test": "vitest run",
26
- "prepublishOnly": "vite build",
27
- "prepack": "vite build",
26
+ "prepublishOnly": "npm run build",
27
+ "prepack": "npm run build",
28
28
  "typecheck": "tsc --noEmit -p tsconfig.build.json"
29
29
  },
30
30
  "engines": {
@@ -77,7 +77,11 @@
77
77
  "@types/react-dom": "^18.3.1",
78
78
  "@vitejs/plugin-react": "^4.3.4",
79
79
  "dagre": "^0.8.5",
80
+ "esbuild": "0.25.12",
80
81
  "hls.js": "^1.7.3",
82
+ "hpack.js": "2.1.6",
83
+ "long": "5.3.2",
84
+ "protobufjs": "8.8.0",
81
85
  "react": "^18.3.1",
82
86
  "react-dom": "^18.3.1",
83
87
  "reactflow": "^11.11.4",
@@ -72,7 +72,26 @@
72
72
  "3.37.0",
73
73
  "3.38.0",
74
74
  "3.38.1",
75
- "3.38.2"
75
+ "3.38.2",
76
+ "3.38.6"
77
+ ],
78
+ "3.39.0": [
79
+ {
80
+ "title": "🔎 Inspect Claude Code telemetry on macOS",
81
+ "body": "Telemetry Radar shows Claude Code's configured telemetry and can inspect traffic at destinations you choose. When settings are absent, it can show observed connections separately; a connection alone does not prove telemetry or reveal message contents. Capture starts only after you run its generated command in Terminal, which uses macOS tcpdump with administrator permission. Captured telemetry can contain prompt or response content, and other apps sending to the same destination may appear too. Messages stay in ccdeck's memory for up to five minutes; no capture file is written."
82
+ },
83
+ {
84
+ "title": "🧭 Open panels from the edges",
85
+ "body": "Open the session list, Claude accounts, Usage, This machine, Usage history and Browser Watch from the window edges. You can keep several panels open beside the canvas; on a phone, the same controls sit in a bottom dock."
86
+ },
87
+ {
88
+ "title": "More control while you work",
89
+ "body": "Turn off single-key shortcuts if typed letters should not control the deck, see each stage while adding a Claude account, and undo a recent rearrangement. The topbar also names the Claude Code session waiting on you."
90
+ },
91
+ {
92
+ "title": "🎨 Two new dark themes",
93
+ "body": "Choose Rider Black or VS Code Black in Settings › Appearance. Both add a darker canvas with flatter panels and distinct focus accents; your existing Light or Dark choice stays as it was."
94
+ }
76
95
  ],
77
96
  "3.38.5": [
78
97
  {
@@ -282,6 +282,36 @@ export function newSlot(before, after) {
282
282
  return fresh.length === 1 ? fresh[0] : null;
283
283
  }
284
284
 
285
+ /**
286
+ * The slot the account that just signed in occupies after `cswap add`, or null.
287
+ *
288
+ * ASKED OF THE IDENTITY, NOT OF WHAT IS NEW. `before` is the store as it was
289
+ * when the sign-in started, minutes ago, and a share or a Local network round
290
+ * can land other accounts in between. Reading "the one new slot" then named
291
+ * somebody else's slot for a re-sign-in of an account already here, and found
292
+ * nothing for a new account that arrived beside another.
293
+ *
294
+ * A NEW slot with this address still comes first, because one address under
295
+ * two organizations is two accounts: signing into the second organization of
296
+ * an address the store already holds adds a slot, and slotOf — which falls
297
+ * back to the first slot with the address when the CLI names no organization
298
+ * — would answer with the old one and call the new account a refresh. A slot
299
+ * whose organization the store does not record is not ruled out by one.
300
+ *
301
+ * newSlot is the last answer, for a store that keeps no address for the slot
302
+ * — the case it was written for — since nothing else can point at it then.
303
+ */
304
+ export function signedInSlot(before, after, identity) {
305
+ const had = new Set(before.slots);
306
+ const org = identity?.orgId ?? "";
307
+ const fresh = after.slots.filter(s =>
308
+ !had.has(s)
309
+ && sameAddress(after.emails[s], identity?.email)
310
+ && (!org || !after.orgs?.[s] || after.orgs[s] === org));
311
+ if (fresh.length === 1) return fresh[0];
312
+ return slotOf(after, identity) ?? newSlot(before, after);
313
+ }
314
+
285
315
  const sameAddress = (a, b) => String(a ?? "").trim().toLowerCase() === String(b ?? "").trim().toLowerCase();
286
316
 
287
317
  /**
@@ -379,9 +409,14 @@ let _starting = null;
379
409
 
380
410
  export function loginState() {
381
411
  if (!_login) return { state: "idle" };
382
- const { state, url, error, account, expiresAt, restore } = _login;
412
+ const { state, step, url, error, account, expiresAt, restore } = _login;
383
413
  return {
384
414
  state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null,
415
+ // Where inside `registering` the flow is — see LOGIN_STEPS. Null before it
416
+ // and on a failure that happened before it, and left on the step that was
417
+ // running when anything after it failed, which is how the dialog knows
418
+ // which of its stages to mark.
419
+ step: step ?? null,
385
420
  // The sign-in's OTHER outcome, and the one it used to drop (#951). `true`
386
421
  // until a restore has actually been attempted, because every state before
387
422
  // that — awaiting_url, awaiting_code, registering — has moved nothing that
@@ -391,6 +426,23 @@ export function loginState() {
391
426
  };
392
427
  }
393
428
 
429
+ /**
430
+ * The points a sign-in passes through after the browser said yes, in order.
431
+ *
432
+ * `registering` covers all three and used to be reported as one state, so the
433
+ * dialog could only say "registering…" for however long they took together —
434
+ * an identity check, a `cswap add` that may first wait its turn on the store
435
+ * lock, and a `cswap switch` back to the account the user was on. Each is set
436
+ * on the flow at the moment it begins, never ahead of it, so a dialog that
437
+ * shows the step is showing what the deck is doing rather than a guess.
438
+ *
439
+ * confirm the claude CLI is asked who signed in (and, for a pasted code,
440
+ * first takes the code)
441
+ * save claude-swap records the account
442
+ * restore the account the user was working in is put back in front
443
+ */
444
+ export const LOGIN_STEPS = Object.freeze(["confirm", "save", "restore"]);
445
+
394
446
  /** The restore verdict, in the two fields a caller reads. Written once here so
395
447
  * the login route, the cancel result and the dialog's type cannot drift. */
396
448
  function restoreFields(restore) {
@@ -624,6 +676,7 @@ async function spawnLogin(email) {
624
676
  // code posted while the question is out would otherwise register the same
625
677
  // login a second time, with a second `cswap add` racing this one.
626
678
  flow.state = "registering";
679
+ flow.step = "confirm";
627
680
  const identity = await currentIdentity();
628
681
  if (flow !== _login) return;
629
682
  // A clean exit with somebody logged in is a completed sign-in, including
@@ -693,6 +746,10 @@ function loginFailureText(r) {
693
746
  * with; the done handler simply drops it.
694
747
  */
695
748
  async function registerSignedIn(flow, identity) {
749
+ // Before the lock, not inside it: waiting for another change to the store to
750
+ // finish is part of saving this one, and the dialog says so rather than
751
+ // going on claiming the sign-in is still being confirmed.
752
+ flow.step = "save";
696
753
  return withStoreLock(async () => {
697
754
  const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
698
755
  if (!add.ok) {
@@ -707,10 +764,9 @@ async function registerSignedIn(flow, identity) {
707
764
  }
708
765
 
709
766
  const after = await readStore();
710
- const slot = newSlot(flow.before, after);
711
- // No new slot means the account was already managed and cswap refreshed its
712
- // credentials in place. That is a success with a different sentence.
713
- const num = slot ?? slotOf(after, identity) ?? null;
767
+ // The slot that holds the identity that just signed in, found by asking for
768
+ // it rather than by looking for what is new: see signedInSlot.
769
+ const num = signedInSlot(flow.before, after, identity);
714
770
 
715
771
  // Recorded now that claude-swap holds the account, and before the roster
716
772
  // is invalidated below, so no read can land between the two and cache a
@@ -722,16 +778,20 @@ async function registerSignedIn(flow, identity) {
722
778
  // or a Local network round can land a different account in between: two
723
779
  // new slots, newSlot answers null, and the account this sign-in did add
724
780
  // would never be marked.
725
- if (num != null) {
726
- const email = after.emails[num] || identity.email;
727
- const org = after.orgs?.[num] ?? "";
728
- await tellOrigins("signedIn", { email, org, added: !holds(flow.before, email, org) });
729
- }
781
+ //
782
+ // And the success card's "added" is the same answer, asked once. It used to
783
+ // be `newSlot(before, after) != null`, which a share landing during the
784
+ // sign-in turned into the wrong sentence either way.
785
+ const email = num != null ? (after.emails[num] || identity.email) : identity.email;
786
+ const org = num != null ? (after.orgs?.[num] ?? "") : "";
787
+ const added = num != null && !holds(flow.before, email, org);
788
+ if (num != null) await tellOrigins("signedIn", { email, org, added });
730
789
 
731
790
  // Assigned, not discarded. `done` is still the right state — the account
732
791
  // WAS added — but it is `done` with a qualification whenever this says the
733
792
  // machine did not go back, and the dialog renders that qualification
734
793
  // instead of "The account you were using is still active".
794
+ flow.step = "restore";
735
795
  flow.restore = await restoreActive(flow.previousActive);
736
796
  invalidateClaudeAccountsCache();
737
797
  // Collect straight away, so the new row shows numbers instead of "never
@@ -739,7 +799,7 @@ async function registerSignedIn(flow, identity) {
739
799
  runDetached(await cswapBin(), ["list"]);
740
800
 
741
801
  flow.state = "done";
742
- flow.account = { num, email: identity.email, added: slot != null };
802
+ flow.account = { num, email: identity.email, added };
743
803
  return { ok: true, ...loginState() };
744
804
  });
745
805
  }
@@ -759,6 +819,7 @@ export async function submitLoginCode(code) {
759
819
 
760
820
  const askedBefore = flow.prompts;
761
821
  flow.state = "registering";
822
+ flow.step = "confirm";
762
823
  flow.child.write(code.trim() + "\n");
763
824
 
764
825
  // Whichever comes first: the CLI finishing, or it asking again. A wrong code
@@ -770,11 +831,13 @@ export async function submitLoginCode(code) {
770
831
  ]);
771
832
  if (r === "rejected") {
772
833
  flow.state = "awaiting_code";
834
+ flow.step = null;
773
835
  flow.error = "that code was not accepted — copy it again from the browser";
774
836
  return { ok: false, reason: "code_rejected", ...loginState() };
775
837
  }
776
838
  if (r === "slow") {
777
839
  flow.state = "awaiting_code";
840
+ flow.step = null;
778
841
  flow.error = "the claude CLI has not answered — try the code again";
779
842
  return { ok: false, reason: "no_verdict", ...loginState() };
780
843
  }
@@ -20,6 +20,8 @@ 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 { trafficRadar } from "./traffic-radar.mjs";
24
+ import { handleTrafficCapture, isAuthorizedTrafficIngest } from "./traffic-radar-routes.mjs";
23
25
  // How much the event ring may hold and what one event is charged against it —
24
26
  // see ring-bounds.mjs. The four it exported from this file, it still exports.
25
27
  export { MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, payloadChars } from "./ring-bounds.mjs";
@@ -280,7 +282,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
280
282
  // added later is protected until someone deliberately lists it as open,
281
283
  // which is the direction this has to fail in. See isAuthorizedMutation.
282
284
  if (req.method !== "GET" && req.method !== "HEAD"
283
- && !OPEN_MUTATIONS.has(url.pathname) && !isAuthorizedMutation(req)) {
285
+ && !OPEN_MUTATIONS.has(url.pathname) && !isAuthorizedMutation(req) && !isAuthorizedTrafficIngest(req, url)) {
284
286
  return send(res, 401, { error: "unauthenticated" });
285
287
  }
286
288
 
@@ -330,6 +332,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
330
332
  if (req.method === "GET" && url.pathname === "/api/rating") return handleRatingRead(req, res);
331
333
  if (req.method === "POST" && url.pathname === "/api/rating") return guard(handleRatingWrite(req, res), res);
332
334
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
335
+ if (req.method === "GET" && url.pathname === "/api/system/traffic-radar") {
336
+ return guard(trafficRadar.read().then(snapshot => send(res, 200, snapshot)), res);
337
+ }
338
+ if (url.pathname.startsWith("/api/system/traffic-radar/")) return guard(handleTrafficCapture(req, res, url), res);
333
339
  // On demand only — the process list costs a subprocess on every platform,
334
340
  // so it is fetched while the detail panel is open and never on the timer.
335
341
  if (req.method === "GET" && url.pathname === "/api/system/processes") {
@@ -430,6 +430,9 @@ export function isAuthorizedDataRead(req) {
430
430
  * costs nothing: every fetch of both is in src/web, and a page's own GET
431
431
  * carries Sec-Fetch-Site: same-origin. */
432
432
  export const GUARDED_READS = new Set([
433
+ "/api/system/traffic-radar",
434
+ "/api/system/traffic-radar/capture",
435
+ "/api/system/traffic-radar/capture/event",
433
436
  "/events",
434
437
  "/api/events",
435
438
  "/api/claude-accounts",
@@ -0,0 +1,136 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { isIP } from "node:net";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createWireCapture } from "./traffic-radar-wire.mjs";
6
+
7
+ const WINDOW = 300_000;
8
+ const DURATION = 600_000;
9
+ const MAX_CHARS = 4_000_000;
10
+ const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
11
+ export function captureDestination(value) {
12
+ const match = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/.exec(String(value));
13
+ if (!match || isIP(match[1]) !== 4 || +match[2] < 1 || +match[2] > 65535) throw new Error('Choose an IPv4 address and TCP port.');
14
+ return { host: match[1], port: +match[2] };
15
+ }
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
+ export function exportSummary(payload, signal) {
23
+ const group = signal === 'traces' ? 'Spans' : signal === 'logs' ? 'Logs' : 'Metrics';
24
+ const item = signal === 'traces' ? 'spans' : signal === 'logs' ? 'logRecords' : 'metrics';
25
+ const records = (payload['resource' + group] ?? []).flatMap(r => (r['scope' + group] ?? []).flatMap(s => s[item] ?? []));
26
+ const fields = new Set(), sessionIds = new Set();
27
+ const collect = attributes => {
28
+ for (const a of attributes ?? []) if (['session.id', 'session_id'].includes(a.key)) {
29
+ const id = a.value?.stringValue;
30
+ if (typeof id === 'string' && id.length > 0 && id.length <= 200) sessionIds.add(id);
31
+ }
32
+ };
33
+ for (const resource of payload['resource' + group] ?? []) {
34
+ collect(resource.resource?.attributes);
35
+ for (const scope of resource['scope' + group] ?? []) for (const record of scope[item] ?? []) {
36
+ collect(record.attributes);
37
+ for (const kind of ['gauge', 'sum', 'histogram', 'exponentialHistogram', 'summary'])
38
+ for (const point of record[kind]?.dataPoints ?? []) collect(point.attributes);
39
+ }
40
+ }
41
+ for (const record of records) for (const a of record.attributes ?? []) {
42
+ if (/prompt/i.test(a.key)) fields.add('Prompt');
43
+ if (/response|completion/i.test(a.key)) fields.add('Response');
44
+ if (/tool/i.test(a.key)) fields.add('Tool');
45
+ }
46
+ return { count: records.length, name: String(records[0]?.eventName || records[0]?.name || signal).slice(0, 200), content: [...fields], sessionIds: [...sessionIds] };
47
+ }
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;
50
+ const events = new Map();
51
+ let observations = [];
52
+ function prune() {
53
+ observations = observations.filter(entry => now() - entry.observedAt <= WINDOW);
54
+ 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');
58
+ if (!session?.token && !events.size && !observations.length && timer) { clearInterval(timer); timer = null; }
59
+ }
60
+ function stop(state = 'stopped') {
61
+ if (session) { session.state = state; session.token = null; for (const source of session.sources) source.command = null; }
62
+ for (const parser of parsers) parser.close(); parsers = [];
63
+ }
64
+ function addExport(value) {
65
+ const text = JSON.stringify(value.payload);
66
+ const cost = text.length + value.protobufBase64.length;
67
+ if (cost > 1_000_000) { session.issues.payload_limit = (session.issues.payload_limit ?? 0) + 1; return null; }
68
+ const id = nextId++;
69
+ while (events.size >= 100 || (chars + cost > MAX_CHARS && events.size)) {
70
+ const first = events.keys().next().value; chars -= events.get(first).chars; events.delete(first);
71
+ }
72
+ events.set(id, { ...value, id, observedAt: now(), chars: cost, ...exportSummary(value.payload, value.signal), outcome: 'unconfirmed', response: null });
73
+ chars += cost;
74
+ return id;
75
+ }
76
+ return {
77
+ async prepare(destination, localPort) {
78
+ if (platform !== 'darwin') throw new Error('Passive capture setup is available on macOS.');
79
+ const targets = [...new Set((Array.isArray(destination) ? destination : [destination]).map(String))];
80
+ if (!targets.length || targets.length > 8) throw new Error('Choose between one and eight destinations.');
81
+ const addresses = targets.map(captureDestination);
82
+ if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error('Local ccdeck port unavailable.');
83
+ 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;
86
+ const token = randomBytes(32).toString('hex');
87
+ const helper = fileURLToPath(new URL('./traffic-radar-helper.mjs', import.meta.url));
88
+ const sources = addresses.map(({ host, port }, index) => {
89
+ 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)}`;
91
+ return { destination: `${host}:${port}`, interface: names[index], command, lastInputAt: null, bytes: 0 };
92
+ });
93
+ 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) => {
96
+ const event = events.get(id);
97
+ 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; } }));
99
+ if (!timer) { timer = setInterval(prune, 1000); timer.unref?.(); }
100
+ return { command: sources[0].command, commands: sources.map(({ command, destination }) => ({ command, destination })), expiresAt: session.expiresAt, sessionId: session.id };
101
+ },
102
+ accepts(token) {
103
+ prune();
104
+ return typeof token === 'string' && /^[a-f0-9]{64}$/.test(token) && !!session?.token
105
+ && timingSafeEqual(Buffer.from(token), Buffer.from(session.token));
106
+ },
107
+ ingest(token, chunk, source = 0) {
108
+ if (!this.accepts(token)) return false;
109
+ if (chunk.length > 262_144) throw new Error('Capture chunk too large.');
110
+ if (!Number.isInteger(source) || !parsers[source]) return false;
111
+ session.sources[source].lastInputAt = now();
112
+ session.sources[source].bytes += chunk.length;
113
+ session.lastInputAt = now();
114
+ if (chunk.length) session.state = 'capturing';
115
+ else if (session.state === 'awaiting') session.state = 'receiving';
116
+ session.bytes += chunk.length;
117
+ try { parsers[source].feed(chunk); } catch { stop('error'); return false; }
118
+ return true;
119
+ },
120
+ read() {
121
+ prune();
122
+ return { ok: true, sessionId: session?.id ?? null, state: session?.state ?? 'idle', destination: session?.destination ?? null, interface: session?.interface ?? null,
123
+ startedAt: session?.startedAt ?? null, expiresAt: session?.expiresAt ?? null, lastInputAt: session?.lastInputAt ?? null,
124
+ sources: session?.sources.map(({ command, ...source }) => ({ ...source, active: source.lastInputAt !== null && now() - source.lastInputAt <= 5000 && !!session.token })) ?? [],
125
+ observations: [...observations],
126
+ bytes: session?.bytes ?? 0, issues: { ...session?.issues },
127
+ events: [...events.values()].reverse().map(({ payload, protobufBase64, chars, flow, ...event }) => event),
128
+ };
129
+ },
130
+ detail(id) { prune(); const event = events.get(id); return event ? { payload: event.payload, protobufBase64: event.protobufBase64 } : null; },
131
+ stop,
132
+ clear() { events.clear(); observations = []; chars = 0; },
133
+ dispose() { stop(); events.clear(); observations = []; if (timer) clearInterval(timer); timer = null; },
134
+ };
135
+ }
136
+ export const trafficCapture = createTrafficCapture();