@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.
Files changed (43) hide show
  1. package/dist/AgentManager.d.ts +7 -0
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +9 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/ClaudeCodeAdapter.d.ts +2 -44
  6. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  7. package/dist/adapters/ClaudeCodeAdapter.js +10 -285
  8. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  9. package/dist/adapters/GeminiCliAdapter.d.ts +83 -0
  10. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -0
  11. package/dist/adapters/GeminiCliAdapter.js +438 -0
  12. package/dist/adapters/GeminiCliAdapter.js.map +1 -0
  13. package/dist/adapters/index.d.ts +1 -0
  14. package/dist/adapters/index.d.ts.map +1 -1
  15. package/dist/adapters/index.js +3 -1
  16. package/dist/adapters/index.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.js +38 -27
  23. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  24. package/dist/utils/ClaudeSessionParser.d.ts +112 -0
  25. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -0
  26. package/dist/utils/ClaudeSessionParser.js +334 -0
  27. package/dist/utils/ClaudeSessionParser.js.map +1 -0
  28. package/dist/utils/process.d.ts +3 -4
  29. package/dist/utils/process.d.ts.map +1 -1
  30. package/dist/utils/process.js +11 -15
  31. package/dist/utils/process.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/AgentManager.ts +11 -1
  34. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +29 -29
  35. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +790 -0
  36. package/src/__tests__/utils/process.test.ts +27 -27
  37. package/src/adapters/ClaudeCodeAdapter.ts +17 -365
  38. package/src/adapters/GeminiCliAdapter.ts +488 -0
  39. package/src/adapters/index.ts +1 -0
  40. package/src/index.ts +1 -0
  41. package/src/terminal/TerminalFocusManager.ts +38 -26
  42. package/src/utils/ClaudeSessionParser.ts +383 -0
  43. 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 { execSync } from 'child_process';
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
- execSync: jest.fn(),
15
+ execFileSync: jest.fn(),
16
16
  }));
17
17
 
18
- const mockedExecSync = execSync as jest.MockedFunction<typeof execSync>;
18
+ const mockedExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>;
19
19
 
20
20
  describe('listAgentProcesses', () => {
21
21
  beforeEach(() => {
22
- mockedExecSync.mockReset();
22
+ mockedExecFileSync.mockReset();
23
23
  });
24
24
 
25
25
  it('should parse ps aux | grep output and post-filter by executable name', () => {
26
- mockedExecSync.mockReturnValue(
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
- mockedExecSync.mockReturnValue(
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
- mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
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
- mockedExecSync.mockReturnValue('');
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(mockedExecSync).not.toHaveBeenCalled();
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(mockedExecSync).not.toHaveBeenCalled();
71
+ expect(mockedExecFileSync).not.toHaveBeenCalled();
72
72
  });
73
73
 
74
74
  it('should accept valid patterns with dashes and underscores', () => {
75
- mockedExecSync.mockReturnValue('');
75
+ mockedExecFileSync.mockReturnValue('');
76
76
  listAgentProcesses('claude-code');
77
- expect(mockedExecSync).toHaveBeenCalled();
77
+ expect(mockedExecFileSync).toHaveBeenCalled();
78
78
 
79
- mockedExecSync.mockReset();
80
- mockedExecSync.mockReturnValue('');
79
+ mockedExecFileSync.mockReset();
80
+ mockedExecFileSync.mockReturnValue('');
81
81
  listAgentProcesses('my_agent');
82
- expect(mockedExecSync).toHaveBeenCalled();
82
+ expect(mockedExecFileSync).toHaveBeenCalled();
83
83
  });
84
84
  });
85
85
 
86
86
  describe('batchGetProcessCwds', () => {
87
87
  beforeEach(() => {
88
- mockedExecSync.mockReset();
88
+ mockedExecFileSync.mockReset();
89
89
  });
90
90
 
91
91
  it('should parse batched lsof output', () => {
92
- mockedExecSync.mockReturnValue(
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
- mockedExecSync.mockReturnValue(
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
- mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
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
- mockedExecSync.mockReset();
126
+ mockedExecFileSync.mockReset();
127
127
  });
128
128
 
129
129
  it('should parse ps lstart output', () => {
130
- mockedExecSync.mockReturnValue(
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
- mockedExecSync.mockReturnValue(
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
- mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
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
- mockedExecSync.mockReset();
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
- mockedExecSync
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(mockedExecSync).not.toHaveBeenCalled();
185
+ expect(mockedExecFileSync).not.toHaveBeenCalled();
186
186
  });
187
187
 
188
188
  it('should handle partial failures', () => {
189
189
  // lsof succeeds, ps lstart fails
190
- mockedExecSync
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
- * Entry in session JSONL file
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
- startedAt: number; // epoch milliseconds
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
- * Claude Code session information
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 (>60 s from proc.startTime), or missing JSONL.
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 > 60000) {
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
- 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;
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
  }