claude-inspect 0.3.0 → 0.4.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.de.md +13 -3
- package/README.md +9 -5
- package/dist/app/server/cli/demo.js +43 -0
- package/dist/app/server/cli.js +5 -2
- package/dist/app/server/config.js +13 -0
- package/dist/app/server/cpu-monitor.js +159 -0
- package/dist/app/server/http.js +4 -0
- package/dist/app/server/inspector.js +22 -0
- package/dist/app/server/model/cpu.js +155 -0
- package/dist/app/server/run.js +2 -1
- package/dist/app/server/sources/cputable.js +100 -0
- package/dist/web/assets/index-CMrlQKvN.css +1 -0
- package/dist/web/assets/index-CjzXdcSM.js +16 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Dl0gPGSQ.css +0 -1
- package/dist/web/assets/index-Yxm7t-xs.js +0 -16
package/README.de.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# claude-inspect
|
|
2
2
|
|
|
3
|
-
> English: [README.md](README.md) · Screenshots: [docs/screenshots](docs/screenshots)
|
|
3
|
+
> English: [README.md](README.md) · Website: [brokoskokoli.github.io/claude_inspect](https://brokoskokoli.github.io/claude_inspect/) · Screenshots: [docs/screenshots](docs/screenshots)
|
|
4
4
|
>
|
|
5
5
|
> Die Oberfläche ist englisch; hell, dunkel oder nach Systemeinstellung (Umschalter oben rechts).
|
|
6
6
|
|
|
7
7
|
Lokales Live-Dashboard für Claude-Code-Agenten. Liest **passiv** die Dateien unter
|
|
8
|
-
`~/.claude`, ohne Hooks
|
|
8
|
+
`~/.claude`, ohne Hooks und ohne Konfigurationsänderung. Das Einzige, was es schreibt, ist ein
|
|
9
|
+
CPU-Protokoll im eigenen Datenverzeichnis (siehe *CPU*).
|
|
9
10
|
|
|
10
11
|
## Start
|
|
11
12
|
|
|
@@ -23,7 +24,8 @@ beim Login und nach Abstürzen neu und läuft ohne Token (nur von localhost erre
|
|
|
23
24
|
|
|
24
25
|
Manuell gestartet gibt `claude-inspect` eine URL mit Zufallstoken (`?t=…`) aus; der Browser merkt
|
|
25
26
|
es sich als Cookie. `--token` für einen festen Token, `--no-auth` für keinen, `--port` für einen
|
|
26
|
-
anderen Port, `CLAUDE_CONFIG_DIR=/pfad` für ein anderes Datenverzeichnis
|
|
27
|
+
anderen Port, `CLAUDE_CONFIG_DIR=/pfad` für ein anderes Datenverzeichnis, `--no-cpu-log` ohne
|
|
28
|
+
CPU-Protokoll, `CLAUDE_INSPECT_DATA_DIR=/pfad` für einen anderen Ablageort des CPU-Protokolls.
|
|
27
29
|
|
|
28
30
|
Aus den Quellen: `git clone …`, `npm install`, `npm run build`, `npm start`
|
|
29
31
|
(`npm run dev` + `npm run dev:web` für die Entwicklung mit Hot-Reload).
|
|
@@ -43,6 +45,14 @@ Aus den Quellen: `git clone …`, `npm install`, `npm run build`, `npm start`
|
|
|
43
45
|
Balken, Aufruf-Verbindungen, Pausen über 10 min zusammengeschoben, Zoom
|
|
44
46
|
- *Call graph*: wer wen aufgerufen hat, inkl. Fork/Fortsetzung
|
|
45
47
|
- *Tasks*: Task-Liste der Session als Board
|
|
48
|
+
- *CPU*: Auslastung des Claude-Prozesses samt aller Unterprozesse (Shells, Builds, Tests,
|
|
49
|
+
MCP-Server …) als gestapelter Verlauf je Programmname, dazu die Prozesse mit der meisten CPU-Zeit
|
|
50
|
+
- **CPU**: alle 3 s aus der Prozesstabelle gemessen (100 % = ein Kern), Verlaufslinie auf der
|
|
51
|
+
Dashboard-Karte. Pro Session eine CSV-Datei im App-Datenverzeichnis (macOS
|
|
52
|
+
`~/Library/Application Support/claude-inspect/cpu`, Linux `~/.local/share/claude-inspect/cpu`,
|
|
53
|
+
Windows `%LOCALAPPDATA%\claude-inspect\cpu`), damit der Verlauf nach Sessionende abrufbar bleibt.
|
|
54
|
+
Dateien älter als 30 Tage werden gelöscht. Gemessen wird nur, solange claude-inspect läuft;
|
|
55
|
+
Prozesse, die kürzer als 3 s leben, fehlen. Abschalten mit `--no-cpu-log`.
|
|
46
56
|
- **Statistics**: API-Gegenwert (Schätzung nach Listenpreisen), Tokens je Modell/Tag/Projekt,
|
|
47
57
|
Cache-Anteil, Aktivitäts-Heatmap, Tool-Statistik mit Fehlern und Dauer (p95)
|
|
48
58
|
- **Tools**: alle Tool-Aufrufe aller Sessions, filterbar; Klick springt an die Stelle im Verlauf
|
package/README.md
CHANGED
|
@@ -12,10 +12,10 @@ claude-inspect is a local web UI that reads the files Claude Code already writes
|
|
|
12
12
|
and shows running sessions, subagents, `claude -p` worker sessions started by other agents,
|
|
13
13
|
every tool call with its result, timelines, a call graph, token usage and a searchable history.
|
|
14
14
|
|
|
15
|
-
It is **read-only and passive
|
|
16
|
-
|
|
15
|
+
It is **read-only and passive** towards `~/.claude`: no hooks, no settings changes, no proxy, no data leaves
|
|
16
|
+
your machine. The only thing it writes is a CPU log in its own data directory (see *CPU usage*).
|
|
17
17
|
|
|
18
|
-
> Deutsch: [README.de.md](README.de.md) · Design notes (German): [KONZEPT.md](KONZEPT.md)
|
|
18
|
+
> Website: [brokoskokoli.github.io/claude_inspect](https://brokoskokoli.github.io/claude_inspect/) · Deutsch: [README.de.md](README.de.md) · Design notes (German): [KONZEPT.md](KONZEPT.md)
|
|
19
19
|
|
|
20
20
|

|
|
21
21
|
|
|
@@ -41,6 +41,7 @@ becomes hard to tell *who is doing what*. claude-inspect answers:
|
|
|
41
41
|
| **Timeline** | One lane per agent, tool calls as bars, spawn links between lanes, idle gaps > 10 min collapsed, zoom. Click a bar to jump to the call. |
|
|
42
42
|
| **Spawn linking** | Links sessions started by `claude -p …` to the calling tool use via the process tree (exact), `--session-id`, prompt text, `--agent`, time window and working directory – with a confidence label. |
|
|
43
43
|
| **Statistics** | API list-price estimate, tokens per model/day/project, cache share, activity heatmap, tool statistics with error rate and p95 duration. |
|
|
44
|
+
| **CPU usage** | CPU of each Claude Code process plus all its subprocesses (shells, builds, test runners, MCP servers …), measured every 3 s from the process table. Sparkline on the dashboard card, a *CPU* tab per session with a stacked chart by process name and the processes that used the most CPU time. Recorded as one CSV per session in the app data directory (`~/Library/Application Support/claude-inspect/cpu` on macOS, `~/.local/share/claude-inspect/cpu` on Linux, `%LOCALAPPDATA%\claude-inspect\cpu` on Windows), so it stays available after the session ended; files older than 30 days are deleted. Only measured while claude-inspect runs; processes that live less than 3 s are missed. `--no-cpu-log` turns recording off. |
|
|
44
45
|
| **Tool explorer & files** | Every tool call across all sessions, filterable; which files agents read, edited or wrote. |
|
|
45
46
|
| **Full-text search** | Across all transcripts, subagents and the prompt history (including prompts of sessions Claude Code already cleaned up). |
|
|
46
47
|
| **Task boards** | The TaskCreate/TaskUpdate list of a session with dependencies. |
|
|
@@ -48,6 +49,8 @@ becomes hard to tell *who is doing what*. claude-inspect answers:
|
|
|
48
49
|
| **Dark mode** | Light, dark or system theme, remembered per browser. |
|
|
49
50
|
| **Phone-friendly** | On narrow screens (phones, small iframes) the navigation collapses into a menu, tables turn into compact lists, the session view is single-column and long code, paths and JSON scroll inside their block. The server still listens on 127.0.0.1 only – to open it from a phone you need your own tunnel or reverse proxy that forwards to it with a local `Host` header (other hosts are rejected). |
|
|
50
51
|
|
|
52
|
+

|
|
53
|
+
|
|
51
54
|
<table>
|
|
52
55
|
<tr>
|
|
53
56
|
<td><img src="docs/screenshots/timeline.jpg" alt="Timeline of an orchestrator with subagents and a spawned worker session"></td>
|
|
@@ -90,7 +93,8 @@ After `npm install -g claude-inspect@latest`, run `claude-inspect service instal
|
|
|
90
93
|
|
|
91
94
|
Started manually, `claude-inspect` prints a URL with a random access token (`?t=…`) that the browser
|
|
92
95
|
keeps as a cookie. Use `--token <t>` for a fixed token or `--no-auth` for none, `--port` for another port,
|
|
93
|
-
`CLAUDE_CONFIG_DIR=/path/to/.claude` for another data directory
|
|
96
|
+
`CLAUDE_CONFIG_DIR=/path/to/.claude` for another data directory, `--no-cpu-log` to not record CPU usage,
|
|
97
|
+
`CLAUDE_INSPECT_DATA_DIR=/path` for another place for the CPU log. `claude-inspect --help` lists everything.
|
|
94
98
|
|
|
95
99
|
### Try it without your own data
|
|
96
100
|
|
|
@@ -127,7 +131,7 @@ FormatRegistry → versioned decoders → normalized model → REST + SSE → Sv
|
|
|
127
131
|
|
|
128
132
|
- **Per-record format detection:** every decoder scores a record (`type`, version range, structure); the best one wins, a fallback keeps unknown records. Adding support for a new Claude Code version = adding a decoder file. See [docs/FORMATS.md](docs/FORMATS.md).
|
|
129
133
|
- **In memory only:** startup reads process/job data and file heads (< 1 s); the full history (hundreds of MB) is aggregated in the background in a few seconds and then followed incrementally.
|
|
130
|
-
- **Privacy & security:** listens on `127.0.0.1` only (never reachable from the network), rejects requests whose `Host` header isn't `localhost`/`127.0.0.1` (DNS rebinding), sends no CORS headers (other websites in your browser can't read the API), is read-only, never reads credential files (`*.key`, `daemon/auth`, `ide/*.lock`, …) and masks token/secret fields in the raw view. The access token is an extra layer against *other local processes or users* on the same machine; on a single-user machine `--no-auth` (used by the autostart service) is a reasonable trade-off. Cost figures are estimates from public API list prices – with a subscription you do not pay these amounts directly.
|
|
134
|
+
- **Privacy & security:** listens on `127.0.0.1` only (never reachable from the network), rejects requests whose `Host` header isn't `localhost`/`127.0.0.1` (DNS rebinding), sends no CORS headers (other websites in your browser can't read the API), is read-only towards `~/.claude` (the CPU log goes to its own data directory and contains only timestamps, PIDs, program names and CPU percentages – no command lines), never reads credential files (`*.key`, `daemon/auth`, `ide/*.lock`, …) and masks token/secret fields in the raw view. The access token is an extra layer against *other local processes or users* on the same machine; on a single-user machine `--no-auth` (used by the autostart service) is a reasonable trade-off. Cost figures are estimates from public API list prices – with a subscription you do not pay these amounts directly.
|
|
131
135
|
|
|
132
136
|
## Development
|
|
133
137
|
|
|
@@ -11,6 +11,7 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
11
11
|
import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { CSV_HEADER, toCsv } from '../model/cpu.js';
|
|
14
15
|
let ROOT = '';
|
|
15
16
|
const NOW = Date.now();
|
|
16
17
|
const VERSION = '2.1.283';
|
|
@@ -283,6 +284,46 @@ function procStart(pid) {
|
|
|
283
284
|
function sessionFile(pid, data) {
|
|
284
285
|
write(join(ROOT, 'sessions', `${pid}.json`), JSON.stringify({ pid, procStart: procStart(pid), version: VERSION, peerProtocol: 1, pidDomain: process.platform, updatedAt: NOW, statusUpdatedAt: NOW - 20_000, ...data }));
|
|
285
286
|
}
|
|
287
|
+
function cpuHistory(dataDir, sessionId, pid, from, bursts) {
|
|
288
|
+
const lines = [];
|
|
289
|
+
let lastWrite = 0;
|
|
290
|
+
for (let t = from; t <= NOW; t += 3000) {
|
|
291
|
+
const procs = [{ pid, ppid: 1, comm: 'claude', pct: Math.round((3 + rnd() * 9) * 10) / 10 }];
|
|
292
|
+
for (const b of bursts) {
|
|
293
|
+
const start = from + b.at * 60_000;
|
|
294
|
+
if (t < start || t > start + b.secs * 1000)
|
|
295
|
+
continue;
|
|
296
|
+
procs.push({ pid: b.pid, ppid: pid + 1, comm: b.comm, pct: Math.round((b.pct[0] + rnd() * (b.pct[1] - b.pct[0])) * 10) / 10 });
|
|
297
|
+
}
|
|
298
|
+
// like the real recorder: quiet phases at most every 30 s
|
|
299
|
+
if (procs.length === 1 && t - lastWrite < 30_000)
|
|
300
|
+
continue;
|
|
301
|
+
lastWrite = t;
|
|
302
|
+
lines.push(toCsv({ t, procs }));
|
|
303
|
+
}
|
|
304
|
+
write(join(dataDir, 'cpu', `${sessionId}.csv`), CSV_HEADER + lines.join(''));
|
|
305
|
+
}
|
|
306
|
+
function demoCpu(dataDir, s) {
|
|
307
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
308
|
+
cpuHistory(dataDir, s.orchId, 41_200, NOW - 38 * 60_000, [
|
|
309
|
+
{ at: 2, secs: 20, comm: 'rg', pid: 41_310, pct: [60, 95] },
|
|
310
|
+
{ at: 30, secs: 150, comm: 'node', pid: 41_377, pct: [110, 160] },
|
|
311
|
+
{ at: 31, secs: 90, comm: 'eslint', pid: 41_380, pct: [90, 130] },
|
|
312
|
+
]);
|
|
313
|
+
const minutes = Math.max(4, (NOW - s.implStart) / 60_000);
|
|
314
|
+
const at = (f) => f * minutes;
|
|
315
|
+
cpuHistory(dataDir, s.implId, 52_100, s.implStart, [
|
|
316
|
+
{ at: at(0.1), secs: 120, comm: 'vitest', pid: 52_210, pct: [240, 380] },
|
|
317
|
+
{ at: at(0.1), secs: 120, comm: 'esbuild', pid: 52_214, pct: [20, 60] },
|
|
318
|
+
{ at: at(0.3), secs: 75, comm: 'tsc', pid: 52_260, pct: [95, 105] },
|
|
319
|
+
{ at: at(0.45), secs: 180, comm: 'vitest', pid: 52_301, pct: [280, 420] },
|
|
320
|
+
{ at: at(0.45), secs: 180, comm: 'esbuild', pid: 52_305, pct: [30, 70] },
|
|
321
|
+
{ at: at(0.68), secs: 240, comm: 'node', pid: 52_340, pct: [90, 140] },
|
|
322
|
+
{ at: at(0.84), secs: 150, comm: 'vitest', pid: 52_390, pct: [250, 360] },
|
|
323
|
+
{ at: at(0.84), secs: 150, comm: 'esbuild', pid: 52_394, pct: [20, 50] },
|
|
324
|
+
{ at: at(0.93), secs: 60, comm: 'tsc', pid: 52_401, pct: [95, 105] },
|
|
325
|
+
]);
|
|
326
|
+
}
|
|
286
327
|
export async function runDemo(opts) {
|
|
287
328
|
ROOT = opts.out ?? join(existsSync('/tmp') ? '/tmp' : tmpdir(), 'claude-inspect-demo');
|
|
288
329
|
if (!opts.out)
|
|
@@ -320,6 +361,8 @@ export async function runDemo(opts) {
|
|
|
320
361
|
console.log(`Demo data in ${ROOT}`);
|
|
321
362
|
// config.ts reads CLAUDE_CONFIG_DIR on import – set it before loading the server
|
|
322
363
|
process.env.CLAUDE_CONFIG_DIR = ROOT;
|
|
364
|
+
process.env.CLAUDE_INSPECT_DATA_DIR = `${ROOT}-data`;
|
|
365
|
+
demoCpu(`${ROOT}-data`, s);
|
|
323
366
|
const { runServer } = await import('../run.js');
|
|
324
367
|
await runServer({ port: opts.port, open: opts.open, token: process.env.CLAUDE_INSPECT_TOKEN ?? 'demo' });
|
|
325
368
|
}
|
package/dist/app/server/cli.js
CHANGED
|
@@ -40,11 +40,13 @@ Options:
|
|
|
40
40
|
--open open the browser
|
|
41
41
|
--token <t> fixed access token instead of a random one env: CLAUDE_INSPECT_TOKEN
|
|
42
42
|
--no-auth no token (still only reachable from localhost) env: CLAUDE_INSPECT_NO_AUTH=1
|
|
43
|
+
--no-cpu-log don't record CPU usage to the data directory env: CLAUDE_INSPECT_NO_CPU_LOG=1
|
|
43
44
|
--out <dir> demo: only write the demo data to <dir>
|
|
44
45
|
-v, --version print the version
|
|
45
46
|
-h, --help show this help
|
|
46
47
|
|
|
47
|
-
Data directory: $CLAUDE_CONFIG_DIR or ~/.claude
|
|
48
|
+
Data directory: $CLAUDE_CONFIG_DIR or ~/.claude (read only)
|
|
49
|
+
CPU log: $CLAUDE_INSPECT_DATA_DIR/cpu (default: the platform's app data directory)`;
|
|
48
50
|
const { values, positionals } = parseArgs({
|
|
49
51
|
allowPositionals: true,
|
|
50
52
|
options: {
|
|
@@ -53,6 +55,7 @@ const { values, positionals } = parseArgs({
|
|
|
53
55
|
dev: { type: 'boolean', default: false },
|
|
54
56
|
token: { type: 'string', default: env.CLAUDE_INSPECT_TOKEN },
|
|
55
57
|
'no-auth': { type: 'boolean', default: env.CLAUDE_INSPECT_NO_AUTH === '1' },
|
|
58
|
+
'no-cpu-log': { type: 'boolean', default: env.CLAUDE_INSPECT_NO_CPU_LOG === '1' },
|
|
56
59
|
out: { type: 'string' },
|
|
57
60
|
help: { type: 'boolean', short: 'h', default: false },
|
|
58
61
|
version: { type: 'boolean', short: 'v', default: false },
|
|
@@ -76,7 +79,7 @@ else if (cmd === 'demo') {
|
|
|
76
79
|
}
|
|
77
80
|
else if (cmd === undefined) {
|
|
78
81
|
const { runServer } = await import('./run.js');
|
|
79
|
-
await runServer({ port: port(7717), open: values.open, dev: values.dev, noAuth: values['no-auth'], token: values.token });
|
|
82
|
+
await runServer({ port: port(7717), open: values.open, dev: values.dev, noAuth: values['no-auth'], token: values.token, noCpuLog: values['no-cpu-log'] });
|
|
80
83
|
}
|
|
81
84
|
else {
|
|
82
85
|
console.error(`Unknown command "${cmd}".\n\n${HELP}`);
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
export const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
|
|
4
|
+
/** Üblicher Ort für App-Daten je Plattform. */
|
|
5
|
+
function defaultDataDir() {
|
|
6
|
+
if (process.platform === 'darwin')
|
|
7
|
+
return join(homedir(), 'Library', 'Application Support', 'claude-inspect');
|
|
8
|
+
if (process.platform === 'win32')
|
|
9
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'claude-inspect');
|
|
10
|
+
return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'claude-inspect');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Eigenes Datenverzeichnis von claude-inspect – das einzige, in das das Tool schreibt
|
|
14
|
+
* (CPU-Messwerte). ~/.claude wird nie verändert.
|
|
15
|
+
*/
|
|
16
|
+
export const DATA_DIR = process.env.CLAUDE_INSPECT_DATA_DIR || defaultDataDir();
|
|
4
17
|
export const paths = {
|
|
5
18
|
projects: join(CLAUDE_DIR, 'projects'),
|
|
6
19
|
sessions: join(CLAUDE_DIR, 'sessions'),
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
2
|
+
import { appendFile, mkdir, readdir, readFile, stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DATA_DIR } from './config.js';
|
|
5
|
+
import { computeUsage, cpuHistory, CSV_HEADER, parseCsv, sampleTotal, toCsv } from './model/cpu.js';
|
|
6
|
+
import { readCpuTable } from './sources/cputable.js';
|
|
7
|
+
/** So lange bleiben Messungen im Speicher (Verlauf ohne Aufzeichnung, Sparkline). */
|
|
8
|
+
const MEMORY_MS = 60 * 60_000;
|
|
9
|
+
/** Punkte der Sparkline auf dem Dashboard */
|
|
10
|
+
const SPARK_POINTS = 120;
|
|
11
|
+
/** Unterprozesse unter dieser Auslastung (%) landen nicht in der Datei. */
|
|
12
|
+
const MIN_PCT = 1;
|
|
13
|
+
/** Ruhige Phasen: höchstens alle 30 s eine Zeile, damit die Datei klein bleibt. */
|
|
14
|
+
const IDLE_WRITE_MS = 30_000;
|
|
15
|
+
/** Dateien, die so lange nicht mehr geschrieben wurden, werden gelöscht (wie Claude Codes eigene Aufräumfrist). */
|
|
16
|
+
const RETENTION_MS = 30 * 24 * 3600_000;
|
|
17
|
+
/**
|
|
18
|
+
* Misst in festen Abständen die CPU-Auslastung jedes Claude-Prozesses samt Unterprozessen.
|
|
19
|
+
* Liest die Prozesstabelle (ps bzw. /proc), schreibt nur ins eigene Datenverzeichnis.
|
|
20
|
+
*/
|
|
21
|
+
export class CpuMonitor {
|
|
22
|
+
opts;
|
|
23
|
+
intervalMs = process.platform === 'win32' ? 10_000 : 3_000;
|
|
24
|
+
dir = join(DATA_DIR, 'cpu');
|
|
25
|
+
cores = availableParallelism();
|
|
26
|
+
prev;
|
|
27
|
+
lastT = 0;
|
|
28
|
+
buffers = new Map();
|
|
29
|
+
writing = Promise.resolve();
|
|
30
|
+
headerDone = new Set();
|
|
31
|
+
warned = false;
|
|
32
|
+
busy = false;
|
|
33
|
+
timer;
|
|
34
|
+
cleanupTimer;
|
|
35
|
+
constructor(opts) {
|
|
36
|
+
this.opts = opts;
|
|
37
|
+
}
|
|
38
|
+
get logging() {
|
|
39
|
+
return this.opts.log;
|
|
40
|
+
}
|
|
41
|
+
start() {
|
|
42
|
+
void this.tick();
|
|
43
|
+
this.timer = setInterval(() => void this.tick(), this.intervalMs);
|
|
44
|
+
if (this.opts.log) {
|
|
45
|
+
void this.cleanup();
|
|
46
|
+
this.cleanupTimer = setInterval(() => void this.cleanup(), 6 * 3600_000);
|
|
47
|
+
this.cleanupTimer.unref();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
stop() {
|
|
51
|
+
clearInterval(this.timer);
|
|
52
|
+
clearInterval(this.cleanupTimer);
|
|
53
|
+
}
|
|
54
|
+
async tick() {
|
|
55
|
+
if (this.busy)
|
|
56
|
+
return;
|
|
57
|
+
this.busy = true;
|
|
58
|
+
try {
|
|
59
|
+
const roots = this.opts.roots();
|
|
60
|
+
const cur = { t: Date.now(), procs: await readCpuTable() };
|
|
61
|
+
if (this.prev && cur.procs.size > 0) {
|
|
62
|
+
for (const [sessionId, procs] of computeUsage(this.prev, cur, roots))
|
|
63
|
+
this.record(sessionId, { t: cur.t, procs });
|
|
64
|
+
this.lastT = cur.t;
|
|
65
|
+
for (const [id, b] of this.buffers)
|
|
66
|
+
if (cur.t - b.samples[b.samples.length - 1].t > MEMORY_MS)
|
|
67
|
+
this.buffers.delete(id);
|
|
68
|
+
this.opts.onSample(this.snapshot());
|
|
69
|
+
}
|
|
70
|
+
if (cur.procs.size > 0)
|
|
71
|
+
this.prev = cur;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* nächste Runde */
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
this.busy = false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
record(sessionId, sample) {
|
|
81
|
+
let b = this.buffers.get(sessionId);
|
|
82
|
+
if (!b)
|
|
83
|
+
this.buffers.set(sessionId, (b = { samples: [], lastWrite: 0 }));
|
|
84
|
+
b.samples.push(sample);
|
|
85
|
+
while (b.samples.length && sample.t - b.samples[0].t > MEMORY_MS)
|
|
86
|
+
b.samples.shift();
|
|
87
|
+
if (!this.opts.log || !/^[\w-]+$/.test(sessionId))
|
|
88
|
+
return;
|
|
89
|
+
if (sampleTotal(sample) < MIN_PCT && sample.t - b.lastWrite < IDLE_WRITE_MS)
|
|
90
|
+
return;
|
|
91
|
+
b.lastWrite = sample.t;
|
|
92
|
+
// Claude-Prozess immer (markiert die Messung), Unterprozesse nur, wenn sie etwas tun
|
|
93
|
+
const rows = sample.procs.filter((p, i) => i === 0 || p.pct >= MIN_PCT);
|
|
94
|
+
const file = join(this.dir, `${sessionId}.csv`);
|
|
95
|
+
this.writing = this.writing.then(() => this.append(file, toCsv({ t: sample.t, procs: rows })));
|
|
96
|
+
}
|
|
97
|
+
async append(file, text) {
|
|
98
|
+
try {
|
|
99
|
+
if (!this.headerDone.has(file)) {
|
|
100
|
+
await mkdir(this.dir, { recursive: true });
|
|
101
|
+
try {
|
|
102
|
+
await stat(file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
text = CSV_HEADER + text;
|
|
106
|
+
}
|
|
107
|
+
this.headerDone.add(file);
|
|
108
|
+
}
|
|
109
|
+
await appendFile(file, text);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
if (!this.warned)
|
|
113
|
+
console.error(`claude-inspect: cannot write CPU log to ${this.dir}: ${e.message}`);
|
|
114
|
+
this.warned = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async cleanup() {
|
|
118
|
+
try {
|
|
119
|
+
for (const name of await readdir(this.dir)) {
|
|
120
|
+
if (!name.endsWith('.csv'))
|
|
121
|
+
continue;
|
|
122
|
+
const file = join(this.dir, name);
|
|
123
|
+
if (Date.now() - (await stat(file)).mtimeMs > RETENTION_MS)
|
|
124
|
+
await unlink(file);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* Verzeichnis gibt es (noch) nicht */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Aktuelle Auslastung der Sessions, die bei der letzten Messung liefen. */
|
|
132
|
+
snapshot() {
|
|
133
|
+
const sessions = {};
|
|
134
|
+
for (const [id, b] of this.buffers) {
|
|
135
|
+
const last = b.samples[b.samples.length - 1];
|
|
136
|
+
if (last.t !== this.lastT)
|
|
137
|
+
continue;
|
|
138
|
+
sessions[id] = { pct: sampleTotal(last), spark: b.samples.slice(-SPARK_POINTS).map(sampleTotal) };
|
|
139
|
+
}
|
|
140
|
+
return { t: this.lastT, intervalMs: this.intervalMs, cores: this.cores, sessions };
|
|
141
|
+
}
|
|
142
|
+
async history(sessionId) {
|
|
143
|
+
let samples = [];
|
|
144
|
+
const file = this.opts.log && /^[\w-]+$/.test(sessionId) ? join(this.dir, `${sessionId}.csv`) : undefined;
|
|
145
|
+
if (file) {
|
|
146
|
+
try {
|
|
147
|
+
samples = parseCsv(await readFile(file, 'utf8'));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* noch nichts aufgezeichnet */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Messungen, die (noch) nicht in der Datei stehen, aus dem Speicher ergänzen
|
|
154
|
+
const lastT = samples[samples.length - 1]?.t ?? 0;
|
|
155
|
+
samples.push(...(this.buffers.get(sessionId)?.samples.filter((s) => s.t > lastT) ?? []));
|
|
156
|
+
const h = cpuHistory(samples, { intervalMs: this.intervalMs, gapMs: IDLE_WRITE_MS + 5 * this.intervalMs });
|
|
157
|
+
return { sessionId, logging: this.opts.log, file: samples.length ? file : undefined, cores: this.cores, ...h };
|
|
158
|
+
}
|
|
159
|
+
}
|
package/dist/app/server/http.js
CHANGED
|
@@ -88,6 +88,7 @@ export function startServer(inspector, opts) {
|
|
|
88
88
|
if (p === '/api/stream') {
|
|
89
89
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
90
90
|
res.write(`event: dashboard\ndata: ${JSON.stringify(await inspector.getDashboard())}\n\n`);
|
|
91
|
+
res.write(`event: cpu\ndata: ${JSON.stringify(inspector.cpuSnapshot())}\n\n`);
|
|
91
92
|
sseClients.add(res);
|
|
92
93
|
req.on('close', () => sseClients.delete(res));
|
|
93
94
|
return;
|
|
@@ -151,6 +152,9 @@ export function startServer(inspector, opts) {
|
|
|
151
152
|
return json(res, 200, await inspector.fileStats({ ...filter, q: sp.get('q') || undefined }));
|
|
152
153
|
if (p === '/api/search')
|
|
153
154
|
return json(res, 200, await inspector.search(sp.get('q') ?? ''));
|
|
155
|
+
m = /^\/api\/cpu\/([\w-]+)$/.exec(p);
|
|
156
|
+
if (m)
|
|
157
|
+
return json(res, 200, await inspector.cpuHistory(m[1]));
|
|
154
158
|
m = /^\/api\/flow\/([\w-]+)$/.exec(p);
|
|
155
159
|
if (m) {
|
|
156
160
|
const flow = await inspector.flow(m[1]);
|
|
@@ -3,6 +3,7 @@ import { watch } from 'node:fs';
|
|
|
3
3
|
import { stat } from 'node:fs/promises';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { CLAUDE_DIR, relToClaude } from './config.js';
|
|
6
|
+
import { CpuMonitor } from './cpu-monitor.js';
|
|
6
7
|
import { registry } from './formats/index.js';
|
|
7
8
|
import { HistoryIndex } from './history/aggregator.js';
|
|
8
9
|
import { search } from './history/search.js';
|
|
@@ -51,6 +52,16 @@ export class Inspector extends EventEmitter {
|
|
|
51
52
|
timer;
|
|
52
53
|
poller;
|
|
53
54
|
building;
|
|
55
|
+
/** CPU-Auslastung je Claude-Prozess samt Unterprozessen */
|
|
56
|
+
cpu;
|
|
57
|
+
constructor(opts = {}) {
|
|
58
|
+
super();
|
|
59
|
+
this.cpu = new CpuMonitor({
|
|
60
|
+
log: opts.cpuLog ?? false,
|
|
61
|
+
roots: () => new Map(this.processes.filter((p) => p.alive && p.pid > 0).map((p) => [p.pid, p.sessionId])),
|
|
62
|
+
onSample: (data) => this.emit('event', { type: 'cpu', data }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
54
65
|
async start() {
|
|
55
66
|
await this.index.scan();
|
|
56
67
|
await this.rebuild();
|
|
@@ -79,8 +90,10 @@ export class Inspector extends EventEmitter {
|
|
|
79
90
|
}
|
|
80
91
|
// Polling als Netz: Prozess-Liveness (kein Datei-Event beim Absturz) und verpasste Events.
|
|
81
92
|
this.poller = setInterval(() => void this.poll(), 3000);
|
|
93
|
+
this.cpu.start();
|
|
82
94
|
}
|
|
83
95
|
stop() {
|
|
96
|
+
this.cpu.stop();
|
|
84
97
|
this.watcher?.close();
|
|
85
98
|
clearInterval(this.poller);
|
|
86
99
|
clearTimeout(this.timer);
|
|
@@ -160,6 +173,15 @@ export class Inspector extends EventEmitter {
|
|
|
160
173
|
})().finally(() => (this.building = undefined));
|
|
161
174
|
await this.building;
|
|
162
175
|
}
|
|
176
|
+
cpuSnapshot() {
|
|
177
|
+
return this.cpu.snapshot();
|
|
178
|
+
}
|
|
179
|
+
cpuHistory(sessionId) {
|
|
180
|
+
return this.cpu.history(sessionId);
|
|
181
|
+
}
|
|
182
|
+
get cpuLogDir() {
|
|
183
|
+
return this.cpu.logging ? this.cpu.dir : undefined;
|
|
184
|
+
}
|
|
163
185
|
async getDashboard() {
|
|
164
186
|
if (!this.dashboard)
|
|
165
187
|
await this.rebuild();
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
2
|
+
/**
|
|
3
|
+
* Auslastung je Claude-Prozess (`roots`: pid → sessionId) samt aller Nachfahren.
|
|
4
|
+
* Ein Nachfahre, der selbst ein Claude-Prozess ist, zählt nicht mit – er hat seine eigene Kurve.
|
|
5
|
+
* Prozesse, die es in `prev` nicht gab (oder deren PID neu vergeben wurde), sind seitdem gestartet:
|
|
6
|
+
* ihre gesamte CPU-Zeit fällt in dieses Intervall.
|
|
7
|
+
*/
|
|
8
|
+
export function computeUsage(prev, cur, roots) {
|
|
9
|
+
const dt = cur.t - prev.t;
|
|
10
|
+
const out = new Map();
|
|
11
|
+
if (dt <= 0)
|
|
12
|
+
return out;
|
|
13
|
+
const children = new Map();
|
|
14
|
+
for (const p of cur.procs.values()) {
|
|
15
|
+
if (p.ppid === p.pid)
|
|
16
|
+
continue;
|
|
17
|
+
const list = children.get(p.ppid);
|
|
18
|
+
if (list)
|
|
19
|
+
list.push(p.pid);
|
|
20
|
+
else
|
|
21
|
+
children.set(p.ppid, [p.pid]);
|
|
22
|
+
}
|
|
23
|
+
const pct = (p) => {
|
|
24
|
+
const before = prev.procs.get(p.pid);
|
|
25
|
+
const base = before && before.comm === p.comm && before.cpuMs <= p.cpuMs ? before.cpuMs : 0;
|
|
26
|
+
return Math.max(0, ((p.cpuMs - base) / dt) * 100);
|
|
27
|
+
};
|
|
28
|
+
for (const [rootPid, sessionId] of roots) {
|
|
29
|
+
const root = cur.procs.get(rootPid);
|
|
30
|
+
if (!root)
|
|
31
|
+
continue;
|
|
32
|
+
const rows = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
const queue = [rootPid];
|
|
35
|
+
while (queue.length) {
|
|
36
|
+
const pid = queue.shift();
|
|
37
|
+
if (seen.has(pid))
|
|
38
|
+
continue;
|
|
39
|
+
seen.add(pid);
|
|
40
|
+
const p = cur.procs.get(pid);
|
|
41
|
+
rows.push({ pid, ppid: p.ppid, comm: p.comm, pct: round1(pct(p)) });
|
|
42
|
+
for (const c of children.get(pid) ?? [])
|
|
43
|
+
if (!roots.has(c))
|
|
44
|
+
queue.push(c);
|
|
45
|
+
}
|
|
46
|
+
out.set(sessionId, rows);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export const sampleTotal = (s) => round1(s.procs.reduce((a, p) => a + p.pct, 0));
|
|
51
|
+
// --- CSV ---------------------------------------------------------------------
|
|
52
|
+
export const CSV_HEADER = 'ts,pid,ppid,comm,cpu_pct\n';
|
|
53
|
+
function csvField(s) {
|
|
54
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
55
|
+
}
|
|
56
|
+
export function toCsv(s) {
|
|
57
|
+
const ts = new Date(s.t).toISOString();
|
|
58
|
+
return s.procs.map((p) => `${ts},${p.pid},${p.ppid},${csvField(p.comm)},${p.pct}\n`).join('');
|
|
59
|
+
}
|
|
60
|
+
/** Liest die eigene CSV-Datei wieder ein; Zeilen mit gleichem Zeitstempel bilden eine Messung. */
|
|
61
|
+
export function parseCsv(text) {
|
|
62
|
+
const out = [];
|
|
63
|
+
let cur;
|
|
64
|
+
for (const line of text.split('\n')) {
|
|
65
|
+
const m = /^([^,]+),(\d+),(\d+),("(?:[^"]|"")*"|[^,]*),([\d.]+)\r?$/.exec(line);
|
|
66
|
+
if (!m)
|
|
67
|
+
continue;
|
|
68
|
+
const t = Date.parse(m[1]);
|
|
69
|
+
if (Number.isNaN(t))
|
|
70
|
+
continue;
|
|
71
|
+
const comm = m[4].startsWith('"') ? m[4].slice(1, -1).replace(/""/g, '"') : m[4];
|
|
72
|
+
if (!cur || cur.t !== t) {
|
|
73
|
+
cur = { t, procs: [] };
|
|
74
|
+
out.push(cur);
|
|
75
|
+
}
|
|
76
|
+
cur.procs.push({ pid: Number(m[2]), ppid: Number(m[3]), comm, pct: Number(m[5]) });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** Fasst Messungen zu höchstens `maxPoints` Zeitabschnitten zusammen, je Prozessname eine Reihe. */
|
|
81
|
+
export function cpuHistory(samples, opts) {
|
|
82
|
+
const maxPoints = opts.maxPoints ?? 600;
|
|
83
|
+
const series = opts.series ?? 6;
|
|
84
|
+
const stats = new Map();
|
|
85
|
+
let peak = 0;
|
|
86
|
+
let cpuSeconds = 0;
|
|
87
|
+
for (let i = 0; i < samples.length; i++) {
|
|
88
|
+
const s = samples[i];
|
|
89
|
+
// Anteil dieser Messung an der Zeit: Abstand zur vorigen, höchstens zwei Messabstände
|
|
90
|
+
const dt = Math.min(i > 0 ? s.t - samples[i - 1].t : opts.intervalMs, 2 * opts.intervalMs) / 1000;
|
|
91
|
+
const byComm = new Map();
|
|
92
|
+
for (const p of s.procs) {
|
|
93
|
+
byComm.set(p.comm, (byComm.get(p.comm) ?? 0) + p.pct);
|
|
94
|
+
let st = stats.get(p.comm);
|
|
95
|
+
if (!st)
|
|
96
|
+
stats.set(p.comm, (st = { comm: p.comm, cpuSeconds: 0, peak: 0, pids: 0, pidSet: new Set() }));
|
|
97
|
+
st.cpuSeconds += (p.pct / 100) * dt;
|
|
98
|
+
st.pidSet.add(p.pid);
|
|
99
|
+
}
|
|
100
|
+
for (const [comm, v] of byComm)
|
|
101
|
+
stats.get(comm).peak = Math.max(stats.get(comm).peak, v);
|
|
102
|
+
const total = sampleTotal(s);
|
|
103
|
+
peak = Math.max(peak, total);
|
|
104
|
+
cpuSeconds += (total / 100) * dt;
|
|
105
|
+
}
|
|
106
|
+
const processes = [...stats.values()]
|
|
107
|
+
.map(({ pidSet, ...st }) => ({ ...st, pids: pidSet.size, cpuSeconds: round1(st.cpuSeconds), peak: round1(st.peak) }))
|
|
108
|
+
.filter((p) => p.cpuSeconds > 0)
|
|
109
|
+
.sort((a, b) => b.cpuSeconds - a.cpuSeconds);
|
|
110
|
+
const named = processes.slice(0, series).map((p) => p.comm);
|
|
111
|
+
const hasOther = processes.some((p) => p.cpuSeconds > 0 && !named.includes(p.comm));
|
|
112
|
+
const keys = hasOther ? [...named, 'other'] : named;
|
|
113
|
+
const slot = (comm) => {
|
|
114
|
+
const i = named.indexOf(comm);
|
|
115
|
+
return i >= 0 ? i : named.length;
|
|
116
|
+
};
|
|
117
|
+
const base = { keys, processes, cpuSeconds: round1(cpuSeconds), peak: round1(peak) };
|
|
118
|
+
if (samples.length === 0)
|
|
119
|
+
return { ...base, bucketMs: opts.intervalMs, points: [] };
|
|
120
|
+
const from = samples[0].t;
|
|
121
|
+
const to = samples[samples.length - 1].t;
|
|
122
|
+
const bucketMs = Math.max(opts.intervalMs, Math.ceil((to - from + 1) / maxPoints / 1000) * 1000);
|
|
123
|
+
const n = Math.floor((to - from) / bucketMs) + 1;
|
|
124
|
+
const sums = Array.from({ length: n }, () => ({ count: 0, values: new Array(keys.length).fill(0) }));
|
|
125
|
+
for (const s of samples) {
|
|
126
|
+
const b = sums[Math.floor((s.t - from) / bucketMs)];
|
|
127
|
+
b.count++;
|
|
128
|
+
for (const p of s.procs)
|
|
129
|
+
b.values[slot(p.comm)] += p.pct;
|
|
130
|
+
}
|
|
131
|
+
// Leere Abschnitte: Eine Messung gibt den Mittelwert seit der vorigen an, also gilt für den
|
|
132
|
+
// Abschnitt die nächste Messung. Bei größeren Lücken: "nicht gemessen".
|
|
133
|
+
const points = [];
|
|
134
|
+
let si = 0;
|
|
135
|
+
for (let i = 0; i < n; i++) {
|
|
136
|
+
const t = from + i * bucketMs;
|
|
137
|
+
const b = sums[i];
|
|
138
|
+
if (b.count > 0) {
|
|
139
|
+
points.push({ t, values: b.values.map((v) => round1(v / b.count)) });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
while (si < samples.length - 1 && samples[si + 1].t < t)
|
|
143
|
+
si++;
|
|
144
|
+
const next = samples[si + 1];
|
|
145
|
+
if (!next || next.t - samples[si].t > opts.gapMs) {
|
|
146
|
+
points.push({ t, values: null });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const values = new Array(keys.length).fill(0);
|
|
150
|
+
for (const p of next.procs)
|
|
151
|
+
values[slot(p.comm)] += p.pct;
|
|
152
|
+
points.push({ t, values: values.map(round1) });
|
|
153
|
+
}
|
|
154
|
+
return { ...base, bucketMs, points };
|
|
155
|
+
}
|
package/dist/app/server/run.js
CHANGED
|
@@ -20,7 +20,7 @@ function openBrowser(url) {
|
|
|
20
20
|
execFile(bin, args, () => undefined).on('error', () => undefined);
|
|
21
21
|
}
|
|
22
22
|
export async function runServer(opts) {
|
|
23
|
-
const inspector = new Inspector();
|
|
23
|
+
const inspector = new Inspector({ cpuLog: !opts.noCpuLog });
|
|
24
24
|
const t0 = Date.now();
|
|
25
25
|
await inspector.start();
|
|
26
26
|
const noAuth = !!(opts.dev || opts.noAuth);
|
|
@@ -31,6 +31,7 @@ export async function runServer(opts) {
|
|
|
31
31
|
});
|
|
32
32
|
const url = noAuth ? `http://localhost:${opts.port}/` : `http://localhost:${opts.port}/?t=${token}`;
|
|
33
33
|
console.log(`claude-inspect is reading ${CLAUDE_DIR} (started in ${Date.now() - t0} ms)`);
|
|
34
|
+
console.log(inspector.cpuLogDir ? `CPU usage is recorded in ${inspector.cpuLogDir} (--no-cpu-log to turn off)` : 'CPU usage is not recorded (--no-cpu-log)');
|
|
34
35
|
console.log(opts.dev ? `API (dev, no token): ${url} – UI: npm run dev:web` : `Dashboard: ${url}`);
|
|
35
36
|
if (opts.open)
|
|
36
37
|
openBrowser(url);
|