@ai-devkit/agent-manager 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/adapters/AgentAdapter.d.ts +21 -2
  2. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  3. package/dist/adapters/ClaudeCodeAdapter.d.ts +44 -35
  4. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  5. package/dist/adapters/ClaudeCodeAdapter.js +230 -298
  6. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  7. package/dist/adapters/CodexAdapter.d.ts +41 -31
  8. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  9. package/dist/adapters/CodexAdapter.js +198 -278
  10. package/dist/adapters/CodexAdapter.js.map +1 -1
  11. package/dist/index.d.ts +2 -4
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -10
  14. package/dist/index.js.map +1 -1
  15. package/dist/utils/index.d.ts +6 -3
  16. package/dist/utils/index.d.ts.map +1 -1
  17. package/dist/utils/index.js +12 -11
  18. package/dist/utils/index.js.map +1 -1
  19. package/dist/utils/matching.d.ts +39 -0
  20. package/dist/utils/matching.d.ts.map +1 -0
  21. package/dist/utils/matching.js +107 -0
  22. package/dist/utils/matching.js.map +1 -0
  23. package/dist/utils/process.d.ts +25 -40
  24. package/dist/utils/process.d.ts.map +1 -1
  25. package/dist/utils/process.js +151 -105
  26. package/dist/utils/process.js.map +1 -1
  27. package/dist/utils/session.d.ts +30 -0
  28. package/dist/utils/session.d.ts.map +1 -0
  29. package/dist/utils/session.js +101 -0
  30. package/dist/utils/session.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/__tests__/AgentManager.test.ts +5 -27
  33. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +754 -830
  34. package/src/__tests__/adapters/CodexAdapter.test.ts +581 -273
  35. package/src/__tests__/utils/matching.test.ts +199 -0
  36. package/src/__tests__/utils/process.test.ts +202 -0
  37. package/src/__tests__/utils/session.test.ts +117 -0
  38. package/src/adapters/AgentAdapter.ts +23 -4
  39. package/src/adapters/ClaudeCodeAdapter.ts +285 -437
  40. package/src/adapters/CodexAdapter.ts +202 -400
  41. package/src/index.ts +2 -4
  42. package/src/utils/index.ts +6 -3
  43. package/src/utils/matching.ts +96 -0
  44. package/src/utils/process.ts +133 -119
  45. package/src/utils/session.ts +92 -0
  46. package/dist/utils/file.d.ts +0 -52
  47. package/dist/utils/file.d.ts.map +0 -1
  48. package/dist/utils/file.js +0 -135
  49. package/dist/utils/file.js.map +0 -1
  50. package/src/utils/file.ts +0 -100
@@ -6,28 +6,49 @@ import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
8
8
  import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter';
9
- import type { AgentInfo, ProcessInfo } from '../../adapters/AgentAdapter';
9
+ import type { ProcessInfo } from '../../adapters/AgentAdapter';
10
10
  import { AgentStatus } from '../../adapters/AgentAdapter';
11
- import { listProcesses } from '../../utils/process';
12
-
11
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process';
12
+ import { batchGetSessionFileBirthtimes } from '../../utils/session';
13
+ import type { SessionFile } from '../../utils/session';
14
+ import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
15
+ import type { MatchResult } from '../../utils/matching';
13
16
  jest.mock('../../utils/process', () => ({
14
- listProcesses: jest.fn(),
17
+ listAgentProcesses: jest.fn(),
18
+ enrichProcesses: jest.fn(),
15
19
  }));
16
20
 
17
- const mockedListProcesses = listProcesses as jest.MockedFunction<typeof listProcesses>;
18
-
19
- type PrivateMethod<T extends (...args: never[]) => unknown> = T;
21
+ jest.mock('../../utils/session', () => ({
22
+ batchGetSessionFileBirthtimes: jest.fn(),
23
+ }));
20
24
 
21
- interface AdapterPrivates {
22
- readSessions: PrivateMethod<(limit: number) => unknown[]>;
23
- }
25
+ jest.mock('../../utils/matching', () => ({
26
+ matchProcessesToSessions: jest.fn(),
27
+ generateAgentName: jest.fn(),
28
+ }));
24
29
 
30
+ const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction<typeof listAgentProcesses>;
31
+ const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction<typeof enrichProcesses>;
32
+ const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as jest.MockedFunction<typeof batchGetSessionFileBirthtimes>;
33
+ const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction<typeof matchProcessesToSessions>;
34
+ const mockedGenerateAgentName = generateAgentName as jest.MockedFunction<typeof generateAgentName>;
25
35
  describe('ClaudeCodeAdapter', () => {
26
36
  let adapter: ClaudeCodeAdapter;
27
37
 
28
38
  beforeEach(() => {
29
39
  adapter = new ClaudeCodeAdapter();
30
- mockedListProcesses.mockReset();
40
+ mockedListAgentProcesses.mockReset();
41
+ mockedEnrichProcesses.mockReset();
42
+ mockedBatchGetSessionFileBirthtimes.mockReset();
43
+ mockedMatchProcessesToSessions.mockReset();
44
+ mockedGenerateAgentName.mockReset();
45
+ // Default: enrichProcesses returns what it receives
46
+ mockedEnrichProcesses.mockImplementation((procs) => procs);
47
+ // Default: generateAgentName returns "folder (pid)"
48
+ mockedGenerateAgentName.mockImplementation((cwd, pid) => {
49
+ const folder = path.basename(cwd) || 'unknown';
50
+ return `${folder} (${pid})`;
51
+ });
31
52
  });
32
53
 
33
54
  describe('initialization', () => {
@@ -95,303 +116,491 @@ describe('ClaudeCodeAdapter', () => {
95
116
 
96
117
  describe('detectAgents', () => {
97
118
  it('should return empty array if no claude processes running', async () => {
98
- mockedListProcesses.mockReturnValue([]);
119
+ mockedListAgentProcesses.mockReturnValue([]);
99
120
 
100
121
  const agents = await adapter.detectAgents();
101
122
  expect(agents).toEqual([]);
123
+ expect(mockedListAgentProcesses).toHaveBeenCalledWith('claude');
102
124
  });
103
125
 
104
- it('should detect agents using mocked process/session data', async () => {
105
- const processData: ProcessInfo[] = [
126
+ it('should return process-only agents when no sessions discovered', async () => {
127
+ const processes: ProcessInfo[] = [
128
+ { pid: 777, command: 'claude', cwd: '/project/app', tty: 'ttys001' },
129
+ ];
130
+ mockedListAgentProcesses.mockReturnValue(processes);
131
+ mockedEnrichProcesses.mockReturnValue(processes);
132
+
133
+ // No projects dir → discoverSessions returns []
134
+ (adapter as any).projectsDir = '/nonexistent/path';
135
+
136
+ const agents = await adapter.detectAgents();
137
+ expect(agents).toHaveLength(1);
138
+ expect(agents[0]).toMatchObject({
139
+ type: 'claude',
140
+ status: AgentStatus.IDLE,
141
+ pid: 777,
142
+ projectPath: '/project/app',
143
+ sessionId: 'pid-777',
144
+ summary: 'Unknown',
145
+ });
146
+ });
147
+
148
+ it('should detect agents with matched sessions', async () => {
149
+ const processes: ProcessInfo[] = [
106
150
  {
107
151
  pid: 12345,
108
- command: 'claude --continue',
152
+ command: 'claude',
109
153
  cwd: '/Users/test/my-project',
110
154
  tty: 'ttys001',
155
+ startTime: new Date('2026-03-18T23:18:01.000Z'),
111
156
  },
112
157
  ];
113
-
114
- const sessionData = [
158
+ mockedListAgentProcesses.mockReturnValue(processes);
159
+ mockedEnrichProcesses.mockReturnValue(processes);
160
+
161
+ // Set up projects dir with encoded directory name
162
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
163
+ const projectsDir = path.join(tmpDir, 'projects');
164
+ // Claude encodes /Users/test/my-project → -Users-test-my-project
165
+ const projDir = path.join(projectsDir, '-Users-test-my-project');
166
+ fs.mkdirSync(projDir, { recursive: true });
167
+
168
+ // Create session file
169
+ const sessionFile = path.join(projDir, 'session-1.jsonl');
170
+ fs.writeFileSync(sessionFile, [
171
+ JSON.stringify({ type: 'user', timestamp: '2026-03-18T23:18:44Z', cwd: '/Users/test/my-project', message: { content: 'Investigate failing tests' } }),
172
+ JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
173
+ ].join('\n'));
174
+
175
+ (adapter as any).projectsDir = projectsDir;
176
+
177
+ const sessionFiles: SessionFile[] = [
115
178
  {
116
179
  sessionId: 'session-1',
117
- projectPath: '/Users/test/my-project',
118
- slug: 'merry-dog',
119
- sessionStart: new Date(),
120
- lastActive: new Date(),
121
- lastEntryType: 'assistant',
122
- isInterrupted: false,
123
- lastUserMessage: 'Investigate failing tests in package',
180
+ filePath: sessionFile,
181
+ projectDir: projDir,
182
+ birthtimeMs: new Date('2026-03-18T23:18:44Z').getTime(),
183
+ resolvedCwd: '',
124
184
  },
125
185
  ];
186
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
126
187
 
127
- mockedListProcesses.mockReturnValue(processData);
128
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue(sessionData);
188
+ const matches: MatchResult[] = [
189
+ {
190
+ process: processes[0],
191
+ session: { ...sessionFiles[0], resolvedCwd: '/Users/test/my-project' },
192
+ deltaMs: 43000,
193
+ },
194
+ ];
195
+ mockedMatchProcessesToSessions.mockReturnValue(matches);
129
196
 
130
197
  const agents = await adapter.detectAgents();
131
198
 
132
199
  expect(agents).toHaveLength(1);
133
200
  expect(agents[0]).toMatchObject({
134
- name: 'my-project',
135
201
  type: 'claude',
136
202
  status: AgentStatus.WAITING,
137
203
  pid: 12345,
138
204
  projectPath: '/Users/test/my-project',
139
205
  sessionId: 'session-1',
140
- slug: 'merry-dog',
141
206
  });
142
- expect(agents[0].summary).toContain('Investigate failing tests in package');
207
+ expect(agents[0].summary).toContain('Investigate failing tests');
208
+
209
+ fs.rmSync(tmpDir, { recursive: true, force: true });
143
210
  });
144
211
 
145
- it('should include process-only entry when no sessions exist', async () => {
146
- mockedListProcesses.mockReturnValue([
212
+ it('should fall back to process-only for unmatched processes', async () => {
213
+ const processes: ProcessInfo[] = [
214
+ { pid: 100, command: 'claude', cwd: '/project-a', tty: 'ttys001', startTime: new Date() },
215
+ { pid: 200, command: 'claude', cwd: '/project-b', tty: 'ttys002', startTime: new Date() },
216
+ ];
217
+ mockedListAgentProcesses.mockReturnValue(processes);
218
+ mockedEnrichProcesses.mockReturnValue(processes);
219
+
220
+ // Set up projects dir with encoded directory names
221
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
222
+ const projectsDir = path.join(tmpDir, 'projects');
223
+ // /project-a → -project-a, /project-b → -project-b
224
+ const projDirA = path.join(projectsDir, '-project-a');
225
+ const projDirB = path.join(projectsDir, '-project-b');
226
+ fs.mkdirSync(projDirA, { recursive: true });
227
+ fs.mkdirSync(projDirB, { recursive: true });
228
+
229
+ const sessionFile = path.join(projDirA, 'only-session.jsonl');
230
+ fs.writeFileSync(sessionFile,
231
+ JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
232
+ );
233
+
234
+ (adapter as any).projectsDir = projectsDir;
235
+
236
+ const sessionFiles: SessionFile[] = [
147
237
  {
148
- pid: 777,
149
- command: 'claude',
150
- cwd: '/project/without-session',
151
- tty: 'ttys008',
238
+ sessionId: 'only-session',
239
+ filePath: sessionFile,
240
+ projectDir: projDirA,
241
+ birthtimeMs: Date.now(),
242
+ resolvedCwd: '',
152
243
  },
153
- ]);
154
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
244
+ ];
245
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
155
246
 
247
+ // Only process 100 matches
248
+ const matches: MatchResult[] = [
249
+ {
250
+ process: processes[0],
251
+ session: { ...sessionFiles[0], resolvedCwd: '/project-a' },
252
+ deltaMs: 5000,
253
+ },
254
+ ];
255
+ mockedMatchProcessesToSessions.mockReturnValue(matches);
156
256
 
157
257
  const agents = await adapter.detectAgents();
158
- expect(agents).toHaveLength(1);
159
- expect(agents[0]).toMatchObject({
160
- type: 'claude',
161
- status: AgentStatus.IDLE,
162
- pid: 777,
163
- projectPath: '/project/without-session',
164
- sessionId: 'pid-777',
165
- summary: 'Unknown',
166
- });
258
+ expect(agents).toHaveLength(2);
259
+
260
+ const matched = agents.find(a => a.pid === 100);
261
+ const unmatched = agents.find(a => a.pid === 200);
262
+ expect(matched?.sessionId).toBe('only-session');
263
+ expect(unmatched?.sessionId).toBe('pid-200');
264
+ expect(unmatched?.status).toBe(AgentStatus.IDLE);
265
+
266
+ fs.rmSync(tmpDir, { recursive: true, force: true });
167
267
  });
168
268
 
169
- it('should not match process to unrelated session from different project', async () => {
170
- mockedListProcesses.mockReturnValue([
171
- {
172
- pid: 777,
173
- command: 'claude',
174
- cwd: '/project/without-session',
175
- tty: 'ttys008',
176
- },
177
- ]);
178
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
179
- {
180
- sessionId: 'session-2',
181
- projectPath: '/other/project',
182
- sessionStart: new Date(),
183
- lastActive: new Date(),
184
- lastEntryType: 'assistant',
185
- isInterrupted: false,
186
- },
187
- ]);
269
+ it('should handle process with empty cwd in process-only fallback', async () => {
270
+ const processes: ProcessInfo[] = [
271
+ { pid: 300, command: 'claude', cwd: '', tty: 'ttys003' },
272
+ ];
273
+ mockedListAgentProcesses.mockReturnValue(processes);
274
+ mockedEnrichProcesses.mockReturnValue(processes);
188
275
 
276
+ (adapter as any).projectsDir = '/nonexistent';
189
277
 
190
278
  const agents = await adapter.detectAgents();
191
279
  expect(agents).toHaveLength(1);
192
- // Unrelated session should NOT match — falls to process-only
193
280
  expect(agents[0]).toMatchObject({
194
- type: 'claude',
195
- pid: 777,
196
- sessionId: 'pid-777',
197
- projectPath: '/project/without-session',
198
- status: AgentStatus.IDLE,
281
+ pid: 300,
282
+ sessionId: 'pid-300',
283
+ summary: 'Unknown',
284
+ projectPath: '',
199
285
  });
200
286
  });
201
287
 
202
- it('should match process in subdirectory to project-root session via parent-child mode', async () => {
203
- mockedListProcesses.mockReturnValue([
204
- {
205
- pid: 888,
206
- command: 'claude',
207
- cwd: '/Users/test/my-project/packages/cli',
208
- tty: 'ttys009',
209
- },
210
- ]);
211
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
212
- {
213
- sessionId: 'session-3',
214
- projectPath: '/Users/test/my-project',
215
- slug: 'gentle-otter',
216
- sessionStart: new Date(),
217
- lastActive: new Date(),
218
- lastEntryType: 'assistant',
219
- isInterrupted: false,
220
- },
221
- ]);
288
+ it('should use PID file for direct match and skip legacy matching for that process', async () => {
289
+ const startTime = new Date();
290
+ const processes: ProcessInfo[] = [
291
+ { pid: 55001, command: 'claude', cwd: '/project/direct', tty: 'ttys001', startTime },
292
+ ];
293
+ mockedListAgentProcesses.mockReturnValue(processes);
294
+ mockedEnrichProcesses.mockReturnValue(processes);
295
+
296
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-test-'));
297
+ const sessionsDir = path.join(tmpDir, 'sessions');
298
+ const projectsDir = path.join(tmpDir, 'projects');
299
+ const projDir = path.join(projectsDir, '-project-direct');
300
+ fs.mkdirSync(sessionsDir, { recursive: true });
301
+ fs.mkdirSync(projDir, { recursive: true });
302
+
303
+ const sessionId = 'pid-file-session';
304
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
305
+ fs.writeFileSync(jsonlPath, [
306
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/direct', message: { content: 'hello from pid file' } }),
307
+ JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
308
+ ].join('\n'));
309
+
310
+ fs.writeFileSync(
311
+ path.join(sessionsDir, '55001.json'),
312
+ JSON.stringify({ pid: 55001, sessionId, cwd: '/project/direct', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
313
+ );
314
+
315
+ (adapter as any).sessionsDir = sessionsDir;
316
+ (adapter as any).projectsDir = projectsDir;
222
317
 
223
318
  const agents = await adapter.detectAgents();
319
+
320
+ // Legacy matching utilities should NOT have been called (all processes matched via PID file)
321
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
322
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
323
+
224
324
  expect(agents).toHaveLength(1);
225
325
  expect(agents[0]).toMatchObject({
226
326
  type: 'claude',
227
- pid: 888,
228
- sessionId: 'session-3',
229
- projectPath: '/Users/test/my-project',
327
+ pid: 55001,
328
+ sessionId,
329
+ projectPath: '/project/direct',
330
+ status: AgentStatus.WAITING,
230
331
  });
332
+ expect(agents[0].summary).toContain('hello from pid file');
333
+
334
+ fs.rmSync(tmpDir, { recursive: true, force: true });
231
335
  });
232
336
 
233
- it('should show idle status with Unknown summary for process-only fallback when no sessions exist', async () => {
234
- mockedListProcesses.mockReturnValue([
235
- {
236
- pid: 97529,
237
- command: 'claude',
238
- cwd: '/Users/test/my-project/packages/cli',
239
- tty: 'ttys021',
240
- },
241
- ]);
242
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
337
+ it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => {
338
+ const startTime = new Date();
339
+ const processes: ProcessInfo[] = [
340
+ { pid: 66001, command: 'claude', cwd: '/project/gone', tty: 'ttys001', startTime },
341
+ ];
342
+ mockedListAgentProcesses.mockReturnValue(processes);
343
+ mockedEnrichProcesses.mockReturnValue(processes);
344
+
345
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-gone-'));
346
+ const sessionsDir = path.join(tmpDir, 'sessions');
347
+ const projectsDir = path.join(tmpDir, 'projects');
348
+ const projDir = path.join(projectsDir, '-project-gone');
349
+ fs.mkdirSync(sessionsDir, { recursive: true });
350
+ fs.mkdirSync(projDir, { recursive: true });
351
+
352
+ const sessionId = 'gone-session';
353
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
354
+ fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
355
+ fs.writeFileSync(
356
+ path.join(sessionsDir, '66001.json'),
357
+ JSON.stringify({ pid: 66001, sessionId, cwd: '/project/gone', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
358
+ );
359
+
360
+ (adapter as any).sessionsDir = sessionsDir;
361
+ (adapter as any).projectsDir = projectsDir;
362
+
363
+ // Simulate JSONL disappearing between existence check and read
364
+ jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
243
365
 
244
366
  const agents = await adapter.detectAgents();
367
+
368
+ // matchedPids.delete called → process falls back to IDLE
245
369
  expect(agents).toHaveLength(1);
246
- expect(agents[0]).toMatchObject({
247
- type: 'claude',
248
- pid: 97529,
249
- projectPath: '/Users/test/my-project/packages/cli',
250
- sessionId: 'pid-97529',
251
- summary: 'Unknown',
252
- status: AgentStatus.IDLE,
253
- });
370
+ expect(agents[0].sessionId).toBe('pid-66001');
371
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
372
+
373
+ fs.rmSync(tmpDir, { recursive: true, force: true });
374
+ jest.restoreAllMocks();
254
375
  });
255
376
 
256
- it('should match session via parent-child mode when process cwd is under session project path', async () => {
257
- mockedListProcesses.mockReturnValue([
258
- {
259
- pid: 97529,
260
- command: 'claude',
261
- cwd: '/Users/test/my-project/packages/cli',
262
- tty: 'ttys021',
263
- },
264
- ]);
265
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
266
- {
267
- sessionId: 'parent-session',
268
- projectPath: '/Users/test/my-project',
269
- slug: 'fluffy-brewing-kazoo',
270
- sessionStart: new Date('2026-02-23T17:24:50.996Z'),
271
- lastActive: new Date('2026-02-23T17:24:50.996Z'),
272
- lastEntryType: 'assistant',
273
- isInterrupted: false,
274
- },
377
+ it('should fall back to process-only when legacy-matched JSONL becomes unreadable', async () => {
378
+ const startTime = new Date();
379
+ const processes: ProcessInfo[] = [
380
+ { pid: 66002, command: 'claude', cwd: '/project/legacy-gone', tty: 'ttys001', startTime },
381
+ ];
382
+ mockedListAgentProcesses.mockReturnValue(processes);
383
+ mockedEnrichProcesses.mockReturnValue(processes);
384
+
385
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-lgone-'));
386
+ const projectsDir = path.join(tmpDir, 'projects');
387
+ const projDir = path.join(projectsDir, '-project-legacy-gone');
388
+ fs.mkdirSync(projDir, { recursive: true });
389
+
390
+ const sessionId = 'legacy-gone-session';
391
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
392
+ fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
393
+
394
+ // No PID file → process goes to legacy fallback
395
+ (adapter as any).sessionsDir = path.join(tmpDir, 'no-sessions');
396
+ (adapter as any).projectsDir = projectsDir;
397
+
398
+ const legacySessionFile = {
399
+ sessionId,
400
+ filePath: jsonlPath,
401
+ projectDir: projDir,
402
+ birthtimeMs: startTime.getTime(),
403
+ resolvedCwd: '/project/legacy-gone',
404
+ };
405
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
406
+ mockedMatchProcessesToSessions.mockReturnValue([
407
+ { process: processes[0], session: legacySessionFile, deltaMs: 500 },
275
408
  ]);
276
409
 
410
+ // Simulate JSONL disappearing between match and read
411
+ jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
412
+
277
413
  const agents = await adapter.detectAgents();
414
+
278
415
  expect(agents).toHaveLength(1);
279
- // Session matched via parent-child mode
280
- expect(agents[0]).toMatchObject({
281
- type: 'claude',
282
- pid: 97529,
283
- sessionId: 'parent-session',
284
- projectPath: '/Users/test/my-project',
285
- });
416
+ expect(agents[0].sessionId).toBe('pid-66002');
417
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
418
+
419
+ fs.rmSync(tmpDir, { recursive: true, force: true });
420
+ jest.restoreAllMocks();
286
421
  });
287
422
 
288
- it('should fall back to process-only when sessions exist but all are used', async () => {
289
- mockedListProcesses.mockReturnValue([
290
- {
291
- pid: 100,
292
- command: 'claude',
293
- cwd: '/project-a',
294
- tty: 'ttys001',
295
- },
296
- {
297
- pid: 200,
298
- command: 'claude',
299
- cwd: '/project-b',
300
- tty: 'ttys002',
301
- },
302
- ]);
303
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
304
- {
305
- sessionId: 'only-session',
306
- projectPath: '/project-a',
307
- sessionStart: new Date(),
308
- lastActive: new Date(),
309
- lastEntryType: 'assistant',
310
- isInterrupted: false,
311
- },
423
+ it('should mix direct PID-file matches and legacy matches across processes', async () => {
424
+ const startTime = new Date();
425
+ const processes: ProcessInfo[] = [
426
+ { pid: 55002, command: 'claude', cwd: '/project/alpha', tty: 'ttys001', startTime },
427
+ { pid: 55003, command: 'claude', cwd: '/project/beta', tty: 'ttys002', startTime },
428
+ ];
429
+ mockedListAgentProcesses.mockReturnValue(processes);
430
+ mockedEnrichProcesses.mockReturnValue(processes);
431
+
432
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-mix-test-'));
433
+ const sessionsDir = path.join(tmpDir, 'sessions');
434
+ const projectsDir = path.join(tmpDir, 'projects');
435
+ const projAlpha = path.join(projectsDir, '-project-alpha');
436
+ const projBeta = path.join(projectsDir, '-project-beta');
437
+ fs.mkdirSync(sessionsDir, { recursive: true });
438
+ fs.mkdirSync(projAlpha, { recursive: true });
439
+ fs.mkdirSync(projBeta, { recursive: true });
440
+
441
+ // PID file only for process 55002
442
+ const directSessionId = 'direct-session';
443
+ const directJsonl = path.join(projAlpha, `${directSessionId}.jsonl`);
444
+ fs.writeFileSync(directJsonl, [
445
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/alpha', message: { content: 'direct question' } }),
446
+ JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
447
+ ].join('\n'));
448
+ fs.writeFileSync(
449
+ path.join(sessionsDir, '55002.json'),
450
+ JSON.stringify({ pid: 55002, sessionId: directSessionId, cwd: '/project/alpha', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
451
+ );
452
+
453
+ // Legacy session file for process 55003
454
+ const legacySessionId = 'legacy-session';
455
+ const legacyJsonl = path.join(projBeta, `${legacySessionId}.jsonl`);
456
+ fs.writeFileSync(legacyJsonl, [
457
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/beta', message: { content: 'legacy question' } }),
458
+ JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
459
+ ].join('\n'));
460
+
461
+ (adapter as any).sessionsDir = sessionsDir;
462
+ (adapter as any).projectsDir = projectsDir;
463
+
464
+ // Mock legacy matching for process 55003
465
+ const legacySessionFile = {
466
+ sessionId: legacySessionId,
467
+ filePath: legacyJsonl,
468
+ projectDir: projBeta,
469
+ birthtimeMs: startTime.getTime(),
470
+ resolvedCwd: '/project/beta',
471
+ };
472
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
473
+ mockedMatchProcessesToSessions.mockReturnValue([
474
+ { process: processes[1], session: legacySessionFile, deltaMs: 1000 },
312
475
  ]);
313
476
 
314
-
315
477
  const agents = await adapter.detectAgents();
478
+
479
+ // Legacy matching called only for fallback process (55003)
480
+ expect(mockedMatchProcessesToSessions).toHaveBeenCalledTimes(1);
481
+ expect(mockedMatchProcessesToSessions.mock.calls[0][0]).toEqual([processes[1]]);
482
+
316
483
  expect(agents).toHaveLength(2);
317
- // First process matched via cwd
318
- expect(agents[0]).toMatchObject({
319
- pid: 100,
320
- sessionId: 'only-session',
321
- });
322
- // Second process: session used, falls to process-only
323
- expect(agents[1]).toMatchObject({
324
- pid: 200,
325
- sessionId: 'pid-200',
326
- status: AgentStatus.IDLE,
327
- summary: 'Unknown',
328
- });
484
+ const alpha = agents.find(a => a.pid === 55002);
485
+ const beta = agents.find(a => a.pid === 55003);
486
+ expect(alpha?.sessionId).toBe(directSessionId);
487
+ expect(beta?.sessionId).toBe(legacySessionId);
488
+
489
+ fs.rmSync(tmpDir, { recursive: true, force: true });
329
490
  });
491
+ });
330
492
 
331
- it('should handle process with empty cwd in process-only fallback', async () => {
332
- mockedListProcesses.mockReturnValue([
333
- {
334
- pid: 300,
335
- command: 'claude',
336
- cwd: '',
337
- tty: 'ttys003',
338
- },
339
- ]);
340
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
493
+ describe('discoverSessions', () => {
494
+ let tmpDir: string;
341
495
 
342
- const agents = await adapter.detectAgents();
343
- expect(agents).toHaveLength(1);
344
- expect(agents[0]).toMatchObject({
345
- pid: 300,
346
- sessionId: 'pid-300',
347
- summary: 'Unknown',
348
- projectPath: '',
349
- });
496
+ beforeEach(() => {
497
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
350
498
  });
351
499
 
352
- it('should prefer cwd-matched session over any-mode session', async () => {
353
- const now = new Date();
354
- mockedListProcesses.mockReturnValue([
355
- {
356
- pid: 100,
357
- command: 'claude',
358
- cwd: '/Users/test/project-a',
359
- tty: 'ttys001',
360
- },
500
+ afterEach(() => {
501
+ fs.rmSync(tmpDir, { recursive: true, force: true });
502
+ });
503
+
504
+ it('should return empty when projects dir does not exist', () => {
505
+ (adapter as any).projectsDir = path.join(tmpDir, 'nonexistent');
506
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
507
+
508
+ const result = discoverSessions([
509
+ { pid: 1, command: 'claude', cwd: '/test', tty: '' },
361
510
  ]);
362
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
363
- {
364
- sessionId: 'exact-match',
365
- projectPath: '/Users/test/project-a',
366
- sessionStart: now,
367
- lastActive: now,
368
- lastEntryType: 'assistant',
369
- isInterrupted: false,
370
- },
511
+ expect(result).toEqual([]);
512
+ });
513
+
514
+ it('should scan only directories matching process CWDs', () => {
515
+ const projectsDir = path.join(tmpDir, 'projects');
516
+ (adapter as any).projectsDir = projectsDir;
517
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
518
+
519
+ // /my/project → -my-project (encoded dir)
520
+ const encodedDir = path.join(projectsDir, '-my-project');
521
+ fs.mkdirSync(encodedDir, { recursive: true });
522
+
523
+ // Also create another dir that should NOT be scanned
524
+ const otherDir = path.join(projectsDir, '-other-project');
525
+ fs.mkdirSync(otherDir, { recursive: true });
526
+
527
+ const mockFiles: SessionFile[] = [
371
528
  {
372
- sessionId: 'other-project',
373
- projectPath: '/Users/test/project-b',
374
- sessionStart: now,
375
- lastActive: new Date(now.getTime() + 1000), // more recent
376
- lastEntryType: 'user',
377
- isInterrupted: false,
529
+ sessionId: 's1',
530
+ filePath: path.join(encodedDir, 's1.jsonl'),
531
+ projectDir: encodedDir,
532
+ birthtimeMs: 1710800324000,
533
+ resolvedCwd: '',
378
534
  },
535
+ ];
536
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles);
537
+
538
+ const processes = [
539
+ { pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
540
+ ];
541
+
542
+ const result = discoverSessions(processes);
543
+ expect(result).toHaveLength(1);
544
+ expect(result[0].resolvedCwd).toBe('/my/project');
545
+ // batchGetSessionFileBirthtimes called once with all dirs
546
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
547
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
548
+ });
549
+
550
+ it('should return empty when encoded dir does not exist', () => {
551
+ const projectsDir = path.join(tmpDir, 'projects');
552
+ fs.mkdirSync(projectsDir, { recursive: true });
553
+ (adapter as any).projectsDir = projectsDir;
554
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
555
+
556
+ // Process CWD /test encodes to -test, but that dir doesn't exist
557
+ const result = discoverSessions([
558
+ { pid: 1, command: 'claude', cwd: '/test', tty: '' },
379
559
  ]);
560
+ expect(result).toEqual([]);
561
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
562
+ });
380
563
 
564
+ it('should deduplicate when multiple processes share same CWD', () => {
565
+ const projectsDir = path.join(tmpDir, 'projects');
566
+ (adapter as any).projectsDir = projectsDir;
567
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
381
568
 
382
- const agents = await adapter.detectAgents();
383
- expect(agents).toHaveLength(1);
384
- expect(agents[0]).toMatchObject({
385
- sessionId: 'exact-match',
386
- projectPath: '/Users/test/project-a',
387
- });
569
+ const encodedDir = path.join(projectsDir, '-my-project');
570
+ fs.mkdirSync(encodedDir, { recursive: true });
571
+
572
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
573
+ { sessionId: 's1', filePath: path.join(encodedDir, 's1.jsonl'), projectDir: encodedDir, birthtimeMs: 1710800324000, resolvedCwd: '' },
574
+ ]);
575
+
576
+ const processes = [
577
+ { pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
578
+ { pid: 2, command: 'claude', cwd: '/my/project', tty: '' },
579
+ ];
580
+
581
+ const result = discoverSessions(processes);
582
+ // Should only call batch once with deduplicated dir
583
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
584
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
585
+ expect(result).toHaveLength(1);
586
+ });
587
+
588
+ it('should skip processes with empty cwd', () => {
589
+ const projectsDir = path.join(tmpDir, 'projects');
590
+ fs.mkdirSync(projectsDir, { recursive: true });
591
+ (adapter as any).projectsDir = projectsDir;
592
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
593
+
594
+ const result = discoverSessions([
595
+ { pid: 1, command: 'claude', cwd: '', tty: '' },
596
+ ]);
597
+ expect(result).toEqual([]);
388
598
  });
389
599
  });
390
600
 
391
601
  describe('helper methods', () => {
392
602
  describe('determineStatus', () => {
393
603
  it('should return "unknown" for sessions with no last entry type', () => {
394
- const adapter = new ClaudeCodeAdapter();
395
604
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
396
605
 
397
606
  const session = {
@@ -402,12 +611,10 @@ describe('ClaudeCodeAdapter', () => {
402
611
  isInterrupted: false,
403
612
  };
404
613
 
405
- const status = determineStatus(session);
406
- expect(status).toBe(AgentStatus.UNKNOWN);
614
+ expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
407
615
  });
408
616
 
409
617
  it('should return "waiting" for assistant entries', () => {
410
- const adapter = new ClaudeCodeAdapter();
411
618
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
412
619
 
413
620
  const session = {
@@ -419,12 +626,10 @@ describe('ClaudeCodeAdapter', () => {
419
626
  isInterrupted: false,
420
627
  };
421
628
 
422
- const status = determineStatus(session);
423
- expect(status).toBe(AgentStatus.WAITING);
629
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
424
630
  });
425
631
 
426
632
  it('should return "waiting" for user interruption', () => {
427
- const adapter = new ClaudeCodeAdapter();
428
633
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
429
634
 
430
635
  const session = {
@@ -436,12 +641,10 @@ describe('ClaudeCodeAdapter', () => {
436
641
  isInterrupted: true,
437
642
  };
438
643
 
439
- const status = determineStatus(session);
440
- expect(status).toBe(AgentStatus.WAITING);
644
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
441
645
  });
442
646
 
443
647
  it('should return "running" for user/progress entries', () => {
444
- const adapter = new ClaudeCodeAdapter();
445
648
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
446
649
 
447
650
  const session = {
@@ -453,16 +656,13 @@ describe('ClaudeCodeAdapter', () => {
453
656
  isInterrupted: false,
454
657
  };
455
658
 
456
- const status = determineStatus(session);
457
- expect(status).toBe(AgentStatus.RUNNING);
659
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
458
660
  });
459
661
 
460
662
  it('should not override status based on age (process is running)', () => {
461
- const adapter = new ClaudeCodeAdapter();
462
663
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
463
664
 
464
665
  const oldDate = new Date(Date.now() - 10 * 60 * 1000);
465
-
466
666
  const session = {
467
667
  sessionId: 'test',
468
668
  projectPath: '/test',
@@ -472,14 +672,10 @@ describe('ClaudeCodeAdapter', () => {
472
672
  isInterrupted: false,
473
673
  };
474
674
 
475
- // Even with old lastActive, entry type determines status
476
- // because the process is known to be running
477
- const status = determineStatus(session);
478
- expect(status).toBe(AgentStatus.WAITING);
675
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
479
676
  });
480
677
 
481
678
  it('should return "idle" for system entries', () => {
482
- const adapter = new ClaudeCodeAdapter();
483
679
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
484
680
 
485
681
  const session = {
@@ -491,12 +687,10 @@ describe('ClaudeCodeAdapter', () => {
491
687
  isInterrupted: false,
492
688
  };
493
689
 
494
- const status = determineStatus(session);
495
- expect(status).toBe(AgentStatus.IDLE);
690
+ expect(determineStatus(session)).toBe(AgentStatus.IDLE);
496
691
  });
497
692
 
498
693
  it('should return "running" for thinking entries', () => {
499
- const adapter = new ClaudeCodeAdapter();
500
694
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
501
695
 
502
696
  const session = {
@@ -508,12 +702,10 @@ describe('ClaudeCodeAdapter', () => {
508
702
  isInterrupted: false,
509
703
  };
510
704
 
511
- const status = determineStatus(session);
512
- expect(status).toBe(AgentStatus.RUNNING);
705
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
513
706
  });
514
707
 
515
708
  it('should return "running" for progress entries', () => {
516
- const adapter = new ClaudeCodeAdapter();
517
709
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
518
710
 
519
711
  const session = {
@@ -525,12 +717,10 @@ describe('ClaudeCodeAdapter', () => {
525
717
  isInterrupted: false,
526
718
  };
527
719
 
528
- const status = determineStatus(session);
529
- expect(status).toBe(AgentStatus.RUNNING);
720
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
530
721
  });
531
722
 
532
723
  it('should return "unknown" for unrecognized entry types', () => {
533
- const adapter = new ClaudeCodeAdapter();
534
724
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
535
725
 
536
726
  const session = {
@@ -542,380 +732,17 @@ describe('ClaudeCodeAdapter', () => {
542
732
  isInterrupted: false,
543
733
  };
544
734
 
545
- const status = determineStatus(session);
546
- expect(status).toBe(AgentStatus.UNKNOWN);
547
- });
548
- });
549
-
550
- describe('generateAgentName', () => {
551
- it('should use project name for first session', () => {
552
- const adapter = new ClaudeCodeAdapter();
553
- const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
554
-
555
- const session = {
556
- sessionId: 'test-123',
557
- projectPath: '/Users/test/my-project',
558
- sessionStart: new Date(),
559
- lastActive: new Date(),
560
- isInterrupted: false,
561
- };
562
-
563
- const name = generateAgentName(session, []);
564
- expect(name).toBe('my-project');
565
- });
566
-
567
- it('should append slug for duplicate projects', () => {
568
- const adapter = new ClaudeCodeAdapter();
569
- const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
570
-
571
- const existingAgent: AgentInfo = {
572
- name: 'my-project',
573
- projectPath: '/Users/test/my-project',
574
- type: 'claude',
575
- status: AgentStatus.RUNNING,
576
- summary: 'Test',
577
- pid: 123,
578
- sessionId: 'existing-123',
579
- slug: 'happy-cat',
580
- lastActive: new Date(),
581
- };
582
-
583
- const session = {
584
- sessionId: 'test-456',
585
- projectPath: '/Users/test/my-project',
586
- slug: 'merry-dog',
587
- sessionStart: new Date(),
588
- lastActive: new Date(),
589
- isInterrupted: false,
590
- };
591
-
592
- const name = generateAgentName(session, [existingAgent]);
593
- expect(name).toBe('my-project (merry)');
594
- });
595
-
596
- it('should use session ID prefix when no slug available', () => {
597
- const adapter = new ClaudeCodeAdapter();
598
- const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
599
-
600
- const existingAgent: AgentInfo = {
601
- name: 'my-project',
602
- projectPath: '/Users/test/my-project',
603
- type: 'claude',
604
- status: AgentStatus.RUNNING,
605
- summary: 'Test',
606
- pid: 123,
607
- sessionId: 'existing-123',
608
- lastActive: new Date(),
609
- };
610
-
611
- const session = {
612
- sessionId: 'abcdef12-3456-7890',
613
- projectPath: '/Users/test/my-project',
614
- sessionStart: new Date(),
615
- lastActive: new Date(),
616
- isInterrupted: false,
617
- };
618
-
619
- const name = generateAgentName(session, [existingAgent]);
620
- expect(name).toBe('my-project (abcdef12)');
621
- });
622
- });
623
-
624
- describe('parseElapsedSeconds', () => {
625
- it('should parse MM:SS format', () => {
626
- const adapter = new ClaudeCodeAdapter();
627
- const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
628
-
629
- expect(parseElapsedSeconds('05:30')).toBe(330);
630
- });
631
-
632
- it('should parse HH:MM:SS format', () => {
633
- const adapter = new ClaudeCodeAdapter();
634
- const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
635
-
636
- expect(parseElapsedSeconds('02:30:15')).toBe(9015);
637
- });
638
-
639
- it('should parse D-HH:MM:SS format', () => {
640
- const adapter = new ClaudeCodeAdapter();
641
- const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
642
-
643
- expect(parseElapsedSeconds('3-12:00:00')).toBe(302400);
644
- });
645
-
646
- it('should return null for invalid format', () => {
647
- const adapter = new ClaudeCodeAdapter();
648
- const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
649
-
650
- expect(parseElapsedSeconds('invalid')).toBeNull();
651
- });
652
- });
653
-
654
- describe('calculateSessionScanLimit', () => {
655
- it('should return minimum for small process count', () => {
656
- const adapter = new ClaudeCodeAdapter();
657
- const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
658
-
659
- // 1 process * 4 = 4, min(max(4, 12), 40) = 12
660
- expect(calculateSessionScanLimit(1)).toBe(12);
661
- });
662
-
663
- it('should scale with process count', () => {
664
- const adapter = new ClaudeCodeAdapter();
665
- const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
666
-
667
- // 5 processes * 4 = 20, min(max(20, 12), 40) = 20
668
- expect(calculateSessionScanLimit(5)).toBe(20);
669
- });
670
-
671
- it('should cap at maximum', () => {
672
- const adapter = new ClaudeCodeAdapter();
673
- const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
674
-
675
- // 15 processes * 4 = 60, min(max(60, 12), 40) = 40
676
- expect(calculateSessionScanLimit(15)).toBe(40);
677
- });
678
- });
679
-
680
- describe('rankCandidatesByStartTime', () => {
681
- it('should prefer sessions within tolerance window', () => {
682
- const adapter = new ClaudeCodeAdapter();
683
- const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
684
-
685
- const processStart = new Date('2026-03-10T10:00:00Z');
686
- const candidates = [
687
- {
688
- sessionId: 'far',
689
- projectPath: '/test',
690
- sessionStart: new Date('2026-03-10T09:50:00Z'), // 10 min diff
691
- lastActive: new Date('2026-03-10T10:05:00Z'),
692
- isInterrupted: false,
693
- },
694
- {
695
- sessionId: 'close',
696
- projectPath: '/test',
697
- sessionStart: new Date('2026-03-10T10:00:30Z'), // 30s diff
698
- lastActive: new Date('2026-03-10T10:03:00Z'),
699
- isInterrupted: false,
700
- },
701
- ];
702
-
703
- const ranked = rankCandidatesByStartTime(candidates, processStart);
704
- expect(ranked[0].sessionId).toBe('close');
705
- expect(ranked[1].sessionId).toBe('far');
706
- });
707
-
708
- it('should prefer recency over diffMs when both within tolerance', () => {
709
- const adapter = new ClaudeCodeAdapter();
710
- const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
711
-
712
- const processStart = new Date('2026-03-10T10:00:00Z');
713
- const candidates = [
714
- {
715
- sessionId: 'closer-but-stale',
716
- projectPath: '/test',
717
- sessionStart: new Date('2026-03-10T10:00:06Z'), // 6s diff
718
- lastActive: new Date('2026-03-10T10:00:10Z'), // older activity
719
- isInterrupted: false,
720
- },
721
- {
722
- sessionId: 'farther-but-active',
723
- projectPath: '/test',
724
- sessionStart: new Date('2026-03-10T10:00:45Z'), // 45s diff
725
- lastActive: new Date('2026-03-10T10:30:00Z'), // much more recent
726
- isInterrupted: false,
727
- },
728
- ];
729
-
730
- const ranked = rankCandidatesByStartTime(candidates, processStart);
731
- // Both within tolerance — recency wins over smaller diffMs
732
- expect(ranked[0].sessionId).toBe('farther-but-active');
733
- expect(ranked[1].sessionId).toBe('closer-but-stale');
734
- });
735
-
736
- it('should break ties by recency when outside tolerance with same diffMs', () => {
737
- const adapter = new ClaudeCodeAdapter();
738
- const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
739
-
740
- const processStart = new Date('2026-03-10T10:00:00Z');
741
- const candidates = [
742
- {
743
- sessionId: 'older-activity',
744
- projectPath: '/test',
745
- sessionStart: new Date('2026-03-10T09:50:00Z'), // 10min diff
746
- lastActive: new Date('2026-03-10T10:01:00Z'),
747
- isInterrupted: false,
748
- },
749
- {
750
- sessionId: 'newer-activity',
751
- projectPath: '/test',
752
- sessionStart: new Date('2026-03-10T10:10:00Z'), // 10min diff (same abs)
753
- lastActive: new Date('2026-03-10T10:30:00Z'),
754
- isInterrupted: false,
755
- },
756
- ];
757
-
758
- const ranked = rankCandidatesByStartTime(candidates, processStart);
759
- // Both outside tolerance, same diffMs — recency wins
760
- expect(ranked[0].sessionId).toBe('newer-activity');
761
- });
762
-
763
- it('should fall back to recency when both outside tolerance', () => {
764
- const adapter = new ClaudeCodeAdapter();
765
- const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
766
-
767
- const processStart = new Date('2026-03-10T10:00:00Z');
768
- const candidates = [
769
- {
770
- sessionId: 'older',
771
- projectPath: '/test',
772
- sessionStart: new Date('2026-03-10T09:30:00Z'),
773
- lastActive: new Date('2026-03-10T10:01:00Z'),
774
- isInterrupted: false,
775
- },
776
- {
777
- sessionId: 'newer',
778
- projectPath: '/test',
779
- sessionStart: new Date('2026-03-10T09:40:00Z'),
780
- lastActive: new Date('2026-03-10T10:05:00Z'),
781
- isInterrupted: false,
782
- },
783
- ];
784
-
785
- const ranked = rankCandidatesByStartTime(candidates, processStart);
786
- // Both outside tolerance (rank=1), newer has smaller diffMs
787
- expect(ranked[0].sessionId).toBe('newer');
788
- });
789
- });
790
-
791
- describe('filterCandidateSessions', () => {
792
- it('should match by lastCwd in cwd mode', () => {
793
- const adapter = new ClaudeCodeAdapter();
794
- const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
795
-
796
- const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
797
- const sessions = [
798
- {
799
- sessionId: 's1',
800
- projectPath: '/different/path',
801
- lastCwd: '/my/project',
802
- sessionStart: new Date(),
803
- lastActive: new Date(),
804
- isInterrupted: false,
805
- },
806
- ];
807
-
808
- const result = filterCandidateSessions(processInfo, sessions, new Set(), 'cwd');
809
- expect(result).toHaveLength(1);
810
- expect(result[0].sessionId).toBe('s1');
811
- });
812
-
813
- it('should match sessions with no projectPath in missing-cwd mode', () => {
814
- const adapter = new ClaudeCodeAdapter();
815
- const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
816
-
817
- const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
818
- const sessions = [
819
- {
820
- sessionId: 's1',
821
- projectPath: '',
822
- sessionStart: new Date(),
823
- lastActive: new Date(),
824
- isInterrupted: false,
825
- },
826
- {
827
- sessionId: 's2',
828
- projectPath: '/has/path',
829
- sessionStart: new Date(),
830
- lastActive: new Date(),
831
- isInterrupted: false,
832
- },
833
- ];
834
-
835
- const result = filterCandidateSessions(processInfo, sessions, new Set(), 'missing-cwd');
836
- expect(result).toHaveLength(1);
837
- expect(result[0].sessionId).toBe('s1');
838
- });
839
-
840
- it('should include exact CWD matches in parent-child mode', () => {
841
- const adapter = new ClaudeCodeAdapter();
842
- const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
843
-
844
- const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
845
- const sessions = [
846
- {
847
- sessionId: 's1',
848
- projectPath: '/my/project',
849
- lastCwd: '/my/project',
850
- sessionStart: new Date(),
851
- lastActive: new Date(),
852
- isInterrupted: false,
853
- },
854
- ];
855
-
856
- const result = filterCandidateSessions(processInfo, sessions, new Set(), 'parent-child');
857
- expect(result).toHaveLength(1);
858
- expect(result[0].sessionId).toBe('s1');
859
- });
860
-
861
- it('should match parent-child relationships', () => {
862
- const adapter = new ClaudeCodeAdapter();
863
- const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
864
-
865
- const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
866
- const sessions = [
867
- {
868
- sessionId: 'child-session',
869
- projectPath: '/my/project/packages/sub',
870
- lastCwd: '/my/project/packages/sub',
871
- sessionStart: new Date(),
872
- lastActive: new Date(),
873
- isInterrupted: false,
874
- },
875
- {
876
- sessionId: 'parent-session',
877
- projectPath: '/my',
878
- lastCwd: '/my',
879
- sessionStart: new Date(),
880
- lastActive: new Date(),
881
- isInterrupted: false,
882
- },
883
- ];
884
-
885
- const result = filterCandidateSessions(processInfo, sessions, new Set(), 'parent-child');
886
- expect(result).toHaveLength(2);
887
- });
888
-
889
- it('should skip used sessions', () => {
890
- const adapter = new ClaudeCodeAdapter();
891
- const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
892
-
893
- const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
894
- const sessions = [
895
- {
896
- sessionId: 's1',
897
- projectPath: '/my/project',
898
- sessionStart: new Date(),
899
- lastActive: new Date(),
900
- isInterrupted: false,
901
- },
902
- ];
903
-
904
- const result = filterCandidateSessions(processInfo, sessions, new Set(['s1']), 'cwd');
905
- expect(result).toHaveLength(0);
735
+ expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
906
736
  });
907
737
  });
908
738
 
909
739
  describe('extractUserMessageText', () => {
910
740
  it('should extract plain string content', () => {
911
- const adapter = new ClaudeCodeAdapter();
912
741
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
913
-
914
742
  expect(extract('hello world')).toBe('hello world');
915
743
  });
916
744
 
917
745
  it('should extract text from array content blocks', () => {
918
- const adapter = new ClaudeCodeAdapter();
919
746
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
920
747
 
921
748
  const content = [
@@ -926,7 +753,6 @@ describe('ClaudeCodeAdapter', () => {
926
753
  });
927
754
 
928
755
  it('should return undefined for empty/null content', () => {
929
- const adapter = new ClaudeCodeAdapter();
930
756
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
931
757
 
932
758
  expect(extract(undefined)).toBeUndefined();
@@ -935,7 +761,6 @@ describe('ClaudeCodeAdapter', () => {
935
761
  });
936
762
 
937
763
  it('should parse command-message tags', () => {
938
- const adapter = new ClaudeCodeAdapter();
939
764
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
940
765
 
941
766
  const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
@@ -943,7 +768,6 @@ describe('ClaudeCodeAdapter', () => {
943
768
  });
944
769
 
945
770
  it('should parse command-message without args', () => {
946
- const adapter = new ClaudeCodeAdapter();
947
771
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
948
772
 
949
773
  const msg = '<command-message><command-name>help</command-name></command-message>';
@@ -951,7 +775,6 @@ describe('ClaudeCodeAdapter', () => {
951
775
  });
952
776
 
953
777
  it('should extract ARGUMENTS from skill expansion', () => {
954
- const adapter = new ClaudeCodeAdapter();
955
778
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
956
779
 
957
780
  const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
@@ -959,7 +782,6 @@ describe('ClaudeCodeAdapter', () => {
959
782
  });
960
783
 
961
784
  it('should return undefined for skill expansion without ARGUMENTS', () => {
962
- const adapter = new ClaudeCodeAdapter();
963
785
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
964
786
 
965
787
  const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
@@ -967,7 +789,6 @@ describe('ClaudeCodeAdapter', () => {
967
789
  });
968
790
 
969
791
  it('should filter noise messages', () => {
970
- const adapter = new ClaudeCodeAdapter();
971
792
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
972
793
 
973
794
  expect(extract('[Request interrupted by user]')).toBeUndefined();
@@ -978,119 +799,189 @@ describe('ClaudeCodeAdapter', () => {
978
799
 
979
800
  describe('parseCommandMessage', () => {
980
801
  it('should return undefined for malformed command-message', () => {
981
- const adapter = new ClaudeCodeAdapter();
982
802
  const parse = (adapter as any).parseCommandMessage.bind(adapter);
983
-
984
803
  expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
985
804
  });
986
805
  });
987
806
  });
988
807
 
989
- describe('selectBestSession', () => {
990
- it('should defer in cwd mode when best candidate is outside tolerance', () => {
991
- const adapter = new ClaudeCodeAdapter();
992
- const selectBestSession = (adapter as any).selectBestSession.bind(adapter);
993
-
994
- const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
995
- const processStart = new Date('2026-03-10T10:00:00Z');
996
- const processStartByPid = new Map([[1, processStart]]);
997
-
998
- const sessions = [
999
- {
1000
- sessionId: 'stale-exact-cwd',
1001
- projectPath: '/my/project',
1002
- lastCwd: '/my/project',
1003
- sessionStart: new Date('2026-03-07T10:00:00Z'), // 3 days old — outside tolerance
1004
- lastActive: new Date('2026-03-10T10:05:00Z'),
1005
- isInterrupted: false,
1006
- },
1007
- ];
808
+ describe('file I/O methods', () => {
809
+ let tmpDir: string;
810
+
811
+ beforeEach(() => {
812
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
813
+ });
814
+
815
+ afterEach(() => {
816
+ fs.rmSync(tmpDir, { recursive: true, force: true });
817
+ });
1008
818
 
1009
- // In cwd mode, should defer (return undefined) because outside tolerance
1010
- const cwdResult = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd');
1011
- expect(cwdResult).toBeUndefined();
819
+ describe('tryPidFileMatching', () => {
820
+ let sessionsDir: string;
821
+ let projectsDir: string;
1012
822
 
1013
- // In parent-child mode, should accept the same candidate (no tolerance gate)
1014
- const parentChildResult = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'parent-child');
1015
- expect(parentChildResult).toBeDefined();
1016
- expect(parentChildResult.sessionId).toBe('stale-exact-cwd');
823
+ beforeEach(() => {
824
+ sessionsDir = path.join(tmpDir, 'sessions');
825
+ projectsDir = path.join(tmpDir, 'projects');
826
+ fs.mkdirSync(sessionsDir, { recursive: true });
827
+ (adapter as any).sessionsDir = sessionsDir;
828
+ (adapter as any).projectsDir = projectsDir;
1017
829
  });
1018
830
 
1019
- it('should fall back to recency when no processStart available', () => {
1020
- const adapter = new ClaudeCodeAdapter();
1021
- const selectBestSession = (adapter as any).selectBestSession.bind(adapter);
1022
-
1023
- const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
1024
- const processStartByPid = new Map<number, Date>(); // empty — no start time
1025
-
1026
- const sessions = [
1027
- {
1028
- sessionId: 'older',
1029
- projectPath: '/my/project',
1030
- lastCwd: '/my/project',
1031
- sessionStart: new Date('2026-03-10T09:00:00Z'),
1032
- lastActive: new Date('2026-03-10T09:30:00Z'),
1033
- isInterrupted: false,
1034
- },
1035
- {
1036
- sessionId: 'newer',
1037
- projectPath: '/my/project',
1038
- lastCwd: '/my/project',
1039
- sessionStart: new Date('2026-03-10T10:00:00Z'),
1040
- lastActive: new Date('2026-03-10T10:30:00Z'),
1041
- isInterrupted: false,
1042
- },
1043
- ];
831
+ const makeProc = (pid: number, cwd = '/project/test', startTime?: Date): ProcessInfo => ({
832
+ pid, command: 'claude', cwd, tty: 'ttys001', startTime,
833
+ });
1044
834
 
1045
- const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd');
1046
- expect(result).toBeDefined();
1047
- expect(result.sessionId).toBe('newer');
835
+ const writePidFile = (pid: number, sessionId: string, cwd: string, startedAt: number) => {
836
+ fs.writeFileSync(
837
+ path.join(sessionsDir, `${pid}.json`),
838
+ JSON.stringify({ pid, sessionId, cwd, startedAt, kind: 'interactive', entrypoint: 'cli' }),
839
+ );
840
+ };
841
+
842
+ const writeJsonl = (cwd: string, sessionId: string) => {
843
+ const encoded = cwd.replace(/\//g, '-');
844
+ const projDir = path.join(projectsDir, encoded);
845
+ fs.mkdirSync(projDir, { recursive: true });
846
+ const filePath = path.join(projDir, `${sessionId}.jsonl`);
847
+ fs.writeFileSync(filePath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
848
+ return filePath;
849
+ };
850
+
851
+ it('should return direct match when PID file and JSONL both exist within time tolerance', () => {
852
+ const startTime = new Date();
853
+ const proc = makeProc(1001, '/project/test', startTime);
854
+ writePidFile(1001, 'session-abc', '/project/test', startTime.getTime());
855
+ writeJsonl('/project/test', 'session-abc');
856
+
857
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
858
+ const { direct, fallback } = tryMatch([proc]);
859
+
860
+ expect(direct).toHaveLength(1);
861
+ expect(fallback).toHaveLength(0);
862
+ expect(direct[0].sessionFile.sessionId).toBe('session-abc');
863
+ expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test');
864
+ expect(direct[0].process.pid).toBe(1001);
1048
865
  });
1049
866
 
1050
- it('should accept in cwd mode when best candidate is within tolerance', () => {
1051
- const adapter = new ClaudeCodeAdapter();
1052
- const selectBestSession = (adapter as any).selectBestSession.bind(adapter);
1053
-
1054
- const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
1055
- const processStart = new Date('2026-03-10T10:00:00Z');
1056
- const processStartByPid = new Map([[1, processStart]]);
1057
-
1058
- const sessions = [
1059
- {
1060
- sessionId: 'fresh-exact-cwd',
1061
- projectPath: '/my/project',
1062
- lastCwd: '/my/project',
1063
- sessionStart: new Date('2026-03-10T10:00:30Z'), // 30s — within tolerance
1064
- lastActive: new Date('2026-03-10T10:05:00Z'),
1065
- isInterrupted: false,
1066
- },
1067
- ];
867
+ it('should fall back when PID file exists but JSONL is missing', () => {
868
+ const startTime = new Date();
869
+ const proc = makeProc(1002, '/project/test', startTime);
870
+ writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime());
871
+ // No JSONL file written
872
+
873
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
874
+ const { direct, fallback } = tryMatch([proc]);
1068
875
 
1069
- const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd');
1070
- expect(result).toBeDefined();
1071
- expect(result.sessionId).toBe('fresh-exact-cwd');
876
+ expect(direct).toHaveLength(0);
877
+ expect(fallback).toHaveLength(1);
878
+ expect(fallback[0].pid).toBe(1002);
1072
879
  });
1073
- });
1074
880
 
1075
- describe('file I/O methods', () => {
1076
- let tmpDir: string;
881
+ it('should fall back when startedAt is stale (>60s from proc.startTime)', () => {
882
+ const startTime = new Date();
883
+ const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier
884
+ const proc = makeProc(1003, '/project/test', startTime);
885
+ writePidFile(1003, 'stale-session', '/project/test', staleTime);
886
+ writeJsonl('/project/test', 'stale-session');
1077
887
 
1078
- beforeEach(() => {
1079
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
1080
- });
888
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
889
+ const { direct, fallback } = tryMatch([proc]);
1081
890
 
1082
- afterEach(() => {
1083
- fs.rmSync(tmpDir, { recursive: true, force: true });
891
+ expect(direct).toHaveLength(0);
892
+ expect(fallback).toHaveLength(1);
893
+ });
894
+
895
+ it('should accept PID file when startedAt is within 60s tolerance', () => {
896
+ const startTime = new Date();
897
+ const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance
898
+ const proc = makeProc(1004, '/project/test', startTime);
899
+ writePidFile(1004, 'close-session', '/project/test', closeTime);
900
+ writeJsonl('/project/test', 'close-session');
901
+
902
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
903
+ const { direct, fallback } = tryMatch([proc]);
904
+
905
+ expect(direct).toHaveLength(1);
906
+ expect(fallback).toHaveLength(0);
907
+ });
908
+
909
+ it('should fall back when PID file is absent', () => {
910
+ const proc = makeProc(1005, '/project/test', new Date());
911
+ // No PID file written
912
+
913
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
914
+ const { direct, fallback } = tryMatch([proc]);
915
+
916
+ expect(direct).toHaveLength(0);
917
+ expect(fallback).toHaveLength(1);
918
+ });
919
+
920
+ it('should fall back when PID file contains malformed JSON', () => {
921
+ const proc = makeProc(1006, '/project/test', new Date());
922
+ fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{');
923
+
924
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
925
+ expect(() => {
926
+ const { direct, fallback } = tryMatch([proc]);
927
+ expect(direct).toHaveLength(0);
928
+ expect(fallback).toHaveLength(1);
929
+ }).not.toThrow();
930
+ });
931
+
932
+ it('should fall back for all processes when sessions dir does not exist', () => {
933
+ (adapter as any).sessionsDir = path.join(tmpDir, 'nonexistent-sessions');
934
+ const processes = [makeProc(2001, '/a', new Date()), makeProc(2002, '/b', new Date())];
935
+
936
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
937
+ const { direct, fallback } = tryMatch(processes);
938
+
939
+ expect(direct).toHaveLength(0);
940
+ expect(fallback).toHaveLength(2);
941
+ });
942
+
943
+ it('should correctly split mixed processes (some with PID files, some without)', () => {
944
+ const startTime = new Date();
945
+ const proc1 = makeProc(3001, '/project/one', startTime);
946
+ const proc2 = makeProc(3002, '/project/two', startTime);
947
+ const proc3 = makeProc(3003, '/project/three', startTime);
948
+
949
+ writePidFile(3001, 'session-one', '/project/one', startTime.getTime());
950
+ writeJsonl('/project/one', 'session-one');
951
+ writePidFile(3003, 'session-three', '/project/three', startTime.getTime());
952
+ writeJsonl('/project/three', 'session-three');
953
+ // proc2 has no PID file
954
+
955
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
956
+ const { direct, fallback } = tryMatch([proc1, proc2, proc3]);
957
+
958
+ expect(direct).toHaveLength(2);
959
+ expect(fallback).toHaveLength(1);
960
+ expect(direct.map((d: any) => d.process.pid).sort()).toEqual([3001, 3003]);
961
+ expect(fallback[0].pid).toBe(3002);
962
+ });
963
+
964
+ it('should skip stale-file check when proc.startTime is undefined', () => {
965
+ const proc = makeProc(4001, '/project/test', undefined); // no startTime
966
+ writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999);
967
+ writeJsonl('/project/test', 'no-time-session');
968
+
969
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
970
+ const { direct, fallback } = tryMatch([proc]);
971
+
972
+ // startTime undefined → stale check skipped → direct match
973
+ expect(direct).toHaveLength(1);
974
+ expect(fallback).toHaveLength(0);
975
+ });
1084
976
  });
1085
977
 
1086
978
  describe('readSession', () => {
1087
- it('should parse session file with timestamps, slug, cwd, and entry type', () => {
1088
- const adapter = new ClaudeCodeAdapter();
979
+ it('should parse session file with timestamps, cwd, and entry type', () => {
1089
980
  const readSession = (adapter as any).readSession.bind(adapter);
1090
981
 
1091
982
  const filePath = path.join(tmpDir, 'test-session.jsonl');
1092
983
  const lines = [
1093
- 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' }),
1094
985
  JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
1095
986
  ];
1096
987
  fs.writeFileSync(filePath, lines.join('\n'));
@@ -1099,7 +990,6 @@ describe('ClaudeCodeAdapter', () => {
1099
990
  expect(session).toMatchObject({
1100
991
  sessionId: 'test-session',
1101
992
  projectPath: '/my/project',
1102
- slug: 'happy-dog',
1103
993
  lastCwd: '/my/project',
1104
994
  lastEntryType: 'assistant',
1105
995
  isInterrupted: false,
@@ -1109,7 +999,6 @@ describe('ClaudeCodeAdapter', () => {
1109
999
  });
1110
1000
 
1111
1001
  it('should detect user interruption', () => {
1112
- const adapter = new ClaudeCodeAdapter();
1113
1002
  const readSession = (adapter as any).readSession.bind(adapter);
1114
1003
 
1115
1004
  const filePath = path.join(tmpDir, 'interrupted.jsonl');
@@ -1130,28 +1019,22 @@ describe('ClaudeCodeAdapter', () => {
1130
1019
  });
1131
1020
 
1132
1021
  it('should return session with defaults for empty file', () => {
1133
- const adapter = new ClaudeCodeAdapter();
1134
1022
  const readSession = (adapter as any).readSession.bind(adapter);
1135
1023
 
1136
1024
  const filePath = path.join(tmpDir, 'empty.jsonl');
1137
1025
  fs.writeFileSync(filePath, '');
1138
1026
 
1139
1027
  const session = readSession(filePath, '/test');
1140
- // Empty file content trims to '' which splits to [''] — no valid entries parsed
1141
1028
  expect(session).not.toBeNull();
1142
1029
  expect(session.lastEntryType).toBeUndefined();
1143
- expect(session.slug).toBeUndefined();
1144
1030
  });
1145
1031
 
1146
1032
  it('should return null for non-existent file', () => {
1147
- const adapter = new ClaudeCodeAdapter();
1148
1033
  const readSession = (adapter as any).readSession.bind(adapter);
1149
-
1150
1034
  expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
1151
1035
  });
1152
1036
 
1153
1037
  it('should skip metadata entry types for lastEntryType', () => {
1154
- const adapter = new ClaudeCodeAdapter();
1155
1038
  const readSession = (adapter as any).readSession.bind(adapter);
1156
1039
 
1157
1040
  const filePath = path.join(tmpDir, 'metadata-test.jsonl');
@@ -1164,12 +1047,10 @@ describe('ClaudeCodeAdapter', () => {
1164
1047
  fs.writeFileSync(filePath, lines.join('\n'));
1165
1048
 
1166
1049
  const session = readSession(filePath, '/test');
1167
- // lastEntryType should be 'assistant', not 'last-prompt' or 'file-history-snapshot'
1168
1050
  expect(session.lastEntryType).toBe('assistant');
1169
1051
  });
1170
1052
 
1171
1053
  it('should parse snapshot.timestamp from file-history-snapshot first entry', () => {
1172
- const adapter = new ClaudeCodeAdapter();
1173
1054
  const readSession = (adapter as any).readSession.bind(adapter);
1174
1055
 
1175
1056
  const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
@@ -1184,13 +1065,11 @@ describe('ClaudeCodeAdapter', () => {
1184
1065
  fs.writeFileSync(filePath, lines.join('\n'));
1185
1066
 
1186
1067
  const session = readSession(filePath, '/test');
1187
- // sessionStart should come from snapshot.timestamp, not lastActive
1188
1068
  expect(session.sessionStart.toISOString()).toBe('2026-03-10T09:55:00.000Z');
1189
1069
  expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
1190
1070
  });
1191
1071
 
1192
1072
  it('should extract lastUserMessage from session entries', () => {
1193
- const adapter = new ClaudeCodeAdapter();
1194
1073
  const readSession = (adapter as any).readSession.bind(adapter);
1195
1074
 
1196
1075
  const filePath = path.join(tmpDir, 'user-msg.jsonl');
@@ -1203,12 +1082,10 @@ describe('ClaudeCodeAdapter', () => {
1203
1082
  fs.writeFileSync(filePath, lines.join('\n'));
1204
1083
 
1205
1084
  const session = readSession(filePath, '/test');
1206
- // Last user message should be the most recent one
1207
1085
  expect(session.lastUserMessage).toBe('second question');
1208
1086
  });
1209
1087
 
1210
1088
  it('should use lastCwd as projectPath when projectPath is empty', () => {
1211
- const adapter = new ClaudeCodeAdapter();
1212
1089
  const readSession = (adapter as any).readSession.bind(adapter);
1213
1090
 
1214
1091
  const filePath = path.join(tmpDir, 'no-project.jsonl');
@@ -1222,7 +1099,6 @@ describe('ClaudeCodeAdapter', () => {
1222
1099
  });
1223
1100
 
1224
1101
  it('should handle malformed JSON lines gracefully', () => {
1225
- const adapter = new ClaudeCodeAdapter();
1226
1102
  const readSession = (adapter as any).readSession.bind(adapter);
1227
1103
 
1228
1104
  const filePath = path.join(tmpDir, 'malformed.jsonl');
@@ -1237,131 +1113,179 @@ describe('ClaudeCodeAdapter', () => {
1237
1113
  expect(session.lastEntryType).toBe('assistant');
1238
1114
  });
1239
1115
  });
1116
+ });
1240
1117
 
1241
- describe('findSessionFiles', () => {
1242
- it('should return empty when projects dir does not exist', () => {
1243
- const adapter = new ClaudeCodeAdapter();
1244
- (adapter as any).projectsDir = path.join(tmpDir, 'nonexistent');
1245
- const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
1118
+ describe('getConversation', () => {
1119
+ let tmpDir: string;
1246
1120
 
1247
- expect(findSessionFiles(10)).toEqual([]);
1248
- });
1121
+ beforeEach(() => {
1122
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-conv-'));
1123
+ });
1249
1124
 
1250
- it('should find and sort session files by mtime', () => {
1251
- const adapter = new ClaudeCodeAdapter();
1252
- const projectsDir = path.join(tmpDir, 'projects');
1253
- (adapter as any).projectsDir = projectsDir;
1254
- const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
1125
+ afterEach(() => {
1126
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1127
+ });
1255
1128
 
1256
- // Create project dir with sessions-index.json and JSONL files
1257
- const projDir = path.join(projectsDir, 'encoded-path');
1258
- fs.mkdirSync(projDir, { recursive: true });
1259
- fs.writeFileSync(
1260
- path.join(projDir, 'sessions-index.json'),
1261
- JSON.stringify({ originalPath: '/my/project' }),
1262
- );
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
+ }
1263
1134
 
1264
- const file1 = path.join(projDir, 'session-old.jsonl');
1265
- const file2 = path.join(projDir, 'session-new.jsonl');
1266
- fs.writeFileSync(file1, '{}');
1267
- // Ensure different mtime
1268
- const past = new Date(Date.now() - 10000);
1269
- fs.utimesSync(file1, past, past);
1270
- fs.writeFileSync(file2, '{}');
1271
-
1272
- const files = findSessionFiles(10);
1273
- expect(files).toHaveLength(2);
1274
- // Sorted by mtime desc — new first
1275
- expect(files[0].filePath).toContain('session-new');
1276
- expect(files[0].projectPath).toBe('/my/project');
1277
- expect(files[1].filePath).toContain('session-old');
1278
- });
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
+ ]);
1279
1140
 
1280
- it('should respect scan limit', () => {
1281
- const adapter = new ClaudeCodeAdapter();
1282
- const projectsDir = path.join(tmpDir, 'projects');
1283
- (adapter as any).projectsDir = projectsDir;
1284
- const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
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
+ });
1285
1146
 
1286
- const projDir = path.join(projectsDir, 'proj');
1287
- fs.mkdirSync(projDir, { recursive: true });
1288
- fs.writeFileSync(
1289
- path.join(projDir, 'sessions-index.json'),
1290
- JSON.stringify({ originalPath: '/proj' }),
1291
- );
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
+ ]);
1292
1153
 
1293
- for (let i = 0; i < 5; i++) {
1294
- fs.writeFileSync(path.join(projDir, `session-${i}.jsonl`), '{}');
1295
- }
1154
+ const messages = adapter.getConversation(filePath);
1155
+ expect(messages).toHaveLength(1);
1156
+ expect(messages[0].content).toBe('Fix bug');
1157
+ });
1296
1158
 
1297
- const files = findSessionFiles(3);
1298
- expect(files).toHaveLength(3);
1299
- });
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
+ ]);
1300
1166
 
1301
- it('should skip directories starting with dot', () => {
1302
- const adapter = new ClaudeCodeAdapter();
1303
- const projectsDir = path.join(tmpDir, 'projects');
1304
- (adapter as any).projectsDir = projectsDir;
1305
- const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
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
+ });
1306
1172
 
1307
- const hiddenDir = path.join(projectsDir, '.hidden');
1308
- fs.mkdirSync(hiddenDir, { recursive: true });
1309
- fs.writeFileSync(
1310
- path.join(hiddenDir, 'sessions-index.json'),
1311
- JSON.stringify({ originalPath: '/hidden' }),
1312
- );
1313
- fs.writeFileSync(path.join(hiddenDir, 'session.jsonl'), '{}');
1173
+ it('should include system messages', () => {
1174
+ const filePath = writeJsonl([
1175
+ { type: 'system', timestamp: '2026-03-27T10:00:00Z', message: { content: 'System initialized' } },
1176
+ ]);
1314
1177
 
1315
- const files = findSessionFiles(10);
1316
- expect(files).toEqual([]);
1317
- });
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
+ });
1318
1182
 
1319
- it('should include project dirs without sessions-index.json using empty projectPath', () => {
1320
- const adapter = new ClaudeCodeAdapter();
1321
- const projectsDir = path.join(tmpDir, 'projects');
1322
- (adapter as any).projectsDir = projectsDir;
1323
- const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
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
+ ]);
1324
1203
 
1325
- const projDir = path.join(projectsDir, 'no-index');
1326
- fs.mkdirSync(projDir, { recursive: true });
1327
- fs.writeFileSync(path.join(projDir, 'session.jsonl'), '{}');
1204
+ const messages = adapter.getConversation(filePath);
1205
+ expect(messages).toHaveLength(1);
1206
+ expect(messages[0].content).toBe('Let me read the file.');
1207
+ });
1328
1208
 
1329
- const files = findSessionFiles(10);
1330
- expect(files).toHaveLength(1);
1331
- expect(files[0].projectPath).toBe('');
1332
- expect(files[0].filePath).toContain('session.jsonl');
1333
- });
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]');
1334
1235
  });
1335
1236
 
1336
- describe('readSessions', () => {
1337
- it('should parse valid sessions and skip invalid ones', () => {
1338
- const adapter = new ClaudeCodeAdapter();
1339
- const projectsDir = path.join(tmpDir, 'projects');
1340
- (adapter as any).projectsDir = projectsDir;
1341
- const readSessions = (adapter as any).readSessions.bind(adapter);
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
+ ]);
1342
1248
 
1343
- const projDir = path.join(projectsDir, 'proj');
1344
- fs.mkdirSync(projDir, { recursive: true });
1345
- fs.writeFileSync(
1346
- path.join(projDir, 'sessions-index.json'),
1347
- JSON.stringify({ originalPath: '/my/project' }),
1348
- );
1249
+ const messages = adapter.getConversation(filePath, { verbose: true });
1250
+ expect(messages).toHaveLength(1);
1251
+ expect(messages[0].content).toContain('[Tool Error]');
1252
+ });
1349
1253
 
1350
- // Valid session
1351
- fs.writeFileSync(
1352
- path.join(projDir, 'valid.jsonl'),
1353
- JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:00:00Z' }),
1354
- );
1355
- // Empty session (will return null from readSession)
1356
- fs.writeFileSync(path.join(projDir, 'empty.jsonl'), '');
1357
-
1358
- const sessions = readSessions(10);
1359
- expect(sessions).toHaveLength(2);
1360
- // Both are valid (empty file still produces a session with defaults)
1361
- const validSession = sessions.find((s: any) => s.sessionId === 'valid');
1362
- expect(validSession).toBeDefined();
1363
- expect(validSession.lastEntryType).toBe('assistant');
1364
- });
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');
1365
1289
  });
1366
1290
  });
1367
1291
  });