@ai-devkit/agent-manager 0.5.0 → 0.6.1

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.
@@ -168,7 +168,7 @@ describe('ClaudeCodeAdapter', () => {
168
168
  // Create session file
169
169
  const sessionFile = path.join(projDir, 'session-1.jsonl');
170
170
  fs.writeFileSync(sessionFile, [
171
- JSON.stringify({ type: 'user', timestamp: '2026-03-18T23:18:44Z', cwd: '/Users/test/my-project', slug: 'merry-dog', message: { content: 'Investigate failing tests' } }),
171
+ JSON.stringify({ type: 'user', timestamp: '2026-03-18T23:18:44Z', cwd: '/Users/test/my-project', message: { content: 'Investigate failing tests' } }),
172
172
  JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
173
173
  ].join('\n'));
174
174
 
@@ -203,7 +203,6 @@ describe('ClaudeCodeAdapter', () => {
203
203
  pid: 12345,
204
204
  projectPath: '/Users/test/my-project',
205
205
  sessionId: 'session-1',
206
- slug: 'merry-dog',
207
206
  });
208
207
  expect(agents[0].summary).toContain('Investigate failing tests');
209
208
 
@@ -977,12 +976,12 @@ describe('ClaudeCodeAdapter', () => {
977
976
  });
978
977
 
979
978
  describe('readSession', () => {
980
- it('should parse session file with timestamps, slug, cwd, and entry type', () => {
979
+ it('should parse session file with timestamps, cwd, and entry type', () => {
981
980
  const readSession = (adapter as any).readSession.bind(adapter);
982
981
 
983
982
  const filePath = path.join(tmpDir, 'test-session.jsonl');
984
983
  const lines = [
985
- JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project', slug: 'happy-dog' }),
984
+ JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project' }),
986
985
  JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
987
986
  ];
988
987
  fs.writeFileSync(filePath, lines.join('\n'));
@@ -991,7 +990,6 @@ describe('ClaudeCodeAdapter', () => {
991
990
  expect(session).toMatchObject({
992
991
  sessionId: 'test-session',
993
992
  projectPath: '/my/project',
994
- slug: 'happy-dog',
995
993
  lastCwd: '/my/project',
996
994
  lastEntryType: 'assistant',
997
995
  isInterrupted: false,
@@ -1029,7 +1027,6 @@ describe('ClaudeCodeAdapter', () => {
1029
1027
  const session = readSession(filePath, '/test');
1030
1028
  expect(session).not.toBeNull();
1031
1029
  expect(session.lastEntryType).toBeUndefined();
1032
- expect(session.slug).toBeUndefined();
1033
1030
  });
1034
1031
 
1035
1032
  it('should return null for non-existent file', () => {
@@ -1117,4 +1114,178 @@ describe('ClaudeCodeAdapter', () => {
1117
1114
  });
1118
1115
  });
1119
1116
  });
1117
+
1118
+ describe('getConversation', () => {
1119
+ let tmpDir: string;
1120
+
1121
+ beforeEach(() => {
1122
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-conv-'));
1123
+ });
1124
+
1125
+ afterEach(() => {
1126
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1127
+ });
1128
+
1129
+ function writeJsonl(lines: object[]): string {
1130
+ const filePath = path.join(tmpDir, 'session.jsonl');
1131
+ fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n'));
1132
+ return filePath;
1133
+ }
1134
+
1135
+ it('should parse user and assistant text messages', () => {
1136
+ const filePath = writeJsonl([
1137
+ { type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } },
1138
+ { type: 'assistant', timestamp: '2026-03-27T10:00:05Z', message: { content: [{ type: 'text', text: 'Hi there!' }] } },
1139
+ ]);
1140
+
1141
+ const messages = adapter.getConversation(filePath);
1142
+ expect(messages).toHaveLength(2);
1143
+ expect(messages[0]).toEqual({ role: 'user', content: 'Hello', timestamp: '2026-03-27T10:00:00Z' });
1144
+ expect(messages[1]).toEqual({ role: 'assistant', content: 'Hi there!', timestamp: '2026-03-27T10:00:05Z' });
1145
+ });
1146
+
1147
+ it('should skip metadata entry types', () => {
1148
+ const filePath = writeJsonl([
1149
+ { type: 'file-history-snapshot', timestamp: '2026-03-27T10:00:00Z', snapshot: {} },
1150
+ { type: 'last-prompt', timestamp: '2026-03-27T10:00:00Z' },
1151
+ { type: 'user', timestamp: '2026-03-27T10:00:01Z', message: { content: 'Fix bug' } },
1152
+ ]);
1153
+
1154
+ const messages = adapter.getConversation(filePath);
1155
+ expect(messages).toHaveLength(1);
1156
+ expect(messages[0].content).toBe('Fix bug');
1157
+ });
1158
+
1159
+ it('should skip progress and thinking entries', () => {
1160
+ const filePath = writeJsonl([
1161
+ { type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } },
1162
+ { type: 'progress', timestamp: '2026-03-27T10:00:01Z', data: {} },
1163
+ { type: 'thinking', timestamp: '2026-03-27T10:00:02Z' },
1164
+ { type: 'assistant', timestamp: '2026-03-27T10:00:03Z', message: { content: [{ type: 'text', text: 'Done' }] } },
1165
+ ]);
1166
+
1167
+ const messages = adapter.getConversation(filePath);
1168
+ expect(messages).toHaveLength(2);
1169
+ expect(messages[0].role).toBe('user');
1170
+ expect(messages[1].role).toBe('assistant');
1171
+ });
1172
+
1173
+ it('should include system messages', () => {
1174
+ const filePath = writeJsonl([
1175
+ { type: 'system', timestamp: '2026-03-27T10:00:00Z', message: { content: 'System initialized' } },
1176
+ ]);
1177
+
1178
+ const messages = adapter.getConversation(filePath);
1179
+ expect(messages).toHaveLength(1);
1180
+ expect(messages[0]).toEqual({ role: 'system', content: 'System initialized', timestamp: '2026-03-27T10:00:00Z' });
1181
+ });
1182
+
1183
+ it('should skip tool_use and tool_result blocks in default mode', () => {
1184
+ const filePath = writeJsonl([
1185
+ {
1186
+ type: 'assistant', timestamp: '2026-03-27T10:00:00Z',
1187
+ message: {
1188
+ content: [
1189
+ { type: 'text', text: 'Let me read the file.' },
1190
+ { type: 'tool_use', name: 'Read', input: { file_path: '/src/app.ts' } },
1191
+ ],
1192
+ },
1193
+ },
1194
+ {
1195
+ type: 'user', timestamp: '2026-03-27T10:00:01Z',
1196
+ message: {
1197
+ content: [
1198
+ { type: 'tool_result', tool_use_id: 'toolu_1', content: 'file contents here' },
1199
+ ],
1200
+ },
1201
+ },
1202
+ ]);
1203
+
1204
+ const messages = adapter.getConversation(filePath);
1205
+ expect(messages).toHaveLength(1);
1206
+ expect(messages[0].content).toBe('Let me read the file.');
1207
+ });
1208
+
1209
+ it('should include tool_use and tool_result blocks in verbose mode', () => {
1210
+ const filePath = writeJsonl([
1211
+ {
1212
+ type: 'assistant', timestamp: '2026-03-27T10:00:00Z',
1213
+ message: {
1214
+ content: [
1215
+ { type: 'text', text: 'Let me read the file.' },
1216
+ { type: 'tool_use', name: 'Read', input: { file_path: '/src/app.ts' } },
1217
+ ],
1218
+ },
1219
+ },
1220
+ {
1221
+ type: 'user', timestamp: '2026-03-27T10:00:01Z',
1222
+ message: {
1223
+ content: [
1224
+ { type: 'tool_result', tool_use_id: 'toolu_1', content: 'file contents here' },
1225
+ ],
1226
+ },
1227
+ },
1228
+ ]);
1229
+
1230
+ const messages = adapter.getConversation(filePath, { verbose: true });
1231
+ expect(messages).toHaveLength(2);
1232
+ expect(messages[0].content).toContain('[Tool: Read]');
1233
+ expect(messages[0].content).toContain('/src/app.ts');
1234
+ expect(messages[1].content).toContain('[Tool Result]');
1235
+ });
1236
+
1237
+ it('should handle tool_result errors in verbose mode', () => {
1238
+ const filePath = writeJsonl([
1239
+ {
1240
+ type: 'user', timestamp: '2026-03-27T10:00:00Z',
1241
+ message: {
1242
+ content: [
1243
+ { type: 'tool_result', tool_use_id: 'toolu_1', content: 'Something went wrong', is_error: true },
1244
+ ],
1245
+ },
1246
+ },
1247
+ ]);
1248
+
1249
+ const messages = adapter.getConversation(filePath, { verbose: true });
1250
+ expect(messages).toHaveLength(1);
1251
+ expect(messages[0].content).toContain('[Tool Error]');
1252
+ });
1253
+
1254
+ it('should handle malformed JSON lines gracefully', () => {
1255
+ const filePath = path.join(tmpDir, 'malformed.jsonl');
1256
+ fs.writeFileSync(filePath, [
1257
+ JSON.stringify({ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } }),
1258
+ 'this is not valid json',
1259
+ JSON.stringify({ type: 'assistant', timestamp: '2026-03-27T10:00:01Z', message: { content: [{ type: 'text', text: 'World' }] } }),
1260
+ ].join('\n'));
1261
+
1262
+ const messages = adapter.getConversation(filePath);
1263
+ expect(messages).toHaveLength(2);
1264
+ });
1265
+
1266
+ it('should return empty array for missing file', () => {
1267
+ const messages = adapter.getConversation('/nonexistent/path.jsonl');
1268
+ expect(messages).toEqual([]);
1269
+ });
1270
+
1271
+ it('should return empty array for empty file', () => {
1272
+ const filePath = path.join(tmpDir, 'empty.jsonl');
1273
+ fs.writeFileSync(filePath, '');
1274
+
1275
+ const messages = adapter.getConversation(filePath);
1276
+ expect(messages).toEqual([]);
1277
+ });
1278
+
1279
+ it('should filter noise messages from user entries', () => {
1280
+ const filePath = writeJsonl([
1281
+ { type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
1282
+ { type: 'user', timestamp: '2026-03-27T10:00:01Z', message: { content: 'Tool loaded.' } },
1283
+ { type: 'user', timestamp: '2026-03-27T10:00:02Z', message: { content: 'Real question' } },
1284
+ ]);
1285
+
1286
+ const messages = adapter.getConversation(filePath);
1287
+ expect(messages).toHaveLength(1);
1288
+ expect(messages[0].content).toBe('Real question');
1289
+ });
1290
+ });
1120
1291
  });
@@ -515,4 +515,119 @@ describe('CodexAdapter', () => {
515
515
  });
516
516
  });
517
517
  });
518
+
519
+ describe('getConversation', () => {
520
+ let tmpDir: string;
521
+
522
+ beforeEach(() => {
523
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-conv-'));
524
+ });
525
+
526
+ afterEach(() => {
527
+ fs.rmSync(tmpDir, { recursive: true, force: true });
528
+ });
529
+
530
+ function writeJsonl(lines: object[]): string {
531
+ const filePath = path.join(tmpDir, 'session.jsonl');
532
+ fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n'));
533
+ return filePath;
534
+ }
535
+
536
+ it('should parse user and agent messages', () => {
537
+ const filePath = writeJsonl([
538
+ { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
539
+ { type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Fix the bug' } },
540
+ { type: 'event', timestamp: '2026-03-27T10:00:05Z', payload: { type: 'agent_message', message: 'I found the issue' } },
541
+ ]);
542
+
543
+ const messages = adapter.getConversation(filePath);
544
+ expect(messages).toHaveLength(2);
545
+ expect(messages[0]).toEqual({ role: 'user', content: 'Fix the bug', timestamp: '2026-03-27T10:00:01Z' });
546
+ expect(messages[1]).toEqual({ role: 'assistant', content: 'I found the issue', timestamp: '2026-03-27T10:00:05Z' });
547
+ });
548
+
549
+ it('should skip session_meta entry', () => {
550
+ const filePath = writeJsonl([
551
+ { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
552
+ { type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } },
553
+ ]);
554
+
555
+ const messages = adapter.getConversation(filePath);
556
+ expect(messages).toHaveLength(1);
557
+ expect(messages[0].role).toBe('user');
558
+ });
559
+
560
+ it('should map task_complete to assistant role', () => {
561
+ const filePath = writeJsonl([
562
+ { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
563
+ { type: 'event', timestamp: '2026-03-27T10:00:05Z', payload: { type: 'task_complete', message: 'Task finished successfully' } },
564
+ ]);
565
+
566
+ const messages = adapter.getConversation(filePath);
567
+ expect(messages).toHaveLength(1);
568
+ expect(messages[0].role).toBe('assistant');
569
+ expect(messages[0].content).toBe('Task finished successfully');
570
+ });
571
+
572
+ it('should skip non-conversation types in default mode', () => {
573
+ const filePath = writeJsonl([
574
+ { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
575
+ { type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } },
576
+ { type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'exec_command', message: 'Running npm test' } },
577
+ { type: 'event', timestamp: '2026-03-27T10:00:03Z', payload: { type: 'agent_message', message: 'Done' } },
578
+ ]);
579
+
580
+ const messages = adapter.getConversation(filePath);
581
+ expect(messages).toHaveLength(2);
582
+ });
583
+
584
+ it('should include non-conversation types as system in verbose mode', () => {
585
+ const filePath = writeJsonl([
586
+ { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
587
+ { type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'exec_command', message: 'Running npm test' } },
588
+ ]);
589
+
590
+ const messages = adapter.getConversation(filePath, { verbose: true });
591
+ expect(messages).toHaveLength(1);
592
+ expect(messages[0].role).toBe('system');
593
+ expect(messages[0].content).toBe('Running npm test');
594
+ });
595
+
596
+ it('should handle malformed JSON lines gracefully', () => {
597
+ const filePath = path.join(tmpDir, 'malformed.jsonl');
598
+ fs.writeFileSync(filePath, [
599
+ JSON.stringify({ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }),
600
+ 'invalid json line',
601
+ JSON.stringify({ type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } }),
602
+ ].join('\n'));
603
+
604
+ const messages = adapter.getConversation(filePath);
605
+ expect(messages).toHaveLength(1);
606
+ });
607
+
608
+ it('should return empty array for missing file', () => {
609
+ const messages = adapter.getConversation('/nonexistent/path.jsonl');
610
+ expect(messages).toEqual([]);
611
+ });
612
+
613
+ it('should return empty array for empty file', () => {
614
+ const filePath = path.join(tmpDir, 'empty.jsonl');
615
+ fs.writeFileSync(filePath, '');
616
+
617
+ const messages = adapter.getConversation(filePath);
618
+ expect(messages).toEqual([]);
619
+ });
620
+
621
+ it('should skip entries with empty payload message', () => {
622
+ const filePath = writeJsonl([
623
+ { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
624
+ { type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: '' } },
625
+ { type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'agent_message', message: 'Response' } },
626
+ ]);
627
+
628
+ const messages = adapter.getConversation(filePath);
629
+ expect(messages).toHaveLength(1);
630
+ expect(messages[0].content).toBe('Response');
631
+ });
632
+ });
518
633
  });
@@ -40,16 +40,22 @@ describe('TtyWriter', () => {
40
40
  tty: '/dev/ttys030',
41
41
  };
42
42
 
43
- it('sends message via tmux send-keys', async () => {
43
+ it('sends message and Enter as separate tmux send-keys calls', async () => {
44
44
  mockExecFileSuccess();
45
45
 
46
46
  await TtyWriter.send(location, 'continue');
47
47
 
48
48
  expect(mockedExecFile).toHaveBeenCalledWith(
49
49
  'tmux',
50
- ['send-keys', '-t', 'main:0.1', 'continue', 'Enter'],
50
+ ['send-keys', '-t', 'main:0.1', '-l', 'continue'],
51
51
  expect.any(Function),
52
52
  );
53
+ expect(mockedExecFile).toHaveBeenCalledWith(
54
+ 'tmux',
55
+ ['send-keys', '-t', 'main:0.1', 'Enter'],
56
+ expect.any(Function),
57
+ );
58
+ expect(mockedExecFile).toHaveBeenCalledTimes(2);
53
59
  });
54
60
 
55
61
  it('throws on tmux failure', async () => {
@@ -74,9 +80,12 @@ describe('TtyWriter', () => {
74
80
 
75
81
  expect(mockedExecFile).toHaveBeenCalledWith(
76
82
  'osascript',
77
- ['-e', expect.stringContaining('write text "hello"')],
83
+ ['-e', expect.stringContaining('write text "hello" newline no')],
78
84
  expect.any(Function),
79
85
  );
86
+ const scriptArg = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
87
+ const script = scriptArg[1];
88
+ expect(script).toContain('key code 36');
80
89
  });
81
90
 
82
91
  it('escapes special characters in message', async () => {
@@ -86,7 +95,7 @@ describe('TtyWriter', () => {
86
95
 
87
96
  expect(mockedExecFile).toHaveBeenCalledWith(
88
97
  'osascript',
89
- ['-e', expect.stringContaining('write text "say \\"hi\\" \\\\ there"')],
98
+ ['-e', expect.stringContaining('write text "say \\"hi\\" \\\\ there" newline no')],
90
99
  expect.any(Function),
91
100
  );
92
101
  });
@@ -113,24 +122,11 @@ describe('TtyWriter', () => {
113
122
 
114
123
  const scriptArg = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
115
124
  const script = scriptArg[1];
116
- // Must use keystroke, NOT do script
117
125
  expect(script).toContain('keystroke "hello"');
118
126
  expect(script).toContain('key code 36');
119
127
  expect(script).not.toContain('do script');
120
128
  });
121
129
 
122
- it('uses execFile to avoid shell injection', async () => {
123
- mockExecFileSuccess('ok');
124
-
125
- await TtyWriter.send(location, "don't stop");
126
-
127
- expect(mockedExecFile).toHaveBeenCalledWith(
128
- 'osascript',
129
- ['-e', expect.any(String)],
130
- expect.any(Function),
131
- );
132
- });
133
-
134
130
  it('throws when tab not found', async () => {
135
131
  mockExecFileSuccess('not_found');
136
132
 
@@ -173,19 +173,27 @@ describe('matchProcessesToSessions', () => {
173
173
  });
174
174
 
175
175
  describe('generateAgentName', () => {
176
- it('should return folderName (pid)', () => {
177
- expect(generateAgentName('/projects/my-app', 12345)).toBe('my-app (12345)');
176
+ it('should return lowercase kebab-case name with pid', () => {
177
+ expect(generateAgentName('/projects/my-app', 12345)).toBe('my-app-12345');
178
178
  });
179
179
 
180
180
  it('should handle root path', () => {
181
- expect(generateAgentName('/', 100)).toBe('unknown (100)');
181
+ expect(generateAgentName('/', 100)).toBe('unknown-100');
182
182
  });
183
183
 
184
184
  it('should handle empty cwd', () => {
185
- expect(generateAgentName('', 100)).toBe('unknown (100)');
185
+ expect(generateAgentName('', 100)).toBe('unknown-100');
186
186
  });
187
187
 
188
188
  it('should handle nested paths', () => {
189
- expect(generateAgentName('/home/user/projects/ai-devkit', 78070)).toBe('ai-devkit (78070)');
189
+ expect(generateAgentName('/home/user/projects/ai-devkit', 78070)).toBe('ai-devkit-78070');
190
+ });
191
+
192
+ it('should convert spaces and special chars to kebab-case', () => {
193
+ expect(generateAgentName('/projects/AI DevKit', 123)).toBe('ai-devkit-123');
194
+ });
195
+
196
+ it('should convert uppercase to lowercase', () => {
197
+ expect(generateAgentName('/projects/MyProject', 456)).toBe('myproject-456');
190
198
  });
191
199
  });
@@ -45,12 +45,11 @@ export interface AgentInfo {
45
45
  /** Session UUID */
46
46
  sessionId: string;
47
47
 
48
- /** Human-readable session name (e.g., "merry-wobbling-starlight"), may be undefined for new sessions */
49
- slug?: string;
50
-
51
48
  /** Timestamp of last activity */
52
49
  lastActive: Date;
53
50
 
51
+ /** Path to the session JSONL file on disk */
52
+ sessionFilePath?: string;
54
53
  }
55
54
 
56
55
  /**
@@ -73,9 +72,18 @@ export interface ProcessInfo {
73
72
  startTime?: Date;
74
73
  }
75
74
 
75
+ /**
76
+ * A single message in a conversation
77
+ */
78
+ export interface ConversationMessage {
79
+ role: 'user' | 'assistant' | 'system';
80
+ content: string;
81
+ timestamp?: string;
82
+ }
83
+
76
84
  /**
77
85
  * Agent Adapter Interface
78
- *
86
+ *
79
87
  * Implementations must provide detection logic for a specific agent type.
80
88
  */
81
89
  export interface AgentAdapter {
@@ -94,4 +102,12 @@ export interface AgentAdapter {
94
102
  * @returns True if this adapter can handle the process
95
103
  */
96
104
  canHandle(processInfo: ProcessInfo): boolean;
105
+
106
+ /**
107
+ * Read the full conversation from a session file
108
+ * @param sessionFilePath Path to the session JSONL file
109
+ * @param options.verbose Include tool call/result details
110
+ * @returns Array of conversation messages
111
+ */
112
+ getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[];
97
113
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
3
+ import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
4
4
  import { AgentStatus } from './AgentAdapter';
5
5
  import { listAgentProcesses, enrichProcesses } from '../utils/process';
6
6
  import { batchGetSessionFileBirthtimes } from '../utils/session';
@@ -9,17 +9,22 @@ import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
9
9
  /**
10
10
  * Entry in session JSONL file
11
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
+
12
22
  interface SessionEntry {
13
23
  type?: string;
14
24
  timestamp?: string;
15
- slug?: string;
16
25
  cwd?: string;
17
26
  message?: {
18
- content?: string | Array<{
19
- type?: string;
20
- text?: string;
21
- content?: string;
22
- }>;
27
+ content?: string | ContentBlock[];
23
28
  };
24
29
  }
25
30
 
@@ -50,7 +55,6 @@ interface ClaudeSession {
50
55
  sessionId: string;
51
56
  projectPath: string;
52
57
  lastCwd?: string;
53
- slug?: string;
54
58
  sessionStart: Date;
55
59
  lastActive: Date;
56
60
  lastEntryType?: string;
@@ -275,8 +279,8 @@ export class ClaudeCodeAdapter implements AgentAdapter {
275
279
  pid: processInfo.pid,
276
280
  projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
277
281
  sessionId: sessionFile.sessionId,
278
- slug: session.slug,
279
282
  lastActive: session.lastActive,
283
+ sessionFilePath: sessionFile.filePath,
280
284
  };
281
285
  }
282
286
 
@@ -333,7 +337,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
333
337
  }
334
338
 
335
339
  // Parse all lines for session state (file already in memory)
336
- let slug: string | undefined;
337
340
  let lastEntryType: string | undefined;
338
341
  let lastActive: Date | undefined;
339
342
  let lastCwd: string | undefined;
@@ -351,10 +354,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
351
354
  }
352
355
  }
353
356
 
354
- if (entry.slug && !slug) {
355
- slug = entry.slug;
356
- }
357
-
358
357
  if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
359
358
  lastCwd = entry.cwd;
360
359
  }
@@ -392,7 +391,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
392
391
  sessionId,
393
392
  projectPath: projectPath || lastCwd || '',
394
393
  lastCwd,
395
- slug,
396
394
  sessionStart: sessionStart || lastActive || new Date(),
397
395
  lastActive: lastActive || new Date(),
398
396
  lastEntryType,
@@ -517,4 +515,102 @@ export class ClaudeCodeAdapter implements AgentAdapter {
517
515
  return type === 'last-prompt' || type === 'file-history-snapshot';
518
516
  }
519
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
+ 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
+ }
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
+
520
616
  }