@ai-devkit/agent-manager 0.4.0 → 0.6.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/dist/adapters/AgentAdapter.d.ts +21 -2
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +44 -35
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +230 -298
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +41 -31
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +198 -278
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -10
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +6 -3
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +12 -11
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/matching.d.ts +39 -0
- package/dist/utils/matching.d.ts.map +1 -0
- package/dist/utils/matching.js +107 -0
- package/dist/utils/matching.js.map +1 -0
- package/dist/utils/process.d.ts +25 -40
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +151 -105
- package/dist/utils/process.js.map +1 -1
- package/dist/utils/session.d.ts +30 -0
- package/dist/utils/session.d.ts.map +1 -0
- package/dist/utils/session.js +101 -0
- package/dist/utils/session.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/AgentManager.test.ts +5 -27
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +754 -830
- package/src/__tests__/adapters/CodexAdapter.test.ts +581 -273
- package/src/__tests__/utils/matching.test.ts +199 -0
- package/src/__tests__/utils/process.test.ts +202 -0
- package/src/__tests__/utils/session.test.ts +117 -0
- package/src/adapters/AgentAdapter.ts +23 -4
- package/src/adapters/ClaudeCodeAdapter.ts +285 -437
- package/src/adapters/CodexAdapter.ts +202 -400
- package/src/index.ts +2 -4
- package/src/utils/index.ts +6 -3
- package/src/utils/matching.ts +96 -0
- package/src/utils/process.ts +133 -119
- package/src/utils/session.ts +92 -0
- package/dist/utils/file.d.ts +0 -52
- package/dist/utils/file.d.ts.map +0 -1
- package/dist/utils/file.js +0 -135
- package/dist/utils/file.js.map +0 -1
- package/src/utils/file.ts +0 -100
|
@@ -1,35 +1,53 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import {
|
|
4
|
-
import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
|
|
3
|
+
import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
|
|
5
4
|
import { AgentStatus } from './AgentAdapter';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
5
|
+
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
6
|
+
import { batchGetSessionFileBirthtimes } from '../utils/session';
|
|
7
|
+
import type { SessionFile } from '../utils/session';
|
|
8
|
+
import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Entry in session JSONL file
|
|
11
11
|
*/
|
|
12
|
-
interface
|
|
13
|
-
|
|
12
|
+
interface ContentBlock {
|
|
13
|
+
type?: string;
|
|
14
|
+
text?: string;
|
|
15
|
+
content?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
input?: Record<string, unknown>;
|
|
18
|
+
tool_use_id?: string;
|
|
19
|
+
is_error?: boolean;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
/**
|
|
17
|
-
* Entry in session JSONL file
|
|
18
|
-
*/
|
|
19
22
|
interface SessionEntry {
|
|
20
23
|
type?: string;
|
|
21
24
|
timestamp?: string;
|
|
22
|
-
slug?: string;
|
|
23
25
|
cwd?: string;
|
|
24
26
|
message?: {
|
|
25
|
-
content?: string |
|
|
26
|
-
type?: string;
|
|
27
|
-
text?: string;
|
|
28
|
-
content?: string;
|
|
29
|
-
}>;
|
|
27
|
+
content?: string | ContentBlock[];
|
|
30
28
|
};
|
|
31
29
|
}
|
|
32
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Entry in ~/.claude/sessions/<pid>.json written by Claude Code
|
|
33
|
+
*/
|
|
34
|
+
interface PidFileEntry {
|
|
35
|
+
pid: number;
|
|
36
|
+
sessionId: string;
|
|
37
|
+
cwd: string;
|
|
38
|
+
startedAt: number; // epoch milliseconds
|
|
39
|
+
kind: string;
|
|
40
|
+
entrypoint: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A process directly matched to a session via PID file (authoritative path)
|
|
45
|
+
*/
|
|
46
|
+
interface DirectMatch {
|
|
47
|
+
process: ProcessInfo;
|
|
48
|
+
sessionFile: SessionFile;
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
/**
|
|
34
52
|
* Claude Code session information
|
|
35
53
|
*/
|
|
@@ -37,7 +55,6 @@ interface ClaudeSession {
|
|
|
37
55
|
sessionId: string;
|
|
38
56
|
projectPath: string;
|
|
39
57
|
lastCwd?: string;
|
|
40
|
-
slug?: string;
|
|
41
58
|
sessionStart: Date;
|
|
42
59
|
lastActive: Date;
|
|
43
60
|
lastEntryType?: string;
|
|
@@ -45,33 +62,26 @@ interface ClaudeSession {
|
|
|
45
62
|
lastUserMessage?: string;
|
|
46
63
|
}
|
|
47
64
|
|
|
48
|
-
type SessionMatchMode = 'cwd' | 'missing-cwd' | 'parent-child';
|
|
49
|
-
|
|
50
65
|
/**
|
|
51
66
|
* Claude Code Adapter
|
|
52
67
|
*
|
|
53
68
|
* Detects Claude Code agents by:
|
|
54
|
-
* 1. Finding running claude processes
|
|
55
|
-
* 2.
|
|
56
|
-
* 3.
|
|
57
|
-
* 4.
|
|
69
|
+
* 1. Finding running claude processes via shared listAgentProcesses()
|
|
70
|
+
* 2. Enriching with CWD and start times via shared enrichProcesses()
|
|
71
|
+
* 3. Attempting authoritative PID-file matching via ~/.claude/sessions/<pid>.json
|
|
72
|
+
* 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file
|
|
58
73
|
* 5. Extracting summary from last user message in session JSONL
|
|
59
74
|
*/
|
|
60
75
|
export class ClaudeCodeAdapter implements AgentAdapter {
|
|
61
76
|
readonly type = 'claude' as const;
|
|
62
77
|
|
|
63
|
-
/** Limit session parsing per run to keep list latency bounded. */
|
|
64
|
-
private static readonly MIN_SESSION_SCAN = 12;
|
|
65
|
-
private static readonly MAX_SESSION_SCAN = 40;
|
|
66
|
-
private static readonly SESSION_SCAN_MULTIPLIER = 4;
|
|
67
|
-
/** Matching tolerance between process start time and session start time. */
|
|
68
|
-
private static readonly PROCESS_SESSION_TIME_TOLERANCE_MS = 2 * 60 * 1000;
|
|
69
|
-
|
|
70
78
|
private projectsDir: string;
|
|
79
|
+
private sessionsDir: string;
|
|
71
80
|
|
|
72
81
|
constructor() {
|
|
73
82
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
74
83
|
this.projectsDir = path.join(homeDir, '.claude', 'projects');
|
|
84
|
+
this.sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
/**
|
|
@@ -91,398 +101,202 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
91
101
|
* Detect running Claude Code agents
|
|
92
102
|
*/
|
|
93
103
|
async detectAgents(): Promise<AgentInfo[]> {
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
104
|
+
const processes = enrichProcesses(listAgentProcesses('claude'));
|
|
105
|
+
if (processes.length === 0) {
|
|
96
106
|
return [];
|
|
97
107
|
}
|
|
98
108
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
);
|
|
102
|
-
const sessionScanLimit = this.calculateSessionScanLimit(claudeProcesses.length);
|
|
103
|
-
const sessions = this.readSessions(sessionScanLimit);
|
|
109
|
+
// Step 1: try authoritative PID-file matching for every process
|
|
110
|
+
const { direct, fallback } = this.tryPidFileMatching(processes);
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
// Step 2: run legacy CWD+birthtime matching only for processes without a PID file
|
|
113
|
+
const legacySessions = this.discoverSessions(fallback);
|
|
114
|
+
const legacyMatches =
|
|
115
|
+
fallback.length > 0 && legacySessions.length > 0
|
|
116
|
+
? matchProcessesToSessions(fallback, legacySessions)
|
|
117
|
+
: [];
|
|
118
|
+
|
|
119
|
+
const matchedPids = new Set([
|
|
120
|
+
...direct.map((d) => d.process.pid),
|
|
121
|
+
...legacyMatches.map((m) => m.process.pid),
|
|
122
|
+
]);
|
|
110
123
|
|
|
111
|
-
const sortedSessions = [...sessions].sort(
|
|
112
|
-
(a, b) => b.lastActive.getTime() - a.lastActive.getTime(),
|
|
113
|
-
);
|
|
114
|
-
const usedSessionIds = new Set<string>();
|
|
115
|
-
const assignedPids = new Set<number>();
|
|
116
124
|
const agents: AgentInfo[] = [];
|
|
117
125
|
|
|
118
|
-
|
|
119
|
-
for (const
|
|
120
|
-
this.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
processStartByPid,
|
|
127
|
-
agents,
|
|
128
|
-
);
|
|
126
|
+
// Build agents from direct (PID-file) matches
|
|
127
|
+
for (const { process: proc, sessionFile } of direct) {
|
|
128
|
+
const sessionData = this.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
|
|
129
|
+
if (sessionData) {
|
|
130
|
+
agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile));
|
|
131
|
+
} else {
|
|
132
|
+
matchedPids.delete(proc.pid);
|
|
133
|
+
}
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
136
|
+
// Build agents from legacy matches
|
|
137
|
+
for (const match of legacyMatches) {
|
|
138
|
+
const sessionData = this.readSession(
|
|
139
|
+
match.session.filePath,
|
|
140
|
+
match.session.resolvedCwd,
|
|
141
|
+
);
|
|
142
|
+
if (sessionData) {
|
|
143
|
+
agents.push(this.mapSessionToAgent(sessionData, match.process, match.session));
|
|
144
|
+
} else {
|
|
145
|
+
matchedPids.delete(match.process.pid);
|
|
134
146
|
}
|
|
147
|
+
}
|
|
135
148
|
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
// Any process with no match (direct or legacy) appears as IDLE
|
|
150
|
+
for (const proc of processes) {
|
|
151
|
+
if (!matchedPids.has(proc.pid)) {
|
|
152
|
+
agents.push(this.mapProcessOnlyAgent(proc));
|
|
153
|
+
}
|
|
138
154
|
}
|
|
139
155
|
|
|
140
156
|
return agents;
|
|
141
157
|
}
|
|
142
158
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Discover session files for the given processes.
|
|
161
|
+
*
|
|
162
|
+
* For each unique process CWD, encodes it to derive the expected
|
|
163
|
+
* ~/.claude/projects/<encoded>/ directory, then gets session file birthtimes
|
|
164
|
+
* via a single batched stat call across all directories.
|
|
165
|
+
*/
|
|
166
|
+
private discoverSessions(processes: ProcessInfo[]): SessionFile[] {
|
|
167
|
+
// Collect valid project dirs and map them back to their CWD
|
|
168
|
+
const dirToCwd = new Map<string, string>();
|
|
148
169
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
Math.max(
|
|
152
|
-
processCount * ClaudeCodeAdapter.SESSION_SCAN_MULTIPLIER,
|
|
153
|
-
ClaudeCodeAdapter.MIN_SESSION_SCAN,
|
|
154
|
-
),
|
|
155
|
-
ClaudeCodeAdapter.MAX_SESSION_SCAN,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
170
|
+
for (const proc of processes) {
|
|
171
|
+
if (!proc.cwd) continue;
|
|
158
172
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
claudeProcesses: ProcessInfo[],
|
|
162
|
-
sessions: ClaudeSession[],
|
|
163
|
-
usedSessionIds: Set<string>,
|
|
164
|
-
assignedPids: Set<number>,
|
|
165
|
-
processStartByPid: Map<number, Date>,
|
|
166
|
-
agents: AgentInfo[],
|
|
167
|
-
): void {
|
|
168
|
-
for (const processInfo of claudeProcesses) {
|
|
169
|
-
if (assignedPids.has(processInfo.pid)) {
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
173
|
+
const projectDir = this.getProjectDir(proc.cwd);
|
|
174
|
+
if (dirToCwd.has(projectDir)) continue;
|
|
172
175
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
usedSessionIds,
|
|
177
|
-
processStartByPid,
|
|
178
|
-
mode,
|
|
179
|
-
);
|
|
180
|
-
if (!session) {
|
|
176
|
+
try {
|
|
177
|
+
if (!fs.statSync(projectDir).isDirectory()) continue;
|
|
178
|
+
} catch {
|
|
181
179
|
continue;
|
|
182
180
|
}
|
|
183
181
|
|
|
184
|
-
|
|
185
|
-
assignedPids.add(processInfo.pid);
|
|
186
|
-
agents.push(this.mapSessionToAgent(session, processInfo, agents));
|
|
182
|
+
dirToCwd.set(projectDir, proc.cwd);
|
|
187
183
|
}
|
|
184
|
+
|
|
185
|
+
if (dirToCwd.size === 0) return [];
|
|
186
|
+
|
|
187
|
+
// Single batched stat call across all directories
|
|
188
|
+
const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]);
|
|
189
|
+
|
|
190
|
+
// Set resolvedCwd based on which project dir the file belongs to
|
|
191
|
+
for (const file of files) {
|
|
192
|
+
file.resolvedCwd = dirToCwd.get(file.projectDir) || '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return files;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Attempt to match each process to its session via ~/.claude/sessions/<pid>.json.
|
|
200
|
+
*
|
|
201
|
+
* Returns:
|
|
202
|
+
* direct — processes matched authoritatively via PID file
|
|
203
|
+
* fallback — processes with no valid PID file (sent to legacy matching)
|
|
204
|
+
*
|
|
205
|
+
* Per-process fallback triggers on: file absent, malformed JSON,
|
|
206
|
+
* stale startedAt (>60 s from proc.startTime), or missing JSONL.
|
|
207
|
+
*/
|
|
208
|
+
private tryPidFileMatching(processes: ProcessInfo[]): {
|
|
209
|
+
direct: DirectMatch[];
|
|
210
|
+
fallback: ProcessInfo[];
|
|
211
|
+
} {
|
|
212
|
+
const direct: DirectMatch[] = [];
|
|
213
|
+
const fallback: ProcessInfo[] = [];
|
|
214
|
+
|
|
215
|
+
for (const proc of processes) {
|
|
216
|
+
const pidFilePath = path.join(this.sessionsDir, `${proc.pid}.json`);
|
|
217
|
+
try {
|
|
218
|
+
const entry = JSON.parse(
|
|
219
|
+
fs.readFileSync(pidFilePath, 'utf-8'),
|
|
220
|
+
) as PidFileEntry;
|
|
221
|
+
|
|
222
|
+
// Stale-file guard: reject PID files from a previous process with the same PID
|
|
223
|
+
if (proc.startTime) {
|
|
224
|
+
const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt);
|
|
225
|
+
if (deltaMs > 60000) {
|
|
226
|
+
fallback.push(proc);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const projectDir = this.getProjectDir(entry.cwd);
|
|
232
|
+
const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`);
|
|
233
|
+
|
|
234
|
+
if (!fs.existsSync(jsonlPath)) {
|
|
235
|
+
fallback.push(proc);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
direct.push({
|
|
240
|
+
process: proc,
|
|
241
|
+
sessionFile: {
|
|
242
|
+
sessionId: entry.sessionId,
|
|
243
|
+
filePath: jsonlPath,
|
|
244
|
+
projectDir,
|
|
245
|
+
birthtimeMs: entry.startedAt,
|
|
246
|
+
resolvedCwd: entry.cwd,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
} catch {
|
|
250
|
+
// PID file absent, unreadable, or malformed — fall back per-process
|
|
251
|
+
fallback.push(proc);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { direct, fallback };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Derive the Claude Code project directory for a given CWD.
|
|
260
|
+
*
|
|
261
|
+
* Claude Code encodes paths by replacing '/' with '-':
|
|
262
|
+
* /Users/foo/bar → ~/.claude/projects/-Users-foo-bar/
|
|
263
|
+
*/
|
|
264
|
+
private getProjectDir(cwd: string): string {
|
|
265
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
266
|
+
return path.join(this.projectsDir, encoded);
|
|
188
267
|
}
|
|
189
268
|
|
|
190
269
|
private mapSessionToAgent(
|
|
191
270
|
session: ClaudeSession,
|
|
192
271
|
processInfo: ProcessInfo,
|
|
193
|
-
|
|
272
|
+
sessionFile: SessionFile,
|
|
194
273
|
): AgentInfo {
|
|
195
274
|
return {
|
|
196
|
-
name:
|
|
275
|
+
name: generateAgentName(processInfo.cwd, processInfo.pid),
|
|
197
276
|
type: this.type,
|
|
198
277
|
status: this.determineStatus(session),
|
|
199
278
|
summary: session.lastUserMessage || 'Session started',
|
|
200
279
|
pid: processInfo.pid,
|
|
201
|
-
projectPath:
|
|
202
|
-
sessionId:
|
|
203
|
-
slug: session.slug,
|
|
280
|
+
projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
|
|
281
|
+
sessionId: sessionFile.sessionId,
|
|
204
282
|
lastActive: session.lastActive,
|
|
283
|
+
sessionFilePath: sessionFile.filePath,
|
|
205
284
|
};
|
|
206
285
|
}
|
|
207
286
|
|
|
208
|
-
private mapProcessOnlyAgent(
|
|
209
|
-
processInfo: ProcessInfo,
|
|
210
|
-
existingAgents: AgentInfo[],
|
|
211
|
-
): AgentInfo {
|
|
212
|
-
const processCwd = processInfo.cwd || '';
|
|
213
|
-
const projectName = path.basename(processCwd) || 'claude';
|
|
214
|
-
const hasDuplicate = existingAgents.some((a) => a.projectPath === processCwd);
|
|
215
|
-
|
|
287
|
+
private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo {
|
|
216
288
|
return {
|
|
217
|
-
name:
|
|
289
|
+
name: generateAgentName(processInfo.cwd || '', processInfo.pid),
|
|
218
290
|
type: this.type,
|
|
219
291
|
status: AgentStatus.IDLE,
|
|
220
292
|
summary: 'Unknown',
|
|
221
293
|
pid: processInfo.pid,
|
|
222
|
-
projectPath:
|
|
294
|
+
projectPath: processInfo.cwd || '',
|
|
223
295
|
sessionId: `pid-${processInfo.pid}`,
|
|
224
296
|
lastActive: new Date(),
|
|
225
297
|
};
|
|
226
298
|
}
|
|
227
299
|
|
|
228
|
-
private selectBestSession(
|
|
229
|
-
processInfo: ProcessInfo,
|
|
230
|
-
sessions: ClaudeSession[],
|
|
231
|
-
usedSessionIds: Set<string>,
|
|
232
|
-
processStartByPid: Map<number, Date>,
|
|
233
|
-
mode: SessionMatchMode,
|
|
234
|
-
): ClaudeSession | undefined {
|
|
235
|
-
const candidates = this.filterCandidateSessions(
|
|
236
|
-
processInfo,
|
|
237
|
-
sessions,
|
|
238
|
-
usedSessionIds,
|
|
239
|
-
mode,
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
if (candidates.length === 0) {
|
|
243
|
-
return undefined;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const processStart = processStartByPid.get(processInfo.pid);
|
|
247
|
-
if (!processStart) {
|
|
248
|
-
return candidates.sort(
|
|
249
|
-
(a, b) => b.lastActive.getTime() - a.lastActive.getTime(),
|
|
250
|
-
)[0];
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const best = this.rankCandidatesByStartTime(candidates, processStart)[0];
|
|
254
|
-
if (!best) {
|
|
255
|
-
return undefined;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// In early modes (cwd/missing-cwd), defer assignment when the best
|
|
259
|
-
// candidate is outside start-time tolerance — a closer match may
|
|
260
|
-
// exist in parent-child mode (e.g., worktree sessions).
|
|
261
|
-
if (mode !== 'parent-child') {
|
|
262
|
-
const diffMs = Math.abs(
|
|
263
|
-
best.sessionStart.getTime() - processStart.getTime(),
|
|
264
|
-
);
|
|
265
|
-
if (diffMs > ClaudeCodeAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS) {
|
|
266
|
-
return undefined;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return best;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private filterCandidateSessions(
|
|
274
|
-
processInfo: ProcessInfo,
|
|
275
|
-
sessions: ClaudeSession[],
|
|
276
|
-
usedSessionIds: Set<string>,
|
|
277
|
-
mode: SessionMatchMode,
|
|
278
|
-
): ClaudeSession[] {
|
|
279
|
-
return sessions.filter((session) => {
|
|
280
|
-
if (usedSessionIds.has(session.sessionId)) {
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (mode === 'cwd') {
|
|
285
|
-
return (
|
|
286
|
-
this.pathEquals(processInfo.cwd, session.projectPath) ||
|
|
287
|
-
this.pathEquals(processInfo.cwd, session.lastCwd)
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (mode === 'missing-cwd') {
|
|
292
|
-
return !session.projectPath;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// parent-child mode: match if process CWD equals, is under, or is
|
|
296
|
-
// a parent of session project/lastCwd. This also catches exact CWD
|
|
297
|
-
// matches that were deferred from `cwd` mode due to start-time tolerance.
|
|
298
|
-
return (
|
|
299
|
-
this.pathRelated(processInfo.cwd, session.projectPath) ||
|
|
300
|
-
this.pathRelated(processInfo.cwd, session.lastCwd)
|
|
301
|
-
);
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
private rankCandidatesByStartTime(
|
|
306
|
-
candidates: ClaudeSession[],
|
|
307
|
-
processStart: Date,
|
|
308
|
-
): ClaudeSession[] {
|
|
309
|
-
const toleranceMs = ClaudeCodeAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS;
|
|
310
|
-
|
|
311
|
-
return candidates
|
|
312
|
-
.map((session) => {
|
|
313
|
-
const diffMs = Math.abs(
|
|
314
|
-
session.sessionStart.getTime() - processStart.getTime(),
|
|
315
|
-
);
|
|
316
|
-
const outsideTolerance = diffMs > toleranceMs ? 1 : 0;
|
|
317
|
-
return {
|
|
318
|
-
session,
|
|
319
|
-
rank: outsideTolerance,
|
|
320
|
-
diffMs,
|
|
321
|
-
recency: session.lastActive.getTime(),
|
|
322
|
-
};
|
|
323
|
-
})
|
|
324
|
-
.sort((a, b) => {
|
|
325
|
-
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
326
|
-
// Within tolerance (rank 0): prefer most recently active session.
|
|
327
|
-
// The exact diff is noise — a 6s vs 45s difference is meaningless,
|
|
328
|
-
// but the session with recent activity is more likely the real one.
|
|
329
|
-
if (a.rank === 0) return b.recency - a.recency;
|
|
330
|
-
// Outside tolerance: prefer smallest time difference, then recency.
|
|
331
|
-
if (a.diffMs !== b.diffMs) return a.diffMs - b.diffMs;
|
|
332
|
-
return b.recency - a.recency;
|
|
333
|
-
})
|
|
334
|
-
.map((ranked) => ranked.session);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private getProcessStartTimes(pids: number[]): Map<number, Date> {
|
|
338
|
-
if (pids.length === 0 || process.env.JEST_WORKER_ID) {
|
|
339
|
-
return new Map();
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
const output = execSync(
|
|
344
|
-
`ps -o pid=,etime= -p ${pids.join(',')}`,
|
|
345
|
-
{ encoding: 'utf-8' },
|
|
346
|
-
);
|
|
347
|
-
const nowMs = Date.now();
|
|
348
|
-
const startTimes = new Map<number, Date>();
|
|
349
|
-
|
|
350
|
-
for (const rawLine of output.split('\n')) {
|
|
351
|
-
const line = rawLine.trim();
|
|
352
|
-
if (!line) continue;
|
|
353
|
-
|
|
354
|
-
const parts = line.split(/\s+/);
|
|
355
|
-
if (parts.length < 2) continue;
|
|
356
|
-
|
|
357
|
-
const pid = Number.parseInt(parts[0], 10);
|
|
358
|
-
const elapsedSeconds = this.parseElapsedSeconds(parts[1]);
|
|
359
|
-
if (!Number.isFinite(pid) || elapsedSeconds === null) continue;
|
|
360
|
-
|
|
361
|
-
startTimes.set(pid, new Date(nowMs - elapsedSeconds * 1000));
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
return startTimes;
|
|
365
|
-
} catch {
|
|
366
|
-
return new Map();
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
private parseElapsedSeconds(etime: string): number | null {
|
|
371
|
-
const match = etime
|
|
372
|
-
.trim()
|
|
373
|
-
.match(/^(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/);
|
|
374
|
-
if (!match) {
|
|
375
|
-
return null;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const days = Number.parseInt(match[1] || '0', 10);
|
|
379
|
-
const hours = Number.parseInt(match[2] || '0', 10);
|
|
380
|
-
const minutes = Number.parseInt(match[3] || '0', 10);
|
|
381
|
-
const seconds = Number.parseInt(match[4] || '0', 10);
|
|
382
|
-
|
|
383
|
-
return ((days * 24 + hours) * 60 + minutes) * 60 + seconds;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Read Claude Code sessions with bounded scanning
|
|
388
|
-
*/
|
|
389
|
-
private readSessions(limit: number): ClaudeSession[] {
|
|
390
|
-
const sessionFiles = this.findSessionFiles(limit);
|
|
391
|
-
const sessions: ClaudeSession[] = [];
|
|
392
|
-
|
|
393
|
-
for (const file of sessionFiles) {
|
|
394
|
-
try {
|
|
395
|
-
const session = this.readSession(file.filePath, file.projectPath);
|
|
396
|
-
if (session) {
|
|
397
|
-
sessions.push(session);
|
|
398
|
-
}
|
|
399
|
-
} catch (error) {
|
|
400
|
-
console.error(`Failed to parse Claude session ${file.filePath}:`, error);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
return sessions;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Find session files bounded by mtime, sorted most-recent first
|
|
409
|
-
*/
|
|
410
|
-
private findSessionFiles(
|
|
411
|
-
limit: number,
|
|
412
|
-
): Array<{ filePath: string; projectPath: string; mtimeMs: number }> {
|
|
413
|
-
if (!fs.existsSync(this.projectsDir)) {
|
|
414
|
-
return [];
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const files: Array<{
|
|
418
|
-
filePath: string;
|
|
419
|
-
projectPath: string;
|
|
420
|
-
mtimeMs: number;
|
|
421
|
-
}> = [];
|
|
422
|
-
|
|
423
|
-
for (const dirName of fs.readdirSync(this.projectsDir)) {
|
|
424
|
-
if (dirName.startsWith('.')) {
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const projectDir = path.join(this.projectsDir, dirName);
|
|
429
|
-
try {
|
|
430
|
-
if (!fs.statSync(projectDir).isDirectory()) continue;
|
|
431
|
-
} catch {
|
|
432
|
-
continue;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const indexPath = path.join(projectDir, 'sessions-index.json');
|
|
436
|
-
const index = readJson<SessionsIndex>(indexPath);
|
|
437
|
-
const projectPath = index?.originalPath || '';
|
|
438
|
-
|
|
439
|
-
for (const entry of fs.readdirSync(projectDir)) {
|
|
440
|
-
if (!entry.endsWith('.jsonl')) {
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
const filePath = path.join(projectDir, entry);
|
|
445
|
-
try {
|
|
446
|
-
files.push({
|
|
447
|
-
filePath,
|
|
448
|
-
projectPath,
|
|
449
|
-
mtimeMs: fs.statSync(filePath).mtimeMs,
|
|
450
|
-
});
|
|
451
|
-
} catch {
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Ensure breadth: include at least the most recent session per project,
|
|
458
|
-
// then fill remaining slots with globally most-recent sessions.
|
|
459
|
-
const sorted = files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
460
|
-
const result: typeof files = [];
|
|
461
|
-
const seenProjects = new Set<string>();
|
|
462
|
-
|
|
463
|
-
// First pass: one most-recent session per project directory
|
|
464
|
-
for (const file of sorted) {
|
|
465
|
-
const projDir = path.dirname(file.filePath);
|
|
466
|
-
if (!seenProjects.has(projDir)) {
|
|
467
|
-
seenProjects.add(projDir);
|
|
468
|
-
result.push(file);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Second pass: fill remaining slots with globally most-recent
|
|
473
|
-
if (result.length < limit) {
|
|
474
|
-
const resultSet = new Set(result.map((f) => f.filePath));
|
|
475
|
-
for (const file of sorted) {
|
|
476
|
-
if (result.length >= limit) break;
|
|
477
|
-
if (!resultSet.has(file.filePath)) {
|
|
478
|
-
result.push(file);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return result.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, limit);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
300
|
/**
|
|
487
301
|
* Parse a single session file into ClaudeSession
|
|
488
302
|
*/
|
|
@@ -523,7 +337,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
523
337
|
}
|
|
524
338
|
|
|
525
339
|
// Parse all lines for session state (file already in memory)
|
|
526
|
-
let slug: string | undefined;
|
|
527
340
|
let lastEntryType: string | undefined;
|
|
528
341
|
let lastActive: Date | undefined;
|
|
529
342
|
let lastCwd: string | undefined;
|
|
@@ -541,10 +354,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
541
354
|
}
|
|
542
355
|
}
|
|
543
356
|
|
|
544
|
-
if (entry.slug && !slug) {
|
|
545
|
-
slug = entry.slug;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
357
|
if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
|
|
549
358
|
lastCwd = entry.cwd;
|
|
550
359
|
}
|
|
@@ -582,7 +391,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
582
391
|
sessionId,
|
|
583
392
|
projectPath: projectPath || lastCwd || '',
|
|
584
393
|
lastCwd,
|
|
585
|
-
slug,
|
|
586
394
|
sessionStart: sessionStart || lastActive || new Date(),
|
|
587
395
|
lastActive: lastActive || new Date(),
|
|
588
396
|
lastEntryType,
|
|
@@ -627,57 +435,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
627
435
|
return AgentStatus.UNKNOWN;
|
|
628
436
|
}
|
|
629
437
|
|
|
630
|
-
/**
|
|
631
|
-
* Generate unique agent name
|
|
632
|
-
* Uses project basename, appends slug if multiple sessions for same project
|
|
633
|
-
*/
|
|
634
|
-
private generateAgentName(
|
|
635
|
-
session: ClaudeSession,
|
|
636
|
-
existingAgents: AgentInfo[],
|
|
637
|
-
): string {
|
|
638
|
-
const projectName = path.basename(session.projectPath) || 'claude';
|
|
639
|
-
|
|
640
|
-
const sameProjectAgents = existingAgents.filter(
|
|
641
|
-
(a) => a.projectPath === session.projectPath,
|
|
642
|
-
);
|
|
643
|
-
|
|
644
|
-
if (sameProjectAgents.length === 0) {
|
|
645
|
-
return projectName;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
if (session.slug) {
|
|
649
|
-
const slugPart = session.slug.includes('-')
|
|
650
|
-
? session.slug.split('-')[0]
|
|
651
|
-
: session.slug.slice(0, 8);
|
|
652
|
-
return `${projectName} (${slugPart})`;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
return `${projectName} (${session.sessionId.slice(0, 8)})`;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/** Check if two paths are equal, or one is a parent/child of the other. */
|
|
659
|
-
private pathRelated(a?: string, b?: string): boolean {
|
|
660
|
-
return this.pathEquals(a, b) || this.isChildPath(a, b) || this.isChildPath(b, a);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
private pathEquals(a?: string, b?: string): boolean {
|
|
664
|
-
if (!a || !b) {
|
|
665
|
-
return false;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
return this.normalizePath(a) === this.normalizePath(b);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
private isChildPath(child?: string, parent?: string): boolean {
|
|
672
|
-
if (!child || !parent) {
|
|
673
|
-
return false;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
const normalizedChild = this.normalizePath(child);
|
|
677
|
-
const normalizedParent = this.normalizePath(parent);
|
|
678
|
-
return normalizedChild.startsWith(`${normalizedParent}${path.sep}`);
|
|
679
|
-
}
|
|
680
|
-
|
|
681
438
|
/**
|
|
682
439
|
* Extract meaningful text from a user message content.
|
|
683
440
|
* Handles string and array formats, skill command expansion, and noise filtering.
|
|
@@ -758,11 +515,102 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
758
515
|
return type === 'last-prompt' || type === 'file-history-snapshot';
|
|
759
516
|
}
|
|
760
517
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Read the full conversation from a Claude Code session JSONL file.
|
|
520
|
+
*
|
|
521
|
+
* Default mode returns only text content from user/assistant/system messages.
|
|
522
|
+
* Verbose mode also includes tool_use and tool_result blocks.
|
|
523
|
+
*/
|
|
524
|
+
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
525
|
+
const verbose = options?.verbose ?? false;
|
|
526
|
+
|
|
527
|
+
let content: string;
|
|
528
|
+
try {
|
|
529
|
+
content = fs.readFileSync(sessionFilePath, 'utf-8');
|
|
530
|
+
} catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const lines = content.trim().split('\n');
|
|
535
|
+
const messages: ConversationMessage[] = [];
|
|
536
|
+
|
|
537
|
+
for (const line of lines) {
|
|
538
|
+
let entry: SessionEntry;
|
|
539
|
+
try {
|
|
540
|
+
entry = JSON.parse(line);
|
|
541
|
+
} catch {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const entryType = entry.type;
|
|
546
|
+
if (!entryType || this.isMetadataEntryType(entryType)) continue;
|
|
547
|
+
if (entryType === 'progress' || entryType === 'thinking') continue;
|
|
548
|
+
|
|
549
|
+
let role: ConversationMessage['role'];
|
|
550
|
+
if (entryType === 'user') {
|
|
551
|
+
role = 'user';
|
|
552
|
+
} else if (entryType === 'assistant') {
|
|
553
|
+
role = 'assistant';
|
|
554
|
+
} else if (entryType === 'system') {
|
|
555
|
+
role = 'system';
|
|
556
|
+
} else {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const text = this.extractConversationContent(entry.message?.content, role, verbose);
|
|
561
|
+
if (!text) continue;
|
|
562
|
+
|
|
563
|
+
messages.push({
|
|
564
|
+
role,
|
|
565
|
+
content: text,
|
|
566
|
+
timestamp: entry.timestamp,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return messages;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Extract displayable content from a message content field.
|
|
575
|
+
*/
|
|
576
|
+
private extractConversationContent(
|
|
577
|
+
content: string | ContentBlock[] | undefined,
|
|
578
|
+
role: ConversationMessage['role'],
|
|
579
|
+
verbose: boolean,
|
|
580
|
+
): string | undefined {
|
|
581
|
+
if (!content) return undefined;
|
|
582
|
+
|
|
583
|
+
if (typeof content === 'string') {
|
|
584
|
+
const trimmed = content.trim();
|
|
585
|
+
if (role === 'user' && this.isNoiseMessage(trimmed)) return undefined;
|
|
586
|
+
return trimmed || undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (!Array.isArray(content)) return undefined;
|
|
590
|
+
|
|
591
|
+
const parts: string[] = [];
|
|
592
|
+
|
|
593
|
+
for (const block of content) {
|
|
594
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
595
|
+
if (role === 'user' && this.isNoiseMessage(block.text.trim())) continue;
|
|
596
|
+
parts.push(block.text.trim());
|
|
597
|
+
} else if (block.type === 'tool_use' && verbose) {
|
|
598
|
+
const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || '';
|
|
599
|
+
parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`);
|
|
600
|
+
} else if (block.type === 'tool_result' && verbose) {
|
|
601
|
+
const truncated = this.truncateToolResult(block.content || '');
|
|
602
|
+
const prefix = block.is_error ? '[Tool Error]' : '[Tool Result]';
|
|
603
|
+
parts.push(`${prefix} ${truncated}`);
|
|
604
|
+
}
|
|
765
605
|
}
|
|
766
|
-
|
|
606
|
+
|
|
607
|
+
return parts.length > 0 ? parts.join('\n') : undefined;
|
|
767
608
|
}
|
|
609
|
+
|
|
610
|
+
private truncateToolResult(content: string, maxLength = 200): string {
|
|
611
|
+
const firstLine = content.split('\n')[0] || '';
|
|
612
|
+
if (firstLine.length <= maxLength) return firstLine;
|
|
613
|
+
return firstLine.slice(0, maxLength - 3) + '...';
|
|
614
|
+
}
|
|
615
|
+
|
|
768
616
|
}
|