@ai-devkit/agent-manager 0.9.0 → 0.11.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/AgentManager.d.ts +21 -1
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +47 -0
- package/dist/AgentManager.js.map +1 -1
- package/dist/adapters/AgentAdapter.d.ts +66 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +14 -43
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +60 -275
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +14 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +105 -6
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
- package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
- package/dist/adapters/GeminiCliAdapter.js +77 -6
- package/dist/adapters/GeminiCliAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +36 -27
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +3 -12
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/utils/ClaudeSessionParser.d.ts +114 -0
- package/dist/utils/ClaudeSessionParser.d.ts.map +1 -0
- package/dist/utils/ClaudeSessionParser.js +377 -0
- package/dist/utils/ClaudeSessionParser.js.map +1 -0
- package/dist/utils/applescript.d.ts +6 -0
- package/dist/utils/applescript.d.ts.map +1 -0
- package/dist/utils/applescript.js +14 -0
- package/dist/utils/applescript.js.map +1 -0
- package/dist/utils/process.d.ts +3 -4
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +11 -15
- package/dist/utils/process.js.map +1 -1
- package/dist/utils/session.d.ts +34 -5
- package/dist/utils/session.d.ts.map +1 -1
- package/dist/utils/session.js +90 -44
- package/dist/utils/session.js.map +1 -1
- package/package.json +1 -1
- package/src/AgentManager.ts +66 -4
- package/src/__tests__/AgentManager.test.ts +134 -2
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +188 -32
- package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
- package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
- package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
- package/src/__tests__/utils/process.test.ts +27 -27
- package/src/__tests__/utils/session.test.ts +79 -43
- package/src/adapters/AgentAdapter.ts +76 -0
- package/src/adapters/ClaudeCodeAdapter.ts +82 -356
- package/src/adapters/CodexAdapter.ts +126 -8
- package/src/adapters/GeminiCliAdapter.ts +102 -7
- package/src/index.ts +9 -1
- package/src/terminal/TerminalFocusManager.ts +35 -26
- package/src/terminal/TtyWriter.ts +1 -11
- package/src/utils/ClaudeSessionParser.ts +437 -0
- package/src/utils/applescript.ts +10 -0
- package/src/utils/process.ts +21 -24
- package/src/utils/session.ts +86 -45
|
@@ -1,66 +1,45 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AgentAdapter,
|
|
5
|
+
AgentInfo,
|
|
6
|
+
ProcessInfo,
|
|
7
|
+
ConversationMessage,
|
|
8
|
+
SessionSummary,
|
|
9
|
+
ListSessionsOptions,
|
|
10
|
+
} from './AgentAdapter';
|
|
4
11
|
import { AgentStatus } from './AgentAdapter';
|
|
5
12
|
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
6
|
-
import { batchGetSessionFileBirthtimes } from '../utils/session';
|
|
13
|
+
import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session';
|
|
7
14
|
import type { SessionFile } from '../utils/session';
|
|
8
15
|
import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*/
|
|
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;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface SessionEntry {
|
|
23
|
-
type?: string;
|
|
24
|
-
timestamp?: string;
|
|
25
|
-
cwd?: string;
|
|
26
|
-
message?: {
|
|
27
|
-
content?: string | ContentBlock[];
|
|
28
|
-
};
|
|
29
|
-
}
|
|
16
|
+
import { ClaudeSessionParser } from '../utils/ClaudeSessionParser';
|
|
17
|
+
import type { ClaudeSession } from '../utils/ClaudeSessionParser';
|
|
30
18
|
|
|
31
19
|
/**
|
|
32
|
-
* Entry in ~/.claude/sessions/<pid>.json written by Claude Code
|
|
20
|
+
* Entry in ~/.claude/sessions/<pid>.json written by Claude Code.
|
|
21
|
+
* Maps a running process to its session file via PID.
|
|
33
22
|
*/
|
|
34
23
|
interface PidFileEntry {
|
|
35
24
|
pid: number;
|
|
36
25
|
sessionId: string;
|
|
37
26
|
cwd: string;
|
|
38
|
-
|
|
27
|
+
/** Epoch milliseconds when the Claude Code process started */
|
|
28
|
+
startedAt: number;
|
|
39
29
|
kind: string;
|
|
40
30
|
entrypoint: string;
|
|
41
31
|
}
|
|
42
32
|
|
|
43
33
|
/**
|
|
44
|
-
* A process directly matched to a session via PID file (authoritative path)
|
|
34
|
+
* A process directly matched to a session via PID file (authoritative path).
|
|
45
35
|
*/
|
|
46
36
|
interface DirectMatch {
|
|
47
37
|
process: ProcessInfo;
|
|
48
38
|
sessionFile: SessionFile;
|
|
49
39
|
}
|
|
50
40
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
*/
|
|
54
|
-
interface ClaudeSession {
|
|
55
|
-
sessionId: string;
|
|
56
|
-
projectPath: string;
|
|
57
|
-
lastCwd?: string;
|
|
58
|
-
sessionStart: Date;
|
|
59
|
-
lastActive: Date;
|
|
60
|
-
lastEntryType?: string;
|
|
61
|
-
isInterrupted: boolean;
|
|
62
|
-
lastUserMessage?: string;
|
|
63
|
-
}
|
|
41
|
+
/** Maximum allowed delta (ms) between process start time and PID file startedAt. */
|
|
42
|
+
const PID_FILE_STALENESS_MS = 60000;
|
|
64
43
|
|
|
65
44
|
/**
|
|
66
45
|
* Claude Code Adapter
|
|
@@ -77,16 +56,15 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
77
56
|
|
|
78
57
|
private projectsDir: string;
|
|
79
58
|
private sessionsDir: string;
|
|
59
|
+
private parser: ClaudeSessionParser;
|
|
80
60
|
|
|
81
61
|
constructor() {
|
|
82
62
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
83
63
|
this.projectsDir = path.join(homeDir, '.claude', 'projects');
|
|
84
64
|
this.sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
|
65
|
+
this.parser = new ClaudeSessionParser();
|
|
85
66
|
}
|
|
86
67
|
|
|
87
|
-
/**
|
|
88
|
-
* Check if this adapter can handle a given process
|
|
89
|
-
*/
|
|
90
68
|
canHandle(processInfo: ProcessInfo): boolean {
|
|
91
69
|
return this.isClaudeExecutable(processInfo.command);
|
|
92
70
|
}
|
|
@@ -97,9 +75,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
97
75
|
return base === 'claude' || base === 'claude.exe';
|
|
98
76
|
}
|
|
99
77
|
|
|
100
|
-
/**
|
|
101
|
-
* Detect running Claude Code agents
|
|
102
|
-
*/
|
|
103
78
|
async detectAgents(): Promise<AgentInfo[]> {
|
|
104
79
|
const processes = enrichProcesses(listAgentProcesses('claude'));
|
|
105
80
|
if (processes.length === 0) {
|
|
@@ -125,7 +100,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
125
100
|
|
|
126
101
|
// Build agents from direct (PID-file) matches
|
|
127
102
|
for (const { process: proc, sessionFile } of direct) {
|
|
128
|
-
const sessionData = this.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
|
|
103
|
+
const sessionData = this.parser.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
|
|
129
104
|
if (sessionData) {
|
|
130
105
|
agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile));
|
|
131
106
|
} else {
|
|
@@ -135,7 +110,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
135
110
|
|
|
136
111
|
// Build agents from legacy matches
|
|
137
112
|
for (const match of legacyMatches) {
|
|
138
|
-
const sessionData = this.readSession(
|
|
113
|
+
const sessionData = this.parser.readSession(
|
|
139
114
|
match.session.filePath,
|
|
140
115
|
match.session.resolvedCwd,
|
|
141
116
|
);
|
|
@@ -164,7 +139,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
164
139
|
* via a single batched stat call across all directories.
|
|
165
140
|
*/
|
|
166
141
|
private discoverSessions(processes: ProcessInfo[]): SessionFile[] {
|
|
167
|
-
// Collect valid project dirs and map them back to their CWD
|
|
168
142
|
const dirToCwd = new Map<string, string>();
|
|
169
143
|
|
|
170
144
|
for (const proc of processes) {
|
|
@@ -184,10 +158,8 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
184
158
|
|
|
185
159
|
if (dirToCwd.size === 0) return [];
|
|
186
160
|
|
|
187
|
-
// Single batched stat call across all directories
|
|
188
161
|
const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]);
|
|
189
162
|
|
|
190
|
-
// Set resolvedCwd based on which project dir the file belongs to
|
|
191
163
|
for (const file of files) {
|
|
192
164
|
file.resolvedCwd = dirToCwd.get(file.projectDir) || '';
|
|
193
165
|
}
|
|
@@ -203,7 +175,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
203
175
|
* fallback — processes with no valid PID file (sent to legacy matching)
|
|
204
176
|
*
|
|
205
177
|
* Per-process fallback triggers on: file absent, malformed JSON,
|
|
206
|
-
* stale startedAt (>
|
|
178
|
+
* stale startedAt (>60s from proc.startTime), or missing JSONL.
|
|
207
179
|
*/
|
|
208
180
|
private tryPidFileMatching(processes: ProcessInfo[]): {
|
|
209
181
|
direct: DirectMatch[];
|
|
@@ -222,7 +194,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
222
194
|
// Stale-file guard: reject PID files from a previous process with the same PID
|
|
223
195
|
if (proc.startTime) {
|
|
224
196
|
const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt);
|
|
225
|
-
if (deltaMs >
|
|
197
|
+
if (deltaMs > PID_FILE_STALENESS_MS) {
|
|
226
198
|
fallback.push(proc);
|
|
227
199
|
continue;
|
|
228
200
|
}
|
|
@@ -274,7 +246,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
274
246
|
return {
|
|
275
247
|
name: generateAgentName(processInfo.cwd, processInfo.pid),
|
|
276
248
|
type: this.type,
|
|
277
|
-
status: this.determineStatus(session),
|
|
249
|
+
status: this.parser.determineStatus(session),
|
|
278
250
|
summary: session.lastUserMessage || 'Session started',
|
|
279
251
|
pid: processInfo.pid,
|
|
280
252
|
projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
|
|
@@ -297,320 +269,74 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
297
269
|
};
|
|
298
270
|
}
|
|
299
271
|
|
|
300
|
-
/**
|
|
301
|
-
* Parse a single session file into ClaudeSession
|
|
302
|
-
*/
|
|
303
|
-
private readSession(
|
|
304
|
-
filePath: string,
|
|
305
|
-
projectPath: string,
|
|
306
|
-
): ClaudeSession | null {
|
|
307
|
-
const sessionId = path.basename(filePath, '.jsonl');
|
|
308
|
-
|
|
309
|
-
let content: string;
|
|
310
|
-
try {
|
|
311
|
-
content = fs.readFileSync(filePath, 'utf-8');
|
|
312
|
-
} catch {
|
|
313
|
-
return null;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const allLines = content.trim().split('\n');
|
|
317
|
-
if (allLines.length === 0) {
|
|
318
|
-
return null;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Parse first line for sessionStart.
|
|
322
|
-
// Claude Code may emit a "file-history-snapshot" as the first entry, which
|
|
323
|
-
// stores its timestamp inside "snapshot.timestamp" rather than at the root.
|
|
324
|
-
let sessionStart: Date | null = null;
|
|
325
|
-
try {
|
|
326
|
-
const firstEntry = JSON.parse(allLines[0]);
|
|
327
|
-
const rawTs: string | undefined =
|
|
328
|
-
firstEntry.timestamp || firstEntry.snapshot?.timestamp;
|
|
329
|
-
if (rawTs) {
|
|
330
|
-
const ts = new Date(rawTs);
|
|
331
|
-
if (!Number.isNaN(ts.getTime())) {
|
|
332
|
-
sessionStart = ts;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
} catch {
|
|
336
|
-
/* skip */
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Parse all lines for session state (file already in memory)
|
|
340
|
-
let lastEntryType: string | undefined;
|
|
341
|
-
let lastActive: Date | undefined;
|
|
342
|
-
let lastCwd: string | undefined;
|
|
343
|
-
let isInterrupted = false;
|
|
344
|
-
let lastUserMessage: string | undefined;
|
|
345
|
-
|
|
346
|
-
for (const line of allLines) {
|
|
347
|
-
try {
|
|
348
|
-
const entry: SessionEntry = JSON.parse(line);
|
|
349
|
-
|
|
350
|
-
if (entry.timestamp) {
|
|
351
|
-
const ts = new Date(entry.timestamp);
|
|
352
|
-
if (!Number.isNaN(ts.getTime())) {
|
|
353
|
-
lastActive = ts;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
|
|
358
|
-
lastCwd = entry.cwd;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (entry.type && !this.isMetadataEntryType(entry.type)) {
|
|
362
|
-
lastEntryType = entry.type;
|
|
363
|
-
|
|
364
|
-
if (entry.type === 'user') {
|
|
365
|
-
const msgContent = entry.message?.content;
|
|
366
|
-
isInterrupted =
|
|
367
|
-
Array.isArray(msgContent) &&
|
|
368
|
-
msgContent.some(
|
|
369
|
-
(c) =>
|
|
370
|
-
(c.type === 'text' &&
|
|
371
|
-
c.text?.includes('[Request interrupted')) ||
|
|
372
|
-
(c.type === 'tool_result' &&
|
|
373
|
-
c.content?.includes('[Request interrupted')),
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
// Extract user message text for summary fallback
|
|
377
|
-
const text = this.extractUserMessageText(msgContent);
|
|
378
|
-
if (text) {
|
|
379
|
-
lastUserMessage = text;
|
|
380
|
-
}
|
|
381
|
-
} else {
|
|
382
|
-
isInterrupted = false;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
} catch {
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
return {
|
|
391
|
-
sessionId,
|
|
392
|
-
projectPath: projectPath || lastCwd || '',
|
|
393
|
-
lastCwd,
|
|
394
|
-
sessionStart: sessionStart || lastActive || new Date(),
|
|
395
|
-
lastActive: lastActive || new Date(),
|
|
396
|
-
lastEntryType,
|
|
397
|
-
isInterrupted,
|
|
398
|
-
lastUserMessage,
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Determine agent status from session state
|
|
404
|
-
*/
|
|
405
|
-
private determineStatus(session: ClaudeSession): AgentStatus {
|
|
406
|
-
if (!session.lastEntryType) {
|
|
407
|
-
return AgentStatus.UNKNOWN;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// No age-based IDLE override: every agent in the list is backed by
|
|
411
|
-
// a running process (found via ps), so the entry type is the best
|
|
412
|
-
// indicator of actual state.
|
|
413
|
-
|
|
414
|
-
if (session.lastEntryType === 'user') {
|
|
415
|
-
return session.isInterrupted
|
|
416
|
-
? AgentStatus.WAITING
|
|
417
|
-
: AgentStatus.RUNNING;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (
|
|
421
|
-
session.lastEntryType === 'progress' ||
|
|
422
|
-
session.lastEntryType === 'thinking'
|
|
423
|
-
) {
|
|
424
|
-
return AgentStatus.RUNNING;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (session.lastEntryType === 'assistant') {
|
|
428
|
-
return AgentStatus.WAITING;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (session.lastEntryType === 'system') {
|
|
432
|
-
return AgentStatus.IDLE;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
return AgentStatus.UNKNOWN;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Extract meaningful text from a user message content.
|
|
440
|
-
* Handles string and array formats, skill command expansion, and noise filtering.
|
|
441
|
-
*/
|
|
442
|
-
private extractUserMessageText(
|
|
443
|
-
content: string | Array<{ type?: string; text?: string }> | undefined,
|
|
444
|
-
): string | undefined {
|
|
445
|
-
if (!content) {
|
|
446
|
-
return undefined;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
let raw: string | undefined;
|
|
450
|
-
|
|
451
|
-
if (typeof content === 'string') {
|
|
452
|
-
raw = content.trim();
|
|
453
|
-
} else if (Array.isArray(content)) {
|
|
454
|
-
for (const block of content) {
|
|
455
|
-
if (block.type === 'text' && block.text?.trim()) {
|
|
456
|
-
raw = block.text.trim();
|
|
457
|
-
break;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (!raw) {
|
|
463
|
-
return undefined;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// Skill slash-command: extract /command-name and args
|
|
467
|
-
if (raw.startsWith('<command-message>')) {
|
|
468
|
-
return this.parseCommandMessage(raw);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Expanded skill content: extract ARGUMENTS line if present, skip otherwise
|
|
472
|
-
if (raw.startsWith('Base directory for this skill:')) {
|
|
473
|
-
const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/);
|
|
474
|
-
return argsMatch?.[1]?.trim() || undefined;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Filter noise
|
|
478
|
-
if (this.isNoiseMessage(raw)) {
|
|
479
|
-
return undefined;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return raw;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Parse a <command-message> string into "/command args" format.
|
|
487
|
-
*/
|
|
488
|
-
private parseCommandMessage(raw: string): string | undefined {
|
|
489
|
-
const nameMatch = raw.match(/<command-name>([^<]+)<\/command-name>/);
|
|
490
|
-
const argsMatch = raw.match(/<command-args>([^<]+)<\/command-args>/);
|
|
491
|
-
const name = nameMatch?.[1]?.trim();
|
|
492
|
-
if (!name) {
|
|
493
|
-
return undefined;
|
|
494
|
-
}
|
|
495
|
-
const args = argsMatch?.[1]?.trim();
|
|
496
|
-
return args ? `${name} ${args}` : name;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Check if a message is noise (not a meaningful user intent).
|
|
501
|
-
*/
|
|
502
|
-
private isNoiseMessage(text: string): boolean {
|
|
503
|
-
return (
|
|
504
|
-
text.startsWith('[Request interrupted') ||
|
|
505
|
-
text === 'Tool loaded.' ||
|
|
506
|
-
text.startsWith('This session is being continued')
|
|
507
|
-
);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Check if an entry type is metadata (not conversation state).
|
|
512
|
-
* These should not overwrite lastEntryType used for status determination.
|
|
513
|
-
*/
|
|
514
|
-
private isMetadataEntryType(type: string): boolean {
|
|
515
|
-
return type === 'last-prompt' || type === 'file-history-snapshot';
|
|
516
|
-
}
|
|
517
|
-
|
|
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
272
|
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
525
|
-
|
|
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;
|
|
273
|
+
return this.parser.getConversation(sessionFilePath, options);
|
|
274
|
+
}
|
|
562
275
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
276
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
277
|
+
const filterCwd = opts?.cwd;
|
|
278
|
+
const candidates = this.discoverSessionFiles();
|
|
279
|
+
const summaries: SessionSummary[] = [];
|
|
280
|
+
|
|
281
|
+
for (const { filePath, defaultCwd } of candidates) {
|
|
282
|
+
const session = this.parser.readSession(filePath, defaultCwd);
|
|
283
|
+
if (!session) continue;
|
|
284
|
+
|
|
285
|
+
// Drop sessions whose JSONL had no parseable conversation entries.
|
|
286
|
+
// readSession is permissive (returns a shell record even when every
|
|
287
|
+
// line fails to parse); listSessions needs at least one real entry
|
|
288
|
+
// so we don't surface garbage files.
|
|
289
|
+
if (!session.lastEntryType) continue;
|
|
290
|
+
|
|
291
|
+
const recordedCwd = session.lastCwd || defaultCwd;
|
|
292
|
+
if (filterCwd !== undefined && recordedCwd !== filterCwd) continue;
|
|
293
|
+
|
|
294
|
+
const stat = safeStat(filePath);
|
|
295
|
+
|
|
296
|
+
summaries.push({
|
|
297
|
+
type: 'claude',
|
|
298
|
+
sessionId: session.sessionId,
|
|
299
|
+
cwd: recordedCwd,
|
|
300
|
+
firstUserMessage: session.firstUserMessage || '',
|
|
301
|
+
lastActive: session.lastActive ?? stat?.mtime ?? new Date(),
|
|
302
|
+
startedAt: session.sessionStart ?? stat?.birthtime ?? stat?.mtime ?? new Date(),
|
|
303
|
+
sessionFilePath: filePath,
|
|
567
304
|
});
|
|
568
305
|
}
|
|
569
306
|
|
|
570
|
-
return
|
|
307
|
+
return summaries;
|
|
571
308
|
}
|
|
572
309
|
|
|
573
310
|
/**
|
|
574
|
-
*
|
|
311
|
+
* Discover candidate session files for {@link listSessions}.
|
|
312
|
+
*
|
|
313
|
+
* Always walks every subdirectory of `projectsDir`. We can't use the
|
|
314
|
+
* encoded-dir shortcut for the cwd-scoped path because Claude Code
|
|
315
|
+
* indexes session files by where the *process was launched*, not by
|
|
316
|
+
* the recorded `cwd` field inside the session — these diverge in
|
|
317
|
+
* worktrees and similar setups. The cwd filter is applied later
|
|
318
|
+
* against `session.lastCwd` so callers see exactly the sessions whose
|
|
319
|
+
* recorded cwd matches.
|
|
575
320
|
*/
|
|
576
|
-
private
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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}`);
|
|
321
|
+
private discoverSessionFiles(): Array<{ filePath: string; defaultCwd: string }> {
|
|
322
|
+
const out: Array<{ filePath: string; defaultCwd: string }> = [];
|
|
323
|
+
|
|
324
|
+
if (!isDirectory(this.projectsDir)) return out;
|
|
325
|
+
|
|
326
|
+
for (const dirName of safeReaddir(this.projectsDir)) {
|
|
327
|
+
const projectDir = path.join(this.projectsDir, dirName);
|
|
328
|
+
if (!isDirectory(projectDir)) continue;
|
|
329
|
+
|
|
330
|
+
// Best-effort decode for the rare case session content has no
|
|
331
|
+
// recorded cwd: '-Users-foo-bar' → '/Users/foo/bar'. Lossy for
|
|
332
|
+
// paths containing '-'; session content's lastCwd overrides
|
|
333
|
+
// this when available.
|
|
334
|
+
const decoded = dirName.replace(/-/g, '/');
|
|
335
|
+
for (const name of listJsonl(projectDir)) {
|
|
336
|
+
out.push({ filePath: path.join(projectDir, name), defaultCwd: decoded });
|
|
604
337
|
}
|
|
605
338
|
}
|
|
606
339
|
|
|
607
|
-
return
|
|
340
|
+
return out;
|
|
608
341
|
}
|
|
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
|
-
|
|
616
342
|
}
|