claude-inspect 0.1.0 → 0.2.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.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![npm](https://img.shields.io/npm/v/claude-inspect)](https://www.npmjs.com/package/claude-inspect)
5
- ![Node.js ≥ 20](https://img.shields.io/badge/node-%E2%89%A5%2020-green)
5
+ ![Node.js ≥ 22](https://img.shields.io/badge/node-%E2%89%A5%2022-green)
6
6
  ![TypeScript](https://img.shields.io/badge/TypeScript-Svelte%205-3178c6)
7
7
  ![Dark mode](https://img.shields.io/badge/dark%20mode-%E2%9C%93-2b2a27)
8
8
  ![No hooks](https://img.shields.io/badge/Claude%20Code-no%20hooks%20needed-d97757)
@@ -69,7 +69,7 @@ becomes hard to tell *who is doing what*. claude-inspect answers:
69
69
 
70
70
  ## Quick start
71
71
 
72
- Requirements: Node.js ≥ 20, macOS or Linux, Claude Code writing to `~/.claude`.
72
+ Requirements: Node.js ≥ 22, macOS, Linux or Windows, Claude Code writing to `~/.claude`. On Windows, live process status is read via PowerShell; the autostart service and the live `demo` are macOS/Linux only (`demo --out <dir>` works everywhere).
73
73
 
74
74
  ```bash
75
75
  npx claude-inspect --open # try it once
@@ -292,6 +292,10 @@ export async function runDemo(opts) {
292
292
  console.log(`Demo data written to ${ROOT} (no processes started)`);
293
293
  return;
294
294
  }
295
+ if (process.platform === 'win32') {
296
+ console.error('The live demo starts Unix processes (sh, sleep, pgrep) and needs macOS or Linux. Use `demo --out <dir>` to only generate the data.');
297
+ process.exit(1);
298
+ }
295
299
  // orchestrator = shell that forks the implementer (process tree: implementer → orchestrator)
296
300
  const children = [
297
301
  spawn('sh', ['-c', 'sleep 7200 & wait'], { stdio: 'ignore', detached: true }),
@@ -114,13 +114,15 @@ function macStatus() {
114
114
  // ---------------------------------------------------------------------------
115
115
  // Linux (systemd --user)
116
116
  const unitPath = join(homedir(), '.config', 'systemd', 'user', 'claude-inspect.service');
117
+ /** systemd-Quoting für ExecStart (Pfade mit Leerzeichen, Anführungszeichen, Backslashes, %). */
118
+ const q = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
117
119
  function linuxInstall() {
118
120
  mkdirSync(dirname(unitPath), { recursive: true });
119
121
  writeFileSync(unitPath, `[Unit]
120
122
  Description=claude-inspect – live dashboard for Claude Code agents
121
123
 
122
124
  [Service]
123
- ExecStart=${nodePath()} ${ENTRY} --port ${port} --no-auth
125
+ ExecStart=${q(nodePath())} ${q(ENTRY)} --port ${port} --no-auth
124
126
  WorkingDirectory=${ROOT}
125
127
  Restart=on-failure
126
128
  RestartSec=10
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { toolSummary } from '../../shared/tools.js';
3
3
  import { paths } from '../config.js';
4
4
  import { registry } from '../formats/index.js';
5
+ import { pathTail } from '../sources/projects.js';
5
6
  function snippet(text, needle, radius = 90) {
6
7
  const i = text.toLowerCase().indexOf(needle);
7
8
  if (i < 0)
@@ -117,7 +118,7 @@ export async function search(query, files, knownSessions, limit = 200, budgetMs
117
118
  continue;
118
119
  hits.push({
119
120
  sessionId: p.sessionId ?? '',
120
- project: p.project?.split('/').pop() ?? '?',
121
+ project: p.project ? pathTail(p.project) : '?',
121
122
  title: 'transcript no longer exists',
122
123
  line: i,
123
124
  kind: 'history-prompt',
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { watch } from 'node:fs';
3
3
  import { stat } from 'node:fs/promises';
4
- import { join } from 'node:path';
4
+ import { basename, join } from 'node:path';
5
5
  import { CLAUDE_DIR, relToClaude } from './config.js';
6
6
  import { registry } from './formats/index.js';
7
7
  import { HistoryIndex } from './history/aggregator.js';
@@ -15,6 +15,17 @@ import { ProjectIndex } from './sources/projects.js';
15
15
  import { SpawnIndex } from './sources/spawns.js';
16
16
  import { agentCallIds, listSubagents } from './sources/subagents.js';
17
17
  import { TranscriptStore } from './sources/transcripts.js';
18
+ /**
19
+ * IDE-Erweiterungen und SDK-Hosts (VS Code, JetBrains ACP …) halten claude-Prozesse am Leben,
20
+ * auch wenn das Chat-Panel längst zu ist. Ohne Transcript gab es darin nie ein Gespräch.
21
+ * Terminal-Sessions (entrypoint "cli") bleiben außen vor: Dort sitzt jemand vor dem Prompt.
22
+ */
23
+ const IDE_GRACE_MS = 2 * 60_000;
24
+ function idleIdeProcess(p) {
25
+ if (p.status === 'busy' || !p.entrypoint || p.entrypoint === 'cli' || p.kind === 'bg')
26
+ return false;
27
+ return Date.now() - (p.startedAt ?? 0) > IDE_GRACE_MS;
28
+ }
18
29
  /** Subagenten, deren Datei so lange unverändert ist, gelten ohne Abschluss als "stale". */
19
30
  const SUBAGENT_STALE_MS = 10 * 60_000;
20
31
  /** Subagenten erscheinen auf dem Dashboard, wenn sie so kürzlich aktiv waren. */
@@ -166,10 +177,14 @@ export class Inspector extends EventEmitter {
166
177
  if (p.alive) {
167
178
  try {
168
179
  dp.session = await this.sessionLive(p.sessionId, p);
180
+ if (dp.session?.ended && p.status !== 'busy')
181
+ dp.inactive = dp.session.ended;
169
182
  }
170
183
  catch {
171
184
  /* Transcript nicht lesbar */
172
185
  }
186
+ if (!dp.session && !this.index.get(p.sessionId) && idleIdeProcess(p))
187
+ dp.inactive = { reason: 'no-conversation', at: p.updatedAt ? new Date(p.updatedAt).toISOString() : undefined };
173
188
  }
174
189
  processes.push(dp);
175
190
  }
@@ -214,6 +229,7 @@ export class Inspector extends EventEmitter {
214
229
  costUSD: a.costUSD,
215
230
  lastPrompt: a.lastPrompt,
216
231
  spawned: await this.spawnedSessions(sessionId),
232
+ ended: a.ended,
217
233
  };
218
234
  }
219
235
  async spawnParent(sessionId) {
@@ -260,7 +276,7 @@ export class Inspector extends EventEmitter {
260
276
  * Status und aktuelle Aktivität zu bestimmen (für Dashboard und Detailansicht).
261
277
  */
262
278
  async resolveSubagents(sessionPath, files, load) {
263
- const sessionId = sessionPath.replace(/^.*\//, '').replace(/\.jsonl$/, '');
279
+ const sessionId = basename(sessionPath, '.jsonl');
264
280
  const out = [];
265
281
  for (const sf of files) {
266
282
  const info = {
@@ -9,6 +9,29 @@ function short(s, max = 140) {
9
9
  .trim();
10
10
  return one.length > max ? one.slice(0, max - 1) + '…' : one;
11
11
  }
12
+ /**
13
+ * Wurde die Session nach der letzten echten Eingabe verlassen? Eine neue Eingabe danach hebt das auf.
14
+ * - `/exit`: bei Hintergrund-Sessions trennt das nur das Terminal, der Prozess läuft weiter
15
+ * - Remote Control meldet, dass die Session auf einem anderen Gerät beendet oder archiviert wurde
16
+ */
17
+ function endedAfterLastPrompt(entries) {
18
+ for (let i = entries.length - 1; i >= 0; i--) {
19
+ const e = entries[i];
20
+ if (e.kind === 'user-text') {
21
+ if (/^<command-name>\/(exit|quit)<\/command-name>/.test(e.text))
22
+ return { reason: 'exit', at: e.timestamp };
23
+ if (!e.isMeta && e.text && !e.text.startsWith('<'))
24
+ return undefined;
25
+ }
26
+ else if (e.kind === 'system' && /ended or archived/i.test(e.text ?? '')) {
27
+ return { reason: 'archived', at: e.timestamp };
28
+ }
29
+ else if (e.kind === 'assistant-text' || e.kind === 'tool-use') {
30
+ return undefined; // danach wurde noch gearbeitet
31
+ }
32
+ }
33
+ return undefined;
34
+ }
12
35
  export function analyze(entries) {
13
36
  const a = { outputTokens: 0, toolCalls: 0, pendingTools: [], current: null, finished: false, errored: false };
14
37
  const open = new Map();
@@ -68,6 +91,7 @@ export function analyze(entries) {
68
91
  summary: toolSummary(t.name, t.input),
69
92
  since: t.timestamp,
70
93
  }));
94
+ a.ended = endedAfterLastPrompt(entries);
71
95
  // Aktuelle Aktivität aus dem letzten bedeutsamen Eintrag
72
96
  let last;
73
97
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -11,6 +11,14 @@ function findWebRoot() {
11
11
  const candidates = [join(here, '..', '..', 'dist', 'web'), join(here, '..', '..', 'web')];
12
12
  return candidates.find((d) => existsSync(join(d, 'index.html'))) ?? candidates[0];
13
13
  }
14
+ function openBrowser(url) {
15
+ const [bin, args] = process.platform === 'darwin'
16
+ ? ['open', [url]]
17
+ : process.platform === 'win32'
18
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]] // kein cmd/start: "&" in der URL wäre dort ein Befehlstrenner
19
+ : ['xdg-open', [url]];
20
+ execFile(bin, args, () => undefined).on('error', () => undefined);
21
+ }
14
22
  export async function runServer(opts) {
15
23
  const inspector = new Inspector();
16
24
  const t0 = Date.now();
@@ -24,10 +32,8 @@ export async function runServer(opts) {
24
32
  const url = noAuth ? `http://localhost:${opts.port}/` : `http://localhost:${opts.port}/?t=${token}`;
25
33
  console.log(`claude-inspect is reading ${CLAUDE_DIR} (started in ${Date.now() - t0} ms)`);
26
34
  console.log(opts.dev ? `API (dev, no token): ${url} – UI: npm run dev:web` : `Dashboard: ${url}`);
27
- if (opts.open && process.platform === 'darwin')
28
- execFile('open', [url]);
29
- else if (opts.open && process.platform === 'linux')
30
- execFile('xdg-open', [url]);
35
+ if (opts.open)
36
+ openBrowser(url);
31
37
  const shutdown = () => {
32
38
  inspector.stop();
33
39
  process.exit(0);
@@ -4,13 +4,14 @@ import { join } from 'node:path';
4
4
  import { paths } from '../config.js';
5
5
  import { registry } from '../formats/index.js';
6
6
  import { readJson } from '../util/jsonl.js';
7
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
7
8
  /**
8
9
  * Startzeiten laufender Prozesse per `ps` (rein lesend).
9
10
  * TZ=UTC, weil Claude Code `procStart` in UTC schreibt.
10
11
  */
11
12
  function procStarts(pids) {
12
13
  return new Promise((resolve) => {
13
- if (pids.length === 0 || process.platform === 'win32')
14
+ if (pids.length === 0)
14
15
  return resolve(new Map());
15
16
  execFile('ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')], { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, timeout: 5000 }, (_err, stdout) => {
16
17
  // ps endet mit Code 1, wenn einzelne PIDs fehlen – die Ausgabe ist trotzdem gültig.
@@ -25,10 +26,8 @@ function procStarts(pids) {
25
26
  });
26
27
  }
27
28
  /** Eltern-PID und Programmname aller Prozesse (`ps -A`), für die Zuordnung per Prozessbaum. */
28
- function processTable() {
29
+ function unixTable() {
29
30
  return new Promise((resolve) => {
30
- if (process.platform === 'win32')
31
- return resolve(new Map());
32
31
  execFile('ps', ['-A', '-o', 'pid=,ppid=,comm='], { timeout: 5000, maxBuffer: 8 * 1024 * 1024 }, (_err, stdout) => {
33
32
  const out = new Map();
34
33
  for (const line of String(stdout ?? '').split('\n')) {
@@ -40,13 +39,66 @@ function processTable() {
40
39
  });
41
40
  });
42
41
  }
42
+ /** Windows: Prozesstabelle inkl. Startzeit per CIM (rein lesend). */
43
+ const WIN_PS = "Get-CimInstance Win32_Process | ForEach-Object { '{0} {1} {2} {3}' -f $_.ProcessId, $_.ParentProcessId, " +
44
+ "$(if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('o') } else { '-' }), $_.Name }";
45
+ function windowsTable() {
46
+ return new Promise((resolve) => {
47
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WIN_PS], { timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (_err, stdout) => {
48
+ const out = new Map();
49
+ for (const line of String(stdout ?? '').split(/\r?\n/)) {
50
+ const m = /^(\d+) (\d+) (\S+) (.*)$/.exec(line.trim());
51
+ if (m)
52
+ out.set(Number(m[1]), { ppid: Number(m[2]), start: m[3] === '-' ? undefined : m[3], comm: m[4].toLowerCase().replace(/\.exe$/, '') });
53
+ }
54
+ resolve(out);
55
+ });
56
+ });
57
+ }
58
+ // PowerShell zu starten kostet spürbar CPU – die Tabelle wird unter Windows kurz zwischengespeichert.
59
+ let winCache;
60
+ function cachedWindowsTable() {
61
+ if (!winCache || Date.now() - winCache.at > 5000)
62
+ winCache = { at: Date.now(), table: windowsTable() };
63
+ return winCache.table;
64
+ }
65
+ /** Startzeit als Epoch-ms. `ps lstart` ("Mon Sep 28 19:15:43 2026") hat keine Zone und ist UTC. */
66
+ function parseStart(s) {
67
+ if (s === undefined || s === '')
68
+ return undefined;
69
+ if (typeof s === 'number' || /^\d+$/.test(s))
70
+ return Number(s);
71
+ const zoned = /(?:Z|[+-]\d{2}:?\d{2}|UTC|GMT)$/.test(s.trim());
72
+ const t = Date.parse(zoned ? s : `${s} UTC`);
73
+ return Number.isNaN(t) ? undefined : t;
74
+ }
75
+ /**
76
+ * Läuft der Prozess noch, und ist es derselbe (PIDs werden wiederverwendet)?
77
+ * Exakter Textvergleich, wo das Format bekannt ist; sonst Vergleich als Zeitpunkt.
78
+ * Ist `procStart` unbekannt oder unlesbar, reicht die Existenz der PID.
79
+ */
80
+ export function sameProcess(actual, expected) {
81
+ if (actual === undefined)
82
+ return false;
83
+ if (!expected || norm(actual) === norm(expected))
84
+ return true;
85
+ const a = parseStart(actual);
86
+ const e = parseStart(expected);
87
+ if (a !== undefined && e !== undefined)
88
+ return Math.abs(a - e) <= 2000;
89
+ // Unbekanntes Format: unter Windows (kein ps-Format) zählt die PID, sonst lieber "nicht aktiv".
90
+ return process.platform === 'win32';
91
+ }
43
92
  /**
44
93
  * Programme, die zwischen aufrufendem Agenten und gestartetem claude liegen dürfen
45
94
  * (Bash-Tool → Shell → evtl. Wrapper → claude). Alles andere – etwa der Daemon,
46
95
  * der Hintergrund-Jobs forkt – ist keine Aufrufbeziehung.
47
96
  */
48
- const LAUNCH_INTERMEDIATES = new Set(['sh', 'zsh', 'bash', 'dash', 'fish', '-zsh', '-bash', 'env', 'caffeinate', 'timeout', 'gtimeout', 'nohup', 'time', 'script', 'xargs', 'stdbuf', 'nice', 'sudo']);
49
- const norm = (s) => s.replace(/\s+/g, ' ').trim();
97
+ const LAUNCH_INTERMEDIATES = new Set([
98
+ 'sh', 'zsh', 'bash', 'dash', 'fish', '-zsh', '-bash', 'env', 'caffeinate', 'timeout', 'gtimeout', 'nohup', 'time', 'script', 'xargs', 'stdbuf', 'nice', 'sudo',
99
+ // Windows (Namen ohne .exe)
100
+ 'cmd', 'powershell', 'pwsh',
101
+ ]);
50
102
  export async function readProcesses() {
51
103
  let names = [];
52
104
  try {
@@ -72,12 +124,19 @@ export async function readProcesses() {
72
124
  /* halb geschrieben oder gelöscht */
73
125
  }
74
126
  }
75
- const [running, table] = await Promise.all([procStarts(decoded.map((p) => p.pid).filter((p) => p > 0)), processTable()]);
76
- for (const p of decoded) {
77
- const actual = running.get(p.pid);
78
- const expected = procStart.get(p.pid);
79
- p.alive = actual !== undefined && (!expected || norm(actual) === norm(expected));
127
+ if (decoded.length === 0)
128
+ return decoded;
129
+ let running;
130
+ let table;
131
+ if (process.platform === 'win32') {
132
+ table = await cachedWindowsTable();
133
+ running = new Map([...table].map(([pid, r]) => [pid, r.start ?? '']));
134
+ }
135
+ else {
136
+ [running, table] = await Promise.all([procStarts(decoded.map((p) => p.pid).filter((p) => p > 0)), unixTable()]);
80
137
  }
138
+ for (const p of decoded)
139
+ p.alive = sameProcess(running.get(p.pid), procStart.get(p.pid));
81
140
  // Prozessbaum hochlaufen (claude → zsh → claude …) bis zum nächsten bekannten Claude-Prozess.
82
141
  const alivePids = new Set(decoded.filter((p) => p.alive).map((p) => p.pid));
83
142
  for (const p of decoded) {
@@ -1,14 +1,19 @@
1
1
  import { readdir, readFile, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { paths } from '../config.js';
3
+ import { paths, relToClaude } from '../config.js';
4
4
  import { registry } from '../formats/index.js';
5
5
  import { isObj, num, str } from '../formats/util.js';
6
6
  import { readHeadTail } from '../util/jsonl.js';
7
7
  import { compareVersions } from '../util/semver.js';
8
+ /** Letztes Segment eines Pfads – egal ob mit "/" (macOS/Linux) oder "\\" (Windows). */
9
+ export function pathTail(p) {
10
+ return p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || p;
11
+ }
8
12
  export function projectName(cwd, projectDir) {
9
13
  if (cwd)
10
- return cwd.replace(/\/+$/, '').split('/').pop() || cwd;
11
- return projectDir.replace(/^-Users-[^-]+-/, '');
14
+ return pathTail(cwd);
15
+ // kodiertes Home-Verzeichnis: -Users-<name>- (macOS), -home-<name>- (Linux), C--Users-<name>- (Windows)
16
+ return projectDir.replace(/^(?:[A-Za-z]-)?-(?:Users|home)-[^-]+-/, '');
12
17
  }
13
18
  /**
14
19
  * Index über alle Session-Transcripts unter ~/.claude/projects.
@@ -54,7 +59,7 @@ export class ProjectIndex {
54
59
  }
55
60
  /** Aktualisiert einen einzelnen Eintrag nach einem Watch-Event. */
56
61
  async touch(path) {
57
- const m = /\/projects\/([^/]+)\/([^/]+)\.jsonl$/.exec(path);
62
+ const m = /^projects\/([^/]+)\/([^/]+)\.jsonl$/.exec(relToClaude(path));
58
63
  if (!m)
59
64
  return;
60
65
  try {
@@ -120,7 +120,7 @@ export function scoreSpawn(child, call) {
120
120
  evidence.push('time (rough)');
121
121
  }
122
122
  }
123
- if (child.cwd && call.cwd && child.cwd.replace(/\/$/, '') === call.cwd.replace(/\/$/, '')) {
123
+ if (child.cwd && call.cwd && child.cwd.replace(/[\\/]+$/, '') === call.cwd.replace(/[\\/]+$/, '')) {
124
124
  score += 5;
125
125
  evidence.push('directory');
126
126
  }