claude-inspect 0.6.0 → 0.7.1
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 +21 -2
- package/README.md +5 -2
- package/dist/app/server/cli/demo.js +2 -0
- package/dist/app/server/cli.js +5 -2
- package/dist/app/server/cpu-monitor.js +23 -3
- package/dist/app/server/history/aggregator.js +7 -3
- package/dist/app/server/http.js +57 -1
- package/dist/app/server/inspector.js +100 -6
- package/dist/app/server/model/cpu.js +1 -1
- package/dist/app/server/review/digest.js +362 -0
- package/dist/app/server/review/runner.js +249 -0
- package/dist/app/server/run.js +1 -1
- package/dist/app/server/sources/spawns.js +25 -6
- package/dist/app/shared/review.js +57 -0
- package/dist/web/assets/index-BH5nprTU.js +19 -0
- package/dist/web/assets/{index-B7gX7gY4.css → index-BNso7xYj.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-C2VfGUFF.js +0 -18
package/README.de.md
CHANGED
|
@@ -41,7 +41,8 @@ beim Login und nach Abstürzen neu und läuft ohne Token (nur von localhost erre
|
|
|
41
41
|
Manuell gestartet gibt `claude-inspect` eine URL mit Zufallstoken (`?t=…`) aus; der Browser merkt
|
|
42
42
|
es sich als Cookie. `--token` für einen festen Token, `--no-auth` für keinen, `--port` für einen
|
|
43
43
|
anderen Port, `CLAUDE_CONFIG_DIR=/pfad` für ein anderes Datenverzeichnis, `--no-cpu-log` ohne
|
|
44
|
-
CPU-Protokoll, `CLAUDE_INSPECT_DATA_DIR=/pfad` für einen
|
|
44
|
+
CPU-Protokoll, `--no-analyze` ohne *Analyze with Claude*, `CLAUDE_INSPECT_DATA_DIR=/pfad` für einen
|
|
45
|
+
anderen Ablageort von CPU-Protokoll und Analysen.
|
|
45
46
|
|
|
46
47
|
Aus den Quellen: `git clone …`, `npm install`, `npm run build`, `npm start`
|
|
47
48
|
(`npm run dev` + `npm run dev:web` für die Entwicklung mit Hot-Reload).
|
|
@@ -73,6 +74,18 @@ Aus den Quellen: `git clone …`, `npm install`, `npm run build`, `npm start`
|
|
|
73
74
|
Windows `%LOCALAPPDATA%\claude-inspect\cpu`), damit der Verlauf nach Sessionende abrufbar bleibt.
|
|
74
75
|
Dateien älter als 30 Tage werden gelöscht. Gemessen wird nur, solange claude-inspect läuft;
|
|
75
76
|
Prozesse, die kürzer als 3 s leben, fehlen. Abschalten mit `--no-cpu-log`.
|
|
77
|
+
- **Analyze with Claude**: Knopf auf jeder Session. Im Pop-up wählt man, worauf geachtet wird
|
|
78
|
+
(Auftrag erfüllt, Effizienz & Kosten, Fehler & Umwege, Datenschutz, Sicherheit & riskante
|
|
79
|
+
Aktionen, Agenten & Delegation, Setup-Verbesserungen), dazu optional eine eigene Frage, Modell
|
|
80
|
+
und Antwortsprache. claude-inspect baut einen kompakten Bericht über die Session samt Subagenten
|
|
81
|
+
und gestarteten Sitzungen (Anfragen, Antworten, jeder Tool-Aufruf mit Status, Dauer und Kosten,
|
|
82
|
+
dazu automatische Befunde wie wiederholte Fehler, mehrfach gelesene Dateien, Zugriffe auf
|
|
83
|
+
sensible Dateien, Uploads, riskante Befehle), maskiert Geheimnisse und lässt ihn vom lokalen
|
|
84
|
+
`claude -p` auswerten – ohne Tools, MCP-Server und Einstellungen, nicht als Session gespeichert.
|
|
85
|
+
Die Antwort erscheint live im Tab *Analysis* und wird im Datenverzeichnis (`reviews/`) abgelegt.
|
|
86
|
+
Läuft über die eigene Claude-Code-Anmeldung und zählt zum Abo-Verbrauch; *View what is sent*
|
|
87
|
+
zeigt den genauen Bericht. Das ist die einzige Funktion, die Daten verschickt, und nur auf Klick.
|
|
88
|
+
`--no-analyze` blendet den Knopf aus.
|
|
76
89
|
- **Abo-Budget**: Kosten als geschätzter Anteil am Wochenlimit und am 5-Stunden-Fenster des Tarifs
|
|
77
90
|
(Max 20x, Max 5x, Pro). Der Tarif wird aus `~/.claude.json` erkannt (gelesen wird nur das
|
|
78
91
|
Rate-Limit-Tier) und lässt sich im Browser umstellen. Anthropic nennt keine Dollar- oder
|
|
@@ -121,7 +134,13 @@ Neue Dateiformate: siehe [docs/FORMATS.md](https://github.com/brokoskokoli/claud
|
|
|
121
134
|
|
|
122
135
|
## Speicher & Leistung
|
|
123
136
|
|
|
124
|
-
Außer
|
|
137
|
+
Außer CPU-Log und Analysen im eigenen Datenverzeichnis wird nichts auf Platte geschrieben. Beim Start liest der Server nur Prozess-, Job- und
|
|
125
138
|
Kopfdaten (unter 1 s); die komplette History (hier ~600 MB) wird im Hintergrund in
|
|
126
139
|
2–3 s aggregiert und danach nur noch inkrementell nachgeführt. Vollständige Verläufe
|
|
127
140
|
werden erst beim Öffnen geladen und mit einer Obergrenze im Speicher gehalten.
|
|
141
|
+
|
|
142
|
+
Solange ein Dashboard-Tab sichtbar ist, aktualisiert sich alles live (CPU alle 3 s). Ist kein Tab
|
|
143
|
+
sichtbar (geschlossen oder 15 s im Hintergrund), wechselt der Server in einen Sparbetrieb:
|
|
144
|
+
Prozessliste alle 30 s, CPU alle 15 s, alles andere alle 2 Minuten – etwa 1 % eines Kerns
|
|
145
|
+
(gemessen mit ~1.600 Transcripts und mehreren laufenden Agenten). Beim Zurückwechseln wird sofort
|
|
146
|
+
nachgeladen.
|
package/README.md
CHANGED
|
@@ -23,7 +23,8 @@ No Claude Code data at hand? `npx claude-inspect demo --open` starts it with a f
|
|
|
23
23
|
Permanent install with autostart: see [Installation](#installation).
|
|
24
24
|
|
|
25
25
|
It is **read-only and passive** towards `~/.claude`: no hooks, no settings changes, no proxy, no data leaves
|
|
26
|
-
your machine. The only
|
|
26
|
+
your machine. The only things it writes go to its own data directory: a CPU log (see *CPU usage*) and the
|
|
27
|
+
results of *Analyze with Claude* – the one feature that sends data, and only when you click it.
|
|
27
28
|
|
|
28
29
|
> Website: [brokoskokoli.github.io/claude_inspect](https://brokoskokoli.github.io/claude_inspect/) · Deutsch: [README.de.md](https://github.com/brokoskokoli/claude_inspect/blob/main/README.de.md) · Design notes (German): [KONZEPT.md](https://github.com/brokoskokoli/claude_inspect/blob/main/KONZEPT.md) · [Contributing](https://github.com/brokoskokoli/claude_inspect/blob/main/CONTRIBUTING.md)
|
|
29
30
|
|
|
@@ -52,6 +53,7 @@ becomes hard to tell *who is doing what*. claude-inspect answers:
|
|
|
52
53
|
| **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. |
|
|
53
54
|
| **Cost per agent tree** | Cost tab per session: API list-price estimate for the main agent plus all its subagents and spawned `claude` sessions, per agent and per model with input, output and cache tokens, plus a cost timeline with the most expensive moments. |
|
|
54
55
|
| **Plan budget** | Cost as an estimated share of your plan's weekly limit and 5-hour window (Max 20x, Max 5x, Pro). The plan is detected from `~/.claude.json` (only the rate-limit tier is read) and can be changed in the browser. Anthropic publishes no dollar or token limits; the values are calibrated from real `/usage` readings against the API equivalent spent in the same time – to recalibrate with your own data (e.g. including a server's copy of `~/.claude`), see [Calibrating the plan budgets](https://github.com/brokoskokoli/claude_inspect/blob/main/CONTRIBUTING.md#calibrating-the-plan-budgets). Max 5x/20x are measured, Pro is extrapolated. |
|
|
56
|
+
| **Analyze with Claude** | Button on every session: pick what to check – task outcome, efficiency & cost, errors & dead ends, privacy & data protection, security & risky actions, agents & delegation, setup improvements – plus an optional own question, model and answer language. claude-inspect builds a condensed digest of the session and all its subagents and spawned sessions (requests, answers, every tool call with status, duration and cost, plus computed observations such as repeated failures, repeated reads, access to sensitive files, uploads and risky commands), masks secrets, and runs your local `claude -p` on it – no tools, no MCP servers, no settings, not saved as a session. The answer streams into the *Analysis* tab and is stored in the data directory (`reviews/`). It runs on your own Claude Code login and counts towards your plan usage; *View what is sent* shows the exact digest. `--no-analyze` removes the button. |
|
|
55
57
|
| **Statistics** | API list-price estimate, tokens per model/day/project, cache share, activity heatmap, tool statistics with error rate and p95 duration. |
|
|
56
58
|
| **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. |
|
|
57
59
|
| **Tool explorer & files** | Every tool call across all sessions, filterable; which files agents read, edited or wrote. |
|
|
@@ -106,7 +108,7 @@ After `npm install -g claude-inspect@latest`, run `claude-inspect service instal
|
|
|
106
108
|
Started manually, `claude-inspect` prints a URL with a random access token (`?t=…`) that the browser
|
|
107
109
|
keeps as a cookie. Use `--token <t>` for a fixed token or `--no-auth` for none, `--port` for another port,
|
|
108
110
|
`CLAUDE_CONFIG_DIR=/path/to/.claude` for another data directory, `--no-cpu-log` to not record CPU usage,
|
|
109
|
-
`CLAUDE_INSPECT_DATA_DIR=/path` for another place for the CPU log. `claude-inspect --help` lists everything.
|
|
111
|
+
`--no-analyze` to hide *Analyze with Claude*, `CLAUDE_INSPECT_DATA_DIR=/path` for another place for the CPU log and analyses. `claude-inspect --help` lists everything.
|
|
110
112
|
|
|
111
113
|
### Try it without your own data
|
|
112
114
|
|
|
@@ -143,6 +145,7 @@ FormatRegistry → versioned decoders → normalized model → REST + SSE → Sv
|
|
|
143
145
|
|
|
144
146
|
- **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](https://github.com/brokoskokoli/claude_inspect/blob/main/docs/FORMATS.md).
|
|
145
147
|
- **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.
|
|
148
|
+
- **Light in the background:** while a dashboard tab is visible, it updates live (CPU every 3 s). With no visible tab (closed or in the background for 15 s) it switches to a saving mode – process list every 30 s, CPU every 15 s, everything else every 2 minutes – and uses about 1 % of one core (measured with ~1,600 transcripts and several agents running). Switching back to the tab catches up immediately.
|
|
146
149
|
- **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.
|
|
147
150
|
|
|
148
151
|
## Development
|
|
@@ -360,6 +360,8 @@ export async function runDemo(opts) {
|
|
|
360
360
|
sessionFile(docsProc.pid, { sessionId: s.docsId, cwd: s.DOCS, startedAt: s.docs.o.start, kind: 'interactive', entrypoint: 'cli', name: 'docs', nameSource: 'user', status: 'idle' });
|
|
361
361
|
console.log(`Demo data in ${ROOT}`);
|
|
362
362
|
// config.ts reads CLAUDE_CONFIG_DIR on import – set it before loading the server
|
|
363
|
+
// "Analyze with Claude" starts the real claude – it needs the user's own config dir (login)
|
|
364
|
+
process.env.CLAUDE_INSPECT_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? '';
|
|
363
365
|
process.env.CLAUDE_CONFIG_DIR = ROOT;
|
|
364
366
|
process.env.CLAUDE_INSPECT_DATA_DIR = `${ROOT}-data`;
|
|
365
367
|
demoCpu(`${ROOT}-data`, s);
|
package/dist/app/server/cli.js
CHANGED
|
@@ -41,12 +41,14 @@ Options:
|
|
|
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
43
|
--no-cpu-log don't record CPU usage to the data directory env: CLAUDE_INSPECT_NO_CPU_LOG=1
|
|
44
|
+
--no-analyze hide "Analyze with Claude" (never runs claude) env: CLAUDE_INSPECT_NO_ANALYZE=1
|
|
44
45
|
--out <dir> demo: only write the demo data to <dir>
|
|
45
46
|
-v, --version print the version
|
|
46
47
|
-h, --help show this help
|
|
47
48
|
|
|
48
49
|
Data directory: $CLAUDE_CONFIG_DIR or ~/.claude (read only)
|
|
49
|
-
CPU log: $CLAUDE_INSPECT_DATA_DIR/cpu (default: the platform's app data directory)
|
|
50
|
+
CPU log: $CLAUDE_INSPECT_DATA_DIR/cpu (default: the platform's app data directory)
|
|
51
|
+
Analyses: $CLAUDE_INSPECT_DATA_DIR/reviews`;
|
|
50
52
|
const { values, positionals } = parseArgs({
|
|
51
53
|
allowPositionals: true,
|
|
52
54
|
options: {
|
|
@@ -56,6 +58,7 @@ const { values, positionals } = parseArgs({
|
|
|
56
58
|
token: { type: 'string', default: env.CLAUDE_INSPECT_TOKEN },
|
|
57
59
|
'no-auth': { type: 'boolean', default: env.CLAUDE_INSPECT_NO_AUTH === '1' },
|
|
58
60
|
'no-cpu-log': { type: 'boolean', default: env.CLAUDE_INSPECT_NO_CPU_LOG === '1' },
|
|
61
|
+
'no-analyze': { type: 'boolean', default: env.CLAUDE_INSPECT_NO_ANALYZE === '1' },
|
|
59
62
|
out: { type: 'string' },
|
|
60
63
|
help: { type: 'boolean', short: 'h', default: false },
|
|
61
64
|
version: { type: 'boolean', short: 'v', default: false },
|
|
@@ -79,7 +82,7 @@ else if (cmd === 'demo') {
|
|
|
79
82
|
}
|
|
80
83
|
else if (cmd === undefined) {
|
|
81
84
|
const { runServer } = await import('./run.js');
|
|
82
|
-
await runServer({ port: port(7717), open: values.open, dev: values.dev, noAuth: values['no-auth'], token: values.token, noCpuLog: values['no-cpu-log'] });
|
|
85
|
+
await runServer({ port: port(7717), open: values.open, dev: values.dev, noAuth: values['no-auth'], token: values.token, noCpuLog: values['no-cpu-log'], noAnalyze: values['no-analyze'] });
|
|
83
86
|
}
|
|
84
87
|
else {
|
|
85
88
|
console.error(`Unknown command "${cmd}".\n\n${HELP}`);
|
|
@@ -14,13 +14,20 @@ const MIN_PCT = 1;
|
|
|
14
14
|
const IDLE_WRITE_MS = 30_000;
|
|
15
15
|
/** Dateien, die so lange nicht mehr geschrieben wurden, werden gelöscht (wie Claude Codes eigene Aufräumfrist). */
|
|
16
16
|
const RETENTION_MS = 30 * 24 * 3600_000;
|
|
17
|
+
/**
|
|
18
|
+
* Messabstand im Sparbetrieb (kein Tab offen). Gemessen wird verbrauchte CPU-Zeit, längere
|
|
19
|
+
* Abstände liefern also korrekte Mittelwerte – nur gröber. Spart vor allem die ps-Aufrufe.
|
|
20
|
+
*/
|
|
21
|
+
const IDLE_INTERVAL_MS = 15_000;
|
|
17
22
|
/**
|
|
18
23
|
* Misst in festen Abständen die CPU-Auslastung jedes Claude-Prozesses samt Unterprozessen.
|
|
19
24
|
* Liest die Prozesstabelle (ps bzw. /proc), schreibt nur ins eigene Datenverzeichnis.
|
|
20
25
|
*/
|
|
21
26
|
export class CpuMonitor {
|
|
22
27
|
opts;
|
|
28
|
+
/** Messabstand bei offenem Tab */
|
|
23
29
|
intervalMs = process.platform === 'win32' ? 10_000 : 3_000;
|
|
30
|
+
currentMs = Math.max(IDLE_INTERVAL_MS, this.intervalMs);
|
|
24
31
|
dir = join(DATA_DIR, 'cpu');
|
|
25
32
|
cores = availableParallelism();
|
|
26
33
|
prev;
|
|
@@ -40,13 +47,26 @@ export class CpuMonitor {
|
|
|
40
47
|
}
|
|
41
48
|
start() {
|
|
42
49
|
void this.tick();
|
|
43
|
-
this.timer = setInterval(() => void this.tick(), this.
|
|
50
|
+
this.timer = setInterval(() => void this.tick(), this.currentMs);
|
|
44
51
|
if (this.opts.log) {
|
|
45
52
|
void this.cleanup();
|
|
46
53
|
this.cleanupTimer = setInterval(() => void this.cleanup(), 6 * 3600_000);
|
|
47
54
|
this.cleanupTimer.unref();
|
|
48
55
|
}
|
|
49
56
|
}
|
|
57
|
+
/** Sparbetrieb ein/aus (kein Tab offen → seltener messen) */
|
|
58
|
+
setIdle(idle) {
|
|
59
|
+
const ms = idle ? Math.max(IDLE_INTERVAL_MS, this.intervalMs) : this.intervalMs;
|
|
60
|
+
if (ms === this.currentMs)
|
|
61
|
+
return;
|
|
62
|
+
this.currentMs = ms;
|
|
63
|
+
if (!this.timer)
|
|
64
|
+
return;
|
|
65
|
+
clearInterval(this.timer);
|
|
66
|
+
if (!idle)
|
|
67
|
+
void this.tick();
|
|
68
|
+
this.timer = setInterval(() => void this.tick(), ms);
|
|
69
|
+
}
|
|
50
70
|
stop() {
|
|
51
71
|
clearInterval(this.timer);
|
|
52
72
|
clearInterval(this.cleanupTimer);
|
|
@@ -137,7 +157,7 @@ export class CpuMonitor {
|
|
|
137
157
|
continue;
|
|
138
158
|
sessions[id] = { pct: sampleTotal(last), spark: b.samples.slice(-SPARK_POINTS).map(sampleTotal) };
|
|
139
159
|
}
|
|
140
|
-
return { t: this.lastT, intervalMs: this.
|
|
160
|
+
return { t: this.lastT, intervalMs: this.currentMs, cores: this.cores, sessions };
|
|
141
161
|
}
|
|
142
162
|
async history(sessionId) {
|
|
143
163
|
let samples = [];
|
|
@@ -153,7 +173,7 @@ export class CpuMonitor {
|
|
|
153
173
|
// Messungen, die (noch) nicht in der Datei stehen, aus dem Speicher ergänzen
|
|
154
174
|
const lastT = samples[samples.length - 1]?.t ?? 0;
|
|
155
175
|
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 });
|
|
176
|
+
const h = cpuHistory(samples, { intervalMs: this.intervalMs, gapMs: IDLE_WRITE_MS + 5 * this.intervalMs, maxStepMs: 2 * IDLE_INTERVAL_MS });
|
|
157
177
|
return { sessionId, logging: this.opts.log, file: samples.length ? file : undefined, cores: this.cores, ...h };
|
|
158
178
|
}
|
|
159
179
|
}
|
|
@@ -32,18 +32,22 @@ export class HistoryIndex {
|
|
|
32
32
|
*/
|
|
33
33
|
async refresh(transcripts, projectOf, full) {
|
|
34
34
|
const seen = new Set();
|
|
35
|
+
// Sessions ohne Änderung in der letzten Stunde: Subagenten nicht neu einlesen, nur behalten
|
|
36
|
+
const untouched = new Set();
|
|
35
37
|
for (const f of transcripts) {
|
|
36
38
|
const project = await projectOf(f);
|
|
37
39
|
seen.add(f.path);
|
|
38
40
|
await this.scan(f.path, f.size, f.sessionId, undefined, project);
|
|
39
41
|
if (!full && Date.now() - f.mtime > 3600_000) {
|
|
40
|
-
|
|
41
|
-
if (p.startsWith(subagentDir(f.path)))
|
|
42
|
-
seen.add(p);
|
|
42
|
+
untouched.add(f.sessionId);
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
45
|
await this.scanSubagents(f, project, seen);
|
|
46
46
|
}
|
|
47
|
+
// ein Durchgang statt Sessions × Dateien (war bei ~1.500 Sessions der größte CPU-Verbraucher)
|
|
48
|
+
for (const [p, a] of this.files)
|
|
49
|
+
if (a.agentId && untouched.has(a.sessionId))
|
|
50
|
+
seen.add(p);
|
|
47
51
|
// Von Claude Code aufgeräumte Dateien vergessen
|
|
48
52
|
for (const p of [...this.files.keys()])
|
|
49
53
|
if (!seen.has(p))
|
package/dist/app/server/http.js
CHANGED
|
@@ -66,6 +66,43 @@ export function startServer(inspector, opts) {
|
|
|
66
66
|
res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' });
|
|
67
67
|
createReadStream(file).pipe(res);
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Die einzigen schreibenden Aufrufe: Analyse starten/abbrechen. Nur mit JSON-Body (erzwingt bei
|
|
71
|
+
* fremden Seiten einen CORS-Preflight, den der Server nie erlaubt) und lokalem Origin.
|
|
72
|
+
*/
|
|
73
|
+
async function post(req, res, p) {
|
|
74
|
+
const origin = req.headers.origin;
|
|
75
|
+
if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin))
|
|
76
|
+
return json(res, 403, { error: 'origin not allowed' });
|
|
77
|
+
if (!(req.headers['content-type'] ?? '').startsWith('application/json'))
|
|
78
|
+
return json(res, 415, { error: 'JSON expected' });
|
|
79
|
+
let raw = '';
|
|
80
|
+
for await (const c of req) {
|
|
81
|
+
raw += c;
|
|
82
|
+
if (raw.length > 100_000)
|
|
83
|
+
return json(res, 413, { error: 'too large' });
|
|
84
|
+
}
|
|
85
|
+
let body;
|
|
86
|
+
try {
|
|
87
|
+
body = JSON.parse(raw || '{}');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return json(res, 400, { error: 'invalid JSON' });
|
|
91
|
+
}
|
|
92
|
+
let m = /^\/api\/review\/([\w-]+)$/.exec(p);
|
|
93
|
+
if (m) {
|
|
94
|
+
try {
|
|
95
|
+
return json(res, 200, await inspector.startReview(m[1], body));
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
return json(res, 400, { error: e.message });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
m = /^\/api\/review-cancel\/([\w-]+)$/.exec(p);
|
|
102
|
+
if (m)
|
|
103
|
+
return json(res, 200, { cancelled: inspector.reviewer?.cancel(m[1]) ?? false });
|
|
104
|
+
return json(res, 405, { error: 'read-only' });
|
|
105
|
+
}
|
|
69
106
|
const server = createServer(async (req, res) => {
|
|
70
107
|
try {
|
|
71
108
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
@@ -81,6 +118,8 @@ export function startServer(inspector, opts) {
|
|
|
81
118
|
res.setHeader('set-cookie', `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`);
|
|
82
119
|
}
|
|
83
120
|
const p = url.pathname;
|
|
121
|
+
if (req.method === 'POST')
|
|
122
|
+
return await post(req, res, p);
|
|
84
123
|
if (req.method !== 'GET')
|
|
85
124
|
return json(res, 405, { error: 'read-only' });
|
|
86
125
|
if (p === '/api/dashboard')
|
|
@@ -90,7 +129,11 @@ export function startServer(inspector, opts) {
|
|
|
90
129
|
res.write(`event: dashboard\ndata: ${JSON.stringify(await inspector.getDashboard())}\n\n`);
|
|
91
130
|
res.write(`event: cpu\ndata: ${JSON.stringify(inspector.cpuSnapshot())}\n\n`);
|
|
92
131
|
sseClients.add(res);
|
|
93
|
-
|
|
132
|
+
inspector.setViewers(sseClients.size);
|
|
133
|
+
req.on('close', () => {
|
|
134
|
+
sseClients.delete(res);
|
|
135
|
+
inspector.setViewers(sseClients.size);
|
|
136
|
+
});
|
|
94
137
|
return;
|
|
95
138
|
}
|
|
96
139
|
if (p === '/api/sessions') {
|
|
@@ -160,6 +203,19 @@ export function startServer(inspector, opts) {
|
|
|
160
203
|
const cost = await inspector.sessionCost(m[1]);
|
|
161
204
|
return cost ? json(res, 200, cost) : json(res, 404, { error: 'unknown session' });
|
|
162
205
|
}
|
|
206
|
+
m = /^\/api\/review\/([\w-]+)$/.exec(p);
|
|
207
|
+
if (m) {
|
|
208
|
+
const st = await inspector.reviewState(m[1]);
|
|
209
|
+
return st ? json(res, 200, st) : json(res, 404, { error: 'unknown session' });
|
|
210
|
+
}
|
|
211
|
+
m = /^\/api\/review\/([\w-]+)\/digest$/.exec(p);
|
|
212
|
+
if (m) {
|
|
213
|
+
const d = await inspector.reviewDigest(m[1]);
|
|
214
|
+
if (!d)
|
|
215
|
+
return json(res, 404, { error: 'unknown session' });
|
|
216
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
217
|
+
return res.end(d.text);
|
|
218
|
+
}
|
|
163
219
|
m = /^\/api\/flow\/([\w-]+)$/.exec(p);
|
|
164
220
|
if (m) {
|
|
165
221
|
const flow = await inspector.flow(m[1]);
|
|
@@ -2,7 +2,9 @@ import { EventEmitter } from 'node:events';
|
|
|
2
2
|
import { watch } from 'node:fs';
|
|
3
3
|
import { stat } from 'node:fs/promises';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
|
-
import { CLAUDE_DIR, relToClaude } from './config.js';
|
|
5
|
+
import { CLAUDE_DIR, DATA_DIR, relToClaude } from './config.js';
|
|
6
|
+
import { buildDigest } from './review/digest.js';
|
|
7
|
+
import { findClaude, Reviewer } from './review/runner.js';
|
|
6
8
|
import { CpuMonitor } from './cpu-monitor.js';
|
|
7
9
|
import { registry } from './formats/index.js';
|
|
8
10
|
import { HistoryIndex } from './history/aggregator.js';
|
|
@@ -33,6 +35,9 @@ function idleIdeProcess(p) {
|
|
|
33
35
|
const SUBAGENT_STALE_MS = 10 * 60_000;
|
|
34
36
|
/** Subagenten erscheinen auf dem Dashboard, wenn sie so kürzlich aktiv waren. */
|
|
35
37
|
const SUBAGENT_DASHBOARD_MS = 30 * 60_000;
|
|
38
|
+
/** Sparbetrieb ohne offenen Tab: Prozessliste alle 30 s, Komplettaktualisierung alle 2 min */
|
|
39
|
+
const IDLE_POLL_MS = 30_000;
|
|
40
|
+
const IDLE_REBUILD_MS = 120_000;
|
|
36
41
|
export class Inspector extends EventEmitter {
|
|
37
42
|
index = new ProjectIndex();
|
|
38
43
|
store = new TranscriptStore();
|
|
@@ -42,6 +47,13 @@ export class Inspector extends EventEmitter {
|
|
|
42
47
|
/** Aggregierte History (Tokens, Kosten, Tool-Aufrufe, Dateien) – nur im Speicher. */
|
|
43
48
|
history = new HistoryIndex();
|
|
44
49
|
historyReady = false;
|
|
50
|
+
historyRefreshedAt = 0;
|
|
51
|
+
/**
|
|
52
|
+
* Offene, sichtbare Browser-Tabs (Live-Verbindungen). Ohne Zuschauer läuft nur ein
|
|
53
|
+
* Sparbetrieb: Prozessliste alle 30 s (für die CPU-Messung), alles andere alle 2 min.
|
|
54
|
+
*/
|
|
55
|
+
viewers = 0;
|
|
56
|
+
builtAt = 0;
|
|
45
57
|
historyReadyResolve;
|
|
46
58
|
/** Erfüllt, sobald der erste History-Scan durch ist (API-Aufrufe warten darauf). */
|
|
47
59
|
historyLoaded = new Promise((r) => (this.historyReadyResolve = r));
|
|
@@ -56,8 +68,12 @@ export class Inspector extends EventEmitter {
|
|
|
56
68
|
building;
|
|
57
69
|
/** CPU-Auslastung je Claude-Prozess samt Unterprozessen */
|
|
58
70
|
cpu;
|
|
71
|
+
/** "Analyze with Claude": startet auf Knopfdruck `claude -p` (abschaltbar mit --no-analyze) */
|
|
72
|
+
reviewer;
|
|
59
73
|
constructor(opts = {}) {
|
|
60
74
|
super();
|
|
75
|
+
if (opts.analyze !== false)
|
|
76
|
+
this.reviewer = new Reviewer(join(DATA_DIR, 'reviews'), (data) => this.emit('event', { type: 'review', data }));
|
|
61
77
|
this.cpu = new CpuMonitor({
|
|
62
78
|
log: opts.cpuLog ?? false,
|
|
63
79
|
roots: () => new Map(this.processes.filter((p) => p.alive && p.pid > 0).map((p) => [p.pid, p.sessionId])),
|
|
@@ -91,7 +107,7 @@ export class Inspector extends EventEmitter {
|
|
|
91
107
|
/* Fallback: nur Polling */
|
|
92
108
|
}
|
|
93
109
|
// Polling als Netz: Prozess-Liveness (kein Datei-Event beim Absturz) und verpasste Events.
|
|
94
|
-
this.
|
|
110
|
+
this.startPoller();
|
|
95
111
|
this.cpu.start();
|
|
96
112
|
}
|
|
97
113
|
stop() {
|
|
@@ -100,6 +116,28 @@ export class Inspector extends EventEmitter {
|
|
|
100
116
|
clearInterval(this.poller);
|
|
101
117
|
clearTimeout(this.timer);
|
|
102
118
|
}
|
|
119
|
+
get active() {
|
|
120
|
+
return this.viewers > 0;
|
|
121
|
+
}
|
|
122
|
+
startPoller() {
|
|
123
|
+
clearInterval(this.poller);
|
|
124
|
+
this.poller = setInterval(() => void this.poll(), this.active ? 5000 : IDLE_POLL_MS);
|
|
125
|
+
this.cpu.setIdle(!this.active);
|
|
126
|
+
}
|
|
127
|
+
/** Anzahl offener Live-Verbindungen (sichtbare Tabs); schaltet zwischen Live- und Sparbetrieb. */
|
|
128
|
+
setViewers(n) {
|
|
129
|
+
const was = this.active;
|
|
130
|
+
this.viewers = n;
|
|
131
|
+
if (was === this.active)
|
|
132
|
+
return;
|
|
133
|
+
this.startPoller();
|
|
134
|
+
if (this.active) {
|
|
135
|
+
// Zuschauer zurück: sofort alles auf Stand bringen
|
|
136
|
+
this.dirty.processes = this.dirty.jobs = this.dirty.dashboard = true;
|
|
137
|
+
this.historyRefreshedAt = 0;
|
|
138
|
+
void this.poll();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
103
141
|
onFsEvent(abs) {
|
|
104
142
|
const rel = relToClaude(abs);
|
|
105
143
|
if (rel.startsWith('sessions/'))
|
|
@@ -108,7 +146,8 @@ export class Inspector extends EventEmitter {
|
|
|
108
146
|
this.dirty.jobs = true;
|
|
109
147
|
else if (rel.startsWith('projects/')) {
|
|
110
148
|
if (rel.endsWith('.jsonl')) {
|
|
111
|
-
|
|
149
|
+
if (this.active)
|
|
150
|
+
void this.onTranscriptChanged(abs);
|
|
112
151
|
if (!rel.includes('/subagents/'))
|
|
113
152
|
void this.index.touch(abs);
|
|
114
153
|
}
|
|
@@ -116,7 +155,9 @@ export class Inspector extends EventEmitter {
|
|
|
116
155
|
}
|
|
117
156
|
else
|
|
118
157
|
return;
|
|
119
|
-
|
|
158
|
+
// ohne Zuschauer nur vormerken; der nächste Sparlauf holt es nach
|
|
159
|
+
if (this.active)
|
|
160
|
+
this.schedule();
|
|
120
161
|
}
|
|
121
162
|
async onTranscriptChanged(path) {
|
|
122
163
|
const r = await this.store.refreshIfLoaded(path);
|
|
@@ -127,6 +168,20 @@ export class Inspector extends EventEmitter {
|
|
|
127
168
|
this.emit('event', { type: 'transcript', data: { sessionId: t.sessionId, agentId: t.agentId, total: t.entries.length } });
|
|
128
169
|
}
|
|
129
170
|
async poll() {
|
|
171
|
+
if (!this.active) {
|
|
172
|
+
// Sparbetrieb: Prozessliste aktuell halten (CPU-Messung braucht die Claude-Prozesse),
|
|
173
|
+
// den Rest nur alle 2 Minuten
|
|
174
|
+
if (Date.now() - this.builtAt < IDLE_REBUILD_MS) {
|
|
175
|
+
if (!this.building) {
|
|
176
|
+
this.processes = await readProcesses();
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.dirty.processes = true;
|
|
181
|
+
this.dirty.jobs = true;
|
|
182
|
+
this.schedule(0);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
130
185
|
for (const t of this.store.all()) {
|
|
131
186
|
try {
|
|
132
187
|
const st = await stat(t.path);
|
|
@@ -161,9 +216,13 @@ export class Inspector extends EventEmitter {
|
|
|
161
216
|
}
|
|
162
217
|
if (this.spawnsReady)
|
|
163
218
|
await this.spawns.refresh(this.index.list(), this.processes, false);
|
|
164
|
-
|
|
219
|
+
// Statistik muss nicht im Sekundentakt nachziehen; laufende Agenten schreiben ständig
|
|
220
|
+
if (this.historyReady && (!this.active || Date.now() - this.historyRefreshedAt > 5000)) {
|
|
221
|
+
this.historyRefreshedAt = Date.now();
|
|
165
222
|
await this.history.refresh(this.index.list(), this.projectOf, false);
|
|
223
|
+
}
|
|
166
224
|
this.dirty.dashboard = false;
|
|
225
|
+
this.builtAt = Date.now();
|
|
167
226
|
const d = await this.buildDashboard();
|
|
168
227
|
const { generatedAt: _g, ...cmp } = d;
|
|
169
228
|
const json = JSON.stringify(cmp);
|
|
@@ -185,8 +244,11 @@ export class Inspector extends EventEmitter {
|
|
|
185
244
|
return this.cpu.logging ? this.cpu.dir : undefined;
|
|
186
245
|
}
|
|
187
246
|
async getDashboard() {
|
|
188
|
-
|
|
247
|
+
// Abruf ohne Live-Verbindung (z. B. API) im Sparbetrieb: nicht älter als 5 s ausliefern
|
|
248
|
+
if (!this.dashboard || (!this.active && Date.now() - this.builtAt > 5000)) {
|
|
249
|
+
this.dirty.processes = this.dirty.jobs = true;
|
|
189
250
|
await this.rebuild();
|
|
251
|
+
}
|
|
190
252
|
return this.dashboard;
|
|
191
253
|
}
|
|
192
254
|
// -------------------------------------------------------------------------
|
|
@@ -576,6 +638,38 @@ export class Inspector extends EventEmitter {
|
|
|
576
638
|
formats() {
|
|
577
639
|
return registry.report();
|
|
578
640
|
}
|
|
641
|
+
/** Kompakter Bericht über die Session samt Subagenten und gestarteten Sitzungen (Grundlage der Analyse). */
|
|
642
|
+
async reviewDigest(sessionId) {
|
|
643
|
+
const [flow, cost] = [await this.flow(sessionId), await this.sessionCost(sessionId)];
|
|
644
|
+
const f = this.index.get(sessionId);
|
|
645
|
+
if (!flow || !f)
|
|
646
|
+
return undefined;
|
|
647
|
+
const sum = await this.index.summary(f);
|
|
648
|
+
const costOf = new Map(cost?.rows.map((r) => [r.id, r.costUSD]));
|
|
649
|
+
const lanes = [];
|
|
650
|
+
for (const lane of flow.lanes) {
|
|
651
|
+
const t = await this.transcriptFor(lane.sessionId, lane.agentId);
|
|
652
|
+
if (t)
|
|
653
|
+
lanes.push({ lane, entries: t.entries, costUSD: costOf.get(lane.id) ?? 0 });
|
|
654
|
+
}
|
|
655
|
+
return buildDigest({ title: sum.title, project: sum.project, cwd: sum.cwd, gitBranch: sum.gitBranch, costUSD: cost?.costUSD ?? 0, lanes });
|
|
656
|
+
}
|
|
657
|
+
async reviewState(sessionId) {
|
|
658
|
+
if (!this.index.get(sessionId))
|
|
659
|
+
return undefined;
|
|
660
|
+
if (!this.reviewer)
|
|
661
|
+
return { available: false, disabled: true, runs: [] };
|
|
662
|
+
const claudePath = await findClaude();
|
|
663
|
+
return { available: !!claudePath, claudePath, disabled: false, digest: (await this.reviewDigest(sessionId))?.stats, runs: await this.reviewer.list(sessionId) };
|
|
664
|
+
}
|
|
665
|
+
async startReview(sessionId, req) {
|
|
666
|
+
if (!this.reviewer)
|
|
667
|
+
throw new Error('Analysis is turned off (--no-analyze).');
|
|
668
|
+
const digest = await this.reviewDigest(sessionId);
|
|
669
|
+
if (!digest)
|
|
670
|
+
throw new Error('unknown session');
|
|
671
|
+
return this.reviewer.start(sessionId, digest, req);
|
|
672
|
+
}
|
|
579
673
|
}
|
|
580
674
|
const MIN = 60_000;
|
|
581
675
|
const BUCKET_SIZES = [1, 2, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440].map((m) => m * MIN);
|
|
@@ -87,7 +87,7 @@ export function cpuHistory(samples, opts) {
|
|
|
87
87
|
for (let i = 0; i < samples.length; i++) {
|
|
88
88
|
const s = samples[i];
|
|
89
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;
|
|
90
|
+
const dt = Math.min(i > 0 ? s.t - samples[i - 1].t : opts.intervalMs, opts.maxStepMs ?? 2 * opts.intervalMs) / 1000;
|
|
91
91
|
const byComm = new Map();
|
|
92
92
|
for (const p of s.procs) {
|
|
93
93
|
byComm.set(p.comm, (byComm.get(p.comm) ?? 0) + p.pct);
|