claude-inspect 0.5.1 → 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 +28 -2
- package/README.md +6 -2
- package/dist/app/server/cli/demo.js +2 -0
- package/dist/app/server/cli.js +5 -2
- package/dist/app/server/config.js +2 -0
- 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 +102 -7
- 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/plan.js +23 -0
- package/dist/app/server/sources/spawns.js +25 -6
- package/dist/app/shared/budget.js +18 -0
- package/dist/app/shared/review.js +57 -0
- package/dist/app/shared/types.js +0 -2
- package/dist/web/assets/index-BH5nprTU.js +19 -0
- package/dist/web/assets/index-BNso7xYj.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-CpYuiE4Y.js +0 -16
- package/dist/web/assets/index-Jv_qFlIw.css +0 -1
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,25 @@ 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.
|
|
89
|
+
- **Abo-Budget**: Kosten als geschätzter Anteil am Wochenlimit und am 5-Stunden-Fenster des Tarifs
|
|
90
|
+
(Max 20x, Max 5x, Pro). Der Tarif wird aus `~/.claude.json` erkannt (gelesen wird nur das
|
|
91
|
+
Rate-Limit-Tier) und lässt sich im Browser umstellen. Anthropic nennt keine Dollar- oder
|
|
92
|
+
Token-Grenzen; die Werte sind aus echten `/usage`-Messwerten gegen den im selben Zeitraum
|
|
93
|
+
angefallenen API-Gegenwert kalibriert. Max 5x/20x sind gemessen, Pro ist hochgerechnet.
|
|
94
|
+
Nachkalibrieren mit eigenen Daten (z. B. zusammen mit einer Kopie des `~/.claude` eines Servers):
|
|
95
|
+
siehe [Calibrating the plan budgets](https://github.com/brokoskokoli/claude_inspect/blob/main/CONTRIBUTING.md#calibrating-the-plan-budgets)
|
|
76
96
|
- **Statistics**: API-Gegenwert (Schätzung nach Listenpreisen), Tokens je Modell/Tag/Projekt,
|
|
77
97
|
Cache-Anteil, Aktivitäts-Heatmap, Tool-Statistik mit Fehlern und Dauer (p95)
|
|
78
98
|
- **Tools**: alle Tool-Aufrufe aller Sessions, filterbar; Klick springt an die Stelle im Verlauf
|
|
@@ -114,7 +134,13 @@ Neue Dateiformate: siehe [docs/FORMATS.md](https://github.com/brokoskokoli/claud
|
|
|
114
134
|
|
|
115
135
|
## Speicher & Leistung
|
|
116
136
|
|
|
117
|
-
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
|
|
118
138
|
Kopfdaten (unter 1 s); die komplette History (hier ~600 MB) wird im Hintergrund in
|
|
119
139
|
2–3 s aggregiert und danach nur noch inkrementell nachgeführt. Vollständige Verläufe
|
|
120
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
|
|
|
@@ -51,6 +52,8 @@ becomes hard to tell *who is doing what*. claude-inspect answers:
|
|
|
51
52
|
| **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. |
|
|
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. |
|
|
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. |
|
|
54
57
|
| **Statistics** | API list-price estimate, tokens per model/day/project, cache share, activity heatmap, tool statistics with error rate and p95 duration. |
|
|
55
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. |
|
|
56
59
|
| **Tool explorer & files** | Every tool call across all sessions, filterable; which files agents read, edited or wrote. |
|
|
@@ -105,7 +108,7 @@ After `npm install -g claude-inspect@latest`, run `claude-inspect service instal
|
|
|
105
108
|
Started manually, `claude-inspect` prints a URL with a random access token (`?t=…`) that the browser
|
|
106
109
|
keeps as a cookie. Use `--token <t>` for a fixed token or `--no-auth` for none, `--port` for another port,
|
|
107
110
|
`CLAUDE_CONFIG_DIR=/path/to/.claude` for another data directory, `--no-cpu-log` to not record CPU usage,
|
|
108
|
-
`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.
|
|
109
112
|
|
|
110
113
|
### Try it without your own data
|
|
111
114
|
|
|
@@ -142,6 +145,7 @@ FormatRegistry → versioned decoders → normalized model → REST + SSE → Sv
|
|
|
142
145
|
|
|
143
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).
|
|
144
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.
|
|
145
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.
|
|
146
150
|
|
|
147
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,6 +14,8 @@ function defaultDataDir() {
|
|
|
14
14
|
* (CPU-Messwerte). ~/.claude wird nie verändert.
|
|
15
15
|
*/
|
|
16
16
|
export const DATA_DIR = process.env.CLAUDE_INSPECT_DATA_DIR || defaultDataDir();
|
|
17
|
+
/** Globale Claude-Code-Konfiguration (Konto, Tarif). Liegt neben, nicht in ~/.claude. */
|
|
18
|
+
export const CLAUDE_JSON = process.env.CLAUDE_CONFIG_DIR ? join(process.env.CLAUDE_CONFIG_DIR, '.claude.json') : join(homedir(), '.claude.json');
|
|
17
19
|
export const paths = {
|
|
18
20
|
projects: join(CLAUDE_DIR, 'projects'),
|
|
19
21
|
sessions: join(CLAUDE_DIR, 'sessions'),
|
|
@@ -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';
|
|
@@ -10,6 +12,7 @@ import { emptyTotals, estimateCost, mergeTotals } from '../shared/pricing.js';
|
|
|
10
12
|
import { search } from './history/search.js';
|
|
11
13
|
import { analyze } from './model/analyze.js';
|
|
12
14
|
import { laneData } from './model/flow.js';
|
|
15
|
+
import { readPlan } from './sources/plan.js';
|
|
13
16
|
import { readTasks } from './sources/tasks.js';
|
|
14
17
|
import { readJobs } from './sources/jobs.js';
|
|
15
18
|
import { readProcesses } from './sources/processes.js';
|
|
@@ -32,6 +35,9 @@ function idleIdeProcess(p) {
|
|
|
32
35
|
const SUBAGENT_STALE_MS = 10 * 60_000;
|
|
33
36
|
/** Subagenten erscheinen auf dem Dashboard, wenn sie so kürzlich aktiv waren. */
|
|
34
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;
|
|
35
41
|
export class Inspector extends EventEmitter {
|
|
36
42
|
index = new ProjectIndex();
|
|
37
43
|
store = new TranscriptStore();
|
|
@@ -41,6 +47,13 @@ export class Inspector extends EventEmitter {
|
|
|
41
47
|
/** Aggregierte History (Tokens, Kosten, Tool-Aufrufe, Dateien) – nur im Speicher. */
|
|
42
48
|
history = new HistoryIndex();
|
|
43
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;
|
|
44
57
|
historyReadyResolve;
|
|
45
58
|
/** Erfüllt, sobald der erste History-Scan durch ist (API-Aufrufe warten darauf). */
|
|
46
59
|
historyLoaded = new Promise((r) => (this.historyReadyResolve = r));
|
|
@@ -55,8 +68,12 @@ export class Inspector extends EventEmitter {
|
|
|
55
68
|
building;
|
|
56
69
|
/** CPU-Auslastung je Claude-Prozess samt Unterprozessen */
|
|
57
70
|
cpu;
|
|
71
|
+
/** "Analyze with Claude": startet auf Knopfdruck `claude -p` (abschaltbar mit --no-analyze) */
|
|
72
|
+
reviewer;
|
|
58
73
|
constructor(opts = {}) {
|
|
59
74
|
super();
|
|
75
|
+
if (opts.analyze !== false)
|
|
76
|
+
this.reviewer = new Reviewer(join(DATA_DIR, 'reviews'), (data) => this.emit('event', { type: 'review', data }));
|
|
60
77
|
this.cpu = new CpuMonitor({
|
|
61
78
|
log: opts.cpuLog ?? false,
|
|
62
79
|
roots: () => new Map(this.processes.filter((p) => p.alive && p.pid > 0).map((p) => [p.pid, p.sessionId])),
|
|
@@ -90,7 +107,7 @@ export class Inspector extends EventEmitter {
|
|
|
90
107
|
/* Fallback: nur Polling */
|
|
91
108
|
}
|
|
92
109
|
// Polling als Netz: Prozess-Liveness (kein Datei-Event beim Absturz) und verpasste Events.
|
|
93
|
-
this.
|
|
110
|
+
this.startPoller();
|
|
94
111
|
this.cpu.start();
|
|
95
112
|
}
|
|
96
113
|
stop() {
|
|
@@ -99,6 +116,28 @@ export class Inspector extends EventEmitter {
|
|
|
99
116
|
clearInterval(this.poller);
|
|
100
117
|
clearTimeout(this.timer);
|
|
101
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
|
+
}
|
|
102
141
|
onFsEvent(abs) {
|
|
103
142
|
const rel = relToClaude(abs);
|
|
104
143
|
if (rel.startsWith('sessions/'))
|
|
@@ -107,7 +146,8 @@ export class Inspector extends EventEmitter {
|
|
|
107
146
|
this.dirty.jobs = true;
|
|
108
147
|
else if (rel.startsWith('projects/')) {
|
|
109
148
|
if (rel.endsWith('.jsonl')) {
|
|
110
|
-
|
|
149
|
+
if (this.active)
|
|
150
|
+
void this.onTranscriptChanged(abs);
|
|
111
151
|
if (!rel.includes('/subagents/'))
|
|
112
152
|
void this.index.touch(abs);
|
|
113
153
|
}
|
|
@@ -115,7 +155,9 @@ export class Inspector extends EventEmitter {
|
|
|
115
155
|
}
|
|
116
156
|
else
|
|
117
157
|
return;
|
|
118
|
-
|
|
158
|
+
// ohne Zuschauer nur vormerken; der nächste Sparlauf holt es nach
|
|
159
|
+
if (this.active)
|
|
160
|
+
this.schedule();
|
|
119
161
|
}
|
|
120
162
|
async onTranscriptChanged(path) {
|
|
121
163
|
const r = await this.store.refreshIfLoaded(path);
|
|
@@ -126,6 +168,20 @@ export class Inspector extends EventEmitter {
|
|
|
126
168
|
this.emit('event', { type: 'transcript', data: { sessionId: t.sessionId, agentId: t.agentId, total: t.entries.length } });
|
|
127
169
|
}
|
|
128
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
|
+
}
|
|
129
185
|
for (const t of this.store.all()) {
|
|
130
186
|
try {
|
|
131
187
|
const st = await stat(t.path);
|
|
@@ -160,9 +216,13 @@ export class Inspector extends EventEmitter {
|
|
|
160
216
|
}
|
|
161
217
|
if (this.spawnsReady)
|
|
162
218
|
await this.spawns.refresh(this.index.list(), this.processes, false);
|
|
163
|
-
|
|
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();
|
|
164
222
|
await this.history.refresh(this.index.list(), this.projectOf, false);
|
|
223
|
+
}
|
|
165
224
|
this.dirty.dashboard = false;
|
|
225
|
+
this.builtAt = Date.now();
|
|
166
226
|
const d = await this.buildDashboard();
|
|
167
227
|
const { generatedAt: _g, ...cmp } = d;
|
|
168
228
|
const json = JSON.stringify(cmp);
|
|
@@ -184,8 +244,11 @@ export class Inspector extends EventEmitter {
|
|
|
184
244
|
return this.cpu.logging ? this.cpu.dir : undefined;
|
|
185
245
|
}
|
|
186
246
|
async getDashboard() {
|
|
187
|
-
|
|
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;
|
|
188
250
|
await this.rebuild();
|
|
251
|
+
}
|
|
189
252
|
return this.dashboard;
|
|
190
253
|
}
|
|
191
254
|
// -------------------------------------------------------------------------
|
|
@@ -219,7 +282,7 @@ export class Inspector extends EventEmitter {
|
|
|
219
282
|
const s = await this.index.summary(f);
|
|
220
283
|
recentSessions.push({ ...s, live: liveIds.has(s.sessionId), spawnedBy: this.spawns.parentOf(s.sessionId)?.parentSessionId });
|
|
221
284
|
}
|
|
222
|
-
return { generatedAt: Date.now(), claudeDir: CLAUDE_DIR, platform: process.platform, processes, orphanJobs, recentSessions };
|
|
285
|
+
return { generatedAt: Date.now(), claudeDir: CLAUDE_DIR, platform: process.platform, processes, orphanJobs, recentSessions, plan: await readPlan() };
|
|
223
286
|
}
|
|
224
287
|
async sessionLive(sessionId, p) {
|
|
225
288
|
const f = this.index.get(sessionId);
|
|
@@ -575,6 +638,38 @@ export class Inspector extends EventEmitter {
|
|
|
575
638
|
formats() {
|
|
576
639
|
return registry.report();
|
|
577
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
|
+
}
|
|
578
673
|
}
|
|
579
674
|
const MIN = 60_000;
|
|
580
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);
|