ccdeck 3.44.0 → 3.46.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 +21 -9
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/bin/cli/startup.js +4 -3
- package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
- package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
- package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
- package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
- package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
- package/dist/web/assets/app-BlRY6EME.css +1 -0
- package/dist/web/assets/app-DhGvSH7o.js +319 -0
- package/dist/web/assets/{index-BQbOfxbL.js → index-DmENqVdt.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/release-notes.json +48 -0
- package/src/server/account-notify.mjs +68 -6
- package/src/server/account-watch.mjs +61 -2
- package/src/server/claude-accounts.mjs +45 -19
- package/src/server/claude-login.mjs +111 -0
- package/src/server/cswap-admin.mjs +12 -3
- package/src/server/deck-prefs.mjs +15 -5
- package/src/server/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +6 -2
- package/src/server/installer.mjs +24 -7
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/login-service.mjs +2 -2
- package/src/server/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/request-gates.mjs +19 -0
- package/src/server/skill-hook.mjs +28 -22
- package/src/server/system-metrics.mjs +4 -0
- package/src/server/tool-audit-routes.mjs +4 -0
- package/src/server/traffic-radar-config.mjs +33 -0
- package/src/server/traffic-radar-platform.mjs +84 -17
- package/src/server/traffic-radar-service.mjs +6 -3
- package/src/server/traffic-radar.mjs +32 -9
- package/src/server/usage-day.mjs +2 -0
- package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
- package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
- package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +0 -8
- package/dist/web/assets/app-CQIstkMF.js +0 -318
- package/dist/web/assets/app-Ch-0CYlK.css +0 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Whether the deck may add the UserPromptExpansion hook that Skill activity
|
|
2
|
-
// counts typed slash commands with (#2181)
|
|
3
|
-
// the
|
|
2
|
+
// counts typed slash commands with (#2181). It is the only signal for them: a
|
|
3
|
+
// typed skill never calls the Skill tool, and UserPromptSubmit also carries a
|
|
4
|
+
// prompt that only starts like a command (`/etc is full`, an unknown command
|
|
5
|
+
// Claude Code hands to the model), so without this entry the Skills tab counts
|
|
6
|
+
// none of the commands a person types. On by default, behind the version gate.
|
|
4
7
|
//
|
|
5
8
|
// WHY THIS ENTRY IS GATED HARDER THAN STOPFAILURE. A hook event name Claude
|
|
6
9
|
// Code does not know is not skipped by every version. Up to 2.1.100 it made
|
|
@@ -23,7 +26,8 @@
|
|
|
23
26
|
// it costs the user every setting they have.
|
|
24
27
|
//
|
|
25
28
|
// WHAT HAS TO BE TRUE before the entry is written:
|
|
26
|
-
// - the deck was started with AGENTS_DECK_SKILL_HOOK
|
|
29
|
+
// - the deck was not started with AGENTS_DECK_SKILL_HOOK turned off (any
|
|
30
|
+
// value but 1; unset or empty is on);
|
|
27
31
|
// - at least one Claude Code was found, the way the deck finds `claude`: the
|
|
28
32
|
// candidate list (claude-dir.mjs) and AGENTS_DECK_CLAUDE;
|
|
29
33
|
// - EVERY one found says which version it is, and every one is 2.1.116 or
|
|
@@ -31,11 +35,11 @@
|
|
|
31
35
|
// one that will not say its version is treated as an old one.
|
|
32
36
|
// A version is read off disk first (claude-dir.mjs, no child process). Only
|
|
33
37
|
// for an install whose layout does not name one — a wrapper script, an unusual
|
|
34
|
-
// prefix — and only
|
|
38
|
+
// prefix — and only while the entry is not turned off, is that binary asked
|
|
35
39
|
// `--version`, by its full path and as argv, and believed only when it answers
|
|
36
40
|
// the way Claude Code does: `<x.y.z> (Claude Code)`.
|
|
37
41
|
//
|
|
38
|
-
// Every boot re-decides. A machine that stops passing — the
|
|
42
|
+
// Every boot re-decides. A machine that stops passing — the entry turned off, a
|
|
39
43
|
// Claude Code rolled back, a second, older one installed — has the entry taken
|
|
40
44
|
// back out by the next start (installer.mjs), and the user's own
|
|
41
45
|
// UserPromptExpansion hooks are left where they are.
|
|
@@ -56,13 +60,15 @@ export const SKILL_HOOK_SINCE = claudeVersionAtLeast(USER_PROMPT_EXPANSION_SINCE
|
|
|
56
60
|
? USER_PROMPT_EXPANSION_SINCE
|
|
57
61
|
: UNKNOWN_EVENT_TOLERATED_SINCE;
|
|
58
62
|
|
|
59
|
-
/** Set to "
|
|
63
|
+
/** Set to "0" to leave the entry out. Unset, empty or "1" is on. */
|
|
60
64
|
export const SKILL_HOOK_ENV = "AGENTS_DECK_SKILL_HOOK";
|
|
61
65
|
|
|
62
|
-
/** Whether the person starting the deck
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
/** Whether the person starting the deck left the entry on. Any value but "1"
|
|
67
|
+
* turns it off, so a "false", an "off" or a typo is honoured as a no rather
|
|
68
|
+
* than read as a yes; only unset or empty is no instruction at all. */
|
|
69
|
+
export function skillHookEnabled(env = process.env) {
|
|
70
|
+
const value = env?.[SKILL_HOOK_ENV];
|
|
71
|
+
return value === undefined || value === "" || value === "1";
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/** A `--version` that is not quick is not Claude Code's fast path. */
|
|
@@ -92,7 +98,7 @@ async function askVersion(runImpl, bin, timeout) {
|
|
|
92
98
|
/**
|
|
93
99
|
* The decision, and why:
|
|
94
100
|
*
|
|
95
|
-
* { install: false, state: "off", reason: "
|
|
101
|
+
* { install: false, state: "off", reason: "turned-off" }
|
|
96
102
|
* { install: false, state: "skipped", reason: "no-claude" | "version-unknown" | "too-old", versions }
|
|
97
103
|
* { install: true, state: "on", versions }
|
|
98
104
|
*
|
|
@@ -105,15 +111,15 @@ async function askVersion(runImpl, bin, timeout) {
|
|
|
105
111
|
*/
|
|
106
112
|
export async function skillHookDecision({
|
|
107
113
|
env = process.env,
|
|
108
|
-
|
|
114
|
+
enabled = skillHookEnabled(env),
|
|
109
115
|
installs,
|
|
110
116
|
runImpl = run,
|
|
111
117
|
timeout = PROBE_TIMEOUT_MS,
|
|
112
118
|
} = {}) {
|
|
113
119
|
const since = SKILL_HOOK_SINCE;
|
|
114
|
-
// Off is decided before anything is looked at: a deck
|
|
115
|
-
// spends nothing on the question, and spawns nothing.
|
|
116
|
-
if (!
|
|
120
|
+
// Off is decided before anything is looked at: a deck told to leave the
|
|
121
|
+
// entry out spends nothing on the question, and spawns nothing.
|
|
122
|
+
if (!enabled) return { install: false, state: "off", reason: "turned-off", since, versions: [] };
|
|
117
123
|
let found;
|
|
118
124
|
try {
|
|
119
125
|
found = Array.isArray(installs) ? [...installs] : claudeCodeInstalls();
|
|
@@ -140,16 +146,16 @@ export async function skillHookDecision({
|
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
/**
|
|
143
|
-
* The boot report's row for the entry — `{ ok, detail }` — or null when
|
|
144
|
-
* is nothing to say, which is the default
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
149
|
+
* The boot report's row for the entry — `{ ok: false, detail }` — or null when
|
|
150
|
+
* there is nothing to say: installed, which is the default and needs no line,
|
|
151
|
+
* or turned off by the person starting the deck, who knows. Only an entry the
|
|
152
|
+
* gate or the file kept out is said, since then the Skills tab counts no typed
|
|
153
|
+
* command. `dash` is the screen's own (bin/cli/screen.js), so the line carries
|
|
154
|
+
* no punctuation a legacy console cannot draw.
|
|
148
155
|
*/
|
|
149
156
|
export function skillHookStatus(decision, dash = "-") {
|
|
150
|
-
if (!decision || decision.state === "off") return null;
|
|
157
|
+
if (!decision || decision.state === "off" || decision.state === "on") return null;
|
|
151
158
|
const found = (decision.versions ?? []).map(v => v ?? "unknown").join(", ");
|
|
152
|
-
if (decision.state === "on") return { ok: true, detail: `on ${dash} counts typed slash commands (Claude Code ${found})` };
|
|
153
159
|
const why = {
|
|
154
160
|
"no-claude": "no Claude Code found",
|
|
155
161
|
"version-unknown": `a Claude Code here does not say its version (found: ${found})`,
|
|
@@ -264,6 +264,10 @@ function loadSeries(at, coreCount) {
|
|
|
264
264
|
warnAt: coreCount,
|
|
265
265
|
critAt: null,
|
|
266
266
|
restsAtZero: false,
|
|
267
|
+
// What the chart's reading divides by, so a point read after the fact says
|
|
268
|
+
// how many times over the cores it was — the live note's figure, for a
|
|
269
|
+
// minute that is no longer live.
|
|
270
|
+
cores: coreCount,
|
|
267
271
|
points,
|
|
268
272
|
}];
|
|
269
273
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { toolAudit } from './tool-audit.mjs';
|
|
2
2
|
import { readBody, send } from './http-io.mjs';
|
|
3
|
+
import { skillHookState } from './installer.mjs';
|
|
4
|
+
import { deckProviders } from './deck-scope.mjs';
|
|
3
5
|
const calendarDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value ?? '') && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
4
6
|
export function auditQuery(params) {
|
|
5
7
|
const provider = params.get('provider') ?? 'all';
|
|
@@ -31,6 +33,8 @@ export async function handleToolAudit(req, res) {
|
|
|
31
33
|
try { report = await audit.report(query, controller.signal); }
|
|
32
34
|
catch (e) { if (controller.signal.aborted) return; throw e; }
|
|
33
35
|
finally { res.off('close', cancel); }
|
|
36
|
+
// The Skills tab says when the commands a person types cannot be counted.
|
|
37
|
+
if (report.ok && query.view === 'skills' && deckProviders().claude) report.typedCommands = await skillHookState();
|
|
34
38
|
return send(res, report.ok ? 200 : 409, report);
|
|
35
39
|
}
|
|
36
40
|
let body; try { body = JSON.parse(await readBody(req, res, 2048)); } catch { return send(res, 400, { ok: false, reason: 'bad_body' }); }
|
|
@@ -58,6 +58,39 @@ export function radarVariables(settings, source) {
|
|
|
58
58
|
}));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export const LAUNCH_SOURCE = "Process launch environment";
|
|
62
|
+
|
|
63
|
+
/** Allowlisted telemetry variables from a NUL-separated /proc environ. Every
|
|
64
|
+
* other variable, credentials included, is dropped while parsing. */
|
|
65
|
+
export function environVariables(buffer) {
|
|
66
|
+
const env = {};
|
|
67
|
+
for (const entry of buffer.toString("utf8").split("\0")) {
|
|
68
|
+
const split = entry.indexOf("=");
|
|
69
|
+
const key = entry.slice(0, split);
|
|
70
|
+
if (split > 0 && RADAR_KEYS.includes(key)) env[key] = entry.slice(split + 1);
|
|
71
|
+
}
|
|
72
|
+
return radarVariables({ env }, LAUNCH_SOURCE);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One row per variable and value across running Claude processes, so sessions
|
|
76
|
+
* launched alike read as one source; the lowest PID keeps rows for different
|
|
77
|
+
* values of a variable apart. Launch values are evidence, never switches. */
|
|
78
|
+
export function launchVariables(processes) {
|
|
79
|
+
const rows = new Map();
|
|
80
|
+
for (const { pid, variables } of [...processes].sort((a, b) => a.pid - b.pid)) {
|
|
81
|
+
for (const variable of variables) {
|
|
82
|
+
const id = JSON.stringify([variable.key, variable.value, variable.rawValue ?? null]);
|
|
83
|
+
const row = rows.get(id);
|
|
84
|
+
if (row) row.more++;
|
|
85
|
+
else rows.set(id, { variable, pid, more: 0 });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...rows.values()].map(({ variable, pid, more }) => ({
|
|
89
|
+
...variable, source: `${LAUNCH_SOURCE} · PID ${pid}${more ? ` and ${more} more` : ""}`, launch: true,
|
|
90
|
+
...(BOOLEAN_KEYS.includes(variable.key) ? { editable: false } : {}),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
async function readBoundedJson(path) {
|
|
62
95
|
const file = await open(path, "r");
|
|
63
96
|
try {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// OS-specific inspection stays here; the Radar history and decoder share one model.
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import {
|
|
3
|
+
import { open, readdir, readlink } from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { win32 } from 'node:path';
|
|
6
|
+
import { environVariables } from './traffic-radar-config.mjs';
|
|
6
7
|
|
|
7
8
|
export function systemCommand(file, args) {
|
|
8
9
|
return new Promise(resolve => execFile(file, args, { timeout: 8000, maxBuffer: 262144, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {
|
|
@@ -16,32 +17,98 @@ export function windowsSockets(pids) {
|
|
|
16
17
|
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
|
}
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
// The native installer runs ~/.local/share/claude/versions/<version>. Started by
|
|
21
|
+
// that path rather than the `claude` link, a process's comm is its version, so
|
|
22
|
+
// only those candidates have their executable read: no full /proc scan, no argv.
|
|
23
|
+
export async function linuxVersionedClaude(text, { link = readlink } = {}) {
|
|
24
|
+
const candidates = [];
|
|
25
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
26
|
+
const match = /^\s*(\d+)\s+\d+\.\d+\.\d+\s*$/.exec(line);
|
|
27
|
+
if (match) candidates.push(Number(match[1]));
|
|
28
|
+
}
|
|
29
|
+
const confirmed = await Promise.all(candidates.slice(0, 256).map(async pid =>
|
|
30
|
+
/\/claude\/versions\/\d+\.\d+\.\d+(?: \(deleted\))?$/.test(await link(`/proc/${pid}/exe`).catch(() => '')) ? pid : null));
|
|
31
|
+
return confirmed.filter(pid => pid !== null);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// /proc files report size 0 and answer a page per read. Node 18's
|
|
35
|
+
// fs.promises.readFile stops after that first page, which cut a busy machine's
|
|
36
|
+
// socket table to its first ~27 rows, so /proc is read to EOF within a bound.
|
|
37
|
+
export async function readProc(path, max, openFile = open) {
|
|
38
|
+
const file = await openFile(path, 'r');
|
|
39
|
+
try {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
const chunk = Buffer.alloc(65_536);
|
|
42
|
+
let size = 0;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
|
|
45
|
+
if (!bytesRead) return Buffer.concat(chunks, size);
|
|
46
|
+
size += bytesRead;
|
|
47
|
+
if (size > max) return null;
|
|
48
|
+
chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
|
|
49
|
+
}
|
|
50
|
+
} finally { await file.close(); }
|
|
51
|
+
}
|
|
52
|
+
export const readEnviron = path => readProc(path, 262_144);
|
|
53
|
+
async function readTable(path) {
|
|
54
|
+
const table = await readProc(path, 67_108_864);
|
|
55
|
+
if (!table) throw Object.assign(new Error('Socket table too large.'), { code: 'EFBIG' });
|
|
56
|
+
return table.toString('utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// /proc avoids an ss/lsof dependency and reads socket ownership, cwd and the
|
|
60
|
+
// launch environment only for selected Claude PIDs. Established sockets and
|
|
61
|
+
// attempts still awaiting a reply (SYN-SENT, as lsof's TST field) are kept. A
|
|
62
|
+
// process this user may not inspect, such as another user's Claude, is counted
|
|
63
|
+
// instead of hiding every other process; with none readable the sample is unavailable.
|
|
64
|
+
export async function linuxSockets(pids, { read = readTable, list = readdir, link = readlink, readEnv = readEnviron } = {}) {
|
|
65
|
+
const sockets = [], cwd = [], launch = [];
|
|
66
|
+
let unavailable = false, inspected = 0, denied = 0;
|
|
67
|
+
// A namespace's table lists every socket on the machine (thousands of rows on
|
|
68
|
+
// a busy desktop) and Claude processes usually share one, so each table is
|
|
69
|
+
// parsed once per sample into peers by socket inode. A failed shared read is
|
|
70
|
+
// retried through the next process rather than reused.
|
|
71
|
+
const tables = new Map();
|
|
72
|
+
const peers = async (pid, family, ns) => {
|
|
73
|
+
const key = ns && `${ns}|${family}`;
|
|
74
|
+
if (key && tables.has(key)) { try { return await tables.get(key); } catch {} }
|
|
75
|
+
const parsed = read(`/proc/${pid}/net/${family}`, 'utf8').then(table => {
|
|
76
|
+
const byInode = new Map();
|
|
77
|
+
for (const row of table.trim().split('\n').slice(1)) {
|
|
78
|
+
const fields = row.trim().split(/\s+/);
|
|
79
|
+
if (!['01', '02'].includes(fields[3])) continue;
|
|
80
|
+
const [hex, port] = fields[2].split(':');
|
|
81
|
+
const bytes = Buffer.from(hex, 'hex');
|
|
82
|
+
for (let i = 0; i < bytes.length; i += 4) bytes.subarray(i, i + 4).reverse();
|
|
83
|
+
const address = family === 'tcp' ? [...bytes].join('.') : '[' + Array.from({ length: 8 }, (_, i) => bytes.readUInt16BE(i * 2).toString(16)).join(':') + ']';
|
|
84
|
+
byInode.set(fields[9], `${address}:${parseInt(port, 16)}${fields[3] === '02' ? '\nTST=SYN_SENT' : ''}`);
|
|
85
|
+
}
|
|
86
|
+
return byInode;
|
|
87
|
+
});
|
|
88
|
+
if (key) tables.set(key, parsed);
|
|
89
|
+
return parsed;
|
|
90
|
+
};
|
|
23
91
|
await Promise.all(pids.map(async pid => {
|
|
24
92
|
try {
|
|
25
93
|
const descriptors = await list(`/proc/${pid}/fd`);
|
|
26
94
|
const owned = new Set((await Promise.all(descriptors.map(fd => link(`/proc/${pid}/fd/${fd}`).catch(() => ''))))
|
|
27
95
|
.map(value => /^socket:\[(\d+)\]$/.exec(value)?.[1]).filter(Boolean));
|
|
96
|
+
const ns = await link(`/proc/${pid}/ns/net`).then(value => /^net:\[\d+\]$/.test(value) ? value : null, () => null);
|
|
28
97
|
for (const family of ['tcp', 'tcp6']) {
|
|
29
|
-
const
|
|
30
|
-
for (const
|
|
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
|
-
}
|
|
98
|
+
const byInode = await peers(pid, family, ns);
|
|
99
|
+
for (const inode of owned) if (byInode.has(inode)) sockets.push(`p${pid}\nnlocal->${byInode.get(inode)}`);
|
|
39
100
|
}
|
|
40
101
|
const path = await link(`/proc/${pid}/cwd`).catch(() => null);
|
|
41
102
|
if (path) cwd.push(`p${pid}\nn${path}`);
|
|
42
|
-
|
|
103
|
+
const environ = await readEnv(`/proc/${pid}/environ`).catch(() => null);
|
|
104
|
+
if (environ) launch.push({ pid, variables: environVariables(environ) });
|
|
105
|
+
inspected++;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') denied++;
|
|
108
|
+
else if (error.code !== 'ENOENT' && error.code !== 'ESRCH') unavailable = true;
|
|
109
|
+
}
|
|
43
110
|
}));
|
|
44
|
-
return { sockets: unavailable ? null : sockets.join('\n'), cwd: cwd.join('\n') };
|
|
111
|
+
return { sockets: unavailable || (denied && !inspected) ? null : sockets.join('\n'), cwd: cwd.join('\n'), denied, launch };
|
|
45
112
|
}
|
|
46
113
|
export function captureInterfaceValid(name, platform) {
|
|
47
114
|
return typeof name === 'string' && (platform === 'win32'
|
|
@@ -2,15 +2,18 @@ import { CaptureSetupError } from './traffic-radar-platform.mjs';
|
|
|
2
2
|
import { isIP } from 'node:net';
|
|
3
3
|
|
|
4
4
|
export function radarTargets(snapshot) {
|
|
5
|
-
|
|
5
|
+
// A running Claude's launch environment names a collector as settings do.
|
|
6
|
+
const variables = [...(snapshot?.config?.variables ?? []), ...(snapshot?.environment ?? [])];
|
|
7
|
+
const configured = variables.filter(v => v.key.endsWith('ENDPOINT')).flatMap(({ value }) => {
|
|
6
8
|
try {
|
|
7
9
|
const url = new URL(value);
|
|
8
10
|
return ['http:', 'https:'].includes(url.protocol) && isIP(url.hostname) === 4
|
|
9
11
|
? [`${url.hostname}:${url.port || (url.protocol === 'https:' ? 443 : 80)}`] : [];
|
|
10
12
|
} catch { return []; }
|
|
11
13
|
});
|
|
12
|
-
// An arbitrary Claude HTTPS connection is not evidence of a collector
|
|
13
|
-
|
|
14
|
+
// An arbitrary Claude HTTPS connection is not evidence of a collector, nor an
|
|
15
|
+
// attempt nothing has answered.
|
|
16
|
+
const observed = (snapshot?.connections ?? []).filter(c => c.active === true && !c.connecting && /:4317$/.test(c.destination) && isIP(c.destination.slice(0, c.destination.lastIndexOf(':'))) === 4).map(c => c.destination);
|
|
14
17
|
return { configured: [...new Set(configured)], observed: [...new Set(observed)] };
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { systemCommand, powershell, WINDOWS_PROCESSES, windowsSockets, linuxSockets } from './traffic-radar-platform.mjs';
|
|
4
|
-
import { readRadarConfig } from "./traffic-radar-config.mjs";
|
|
3
|
+
import { systemCommand, powershell, WINDOWS_PROCESSES, windowsSockets, linuxSockets, linuxVersionedClaude } from './traffic-radar-platform.mjs';
|
|
4
|
+
import { launchVariables, readRadarConfig } from "./traffic-radar-config.mjs";
|
|
5
5
|
|
|
6
6
|
export function claudePids(text) {
|
|
7
7
|
const pids = [];
|
|
@@ -15,14 +15,20 @@ export function claudePids(text) {
|
|
|
15
15
|
return [...new Set(pids)];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// A TST=SYN_SENT line (only Linux /proc rows carry one) marks the socket before
|
|
19
|
+
// it as an attempt still awaiting a reply. A destination is connecting only
|
|
20
|
+
// while every socket to it is such an attempt.
|
|
18
21
|
export function parseRadarSockets(text, pids) {
|
|
19
22
|
const allowed = new Set(pids);
|
|
20
23
|
const sockets = new Map();
|
|
21
24
|
let pid = null;
|
|
25
|
+
let last = null;
|
|
22
26
|
for (const rawLine of String(text ?? "").split("\n")) {
|
|
23
27
|
const line = rawLine.trimEnd();
|
|
24
|
-
if (/^p\d+$/.test(line)) pid = Number(line.slice(1));
|
|
28
|
+
if (/^p\d+$/.test(line)) { pid = Number(line.slice(1)); last = null; }
|
|
29
|
+
if (line === "TST=SYN_SENT" && last) { last.attempts++; continue; }
|
|
25
30
|
if (!allowed.has(pid) || !line.startsWith("n")) continue;
|
|
31
|
+
last = null;
|
|
26
32
|
const peer = line.slice(1).split("->")[1];
|
|
27
33
|
if (!peer) continue;
|
|
28
34
|
const match = /^(\[[0-9a-fA-F:.%]+\]|[0-9.]+):(\d+)$/.exec(peer);
|
|
@@ -30,9 +36,12 @@ export function parseRadarSockets(text, pids) {
|
|
|
30
36
|
const port = Number(match[2]);
|
|
31
37
|
if (port < 1 || port > 65535) continue;
|
|
32
38
|
const destination = `${match[1]}:${port}`;
|
|
33
|
-
|
|
39
|
+
const key = `${pid}|${destination}`;
|
|
40
|
+
last = sockets.get(key) ?? { pid, destination, count: 0, attempts: 0 };
|
|
41
|
+
last.count++;
|
|
42
|
+
sockets.set(key, last);
|
|
34
43
|
}
|
|
35
|
-
return [...sockets.values()];
|
|
44
|
+
return [...sockets.values()].map(({ pid, destination, count, attempts }) => attempts === count ? { pid, destination, connecting: true } : { pid, destination });
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
export function parseRadarWorkspaces(text, pids, home = homedir()) {
|
|
@@ -50,7 +59,7 @@ export function parseRadarWorkspaces(text, pids, home = homedir()) {
|
|
|
50
59
|
return workspaces;
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
export function createTrafficRadar({ platform = process.platform, now = Date.now, run = systemCommand, config = readRadarConfig, readLinuxSockets = linuxSockets } = {}) {
|
|
62
|
+
export function createTrafficRadar({ platform = process.platform, now = Date.now, run = systemCommand, config = readRadarConfig, readLinuxSockets = linuxSockets, findLinuxClaude = linuxVersionedClaude } = {}) {
|
|
54
63
|
let pending = null;
|
|
55
64
|
let cached = null;
|
|
56
65
|
let previousAt = null;
|
|
@@ -84,11 +93,15 @@ export function createTrafficRadar({ platform = process.platform, now = Date.now
|
|
|
84
93
|
if (/^OTEL_LOG_/.test(variable) && value.startsWith("Enabled")) addAlert("Content capture configuration changed to enabled. Review settings below.", sampledAt);
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
|
-
const pids =
|
|
96
|
+
const pids = platform === "linux" && processes !== null
|
|
97
|
+
? [...new Set([...claudePids(processes), ...await findLinuxClaude(processes)])].sort((a, b) => a - b)
|
|
98
|
+
: claudePids(processes);
|
|
88
99
|
const selected = pids.slice(0, 64);
|
|
89
100
|
let sockets = "";
|
|
90
101
|
let cwd = "";
|
|
91
|
-
|
|
102
|
+
let denied = 0;
|
|
103
|
+
let launch = [];
|
|
104
|
+
if (selected.length && platform === "linux") ({ sockets, cwd, denied = 0, launch = [] } = await readLinuxSockets(selected));
|
|
92
105
|
else if (selected.length && platform === "win32") sockets = await run("powershell.exe", powershell(windowsSockets(selected)));
|
|
93
106
|
else if (selected.length) [sockets, cwd] = await Promise.all([
|
|
94
107
|
run("/usr/sbin/lsof", ["-nP", "-a", "-p", selected.join(","), "-iTCP", "-sTCP:ESTABLISHED", "-Fpn"]),
|
|
@@ -106,13 +119,23 @@ export function createTrafficRadar({ platform = process.platform, now = Date.now
|
|
|
106
119
|
if (previousAt !== null && previousConfig && !destinations.has(connection.destination)) addAlert(`New Claude connection destination: ${connection.destination}`, sampledAt);
|
|
107
120
|
destinations.add(connection.destination);
|
|
108
121
|
seen.set(key, { ...connection, workspace: workspaces.get(connection.pid) ?? null, firstSeenAt: sampledAt, lastSeenAt: sampledAt });
|
|
109
|
-
} else
|
|
122
|
+
} else {
|
|
123
|
+
const entry = seen.get(key);
|
|
124
|
+
entry.lastSeenAt = sampledAt;
|
|
125
|
+
if (connection.connecting) entry.connecting = true;
|
|
126
|
+
else delete entry.connecting;
|
|
127
|
+
}
|
|
110
128
|
}
|
|
111
129
|
for (const [key, connection] of seen) if (sampledAt - connection.lastSeenAt > 300_000) seen.delete(key);
|
|
112
130
|
while (seen.size > 100) seen.delete(seen.keys().next().value);
|
|
113
131
|
previousAt = sampledAt;
|
|
114
132
|
previousConfig = nextConfig;
|
|
133
|
+
// Launch values sit beside the settings, not in them: processes starting
|
|
134
|
+
// and stopping are not configuration changes, and a saved switch replaces
|
|
135
|
+
// only the settings.
|
|
136
|
+
const environment = launchVariables(launch);
|
|
115
137
|
return { ...base, status, config: configuration, processCount: pids.length, limited: pids.length > selected.length,
|
|
138
|
+
...(denied ? { uninspected: denied } : {}), ...(environment.length ? { environment } : {}),
|
|
116
139
|
connections: [...seen.entries()].map(([key, value]) => ({ ...value, active: status === "observing" ? active.has(key) : null }))
|
|
117
140
|
.sort((a, b) => b.lastSeenAt - a.lastSeenAt), alerts };
|
|
118
141
|
}
|
package/src/server/usage-day.mjs
CHANGED
|
@@ -49,6 +49,8 @@ export const FEATURES = Object.freeze([
|
|
|
49
49
|
// What the server saw done.
|
|
50
50
|
"account-switch", "accounts-manage", "auto-switch", "lan-pairing", "feedback-sent",
|
|
51
51
|
"self-update", "clear-board",
|
|
52
|
+
// A connected tracker's panel was read (#2178, #2179): the provider, never the connection.
|
|
53
|
+
"jira", "github", "gitlab",
|
|
52
54
|
// Which CLI the day's sessions came from.
|
|
53
55
|
"claude-sessions", "codex-sessions",
|
|
54
56
|
]);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{j as e,r,n as Q,o as _,q as z,v as Y,t as V,w as X,x as Z,y as ee,s as se,u as te,g as ne,z as ae,A as ie,R as re,k as oe}from"./app-CQIstkMF.js";function le({snap:s}){return e.jsxs("section",{className:"bw-feed",children:[e.jsxs("h4",{className:"bw-sec-head bw-head-row",children:["Live activity",e.jsxs("span",{className:"bw-feed-count",children:[s.log.length," ",s.log.length===1?"event":"events"]})]}),e.jsx("p",{className:"bw-since bw-feed-note",children:"Pages you opened yourself — counted to run the rule, never written down. Addresses appear under Findings, and only for pages a program opened."}),e.jsx("div",{className:"bw-log","aria-live":"polite","aria-relevant":"additions",children:s.log.length===0?e.jsx("p",{className:"bw-note",children:"The deck writes a line here each time it looks at a profile."}):s.log.map(a=>e.jsxs("div",{className:`bw-log-line ${a.level}${a.parts?"":" sys"}`,children:[e.jsx("span",{className:"bw-log-time",children:new Date(a.atMs).toLocaleTimeString("en-GB",{hour12:!1})}),a.parts?e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"bw-log-browser",children:a.parts.browser}),e.jsx("span",{className:"bw-log-profile",title:a.parts.profile,children:a.parts.profile}),e.jsxs("span",{className:"bw-log-value",children:[a.parts.value,a.parts.flagged>0&&e.jsxs("span",{className:"bw-log-flag",children:[" · ",a.parts.flagged," flagged"]})]})]}):e.jsx("span",{className:"bw-log-sys",children:a.text})]},`${a.atMs}-${a.level}-${a.text}`))})]})}const E=s=>`${s.host}-${s.startMs}`;function ce(s){const a=h=>new Date(h).toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",hour12:!1});return s.count===1||s.endMs-s.startMs<6e4?a(s.startMs):`${a(s.startMs)} → ${a(s.endMs)}`}function de(s){return new Date(s).toLocaleDateString(void 0,{weekday:"short",day:"numeric",month:"short"})}function K(s){const a=Math.round((s.endMs-s.startMs)/6e4);return a<1?"":`${a} min`}function he({snap:s,dismiss:a,refreshRef:h}){const[c,d]=r.useState(null),u=r.useRef(new Map),t=r.useRef(null);r.useEffect(()=>{var i,w;const o=t.current;!o||s.episodes.some(b=>E(b)===o.id)||(t.current=null,Q(((i=document.activeElement)==null?void 0:i.tagName)??null)&&((w=(o.next?u.current.get(o.next):void 0)??h.current)==null||w.focus()))},[s]);const n=o=>{t.current={id:E(o),next:_(s.episodes.map(E),E(o))},a(o)},l=r.useMemo(()=>{const o=[];for(const i of s.episodes){const w=de(i.startMs),b=o[o.length-1];b&&b.label===w?b.episodes.push(i):o.push({label:w,episodes:[i]})}return o},[s]);return e.jsxs("section",{className:"bw-findings",children:[e.jsxs("h4",{className:"bw-sec-head bw-head-row",children:["Findings",s.episodes.length>0&&e.jsxs("span",{className:"bw-feed-count",children:[s.episodes.length," ",s.episodes.length===1?"episode":"episodes"]})]}),e.jsx("div",{className:"bw-eps",children:l.length===0?e.jsxs("div",{className:"bw-empty",children:[e.jsx("p",{className:"bw-empty-head",children:"Nothing found yet"}),e.jsxs("p",{className:"bw-empty-note",children:["An episode lands here when a program opens pages in a browser nobody has touched for"," ",s.settings.quietMinutes," ",s.settings.quietMinutes===1?"minute":"minutes",". On most machines that is rare, so an empty list is the ordinary result rather than a sign something is wrong."]}),!s.settings.enabled&&e.jsx("p",{className:"bw-empty-note",children:"Watching is off, so this shows only what this deck has seen since it started. Anything an earlier run recorded comes back when you switch it on."})]}):l.map(o=>e.jsxs("div",{className:"bw-day",children:[e.jsx("h4",{className:"bw-sec-head",children:o.label}),o.episodes.map(i=>{const w=E(i),b=c===w;return e.jsxs("div",{className:`bw-ep${b?" open":""}`,children:[e.jsxs("div",{className:"bw-ep-row",children:[e.jsxs("button",{className:"bw-ep-head",ref:p=>{p?u.current.set(w,p):u.current.delete(w)},onClick:()=>d(b?null:w),"aria-expanded":b,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:b?"▾":"▸"}),e.jsx("span",{className:"bw-ep-host",children:i.host}),e.jsxs("span",{className:"bw-ep-meta",children:[ce(i),K(i)&&e.jsxs(e.Fragment,{children:[" · ",K(i)]}),i.provisional&&e.jsxs(e.Fragment,{children:[" · ",e.jsx("span",{title:"Its quiet window has not closed yet. If somebody used the browser in the minutes after it, it is withdrawn; until then it is not written down and no reaction runs.",children:"provisional"})]})]}),e.jsxs("span",{className:"bw-ep-count",children:[i.count," ",i.count===1?"page":"pages"]})]}),e.jsx("button",{className:"glyph-btn bw-ep-x",onClick:()=>n(i),"aria-label":`Dismiss ${i.host}`,title:"Dismiss — it leaves this list for good, and stays in the log file",children:"×"})]}),b&&e.jsx("ul",{className:"bw-urls",children:i.urls.map((p,y)=>e.jsxs("li",{children:[e.jsx("span",{className:"bw-url-time",children:new Date(p.timeMs).toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",hour12:!1})}),e.jsx("span",{className:"bw-url",children:p.url})]},`${p.url}-${p.timeMs}-${y}`))})]},w)})]},o.label))})]})}function ue(s){const a=/^#?([0-9a-f]{6})$/i.exec((s??"").trim());if(!a)return!1;const h=parseInt(a[1],16),c=[h>>16&255,h>>8&255,h&255].map(d=>{const u=d/255;return u<=.03928?u/12.92:Math.pow((u+.055)/1.055,2.4)});return .2126*c[0]+.7152*c[1]+.0722*c[2]>.5}const U=1e4,we=12e3;function me({browsers:s,findings:a,watching:h,palette:c}){const d=r.useRef(null),u=r.useRef(null);return u.current={browsers:s,findings:a,watching:h},r.useEffect(()=>{var b;const t=d.current;if(!t)return;const n=t.getContext("2d");if(!n)return;const l=((b=window.matchMedia)==null?void 0:b.call(window,"(prefers-reduced-motion: reduce)").matches)??!1;let o=0,i=!1;const w=()=>{var q;if(i)return;const p=u.current;if(!p)return;const{browsers:y,findings:j,watching:P}=p,S=c["--accent"]||"#7dd3fc",g=c["--line"]||"#1f2229",v=c["--text-dim"]||"#6e727c",$=ue(c["--bg"]),N=$?42:26,k=$?.85:.65,W=Date.now(),R=Math.min(window.devicePixelRatio||1,2),C=t.clientWidth,A=t.clientHeight;(t.width!==C*R||t.height!==A*R)&&(t.width=C*R,t.height=A*R),n.setTransform(R,0,0,R,0,0),n.clearRect(0,0,C,A);const m=C/2,x=A/2,M=Math.min(C,A)/2-6;n.strokeStyle=g,n.lineWidth=1,n.globalAlpha=$?1:.9;for(const f of[.4,.7,1])n.beginPath(),n.arc(m,x,M*f,0,Math.PI*2),n.stroke();n.beginPath(),n.moveTo(m-M,x),n.lineTo(m+M,x),n.moveTo(m,x-M),n.lineTo(m,x+M),n.stroke(),n.globalAlpha=1;const I=(P&&!l?W%U/U:0)*Math.PI*2-Math.PI/2;if(P){const f=(q=n.createConicGradient)==null?void 0:q.call(n,I-.85,m,x);f&&(f.addColorStop(0,"transparent"),f.addColorStop(.85/(Math.PI*2),`color-mix(in srgb, ${S} ${N}%, transparent)`),f.addColorStop(1,"transparent"),n.fillStyle=f,n.beginPath(),n.moveTo(m,x),n.arc(m,x,M,I-.85,I),n.closePath(),n.fill()),n.strokeStyle=S,n.globalAlpha=k,n.beginPath(),n.moveTo(m,x),n.lineTo(m+Math.cos(I)*M,x+Math.sin(I)*M),n.stroke(),n.globalAlpha=1}y.forEach((f,T)=>{const B=T/Math.max(y.length,1)*Math.PI*2-Math.PI/2,D=f.lastReadMs===null?1:Math.min(1,(W-f.lastReadMs)/12e4),O=M*(.42+D*.5),F=m+Math.cos(B)*O,L=x+Math.sin(B)*O,J=Math.abs((I-B+Math.PI*3)%(Math.PI*2)-Math.PI),G=P?Math.max(0,1-J/.55):0;n.beginPath(),n.arc(F,L,3+G*1.6,0,Math.PI*2),n.fillStyle=f.running?S:v,n.globalAlpha=f.running?.55+G*.45:.35,n.fill(),n.globalAlpha=1});for(const f of j){const T=(W-f.atMs)/we;if(T<0||T>1)continue;const D=Math.max(0,y.findIndex(L=>L.key===f.browser))/Math.max(y.length,1)*Math.PI*2-Math.PI/2,O=m+Math.cos(D)*M*.6,F=x+Math.sin(D)*M*.6;n.beginPath(),n.arc(O,F,4+T*22,0,Math.PI*2),n.strokeStyle=S,n.globalAlpha=(1-T)*.8,n.lineWidth=1.5,n.stroke(),n.globalAlpha=1}o=requestAnimationFrame(w)};return w(),()=>{i=!0,cancelAnimationFrame(o)}},[c]),e.jsx("canvas",{ref:d,className:"bw-radar",role:"img","aria-label":h?`Watching ${s.length} browser${s.length===1?"":"s"}`:"Not watching"})}function be({snap:s,watching:a,palette:h}){const[c,d]=r.useState(!1),[u,t]=r.useState(()=>Date.now());r.useEffect(()=>{const l=setInterval(()=>t(Date.now()),1e3);return()=>clearInterval(l)},[]);const n=z(s.coverage.lastHumanMs,s.coverage.quietMs,u);return e.jsxs("section",{className:"bw-sec",children:[e.jsxs("h4",{className:"bw-sec-head",children:["Activity overview",e.jsx("button",{className:"bw-help",onClick:()=>d(l=>!l),"aria-expanded":c,"aria-label":"What the sweep and the dots mean",children:"?"})]}),e.jsxs("div",{className:"bw-glance",children:[e.jsx(me,{browsers:a.map(l=>{var o;return{key:l.key,name:l.name,running:l.running,lastReadMs:((o=s.profiles.find(i=>i.browser===l.key))==null?void 0:o.lastWrittenMs)??null}}),findings:s.episodes.slice(0,6).map(l=>({browser:l.browser??null,atMs:l.endMs})),watching:s.settings.enabled,palette:h}),e.jsx("ul",{className:"bw-counts",children:Y(a,s.profiles).map(l=>e.jsxs("li",{children:[e.jsx("span",{className:"bw-count-n",children:l.visits.toLocaleString("en-US")}),e.jsx("span",{className:"bw-count-of",children:l.name})]},l.key))})]}),e.jsx("p",{className:"bw-since",children:"Pages a program opened, since this deck started"}),s.coverage.checkedMs>0&&e.jsxs("p",{className:"bw-since",children:["Last checked ",V(s.coverage.checkedMs,u),s.coverage.checks>0&&e.jsxs(e.Fragment,{children:[" · ",s.coverage.checks.toLocaleString("en-US")," ",s.coverage.checks===1?"check":"checks"]})]}),n!==null&&e.jsxs("p",{className:"bw-gate",children:["You are browsing — a program page would count in ",X(n)]}),c&&e.jsxs("dl",{className:"bw-key",children:[e.jsx("dt",{children:"Sweep"}),e.jsx("dd",{children:"one poll of every watched profile"}),e.jsx("dt",{children:"Dot"}),e.jsx("dd",{children:"a browser, further out the longer since it last wrote history"}),e.jsx("dt",{children:"Ring"}),e.jsx("dd",{children:"a finding, leaving the browser it came from"})]})]})}function pe({watching:s,rest:a}){const[h,c]=r.useState(!1),[d,u]=r.useState(!1);return e.jsxs("section",{className:"bw-sec",children:[e.jsxs("h4",{className:"bw-sec-head",children:["Watched profiles",e.jsx("button",{className:"bw-help",onClick:()=>c(t=>!t),"aria-expanded":h,"aria-label":"What running, idle and connected to Anthropic mean",children:"?"})]}),h&&e.jsxs("dl",{className:"bw-key",children:[e.jsx("dt",{children:"Running"}),e.jsx("dd",{children:"the browser has a process on this machine right now"}),e.jsx("dt",{children:"Connected"}),e.jsx("dd",{children:"an open connection to an address Anthropic's relay uses — which it shares with claude.ai, so an open tab looks the same as an agent channel"})]}),e.jsx("ul",{className:"bw-profiles",children:s.map(t=>e.jsxs("li",{children:[e.jsx("span",{className:`bw-prof-dot${t.running?" on":""}`,"aria-hidden":!0}),e.jsx("span",{className:"bw-prof-name",children:t.name}),e.jsx("span",{className:"bw-prof-state",children:t.running?"running":"idle"}),t.running&&t.relay.state==="live"&&e.jsx("span",{className:"bw-relay",children:"connected to Anthropic"})]},t.key))}),a.length>0&&e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"bw-why bw-rest-head",onClick:()=>u(t=>!t),"aria-expanded":d,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:d?"▾":"▸"})," ",a.length," not watched"]}),d&&e.jsxs("p",{className:"bw-rest",children:[a.map(t=>t.name).join(", ")," — not installed, or installed and never opened, so there is no history to read."]})]})]})}function ge({snap:s,quiet:a,setQuiet:h,save:c,access:d,setAccess:u}){return e.jsxs("div",{className:"bw-settings",children:[e.jsxs("label",{title:"A program opening a page counts as a finding only if nobody had touched the browser for this long. Shorter catches more and reports more of your own work; longer is quieter.",children:[e.jsx("span",{children:"Nobody browsing for"}),e.jsxs("select",{value:a??s.settings.quietMinutes,onChange:t=>{h(Number(t.target.value)),c({quietMinutes:Number(t.target.value)})},children:[e.jsx("option",{value:1,children:"1 min"}),e.jsx("option",{value:5,children:"5 min"}),e.jsx("option",{value:15,children:"15 min"}),e.jsx("option",{value:30,children:"30 min"}),e.jsx("option",{value:60,children:"60 min"})]})]}),e.jsxs("label",{children:[e.jsx("span",{children:"When it finds one"}),e.jsx("select",{value:s.settings.reaction,onChange:t=>void c({reaction:t.target.value}),disabled:!s.settings.enabled,title:s.settings.enabled?"What to do besides writing it down.":"Turn watching on to arm a reaction.",children:(s.reactions??["notify"]).map(t=>e.jsx("option",{value:t,children:t==="notify"?"notify me":t==="close-tab"?"close the tab":"quit the browser"},t))})]}),e.jsxs("div",{className:"bw-settings-note",children:[e.jsxs("p",{children:["Chrome marks a navigation that came from an extension or a command rather than from a click. These are the ones that happened while nobody had touched the browser for the time above. ",e.jsx("strong",{children:"Usually that is your own agent doing what you asked."})]}),e.jsxs("button",{className:"bw-why",onClick:()=>u(t=>!t),"aria-expanded":d,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:d?"▾":"▸"})," What Browser Watch can access"]}),d&&e.jsxs("dl",{className:"bw-access",children:[e.jsx("dt",{children:"Reads"}),e.jsxs("dd",{children:["A copy of each browser's own history database — the live file is locked while the browser holds it. Only rows newer than the moment this deck started:"," ",new Date(s.coverage.startedMs).toLocaleString(),"."]}),e.jsx("dt",{children:"Keeps"}),e.jsxs("dd",{children:["Only while the switch is on, and only the episodes it flagged — never your ordinary browsing. In ",e.jsx("code",{className:"bw-path",children:s.coverage.logPath}),typeof s.coverage.logBytes=="number"?` (${Z(s.coverage.logBytes)})`:"",", with every address written in full so you can check it yourself."]}),e.jsx("dt",{children:"Sends"}),e.jsx("dd",{children:"Nothing. No part of this reads or writes over the network; the deck serves on 127.0.0.1 and this panel talks only to it."}),e.jsx("dt",{children:"Never reads"}),e.jsx("dd",{children:"Anything from before this deck started, cookies, saved passwords, page contents, or any browser profile with no history file."})]})]})]})}function H(s,a){return a?{kind:"saving",word:"Saving"}:s.settings.enabled?{kind:"on",word:"Watching"}:{kind:"off",word:"Paused"}}function fe({snap:s,saving:a,save:h,why:c,setWhy:d}){return e.jsxs("footer",{className:"bw-status",children:[e.jsx("span",{className:`bw-mode-dot${s.settings.enabled?" on":""}`,"aria-hidden":!0}),e.jsx("span",{className:"bw-mode-word",children:H(s,a).word},H(s,a).kind),e.jsx("span",{className:"bw-status-text",children:s.coverage.archived===0?s.settings.enabled?"No episodes recorded yet":"No episodes on disk":`${s.coverage.archived} ${s.coverage.archived===1?"episode":"episodes"} on disk`}),e.jsx("button",{className:`glyph-btn bw-gear${c?" on":""}`,onClick:()=>d(u=>!u),"aria-expanded":c,"aria-label":"Settings",title:"Settings",children:e.jsxs("svg",{width:"13",height:"13",viewBox:"0 0 14 14",fill:"none",stroke:"currentColor",strokeWidth:"1.3","aria-hidden":!0,children:[e.jsx("circle",{cx:"7",cy:"7",r:"2.1"}),e.jsx("path",{d:"M7 1.2v1.6M7 11.2v1.6M1.2 7h1.6M11.2 7h1.6M2.9 2.9l1.1 1.1M10 10l1.1 1.1M11.1 2.9L10 4M4 10l-1.1 1.1"})]})}),e.jsxs("label",{className:"bw-switch",htmlFor:"bw-enabled",children:[e.jsx("span",{className:"bw-switch-label",id:"bw-enabled-label",children:"Watch browser activity"}),e.jsx("button",{id:"bw-enabled",type:"button",role:"switch","aria-checked":s.settings.enabled,"aria-labelledby":"bw-enabled-label",className:"switch",onClick:()=>void h({enabled:!s.settings.enabled}),title:s.settings.enabled?"On — every episode it finds is written down, so the list outlives the browsing history being cleared":"Off — showing only what this deck has seen since it started. Anything an earlier run archived is hidden until you switch back on, and nothing new is kept",children:e.jsx("span",{className:"switch-knob"})})]})]})}function xe({relay:s}){const[a,h]=r.useState(!1),[c,d]=r.useState(!1),u=s.killswitch.blocked?"unblock":"block",t=s.command[u],n=r.useCallback(async()=>{await ee(t.command)&&(d(!0),window.setTimeout(()=>d(!1),1600))},[t.command]),l=s.profiles.filter(i=>{var w;return(w=i.report)==null?void 0:w.present}),o=s.profiles.filter(i=>i.report===null);return e.jsxs("section",{className:"bw-sec",children:[e.jsx("h4",{className:"bw-sec-head",children:"Remote control"}),e.jsx("p",{className:`bw-rc-verdict ${s.verdict}`,role:"status",children:s.verdict==="exposed"?"Claude in Chrome is live here, so any Claude Code session signed in to your Anthropic account can drive this browser — from any machine, with no prompt on this one.":s.verdict==="protected"?`The relay is black-holed in ${s.hostsPath}, so no new connection to it can be made from this machine.`:"Claude in Chrome is not installed and enabled in any profile here, so there is nothing for the relay to drive."}),s.killswitch.foreign.length>0&&e.jsxs("p",{className:"bw-rc-foreign",children:["Another entry for this host is already in ",s.hostsPath,", and ccdeck will not touch it:",s.killswitch.foreign.map(i=>e.jsx("code",{className:"bw-path",children:i},i))]}),e.jsxs("button",{className:"bw-why",onClick:()=>h(i=>!i),"aria-expanded":a,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:a?"▾":"▸"})," ",s.verdict==="protected"?"How to undo the block":"What it can reach, and how to stop it"]}),a&&e.jsxs("div",{className:"bw-rc-body",children:[l.length>0&&e.jsx("dl",{className:"bw-key",children:l.map(i=>e.jsxs(r.Fragment,{children:[e.jsxs("dt",{children:[i.name,i.profile==="Default"?"":` · ${i.profile}`]}),e.jsxs("dd",{children:[i.report.enabled?"enabled":"installed but switched off",i.report.allUrls?", can act on every site":", limited to the sites it was granted",i.report.sensitiveApis.length>0&&e.jsxs(e.Fragment,{children:[" — ",i.report.sensitiveApis.join(", ")]})]})]},`${i.browser}/${i.profile}`))}),o.length>0&&e.jsxs("p",{className:"bw-rc-note",children:[o.map(i=>i.name).join(", ")," — its settings file could not be read, so nothing is claimed about it either way."]}),!s.hostsRead&&e.jsxs("p",{className:"bw-rc-note",children:[s.hostsPath," could not be read, so whether the relay is already blocked is unknown."]}),l.some(i=>i.report.allUrls)&&e.jsxs("p",{className:"bw-rc-note",children:["Narrowing the sites in ",e.jsx("code",{className:"bw-path",children:"chrome://extensions"})," does not change this: the grant is held at the browser level and the per-site list is enforced inside the extension, by the extension."]}),e.jsxs("div",{className:"bw-rc-cmd",children:[e.jsx("code",{children:t.command}),e.jsx("button",{className:"btn bw-rc-copy",onClick:n,children:c?"Copied":"Copy"})]}),e.jsx("p",{className:"bw-rc-note",children:t.note})]})]})}function je(s){const[a,h]=r.useState(null),[c,d]=r.useState(null),[u,t]=r.useState(null),[n,l]=r.useState(!1),o=r.useRef(!1),[i,w]=r.useState(null),[b,p]=r.useState(!1),y=r.useRef(!1),j=r.useCallback(async g=>{var v,$;if(!(g&&!se(o.current))){g&&(o.current=!0,l(!0));try{const N=await fetch(`/api/browser-watch${g?"?refresh=1":""}`);if(!N.ok)throw new Error(`the deck answered ${N.status}`);const k=await N.json();h(k),y.current||w(((v=k==null?void 0:k.settings)==null?void 0:v.quietMinutes)??15),s((($=k==null?void 0:k.settings)==null?void 0:$.enabled)===!0),d(null)}catch(N){d(N instanceof Error?N.message:String(N))}finally{g&&(o.current=!1,l(!1))}}},[s]);r.useEffect(()=>{j(!1)},[j]),r.useEffect(()=>{const g=setInterval(()=>{j(!1)},1e4);return()=>clearInterval(g)},[j]);const P=r.useCallback(async g=>{try{await fetch("/api/browser-watch/dismiss",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({host:g.host,startMs:g.startMs})})}finally{await j(!1)}},[j]),S=r.useCallback(async g=>{y.current=!0,p(!0);try{const v=await fetch("/api/browser-watch",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(g)});if(!v.ok)throw new Error(`the deck answered ${v.status}`);await j(!1),t(null)}catch(v){t(v instanceof Error?v.message:String(v))}finally{y.current=!1,p(!1)}},[j]);return{snap:a,error:c,writeError:u,setWriteError:t,busy:n,quiet:i,setQuiet:w,saving:b,load:j,dismiss:P,save:S}}function Ne({onClose:s,onSeen:a,onWatching:h,palette:c}){const d=te(s),u=ne(s),{snap:t,error:n,writeError:l,setWriteError:o,busy:i,quiet:w,setQuiet:b,saving:p,load:y,dismiss:j,save:P}=je(h),[S,g]=r.useState(!1),[v,$]=r.useState(!1),N=r.useRef(null),k=r.useRef(a);k.current=a,r.useEffect(()=>()=>k.current(Date.now()),[]);const W=ae(t==null?void 0:t.browsers),R=((t==null?void 0:t.browsers)??[]).filter(m=>!(m.installed&&m.profiles>0)),C=t?ie(t):null,A=W.reduce((m,x)=>m+x.profiles,0);return e.jsx("div",{className:"modal-backdrop",...u,role:"presentation",children:e.jsxs("div",{ref:d,className:"modal bw-modal",onClick:m=>m.stopPropagation(),role:"dialog","aria-modal":"true","aria-labelledby":"bw-title",children:[e.jsxs("header",{className:"modal-head",children:[e.jsxs("div",{className:"modal-title",children:[e.jsx("span",{className:"modal-tool-name",id:"bw-title",children:"Browser watch"}),e.jsx("span",{className:"modal-tool-id",children:t?`${A} ${A===1?"profile":"profiles"} · local only`:"local only"})]}),e.jsxs("div",{className:"modal-actions",children:[e.jsx("button",{ref:N,className:"glyph-btn",onClick:()=>void y(!0),...oe(i),"aria-label":"Re-read every profile now",title:"Re-read every profile now",children:i?"…":e.jsx(re,{})}),e.jsx("button",{className:"glyph-btn",onClick:s,"aria-label":"Close (Esc)",title:"Close (Esc)",children:"×"})]})]}),e.jsxs("div",{className:"modal-body bw-body",children:[!t&&!n&&e.jsxs("div",{className:"bw-loading",children:[e.jsx("span",{className:"bw-loading-bar","aria-hidden":!0}),e.jsx("p",{children:"Reading each browser's history. The first look copies the file, which takes a moment."})]}),n&&e.jsx("div",{className:"bw-state",children:e.jsxs("div",{className:"bw-row err",children:[e.jsx("span",{className:"bw-dot","aria-hidden":!0}),e.jsx("span",{className:"bw-row-label",children:"Could not read"}),e.jsx("span",{className:"bw-row-detail",children:n})]})}),t&&C&&e.jsx("p",{className:`bw-trouble ${C.kind}`,role:"status",children:C.text}),t&&e.jsxs("div",{className:"bw-work",children:[e.jsxs("aside",{className:"bw-side",children:[e.jsx(be,{snap:t,watching:W,palette:c}),e.jsx(pe,{watching:W,rest:R}),t.relay&&e.jsx(xe,{relay:t.relay})]}),e.jsxs("div",{className:"bw-main",children:[e.jsx(he,{snap:t,dismiss:j,refreshRef:N}),e.jsx(le,{snap:t})]})]}),t&&S&&e.jsx(ge,{snap:t,quiet:w,setQuiet:b,save:P,access:v,setAccess:$}),l&&e.jsx("div",{className:"bw-state bw-write-err",role:"alert",children:e.jsxs("div",{className:"bw-row err",children:[e.jsx("span",{className:"bw-dot","aria-hidden":!0}),e.jsx("span",{className:"bw-row-label",children:"Could not save that setting"}),e.jsxs("span",{className:"bw-row-detail",children:[l," — the switches below show what is stored, not what you pressed."]}),e.jsx("button",{className:"glyph-btn bw-write-err-x",onClick:()=>o(null),"aria-label":"Dismiss",title:"Dismiss",children:"×"})]})}),t&&e.jsx(fe,{snap:t,saving:p,save:P,why:S,setWhy:g})]})]})})}export{Ne as default};
|