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 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 anderen Ablageort des CPU-Protokolls.
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 dem CPU-Log im eigenen Datenverzeichnis wird nichts auf Platte geschrieben. Beim Start liest der Server nur Prozess-, Job- und
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 thing it writes is a CPU log in its own data directory (see *CPU usage*).
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);
@@ -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.intervalMs);
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.intervalMs, cores: this.cores, sessions };
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
- for (const p of this.files.keys())
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))
@@ -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
- req.on('close', () => sseClients.delete(res));
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.poller = setInterval(() => void this.poll(), 3000);
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
- void this.onTranscriptChanged(abs);
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
- this.schedule();
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
- if (this.historyReady)
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
- if (!this.dashboard)
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);