claude-inspect 0.1.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/LICENSE +21 -0
- package/README.de.md +84 -0
- package/README.md +167 -0
- package/dist/app/server/cli/demo.js +321 -0
- package/dist/app/server/cli/service.js +170 -0
- package/dist/app/server/cli-snapshot.js +34 -0
- package/dist/app/server/cli.js +84 -0
- package/dist/app/server/config.js +41 -0
- package/dist/app/server/formats/history/prompt.js +17 -0
- package/dist/app/server/formats/index.js +35 -0
- package/dist/app/server/formats/job/job.js +132 -0
- package/dist/app/server/formats/process/session-file.js +77 -0
- package/dist/app/server/formats/registry.js +87 -0
- package/dist/app/server/formats/subagent/meta.js +28 -0
- package/dist/app/server/formats/task/task.js +33 -0
- package/dist/app/server/formats/transcript/common.js +33 -0
- package/dist/app/server/formats/transcript/message.js +206 -0
- package/dist/app/server/formats/transcript/other.js +130 -0
- package/dist/app/server/formats/types.js +10 -0
- package/dist/app/server/formats/util.js +22 -0
- package/dist/app/server/history/aggregator.js +318 -0
- package/dist/app/server/history/search.js +138 -0
- package/dist/app/server/http.js +169 -0
- package/dist/app/server/inspector.js +468 -0
- package/dist/app/server/main.js +2 -0
- package/dist/app/server/model/analyze.js +112 -0
- package/dist/app/server/model/flow.js +47 -0
- package/dist/app/server/run.js +37 -0
- package/dist/app/server/sources/jobs.js +59 -0
- package/dist/app/server/sources/processes.js +99 -0
- package/dist/app/server/sources/projects.js +165 -0
- package/dist/app/server/sources/spawns.js +297 -0
- package/dist/app/server/sources/subagents.js +62 -0
- package/dist/app/server/sources/tasks.js +30 -0
- package/dist/app/server/sources/transcripts.js +121 -0
- package/dist/app/server/util/jsonl.js +124 -0
- package/dist/app/server/util/sanitize.js +42 -0
- package/dist/app/server/util/semver.js +33 -0
- package/dist/app/shared/pricing.js +57 -0
- package/dist/app/shared/tools.js +62 -0
- package/dist/app/shared/types.js +3 -0
- package/dist/web/assets/index-DdxYQpDc.js +14 -0
- package/dist/web/assets/index-VdEOo0XQ.css +1 -0
- package/dist/web/index.html +22 -0
- package/package.json +69 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { paths } from '../config.js';
|
|
4
|
+
import { registry } from '../formats/index.js';
|
|
5
|
+
import { readHeadTail, readJson } from '../util/jsonl.js';
|
|
6
|
+
async function readRoster() {
|
|
7
|
+
try {
|
|
8
|
+
return registry.decode('daemon-roster', await readJson(paths.daemonRoster), paths.daemonRoster).out;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function readTimeline(file) {
|
|
15
|
+
try {
|
|
16
|
+
const { tail } = await readHeadTail(file, 0, 32 * 1024);
|
|
17
|
+
return tail.slice(-30).map((raw) => registry.decode('job-timeline', raw, file).out);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Zuletzt gelesener Zustand je Job – neu dekodiert wird nur bei geänderter Datei. */
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
export async function readJobs() {
|
|
26
|
+
let names = [];
|
|
27
|
+
try {
|
|
28
|
+
names = await readdir(paths.jobs);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const roster = await readRoster();
|
|
34
|
+
const jobs = [];
|
|
35
|
+
for (const short of names) {
|
|
36
|
+
const dir = join(paths.jobs, short);
|
|
37
|
+
try {
|
|
38
|
+
if (!(await stat(dir)).isDirectory())
|
|
39
|
+
continue;
|
|
40
|
+
const file = join(dir, 'state.json');
|
|
41
|
+
const tl = join(dir, 'timeline.jsonl');
|
|
42
|
+
const [st, tst] = await Promise.all([stat(file), stat(tl).catch(() => undefined)]);
|
|
43
|
+
const key = `${st.mtimeMs}:${st.size}:${tst?.mtimeMs ?? 0}:${JSON.stringify(roster[short] ?? null)}`;
|
|
44
|
+
const hit = cache.get(short);
|
|
45
|
+
if (hit && hit.key === key) {
|
|
46
|
+
jobs.push(hit.job);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const { out, decoder } = registry.decode('job-state', await readJson(file), file);
|
|
50
|
+
const job = { ...out, short, decoder, launch: roster[short], timeline: await readTimeline(tl) };
|
|
51
|
+
cache.set(short, { key, job });
|
|
52
|
+
jobs.push(job);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* Job ohne state.json */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return jobs;
|
|
59
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { paths } from '../config.js';
|
|
5
|
+
import { registry } from '../formats/index.js';
|
|
6
|
+
import { readJson } from '../util/jsonl.js';
|
|
7
|
+
/**
|
|
8
|
+
* Startzeiten laufender Prozesse per `ps` (rein lesend).
|
|
9
|
+
* TZ=UTC, weil Claude Code `procStart` in UTC schreibt.
|
|
10
|
+
*/
|
|
11
|
+
function procStarts(pids) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
if (pids.length === 0 || process.platform === 'win32')
|
|
14
|
+
return resolve(new Map());
|
|
15
|
+
execFile('ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')], { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, timeout: 5000 }, (_err, stdout) => {
|
|
16
|
+
// ps endet mit Code 1, wenn einzelne PIDs fehlen – die Ausgabe ist trotzdem gültig.
|
|
17
|
+
const out = new Map();
|
|
18
|
+
for (const line of String(stdout ?? '').split('\n')) {
|
|
19
|
+
const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
|
20
|
+
if (m)
|
|
21
|
+
out.set(Number(m[1]), m[2]);
|
|
22
|
+
}
|
|
23
|
+
resolve(out);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Eltern-PID und Programmname aller Prozesse (`ps -A`), für die Zuordnung per Prozessbaum. */
|
|
28
|
+
function processTable() {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
if (process.platform === 'win32')
|
|
31
|
+
return resolve(new Map());
|
|
32
|
+
execFile('ps', ['-A', '-o', 'pid=,ppid=,comm='], { timeout: 5000, maxBuffer: 8 * 1024 * 1024 }, (_err, stdout) => {
|
|
33
|
+
const out = new Map();
|
|
34
|
+
for (const line of String(stdout ?? '').split('\n')) {
|
|
35
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
36
|
+
if (m)
|
|
37
|
+
out.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim().split('/').pop() ?? '' });
|
|
38
|
+
}
|
|
39
|
+
resolve(out);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Programme, die zwischen aufrufendem Agenten und gestartetem claude liegen dürfen
|
|
45
|
+
* (Bash-Tool → Shell → evtl. Wrapper → claude). Alles andere – etwa der Daemon,
|
|
46
|
+
* der Hintergrund-Jobs forkt – ist keine Aufrufbeziehung.
|
|
47
|
+
*/
|
|
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();
|
|
50
|
+
export async function readProcesses() {
|
|
51
|
+
let names = [];
|
|
52
|
+
try {
|
|
53
|
+
names = await readdir(paths.sessions);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const decoded = [];
|
|
59
|
+
const procStart = new Map();
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
// <pid>.json – die <pid>.<hash>.key-Dateien enthalten Tokens und werden nie gelesen.
|
|
62
|
+
if (!/^\d+\.json$/.test(name))
|
|
63
|
+
continue;
|
|
64
|
+
const file = join(paths.sessions, name);
|
|
65
|
+
try {
|
|
66
|
+
const { out, decoder } = registry.decode('process', await readJson(file), file);
|
|
67
|
+
const { procStart: ps, ...rest } = out;
|
|
68
|
+
procStart.set(rest.pid, ps);
|
|
69
|
+
decoded.push({ ...rest, alive: false, file, decoder });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* halb geschrieben oder gelöscht */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
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));
|
|
80
|
+
}
|
|
81
|
+
// Prozessbaum hochlaufen (claude → zsh → claude …) bis zum nächsten bekannten Claude-Prozess.
|
|
82
|
+
const alivePids = new Set(decoded.filter((p) => p.alive).map((p) => p.pid));
|
|
83
|
+
for (const p of decoded) {
|
|
84
|
+
if (!p.alive || p.kind === 'bg')
|
|
85
|
+
continue;
|
|
86
|
+
let cur = table.get(p.pid)?.ppid;
|
|
87
|
+
for (let depth = 0; cur && cur > 1 && depth < 8; depth++) {
|
|
88
|
+
if (alivePids.has(cur)) {
|
|
89
|
+
p.parentPid = cur;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
const row = table.get(cur);
|
|
93
|
+
if (!row || !LAUNCH_INTERMEDIATES.has(row.comm))
|
|
94
|
+
break;
|
|
95
|
+
cur = row.ppid;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return decoded;
|
|
99
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { paths } from '../config.js';
|
|
4
|
+
import { registry } from '../formats/index.js';
|
|
5
|
+
import { isObj, num, str } from '../formats/util.js';
|
|
6
|
+
import { readHeadTail } from '../util/jsonl.js';
|
|
7
|
+
import { compareVersions } from '../util/semver.js';
|
|
8
|
+
export function projectName(cwd, projectDir) {
|
|
9
|
+
if (cwd)
|
|
10
|
+
return cwd.replace(/\/+$/, '').split('/').pop() || cwd;
|
|
11
|
+
return projectDir.replace(/^-Users-[^-]+-/, '');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Index über alle Session-Transcripts unter ~/.claude/projects.
|
|
15
|
+
* Hält nur Dateinamen, Größe und mtime; Zusammenfassungen werden lazy
|
|
16
|
+
* aus Anfang und Ende der Datei gebildet und nach (size, mtime) gecacht.
|
|
17
|
+
*/
|
|
18
|
+
export class ProjectIndex {
|
|
19
|
+
files = new Map();
|
|
20
|
+
summaries = new Map();
|
|
21
|
+
async scan() {
|
|
22
|
+
const next = new Map();
|
|
23
|
+
let dirs = [];
|
|
24
|
+
try {
|
|
25
|
+
dirs = await readdir(paths.projects);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* noch keine Projekte */
|
|
29
|
+
}
|
|
30
|
+
await Promise.all(dirs.map(async (projectDir) => {
|
|
31
|
+
const dir = join(paths.projects, projectDir);
|
|
32
|
+
let names;
|
|
33
|
+
try {
|
|
34
|
+
names = await readdir(dir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
if (!name.endsWith('.jsonl'))
|
|
41
|
+
continue;
|
|
42
|
+
const path = join(dir, name);
|
|
43
|
+
try {
|
|
44
|
+
const st = await stat(path);
|
|
45
|
+
const sessionId = name.slice(0, -'.jsonl'.length);
|
|
46
|
+
next.set(sessionId, { sessionId, projectDir, path, size: st.size, mtime: st.mtimeMs });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* gerade gelöscht */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
this.files = next;
|
|
54
|
+
}
|
|
55
|
+
/** Aktualisiert einen einzelnen Eintrag nach einem Watch-Event. */
|
|
56
|
+
async touch(path) {
|
|
57
|
+
const m = /\/projects\/([^/]+)\/([^/]+)\.jsonl$/.exec(path);
|
|
58
|
+
if (!m)
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
const st = await stat(path);
|
|
62
|
+
this.files.set(m[2], { sessionId: m[2], projectDir: m[1], path, size: st.size, mtime: st.mtimeMs });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
this.files.delete(m[2]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
get(sessionId) {
|
|
69
|
+
return this.files.get(sessionId);
|
|
70
|
+
}
|
|
71
|
+
list() {
|
|
72
|
+
return [...this.files.values()].sort((a, b) => b.mtime - a.mtime);
|
|
73
|
+
}
|
|
74
|
+
async summary(f) {
|
|
75
|
+
const cached = this.summaries.get(f.path);
|
|
76
|
+
if (cached && cached.size === f.size && cached.mtime === f.mtime)
|
|
77
|
+
return cached.summary;
|
|
78
|
+
const summary = await summarize(f);
|
|
79
|
+
this.summaries.set(f.path, { size: f.size, mtime: f.mtime, summary });
|
|
80
|
+
return summary;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function countSubagents(sessionDir) {
|
|
84
|
+
try {
|
|
85
|
+
return (await readdir(join(sessionDir, 'subagents'))).filter((n) => n.endsWith('.jsonl')).length;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function summarize(f) {
|
|
92
|
+
const s = {
|
|
93
|
+
sessionId: f.sessionId,
|
|
94
|
+
projectDir: f.projectDir,
|
|
95
|
+
project: projectName(undefined, f.projectDir),
|
|
96
|
+
size: f.size,
|
|
97
|
+
mtime: f.mtime,
|
|
98
|
+
versions: [],
|
|
99
|
+
subagentCount: 0,
|
|
100
|
+
};
|
|
101
|
+
const sessionDir = f.path.slice(0, -'.jsonl'.length);
|
|
102
|
+
s.subagentCount = await countSubagents(sessionDir);
|
|
103
|
+
let head = [];
|
|
104
|
+
let tail = [];
|
|
105
|
+
try {
|
|
106
|
+
({ head, tail } = await readHeadTail(f.path, 32 * 1024, 256 * 1024));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
const versions = new Set();
|
|
112
|
+
const titles = {};
|
|
113
|
+
const fold = (raws) => {
|
|
114
|
+
for (const raw of raws) {
|
|
115
|
+
if (!isObj(raw))
|
|
116
|
+
continue;
|
|
117
|
+
const { out } = registry.decode('transcript', raw, f.path, 0, { silent: true });
|
|
118
|
+
const v = str(raw.version);
|
|
119
|
+
if (v)
|
|
120
|
+
versions.add(v);
|
|
121
|
+
if (str(raw.cwd))
|
|
122
|
+
s.cwd = str(raw.cwd);
|
|
123
|
+
if (str(raw.gitBranch))
|
|
124
|
+
s.gitBranch = str(raw.gitBranch);
|
|
125
|
+
for (const e of out) {
|
|
126
|
+
if (e.timestamp) {
|
|
127
|
+
if (!s.firstTimestamp || e.timestamp < s.firstTimestamp)
|
|
128
|
+
s.firstTimestamp = e.timestamp;
|
|
129
|
+
if (!s.lastTimestamp || e.timestamp > s.lastTimestamp)
|
|
130
|
+
s.lastTimestamp = e.timestamp;
|
|
131
|
+
}
|
|
132
|
+
if (e.kind === 'meta') {
|
|
133
|
+
const d = e.data;
|
|
134
|
+
if (e.metaType === 'custom-title' && str(d.customTitle))
|
|
135
|
+
titles.custom = str(d.customTitle);
|
|
136
|
+
if (e.metaType === 'agent-name' && str(d.agentName))
|
|
137
|
+
titles.agent = str(d.agentName);
|
|
138
|
+
if (e.metaType === 'ai-title' && str(d.aiTitle))
|
|
139
|
+
titles.ai = str(d.aiTitle);
|
|
140
|
+
if (e.metaType === 'last-prompt' && str(d.lastPrompt))
|
|
141
|
+
s.lastPrompt = str(d.lastPrompt);
|
|
142
|
+
if (e.metaType === 'cost-state' && num(d.totalCostUSD) !== undefined)
|
|
143
|
+
s.costUSD = num(d.totalCostUSD);
|
|
144
|
+
}
|
|
145
|
+
if ((e.kind === 'assistant-text' || e.kind === 'tool-use' || e.kind === 'thinking') && e.model && e.model !== '<synthetic>') {
|
|
146
|
+
s.model = e.model;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
fold(head);
|
|
152
|
+
fold(tail);
|
|
153
|
+
try {
|
|
154
|
+
const ct = JSON.parse(await readFile(join(sessionDir, 'custom-title.json'), 'utf8'));
|
|
155
|
+
if (isObj(ct) && str(ct.customTitle))
|
|
156
|
+
titles.custom ??= str(ct.customTitle);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* optional */
|
|
160
|
+
}
|
|
161
|
+
s.title = titles.custom ?? titles.agent ?? titles.ai;
|
|
162
|
+
s.versions = [...versions].sort(compareVersions);
|
|
163
|
+
s.project = projectName(s.cwd, f.projectDir);
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isObj, obj, str } from '../formats/util.js';
|
|
4
|
+
import { JsonlTail, readHeadTail } from '../util/jsonl.js';
|
|
5
|
+
import { subagentDir } from './subagents.js';
|
|
6
|
+
/** `claude` als eigenständiges Kommando (auch mit Pfad), nicht z. B. "claude-inspect" oder ".claude/". */
|
|
7
|
+
const CLAUDE_CMD = /(?:^|[\s;&|(`]|\$\()(?:[\w./~-]*\/)?claude(?=\s|$)/m;
|
|
8
|
+
/** Aufrufe, die keine Sitzung erzeugen. */
|
|
9
|
+
const NO_SESSION = /^\s*(?:which|type|command -v)\b|claude\s+(?:--version|-v|update|doctor|mcp|config|install)\b/;
|
|
10
|
+
const NOT_A_CALL_BEFORE = /(?:\bwhich|\btype|command\s+-v|\bpgrep(?:\s+-\w+)*|\bpkill(?:\s+-\w+)*|\bkillall|\bman)\s+["']?[\w./~-]*$/;
|
|
11
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/;
|
|
12
|
+
export function normalize(s) {
|
|
13
|
+
return s
|
|
14
|
+
.replace(/\\\n/g, ' ')
|
|
15
|
+
.replace(/\\(["'$`\\])/g, '$1')
|
|
16
|
+
.replace(/\\012|\\n/g, ' ')
|
|
17
|
+
.replace(/["'`]/g, '')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
function words(s) {
|
|
23
|
+
return normalize(s)
|
|
24
|
+
.split(/[^\p{L}\p{N}_./-]+/u)
|
|
25
|
+
.filter((w) => w.length >= 3);
|
|
26
|
+
}
|
|
27
|
+
/** Zerlegt einen Shell-Befehl in die einzelnen claude-Aufrufe (bei `a && claude …; claude …`). */
|
|
28
|
+
export function claudeInvocations(command) {
|
|
29
|
+
const out = [];
|
|
30
|
+
let pos = 0;
|
|
31
|
+
for (let i = 0; i < 20; i++) {
|
|
32
|
+
const m = CLAUDE_CMD.exec(command.slice(pos));
|
|
33
|
+
if (!m)
|
|
34
|
+
break;
|
|
35
|
+
const abs = pos + m.index;
|
|
36
|
+
const start = abs + m[0].length;
|
|
37
|
+
const next = CLAUDE_CMD.exec(command.slice(start));
|
|
38
|
+
const end = next ? start + next.index : command.length;
|
|
39
|
+
// `which claude`, `pgrep -f claude` … nennen claude nur als Argument
|
|
40
|
+
const before = command.slice(Math.max(0, abs - 24), abs + m[0].indexOf('claude'));
|
|
41
|
+
if (!NOT_A_CALL_BEFORE.test(before))
|
|
42
|
+
out.push(command.slice(abs, end));
|
|
43
|
+
pos = end;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export function parseCall(raw, block, sessionId, agentId) {
|
|
48
|
+
const input = obj(block.input);
|
|
49
|
+
const command = str(input?.command);
|
|
50
|
+
const ts = Date.parse(str(raw.timestamp) ?? '');
|
|
51
|
+
if (!command || Number.isNaN(ts) || !CLAUDE_CMD.test(command))
|
|
52
|
+
return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const inv of claudeInvocations(command)) {
|
|
55
|
+
if (NO_SESSION.test(inv))
|
|
56
|
+
continue;
|
|
57
|
+
const agentFlag = /--agent[=\s]+["']?([\w.-]+)/.exec(inv)?.[1];
|
|
58
|
+
const sessionFlags = [...inv.matchAll(new RegExp(`--(?:session-id|resume|fork-session)[=\\s]+["']?(${UUID.source})`, 'g'))].map((m) => m[1]);
|
|
59
|
+
out.push({
|
|
60
|
+
sessionId,
|
|
61
|
+
agentId,
|
|
62
|
+
toolUseId: str(block.id) ?? '',
|
|
63
|
+
ts,
|
|
64
|
+
command,
|
|
65
|
+
normCommand: normalize(inv),
|
|
66
|
+
words: new Set(words(inv)),
|
|
67
|
+
cwd: /(?:^|[\s;&(])cd\s+["']?([^\s"';&]+)/.exec(command)?.[1] ?? str(raw.cwd),
|
|
68
|
+
agentFlag,
|
|
69
|
+
sessionFlags,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** Bewertet, wie gut ein claude-Aufruf zu einer Kind-Sitzung passt (höher = besser). */
|
|
75
|
+
export function scoreSpawn(child, call) {
|
|
76
|
+
const evidence = [];
|
|
77
|
+
let score = 0;
|
|
78
|
+
if (call.sessionFlags.includes(child.sessionId))
|
|
79
|
+
return { score: 1000, evidence: ['session id in command'] };
|
|
80
|
+
if (child.prompt) {
|
|
81
|
+
const np = normalize(child.prompt);
|
|
82
|
+
const prefix = np.slice(0, 160);
|
|
83
|
+
if (prefix.length >= 20 && call.normCommand.includes(prefix)) {
|
|
84
|
+
score += 60;
|
|
85
|
+
evidence.push('prompt');
|
|
86
|
+
}
|
|
87
|
+
else if (prefix.length > 0 && prefix.length < 20 && new RegExp(`(?:^|\\s)-p ${prefix.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}(?:\\s|$)`).test(call.normCommand)) {
|
|
88
|
+
// kurze Prompts wie "/usage": nur als exaktes -p-Argument werten
|
|
89
|
+
score += 40;
|
|
90
|
+
evidence.push('prompt');
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const pw = words(child.prompt).slice(0, 80);
|
|
94
|
+
if (pw.length >= 5) {
|
|
95
|
+
const ratio = pw.filter((w) => call.words.has(w)).length / pw.length;
|
|
96
|
+
if (ratio >= 0.5) {
|
|
97
|
+
score += Math.round(ratio * 45);
|
|
98
|
+
evidence.push(`prompt ~${Math.round(ratio * 100)}%`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (call.agentFlag || child.agentSetting) {
|
|
104
|
+
if (call.agentFlag && call.agentFlag === child.agentSetting) {
|
|
105
|
+
score += 15;
|
|
106
|
+
evidence.push('agent');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
score -= 25;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (child.startTs !== undefined) {
|
|
113
|
+
const dt = (child.startTs - call.ts) / 1000;
|
|
114
|
+
if (dt >= -2 && dt <= 30) {
|
|
115
|
+
score += 15;
|
|
116
|
+
evidence.push('time');
|
|
117
|
+
}
|
|
118
|
+
else if (dt > 30 && dt <= 180) {
|
|
119
|
+
score += 5;
|
|
120
|
+
evidence.push('time (rough)');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (child.cwd && call.cwd && child.cwd.replace(/\/$/, '') === call.cwd.replace(/\/$/, '')) {
|
|
124
|
+
score += 5;
|
|
125
|
+
evidence.push('directory');
|
|
126
|
+
}
|
|
127
|
+
return { score, evidence };
|
|
128
|
+
}
|
|
129
|
+
export class SpawnIndex {
|
|
130
|
+
files = new Map();
|
|
131
|
+
children = new Map();
|
|
132
|
+
links = new Map();
|
|
133
|
+
/** Neue Zeilen aller Transcripts nach claude-Aufrufen durchsuchen (inkrementell). */
|
|
134
|
+
async refresh(transcripts, processes, full) {
|
|
135
|
+
for (const f of transcripts) {
|
|
136
|
+
await this.scanFile(f.path, f.size, f.sessionId);
|
|
137
|
+
// Subagenten können ebenfalls claude-Aufrufe machen. Nach dem ersten
|
|
138
|
+
// Durchlauf nur noch bei kürzlich aktiven Sessions nachsehen.
|
|
139
|
+
if (!full && Date.now() - f.mtime > 3600_000)
|
|
140
|
+
continue;
|
|
141
|
+
const dir = subagentDir(f.path);
|
|
142
|
+
let names = [];
|
|
143
|
+
try {
|
|
144
|
+
names = await readdir(dir);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* keine Subagenten */
|
|
148
|
+
}
|
|
149
|
+
for (const n of names) {
|
|
150
|
+
const m = /^agent-(.+)\.jsonl$/.exec(n);
|
|
151
|
+
if (!m)
|
|
152
|
+
continue;
|
|
153
|
+
const path = join(dir, n);
|
|
154
|
+
try {
|
|
155
|
+
await this.scanFile(path, (await stat(path)).size, f.sessionId, m[1]);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* gelöscht */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const f of transcripts)
|
|
163
|
+
await this.readChild(f);
|
|
164
|
+
this.link(processes);
|
|
165
|
+
}
|
|
166
|
+
async scanFile(path, size, sessionId, agentId) {
|
|
167
|
+
let st = this.files.get(path);
|
|
168
|
+
if (st && st.size === size)
|
|
169
|
+
return;
|
|
170
|
+
if (!st || size < st.size) {
|
|
171
|
+
st = { size: 0, tail: new JsonlTail(path), calls: [] };
|
|
172
|
+
this.files.set(path, st);
|
|
173
|
+
}
|
|
174
|
+
st.size = size;
|
|
175
|
+
try {
|
|
176
|
+
const { lines, reset } = await st.tail.readNew((t) => t.includes('"Bash"') && t.includes('claude'));
|
|
177
|
+
if (reset)
|
|
178
|
+
st.calls = [];
|
|
179
|
+
for (const l of lines) {
|
|
180
|
+
const raw = l.value;
|
|
181
|
+
if (!isObj(raw) || raw.type !== 'assistant')
|
|
182
|
+
continue;
|
|
183
|
+
const content = obj(raw.message)?.content;
|
|
184
|
+
if (!Array.isArray(content))
|
|
185
|
+
continue;
|
|
186
|
+
for (const b of content) {
|
|
187
|
+
if (isObj(b) && b.type === 'tool_use' && b.name === 'Bash')
|
|
188
|
+
st.calls.push(...parseCall(raw, b, sessionId, agentId));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* gelöscht */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Kopf eines Transcripts: Einstiegspunkt, erster Prompt, Startzeit, Agent. */
|
|
197
|
+
async readChild(f) {
|
|
198
|
+
const cached = this.children.get(f.sessionId);
|
|
199
|
+
if (cached && cached.info.prompt !== undefined && cached.size <= f.size)
|
|
200
|
+
return;
|
|
201
|
+
const info = { sessionId: f.sessionId };
|
|
202
|
+
try {
|
|
203
|
+
const { head } = await readHeadTail(f.path, 96 * 1024, 0);
|
|
204
|
+
for (const raw of head) {
|
|
205
|
+
if (!isObj(raw))
|
|
206
|
+
continue;
|
|
207
|
+
if (raw.type === 'agent-setting')
|
|
208
|
+
info.agentSetting ??= str(raw.agentSetting);
|
|
209
|
+
const ts = Date.parse(str(raw.timestamp) ?? '');
|
|
210
|
+
if (!Number.isNaN(ts) && (info.startTs === undefined || ts < info.startTs))
|
|
211
|
+
info.startTs = ts;
|
|
212
|
+
if (raw.type === 'queue-operation' && raw.operation === 'enqueue' && info.prompt === undefined)
|
|
213
|
+
info.prompt = str(raw.content);
|
|
214
|
+
if (raw.type === 'user' && !raw.isMeta) {
|
|
215
|
+
info.entrypoint ??= str(raw.entrypoint);
|
|
216
|
+
info.promptSource ??= str(raw.promptSource);
|
|
217
|
+
info.cwd ??= str(raw.cwd);
|
|
218
|
+
const c = obj(raw.message)?.content;
|
|
219
|
+
const text = typeof c === 'string' ? c : Array.isArray(c) ? c.map((b) => (isObj(b) && b.type === 'text' ? str(b.text) : '')).join('\n') : '';
|
|
220
|
+
if (text && !text.startsWith('<') && (info.prompt === undefined || info.promptSource === 'sdk'))
|
|
221
|
+
info.prompt = text;
|
|
222
|
+
if (info.entrypoint)
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* gelöscht */
|
|
229
|
+
}
|
|
230
|
+
this.children.set(f.sessionId, { size: f.size, info });
|
|
231
|
+
}
|
|
232
|
+
link(processes) {
|
|
233
|
+
const allCalls = [...this.files.values()].flatMap((f) => f.calls);
|
|
234
|
+
const byPid = new Map(processes.filter((p) => p.alive).map((p) => [p.pid, p]));
|
|
235
|
+
const links = new Map();
|
|
236
|
+
for (const { info: child } of this.children.values()) {
|
|
237
|
+
// Nur Sitzungen, die nicht interaktiv gestartet wurden, kommen als Kind in Frage.
|
|
238
|
+
const proc = processes.find((p) => p.alive && p.sessionId === child.sessionId);
|
|
239
|
+
const parentProc = proc?.parentPid ? byPid.get(proc.parentPid) : undefined;
|
|
240
|
+
const isSdk = child.promptSource === 'sdk' || child.entrypoint?.startsWith('sdk');
|
|
241
|
+
if (!isSdk && !parentProc)
|
|
242
|
+
continue;
|
|
243
|
+
let candidates = allCalls.filter((c) => c.sessionId !== child.sessionId);
|
|
244
|
+
if (parentProc) {
|
|
245
|
+
const own = candidates.filter((c) => c.sessionId === parentProc.sessionId);
|
|
246
|
+
if (own.length)
|
|
247
|
+
candidates = own;
|
|
248
|
+
}
|
|
249
|
+
else if (child.startTs !== undefined) {
|
|
250
|
+
// ohne Prozessbaum: nur Aufrufe, die zeitlich passen
|
|
251
|
+
candidates = candidates.filter((c) => child.startTs - c.ts >= -2000 && child.startTs - c.ts <= 600_000);
|
|
252
|
+
}
|
|
253
|
+
let best;
|
|
254
|
+
for (const call of candidates) {
|
|
255
|
+
const r = scoreSpawn(child, call);
|
|
256
|
+
if (!best || r.score > best.score || (r.score === best.score && call.ts > best.call.ts))
|
|
257
|
+
best = { call, ...r };
|
|
258
|
+
}
|
|
259
|
+
if (parentProc) {
|
|
260
|
+
// Die Eltern-Session steht fest; der Tool-Aufruf ist das beste Indiz innerhalb dieser Session.
|
|
261
|
+
const call = best && best.call.sessionId === parentProc.sessionId && best.score > 0 ? best : undefined;
|
|
262
|
+
links.set(child.sessionId, {
|
|
263
|
+
childSessionId: child.sessionId,
|
|
264
|
+
parentSessionId: parentProc.sessionId,
|
|
265
|
+
parentAgentId: call?.call.agentId,
|
|
266
|
+
toolUseId: call?.call.toolUseId,
|
|
267
|
+
confidence: 'exact',
|
|
268
|
+
evidence: ['process tree', ...(call?.evidence ?? [])],
|
|
269
|
+
score: 1000 + (call?.score ?? 0),
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!best || best.score < 40)
|
|
274
|
+
continue;
|
|
275
|
+
const confidence = best.score >= 1000 ? 'exact' : best.score >= 70 ? 'high' : 'likely';
|
|
276
|
+
links.set(child.sessionId, {
|
|
277
|
+
childSessionId: child.sessionId,
|
|
278
|
+
parentSessionId: best.call.sessionId,
|
|
279
|
+
parentAgentId: best.call.agentId,
|
|
280
|
+
toolUseId: best.call.toolUseId,
|
|
281
|
+
confidence,
|
|
282
|
+
evidence: best.evidence,
|
|
283
|
+
score: best.score,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
this.links = links;
|
|
287
|
+
}
|
|
288
|
+
parentOf(sessionId) {
|
|
289
|
+
return this.links.get(sessionId);
|
|
290
|
+
}
|
|
291
|
+
childrenOf(sessionId) {
|
|
292
|
+
return [...this.links.values()].filter((l) => l.parentSessionId === sessionId);
|
|
293
|
+
}
|
|
294
|
+
childInfo(sessionId) {
|
|
295
|
+
return this.children.get(sessionId)?.info;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { registry } from '../formats/index.js';
|
|
4
|
+
import { readJson } from '../util/jsonl.js';
|
|
5
|
+
export function subagentDir(sessionPath) {
|
|
6
|
+
return join(sessionPath.slice(0, -'.jsonl'.length), 'subagents');
|
|
7
|
+
}
|
|
8
|
+
export async function listSubagents(sessionPath) {
|
|
9
|
+
const dir = subagentDir(sessionPath);
|
|
10
|
+
let names;
|
|
11
|
+
try {
|
|
12
|
+
names = await readdir(dir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const name of names) {
|
|
19
|
+
const m = /^agent-(.+)\.jsonl$/.exec(name);
|
|
20
|
+
if (!m)
|
|
21
|
+
continue;
|
|
22
|
+
const path = join(dir, name);
|
|
23
|
+
const metaPath = join(dir, `agent-${m[1]}.meta.json`);
|
|
24
|
+
let meta = {};
|
|
25
|
+
try {
|
|
26
|
+
meta = registry.decode('subagent-meta', await readJson(metaPath), metaPath).out;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* ältere Versionen ohne meta.json */
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const st = await stat(path);
|
|
33
|
+
out.push({ agentId: m[1], path, mtime: st.mtimeMs, size: st.size, meta });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* gelöscht */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Welche Agent-Tool-Aufrufe (tool_use ids) stecken in einer Datei?
|
|
43
|
+
* Schneller Textscan statt vollständigem Parsen; gecacht nach Dateigröße.
|
|
44
|
+
*/
|
|
45
|
+
const agentCallCache = new Map();
|
|
46
|
+
const AGENT_CALL = /"id":"([^"]+)","name":"(?:Agent|Task)"/g;
|
|
47
|
+
export async function agentCallIds(path, size) {
|
|
48
|
+
const c = agentCallCache.get(path);
|
|
49
|
+
if (c && c.size === size)
|
|
50
|
+
return c.ids;
|
|
51
|
+
const ids = new Set();
|
|
52
|
+
try {
|
|
53
|
+
const text = await readFile(path, 'utf8');
|
|
54
|
+
for (const m of text.matchAll(AGENT_CALL))
|
|
55
|
+
ids.add(m[1]);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* gelöscht */
|
|
59
|
+
}
|
|
60
|
+
agentCallCache.set(path, { size, ids });
|
|
61
|
+
return ids;
|
|
62
|
+
}
|