claude-inspect 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.de.md 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);
@@ -12,6 +12,10 @@ import { readFileSync } from 'node:fs';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { parseArgs } from 'node:util';
15
+ import v8 from 'node:v8';
16
+ // Läuft dauerhaft im Hintergrund: kleiner Heap statt Höchsttempo. Gemessen mit ~1.600 Transcripts
17
+ // 140 statt 700 MB Speicher, der erste Scan dauert dafür ~20 % länger. Keine harte Grenze.
18
+ v8.setFlagsFromString('--optimize-for-size');
15
19
  const env = process.env;
16
20
  function version() {
17
21
  const here = dirname(fileURLToPath(import.meta.url));
@@ -41,12 +45,14 @@ Options:
41
45
  --token <t> fixed access token instead of a random one env: CLAUDE_INSPECT_TOKEN
42
46
  --no-auth no token (still only reachable from localhost) env: CLAUDE_INSPECT_NO_AUTH=1
43
47
  --no-cpu-log don't record CPU usage to the data directory env: CLAUDE_INSPECT_NO_CPU_LOG=1
48
+ --no-analyze hide "Analyze with Claude" (never runs claude) env: CLAUDE_INSPECT_NO_ANALYZE=1
44
49
  --out <dir> demo: only write the demo data to <dir>
45
50
  -v, --version print the version
46
51
  -h, --help show this help
47
52
 
48
53
  Data directory: $CLAUDE_CONFIG_DIR or ~/.claude (read only)
49
- CPU log: $CLAUDE_INSPECT_DATA_DIR/cpu (default: the platform's app data directory)`;
54
+ CPU log: $CLAUDE_INSPECT_DATA_DIR/cpu (default: the platform's app data directory)
55
+ Analyses: $CLAUDE_INSPECT_DATA_DIR/reviews`;
50
56
  const { values, positionals } = parseArgs({
51
57
  allowPositionals: true,
52
58
  options: {
@@ -56,6 +62,7 @@ const { values, positionals } = parseArgs({
56
62
  token: { type: 'string', default: env.CLAUDE_INSPECT_TOKEN },
57
63
  'no-auth': { type: 'boolean', default: env.CLAUDE_INSPECT_NO_AUTH === '1' },
58
64
  'no-cpu-log': { type: 'boolean', default: env.CLAUDE_INSPECT_NO_CPU_LOG === '1' },
65
+ 'no-analyze': { type: 'boolean', default: env.CLAUDE_INSPECT_NO_ANALYZE === '1' },
59
66
  out: { type: 'string' },
60
67
  help: { type: 'boolean', short: 'h', default: false },
61
68
  version: { type: 'boolean', short: 'v', default: false },
@@ -79,7 +86,7 @@ else if (cmd === 'demo') {
79
86
  }
80
87
  else if (cmd === undefined) {
81
88
  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'] });
89
+ 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
90
  }
84
91
  else {
85
92
  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
  }
@@ -5,6 +5,22 @@ import { toolSummary } from '../../shared/tools.js';
5
5
  import { registry } from '../formats/index.js';
6
6
  import { subagentDir } from '../sources/subagents.js';
7
7
  import { JsonlTail } from '../util/jsonl.js';
8
+ /** Wiederkehrende Texte (Modell- und Tool-Namen) nur einmal im Speicher halten. */
9
+ const interned = new Map();
10
+ function intern(s) {
11
+ let v = interned.get(s);
12
+ if (v === undefined)
13
+ interned.set(s, (v = s));
14
+ return v;
15
+ }
16
+ /**
17
+ * Eigene Kopie eines gekürzten Strings. V8 legt Teilstrings als Verweis auf den Ausgangstext an –
18
+ * eine 200-Zeichen-Zusammenfassung hielte sonst den kompletten (evtl. mehrere KB großen) Befehl fest.
19
+ */
20
+ function own(s) {
21
+ return s.length < 13 ? s : Buffer.from(s, 'utf8').toString('utf8');
22
+ }
23
+ const CHUNK_BYTES = 2 * 1024 * 1024;
8
24
  const FILE_TOOLS = {
9
25
  Read: 'reads',
10
26
  Edit: 'edits',
@@ -32,18 +48,22 @@ export class HistoryIndex {
32
48
  */
33
49
  async refresh(transcripts, projectOf, full) {
34
50
  const seen = new Set();
51
+ // Sessions ohne Änderung in der letzten Stunde: Subagenten nicht neu einlesen, nur behalten
52
+ const untouched = new Set();
35
53
  for (const f of transcripts) {
36
54
  const project = await projectOf(f);
37
55
  seen.add(f.path);
38
56
  await this.scan(f.path, f.size, f.sessionId, undefined, project);
39
57
  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);
58
+ untouched.add(f.sessionId);
43
59
  continue;
44
60
  }
45
61
  await this.scanSubagents(f, project, seen);
46
62
  }
63
+ // ein Durchgang statt Sessions × Dateien (war bei ~1.500 Sessions der größte CPU-Verbraucher)
64
+ for (const [p, a] of this.files)
65
+ if (a.agentId && untouched.has(a.sessionId))
66
+ seen.add(p);
47
67
  // Von Claude Code aufgeräumte Dateien vergessen
48
68
  for (const p of [...this.files.keys()])
49
69
  if (!seen.has(p))
@@ -106,19 +126,22 @@ export class HistoryIndex {
106
126
  }
107
127
  agg.size = size;
108
128
  agg.project = project;
109
- let lines;
110
- try {
111
- ({ lines } = await agg.tail.readNew());
112
- }
113
- catch {
114
- return;
115
- }
116
- for (const l of lines) {
117
- if (l.value === undefined)
118
- continue;
119
- const { out } = registry.decode('transcript', l.value, path, l.line, { silent: true });
120
- for (const e of out)
121
- this.fold(agg, e);
129
+ // in Stücken lesen: große Transcripts (50–100 MB) nicht komplett geparst im Speicher halten
130
+ for (let more = true; more;) {
131
+ let lines;
132
+ try {
133
+ ({ lines, more } = await agg.tail.readNew(undefined, CHUNK_BYTES));
134
+ }
135
+ catch {
136
+ return;
137
+ }
138
+ for (const l of lines) {
139
+ if (l.value === undefined)
140
+ continue;
141
+ const { out } = registry.decode('transcript', l.value, path, l.line, { silent: true });
142
+ for (const e of out)
143
+ this.fold(agg, e);
144
+ }
122
145
  }
123
146
  }
124
147
  fold(agg, e) {
@@ -140,8 +163,8 @@ export class HistoryIndex {
140
163
  agentId: agg.agentId,
141
164
  project: agg.project,
142
165
  toolUseId: e.toolUseId,
143
- name: e.name,
144
- summary: toolSummary(e.name, e.input).slice(0, 200),
166
+ name: intern(e.name),
167
+ summary: own(toolSummary(e.name, e.input).slice(0, 200)),
145
168
  ts: Number.isNaN(ts) ? 0 : ts,
146
169
  file,
147
170
  });
@@ -164,7 +187,9 @@ export class HistoryIndex {
164
187
  break;
165
188
  }
166
189
  if ((e.kind === 'assistant-text' || e.kind === 'thinking' || e.kind === 'tool-use') && e.usage && e.messageId && e.model && e.model !== '<synthetic>') {
167
- agg.usage.set(e.messageId, { model: e.model, ts: Number.isNaN(ts) ? 0 : ts, usage: e.usage });
190
+ const tokens = emptyTotals();
191
+ addUsage(tokens, e.usage);
192
+ agg.usage.set(e.messageId, { model: intern(e.model), ts: Number.isNaN(ts) ? 0 : ts, tokens });
168
193
  }
169
194
  }
170
195
  // ---------------------------------------------------------------------------
@@ -205,8 +230,7 @@ export class HistoryIndex {
205
230
  for (const u of a.usage.values()) {
206
231
  if (u.ts < since)
207
232
  continue;
208
- const one = emptyTotals();
209
- addUsage(one, u.usage);
233
+ const one = u.tokens;
210
234
  let m = byModel.get(u.model);
211
235
  if (!m)
212
236
  byModel.set(u.model, (m = emptyTotals()));
@@ -303,12 +327,9 @@ export class HistoryIndex {
303
327
  let t = out.get(u.model);
304
328
  if (!t)
305
329
  out.set(u.model, (t = emptyTotals()));
306
- addUsage(t, u.usage);
307
- if (each) {
308
- const one = emptyTotals();
309
- addUsage(one, u.usage);
310
- each(u.model, u.ts, one);
311
- }
330
+ mergeTotals(t, u.tokens);
331
+ if (each)
332
+ each(u.model, u.ts, { ...u.tokens });
312
333
  }
313
334
  }
314
335
  return out;
@@ -1,8 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
2
3
  import { toolSummary } from '../../shared/tools.js';
3
4
  import { paths } from '../config.js';
4
5
  import { registry } from '../formats/index.js';
5
6
  import { pathTail } from '../sources/projects.js';
7
+ const home = homedir();
8
+ const tildify = (p) => (home && p.startsWith(home) ? '~' + p.slice(home.length) : p);
6
9
  function snippet(text, needle, radius = 90) {
7
10
  const i = text.toLowerCase().indexOf(needle);
8
11
  if (i < 0)
@@ -33,7 +36,7 @@ function searchable(e) {
33
36
  * nach dem Suchbegriff durchsucht, nur Trefferzeilen werden dekodiert.
34
37
  * Neueste Dateien zuerst; bricht bei `limit` Treffern oder nach `budgetMs` ab.
35
38
  */
36
- export async function search(query, files, knownSessions, limit = 200, budgetMs = 6000, perFile = 5) {
39
+ export async function search(query, files, knownSessions, limit = 200, budgetMs = 15_000, perFile = 5) {
37
40
  const t0 = Date.now();
38
41
  const needle = query.trim().toLowerCase();
39
42
  const hits = [];
@@ -41,6 +44,16 @@ export async function search(query, files, knownSessions, limit = 200, budgetMs
41
44
  let truncated = false;
42
45
  if (needle.length < 2)
43
46
  return { query, hits, scannedFiles: 0, totalFiles: files.length, truncated, ms: 0 };
47
+ // Dateinamen und Pfade der Transcripts (z. B. "agent-a1b2….jsonl", Session-Id, Pfadteile)
48
+ const pathNeedle = (needle.startsWith('~') && home ? home.toLowerCase() + needle.slice(1) : needle).replace(/\\/g, '/');
49
+ for (const f of files) {
50
+ const p = f.path.replace(/\\/g, '/');
51
+ if (!p.toLowerCase().includes(pathNeedle))
52
+ continue;
53
+ hits.push({ sessionId: f.sessionId, agentId: f.agentId, project: f.project, title: f.title, line: 0, kind: 'file', snippet: tildify(f.path) });
54
+ if (hits.length >= 50)
55
+ break;
56
+ }
44
57
  outer: for (const f of files) {
45
58
  if (Date.now() - t0 > budgetMs) {
46
59
  truncated = true;
@@ -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]);