@ai-devkit/agent-manager 0.8.0 → 0.10.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 +7 -0
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +9 -0
- package/dist/AgentManager.js.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +2 -44
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +10 -285
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/GeminiCliAdapter.d.ts +83 -0
- package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -0
- package/dist/adapters/GeminiCliAdapter.js +438 -0
- package/dist/adapters/GeminiCliAdapter.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +3 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +38 -27
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/utils/ClaudeSessionParser.d.ts +112 -0
- package/dist/utils/ClaudeSessionParser.d.ts.map +1 -0
- package/dist/utils/ClaudeSessionParser.js +334 -0
- package/dist/utils/ClaudeSessionParser.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/package.json +1 -1
- package/src/AgentManager.ts +11 -1
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +29 -29
- package/src/__tests__/adapters/GeminiCliAdapter.test.ts +790 -0
- package/src/__tests__/utils/process.test.ts +27 -27
- package/src/adapters/ClaudeCodeAdapter.ts +17 -365
- package/src/adapters/GeminiCliAdapter.ts +488 -0
- package/src/adapters/index.ts +1 -0
- package/src/index.ts +1 -0
- package/src/terminal/TerminalFocusManager.ts +38 -26
- package/src/utils/ClaudeSessionParser.ts +383 -0
- package/src/utils/process.ts +21 -24
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
7
|
import {
|
|
8
8
|
listAgentProcesses,
|
|
9
9
|
batchGetProcessCwds,
|
|
@@ -12,18 +12,18 @@ import {
|
|
|
12
12
|
} from '../../utils/process';
|
|
13
13
|
|
|
14
14
|
jest.mock('child_process', () => ({
|
|
15
|
-
|
|
15
|
+
execFileSync: jest.fn(),
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const mockedExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>;
|
|
19
19
|
|
|
20
20
|
describe('listAgentProcesses', () => {
|
|
21
21
|
beforeEach(() => {
|
|
22
|
-
|
|
22
|
+
mockedExecFileSync.mockReset();
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
it('should parse ps aux | grep output and post-filter by executable name', () => {
|
|
26
|
-
|
|
26
|
+
mockedExecFileSync.mockReturnValue(
|
|
27
27
|
'user 78070 1.0 0.5 485636016 245952 s018 S+ 11:18PM 1:55.14 claude\n' +
|
|
28
28
|
'user 55106 0.1 0.4 485620368 72496 s015 S+ 9Mar26 8:06.36 claude\n',
|
|
29
29
|
);
|
|
@@ -38,7 +38,7 @@ describe('listAgentProcesses', () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it('should filter out non-matching executables', () => {
|
|
41
|
-
|
|
41
|
+
mockedExecFileSync.mockReturnValue(
|
|
42
42
|
'user 100 0.0 0.0 0 0 s001 S 1:00PM 0:00 claude\n' +
|
|
43
43
|
'user 200 0.0 0.0 0 0 s002 S 1:00PM 0:00 claude-helper --pid 100\n' +
|
|
44
44
|
'user 300 0.0 0.0 0 0 s003 S 1:00PM 0:00 /usr/bin/claude\n',
|
|
@@ -50,46 +50,46 @@ describe('listAgentProcesses', () => {
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
it('should return empty array on command failure', () => {
|
|
53
|
-
|
|
53
|
+
mockedExecFileSync.mockImplementation(() => { throw new Error('fail'); });
|
|
54
54
|
expect(listAgentProcesses('claude')).toEqual([]);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
it('should handle empty output', () => {
|
|
58
|
-
|
|
58
|
+
mockedExecFileSync.mockReturnValue('');
|
|
59
59
|
expect(listAgentProcesses('claude')).toEqual([]);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('should reject empty pattern', () => {
|
|
63
63
|
expect(listAgentProcesses('')).toEqual([]);
|
|
64
|
-
expect(
|
|
64
|
+
expect(mockedExecFileSync).not.toHaveBeenCalled();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
it('should reject patterns with shell injection characters', () => {
|
|
68
68
|
expect(listAgentProcesses('claude; rm -rf /')).toEqual([]);
|
|
69
69
|
expect(listAgentProcesses("claude' || true")).toEqual([]);
|
|
70
70
|
expect(listAgentProcesses('$(whoami)')).toEqual([]);
|
|
71
|
-
expect(
|
|
71
|
+
expect(mockedExecFileSync).not.toHaveBeenCalled();
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
it('should accept valid patterns with dashes and underscores', () => {
|
|
75
|
-
|
|
75
|
+
mockedExecFileSync.mockReturnValue('');
|
|
76
76
|
listAgentProcesses('claude-code');
|
|
77
|
-
expect(
|
|
77
|
+
expect(mockedExecFileSync).toHaveBeenCalled();
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
mockedExecFileSync.mockReset();
|
|
80
|
+
mockedExecFileSync.mockReturnValue('');
|
|
81
81
|
listAgentProcesses('my_agent');
|
|
82
|
-
expect(
|
|
82
|
+
expect(mockedExecFileSync).toHaveBeenCalled();
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
describe('batchGetProcessCwds', () => {
|
|
87
87
|
beforeEach(() => {
|
|
88
|
-
|
|
88
|
+
mockedExecFileSync.mockReset();
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
it('should parse batched lsof output', () => {
|
|
92
|
-
|
|
92
|
+
mockedExecFileSync.mockReturnValue(
|
|
93
93
|
'p78070\nn/Users/user/ai-devkit\np55106\nn/Users/user/other-project\n',
|
|
94
94
|
);
|
|
95
95
|
|
|
@@ -104,7 +104,7 @@ describe('batchGetProcessCwds', () => {
|
|
|
104
104
|
|
|
105
105
|
it('should return partial results when lsof succeeds for some PIDs', () => {
|
|
106
106
|
// lsof might not return entries for dead processes
|
|
107
|
-
|
|
107
|
+
mockedExecFileSync.mockReturnValue(
|
|
108
108
|
'p78070\nn/Users/user/ai-devkit\n',
|
|
109
109
|
);
|
|
110
110
|
|
|
@@ -114,7 +114,7 @@ describe('batchGetProcessCwds', () => {
|
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
it('should return empty map on total failure', () => {
|
|
117
|
-
|
|
117
|
+
mockedExecFileSync.mockImplementation(() => { throw new Error('fail'); });
|
|
118
118
|
const cwds = batchGetProcessCwds([78070]);
|
|
119
119
|
// Falls through to pwdx fallback which also fails
|
|
120
120
|
expect(cwds.size).toBe(0);
|
|
@@ -123,11 +123,11 @@ describe('batchGetProcessCwds', () => {
|
|
|
123
123
|
|
|
124
124
|
describe('batchGetProcessStartTimes', () => {
|
|
125
125
|
beforeEach(() => {
|
|
126
|
-
|
|
126
|
+
mockedExecFileSync.mockReset();
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
it('should parse ps lstart output', () => {
|
|
130
|
-
|
|
130
|
+
mockedExecFileSync.mockReturnValue(
|
|
131
131
|
' 78070 Wed Mar 18 23:18:01 2026\n' +
|
|
132
132
|
' 55106 Mon Mar 9 21:41:42 2026\n',
|
|
133
133
|
);
|
|
@@ -143,7 +143,7 @@ describe('batchGetProcessStartTimes', () => {
|
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
it('should skip lines with unparseable dates', () => {
|
|
146
|
-
|
|
146
|
+
mockedExecFileSync.mockReturnValue(
|
|
147
147
|
' 78070 Wed Mar 18 23:18:01 2026\n' +
|
|
148
148
|
' 99999 INVALID_DATE\n',
|
|
149
149
|
);
|
|
@@ -154,20 +154,20 @@ describe('batchGetProcessStartTimes', () => {
|
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
it('should return empty map on failure', () => {
|
|
157
|
-
|
|
157
|
+
mockedExecFileSync.mockImplementation(() => { throw new Error('fail'); });
|
|
158
158
|
expect(batchGetProcessStartTimes([78070])).toEqual(new Map());
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
describe('enrichProcesses', () => {
|
|
163
163
|
beforeEach(() => {
|
|
164
|
-
|
|
164
|
+
mockedExecFileSync.mockReset();
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it('should populate cwd and startTime on processes', () => {
|
|
168
168
|
// First call: batchGetProcessCwds (lsof)
|
|
169
169
|
// Second call: batchGetProcessStartTimes (ps lstart)
|
|
170
|
-
|
|
170
|
+
mockedExecFileSync
|
|
171
171
|
.mockReturnValueOnce('p100\nn/projects/app\n')
|
|
172
172
|
.mockReturnValueOnce(' 100 Wed Mar 18 23:18:01 2026\n');
|
|
173
173
|
|
|
@@ -182,12 +182,12 @@ describe('enrichProcesses', () => {
|
|
|
182
182
|
|
|
183
183
|
it('should return empty array for empty input', () => {
|
|
184
184
|
expect(enrichProcesses([])).toEqual([]);
|
|
185
|
-
expect(
|
|
185
|
+
expect(mockedExecFileSync).not.toHaveBeenCalled();
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
it('should handle partial failures', () => {
|
|
189
189
|
// lsof succeeds, ps lstart fails
|
|
190
|
-
|
|
190
|
+
mockedExecFileSync
|
|
191
191
|
.mockReturnValueOnce('p100\nn/projects/app\n')
|
|
192
192
|
.mockImplementationOnce(() => { throw new Error('fail'); });
|
|
193
193
|
|
|
@@ -6,61 +6,33 @@ import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
|
6
6
|
import { batchGetSessionFileBirthtimes } from '../utils/session';
|
|
7
7
|
import type { SessionFile } from '../utils/session';
|
|
8
8
|
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
|
-
}
|
|
9
|
+
import { ClaudeSessionParser } from '../utils/ClaudeSessionParser';
|
|
10
|
+
import type { ClaudeSession } from '../utils/ClaudeSessionParser';
|
|
30
11
|
|
|
31
12
|
/**
|
|
32
|
-
* Entry in ~/.claude/sessions/<pid>.json written by Claude Code
|
|
13
|
+
* Entry in ~/.claude/sessions/<pid>.json written by Claude Code.
|
|
14
|
+
* Maps a running process to its session file via PID.
|
|
33
15
|
*/
|
|
34
16
|
interface PidFileEntry {
|
|
35
17
|
pid: number;
|
|
36
18
|
sessionId: string;
|
|
37
19
|
cwd: string;
|
|
38
|
-
|
|
20
|
+
/** Epoch milliseconds when the Claude Code process started */
|
|
21
|
+
startedAt: number;
|
|
39
22
|
kind: string;
|
|
40
23
|
entrypoint: string;
|
|
41
24
|
}
|
|
42
25
|
|
|
43
26
|
/**
|
|
44
|
-
* A process directly matched to a session via PID file (authoritative path)
|
|
27
|
+
* A process directly matched to a session via PID file (authoritative path).
|
|
45
28
|
*/
|
|
46
29
|
interface DirectMatch {
|
|
47
30
|
process: ProcessInfo;
|
|
48
31
|
sessionFile: SessionFile;
|
|
49
32
|
}
|
|
50
33
|
|
|
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
|
-
}
|
|
34
|
+
/** Maximum allowed delta (ms) between process start time and PID file startedAt. */
|
|
35
|
+
const PID_FILE_STALENESS_MS = 60000;
|
|
64
36
|
|
|
65
37
|
/**
|
|
66
38
|
* Claude Code Adapter
|
|
@@ -77,16 +49,15 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
77
49
|
|
|
78
50
|
private projectsDir: string;
|
|
79
51
|
private sessionsDir: string;
|
|
52
|
+
private parser: ClaudeSessionParser;
|
|
80
53
|
|
|
81
54
|
constructor() {
|
|
82
55
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
83
56
|
this.projectsDir = path.join(homeDir, '.claude', 'projects');
|
|
84
57
|
this.sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
|
58
|
+
this.parser = new ClaudeSessionParser();
|
|
85
59
|
}
|
|
86
60
|
|
|
87
|
-
/**
|
|
88
|
-
* Check if this adapter can handle a given process
|
|
89
|
-
*/
|
|
90
61
|
canHandle(processInfo: ProcessInfo): boolean {
|
|
91
62
|
return this.isClaudeExecutable(processInfo.command);
|
|
92
63
|
}
|
|
@@ -97,9 +68,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
97
68
|
return base === 'claude' || base === 'claude.exe';
|
|
98
69
|
}
|
|
99
70
|
|
|
100
|
-
/**
|
|
101
|
-
* Detect running Claude Code agents
|
|
102
|
-
*/
|
|
103
71
|
async detectAgents(): Promise<AgentInfo[]> {
|
|
104
72
|
const processes = enrichProcesses(listAgentProcesses('claude'));
|
|
105
73
|
if (processes.length === 0) {
|
|
@@ -125,7 +93,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
125
93
|
|
|
126
94
|
// Build agents from direct (PID-file) matches
|
|
127
95
|
for (const { process: proc, sessionFile } of direct) {
|
|
128
|
-
const sessionData = this.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
|
|
96
|
+
const sessionData = this.parser.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
|
|
129
97
|
if (sessionData) {
|
|
130
98
|
agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile));
|
|
131
99
|
} else {
|
|
@@ -135,7 +103,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
135
103
|
|
|
136
104
|
// Build agents from legacy matches
|
|
137
105
|
for (const match of legacyMatches) {
|
|
138
|
-
const sessionData = this.readSession(
|
|
106
|
+
const sessionData = this.parser.readSession(
|
|
139
107
|
match.session.filePath,
|
|
140
108
|
match.session.resolvedCwd,
|
|
141
109
|
);
|
|
@@ -164,7 +132,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
164
132
|
* via a single batched stat call across all directories.
|
|
165
133
|
*/
|
|
166
134
|
private discoverSessions(processes: ProcessInfo[]): SessionFile[] {
|
|
167
|
-
// Collect valid project dirs and map them back to their CWD
|
|
168
135
|
const dirToCwd = new Map<string, string>();
|
|
169
136
|
|
|
170
137
|
for (const proc of processes) {
|
|
@@ -184,10 +151,8 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
184
151
|
|
|
185
152
|
if (dirToCwd.size === 0) return [];
|
|
186
153
|
|
|
187
|
-
// Single batched stat call across all directories
|
|
188
154
|
const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]);
|
|
189
155
|
|
|
190
|
-
// Set resolvedCwd based on which project dir the file belongs to
|
|
191
156
|
for (const file of files) {
|
|
192
157
|
file.resolvedCwd = dirToCwd.get(file.projectDir) || '';
|
|
193
158
|
}
|
|
@@ -203,7 +168,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
203
168
|
* fallback — processes with no valid PID file (sent to legacy matching)
|
|
204
169
|
*
|
|
205
170
|
* Per-process fallback triggers on: file absent, malformed JSON,
|
|
206
|
-
* stale startedAt (>
|
|
171
|
+
* stale startedAt (>60s from proc.startTime), or missing JSONL.
|
|
207
172
|
*/
|
|
208
173
|
private tryPidFileMatching(processes: ProcessInfo[]): {
|
|
209
174
|
direct: DirectMatch[];
|
|
@@ -222,7 +187,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
222
187
|
// Stale-file guard: reject PID files from a previous process with the same PID
|
|
223
188
|
if (proc.startTime) {
|
|
224
189
|
const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt);
|
|
225
|
-
if (deltaMs >
|
|
190
|
+
if (deltaMs > PID_FILE_STALENESS_MS) {
|
|
226
191
|
fallback.push(proc);
|
|
227
192
|
continue;
|
|
228
193
|
}
|
|
@@ -274,7 +239,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
274
239
|
return {
|
|
275
240
|
name: generateAgentName(processInfo.cwd, processInfo.pid),
|
|
276
241
|
type: this.type,
|
|
277
|
-
status: this.determineStatus(session),
|
|
242
|
+
status: this.parser.determineStatus(session),
|
|
278
243
|
summary: session.lastUserMessage || 'Session started',
|
|
279
244
|
pid: processInfo.pid,
|
|
280
245
|
projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
|
|
@@ -297,320 +262,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
297
262
|
};
|
|
298
263
|
}
|
|
299
264
|
|
|
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
265
|
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;
|
|
562
|
-
|
|
563
|
-
messages.push({
|
|
564
|
-
role,
|
|
565
|
-
content: text,
|
|
566
|
-
timestamp: entry.timestamp,
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
return messages;
|
|
266
|
+
return this.parser.getConversation(sessionFilePath, options);
|
|
571
267
|
}
|
|
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
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return parts.length > 0 ? parts.join('\n') : undefined;
|
|
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
|
-
|
|
616
268
|
}
|