@ai-devkit/agent-manager 0.4.0 → 0.5.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 +2 -0
  2. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  3. package/dist/adapters/ClaudeCodeAdapter.d.ts +29 -34
  4. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  5. package/dist/adapters/ClaudeCodeAdapter.js +138 -294
  6. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  7. package/dist/adapters/CodexAdapter.d.ts +32 -30
  8. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  9. package/dist/adapters/CodexAdapter.js +148 -282
  10. package/dist/adapters/CodexAdapter.js.map +1 -1
  11. package/dist/index.d.ts +1 -3
  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 +103 -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 +0 -25
  33. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +598 -845
  34. package/src/__tests__/adapters/CodexAdapter.test.ts +467 -274
  35. package/src/__tests__/utils/matching.test.ts +191 -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 +3 -0
  39. package/src/adapters/ClaudeCodeAdapter.ts +177 -425
  40. package/src/adapters/CodexAdapter.ts +155 -409
  41. package/src/index.ts +1 -3
  42. package/src/utils/index.ts +6 -3
  43. package/src/utils/matching.ts +92 -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,43 +116,88 @@ 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');
124
+ });
125
+
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
+ });
102
146
  });
103
147
 
104
- it('should detect agents using mocked process/session data', async () => {
105
- const processData: ProcessInfo[] = [
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', slug: 'merry-dog', 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,
@@ -139,259 +205,403 @@ describe('ClaudeCodeAdapter', () => {
139
205
  sessionId: 'session-1',
140
206
  slug: 'merry-dog',
141
207
  });
142
- expect(agents[0].summary).toContain('Investigate failing tests in package');
208
+ expect(agents[0].summary).toContain('Investigate failing tests');
209
+
210
+ fs.rmSync(tmpDir, { recursive: true, force: true });
143
211
  });
144
212
 
145
- it('should include process-only entry when no sessions exist', async () => {
146
- mockedListProcesses.mockReturnValue([
213
+ it('should fall back to process-only for unmatched processes', async () => {
214
+ const processes: ProcessInfo[] = [
215
+ { pid: 100, command: 'claude', cwd: '/project-a', tty: 'ttys001', startTime: new Date() },
216
+ { pid: 200, command: 'claude', cwd: '/project-b', tty: 'ttys002', startTime: new Date() },
217
+ ];
218
+ mockedListAgentProcesses.mockReturnValue(processes);
219
+ mockedEnrichProcesses.mockReturnValue(processes);
220
+
221
+ // Set up projects dir with encoded directory names
222
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
223
+ const projectsDir = path.join(tmpDir, 'projects');
224
+ // /project-a → -project-a, /project-b → -project-b
225
+ const projDirA = path.join(projectsDir, '-project-a');
226
+ const projDirB = path.join(projectsDir, '-project-b');
227
+ fs.mkdirSync(projDirA, { recursive: true });
228
+ fs.mkdirSync(projDirB, { recursive: true });
229
+
230
+ const sessionFile = path.join(projDirA, 'only-session.jsonl');
231
+ fs.writeFileSync(sessionFile,
232
+ JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
233
+ );
234
+
235
+ (adapter as any).projectsDir = projectsDir;
236
+
237
+ const sessionFiles: SessionFile[] = [
147
238
  {
148
- pid: 777,
149
- command: 'claude',
150
- cwd: '/project/without-session',
151
- tty: 'ttys008',
239
+ sessionId: 'only-session',
240
+ filePath: sessionFile,
241
+ projectDir: projDirA,
242
+ birthtimeMs: Date.now(),
243
+ resolvedCwd: '',
152
244
  },
153
- ]);
154
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
245
+ ];
246
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
155
247
 
248
+ // Only process 100 matches
249
+ const matches: MatchResult[] = [
250
+ {
251
+ process: processes[0],
252
+ session: { ...sessionFiles[0], resolvedCwd: '/project-a' },
253
+ deltaMs: 5000,
254
+ },
255
+ ];
256
+ mockedMatchProcessesToSessions.mockReturnValue(matches);
156
257
 
157
258
  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
- });
259
+ expect(agents).toHaveLength(2);
260
+
261
+ const matched = agents.find(a => a.pid === 100);
262
+ const unmatched = agents.find(a => a.pid === 200);
263
+ expect(matched?.sessionId).toBe('only-session');
264
+ expect(unmatched?.sessionId).toBe('pid-200');
265
+ expect(unmatched?.status).toBe(AgentStatus.IDLE);
266
+
267
+ fs.rmSync(tmpDir, { recursive: true, force: true });
167
268
  });
168
269
 
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
- ]);
270
+ it('should handle process with empty cwd in process-only fallback', async () => {
271
+ const processes: ProcessInfo[] = [
272
+ { pid: 300, command: 'claude', cwd: '', tty: 'ttys003' },
273
+ ];
274
+ mockedListAgentProcesses.mockReturnValue(processes);
275
+ mockedEnrichProcesses.mockReturnValue(processes);
188
276
 
277
+ (adapter as any).projectsDir = '/nonexistent';
189
278
 
190
279
  const agents = await adapter.detectAgents();
191
280
  expect(agents).toHaveLength(1);
192
- // Unrelated session should NOT match — falls to process-only
193
281
  expect(agents[0]).toMatchObject({
194
- type: 'claude',
195
- pid: 777,
196
- sessionId: 'pid-777',
197
- projectPath: '/project/without-session',
198
- status: AgentStatus.IDLE,
282
+ pid: 300,
283
+ sessionId: 'pid-300',
284
+ summary: 'Unknown',
285
+ projectPath: '',
199
286
  });
200
287
  });
201
288
 
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
- ]);
289
+ it('should use PID file for direct match and skip legacy matching for that process', async () => {
290
+ const startTime = new Date();
291
+ const processes: ProcessInfo[] = [
292
+ { pid: 55001, command: 'claude', cwd: '/project/direct', tty: 'ttys001', startTime },
293
+ ];
294
+ mockedListAgentProcesses.mockReturnValue(processes);
295
+ mockedEnrichProcesses.mockReturnValue(processes);
296
+
297
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-test-'));
298
+ const sessionsDir = path.join(tmpDir, 'sessions');
299
+ const projectsDir = path.join(tmpDir, 'projects');
300
+ const projDir = path.join(projectsDir, '-project-direct');
301
+ fs.mkdirSync(sessionsDir, { recursive: true });
302
+ fs.mkdirSync(projDir, { recursive: true });
303
+
304
+ const sessionId = 'pid-file-session';
305
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
306
+ fs.writeFileSync(jsonlPath, [
307
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/direct', message: { content: 'hello from pid file' } }),
308
+ JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
309
+ ].join('\n'));
310
+
311
+ fs.writeFileSync(
312
+ path.join(sessionsDir, '55001.json'),
313
+ JSON.stringify({ pid: 55001, sessionId, cwd: '/project/direct', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
314
+ );
315
+
316
+ (adapter as any).sessionsDir = sessionsDir;
317
+ (adapter as any).projectsDir = projectsDir;
222
318
 
223
319
  const agents = await adapter.detectAgents();
320
+
321
+ // Legacy matching utilities should NOT have been called (all processes matched via PID file)
322
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
323
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
324
+
224
325
  expect(agents).toHaveLength(1);
225
326
  expect(agents[0]).toMatchObject({
226
327
  type: 'claude',
227
- pid: 888,
228
- sessionId: 'session-3',
229
- projectPath: '/Users/test/my-project',
328
+ pid: 55001,
329
+ sessionId,
330
+ projectPath: '/project/direct',
331
+ status: AgentStatus.WAITING,
230
332
  });
333
+ expect(agents[0].summary).toContain('hello from pid file');
334
+
335
+ fs.rmSync(tmpDir, { recursive: true, force: true });
231
336
  });
232
337
 
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([]);
338
+ it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => {
339
+ const startTime = new Date();
340
+ const processes: ProcessInfo[] = [
341
+ { pid: 66001, command: 'claude', cwd: '/project/gone', tty: 'ttys001', startTime },
342
+ ];
343
+ mockedListAgentProcesses.mockReturnValue(processes);
344
+ mockedEnrichProcesses.mockReturnValue(processes);
345
+
346
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-gone-'));
347
+ const sessionsDir = path.join(tmpDir, 'sessions');
348
+ const projectsDir = path.join(tmpDir, 'projects');
349
+ const projDir = path.join(projectsDir, '-project-gone');
350
+ fs.mkdirSync(sessionsDir, { recursive: true });
351
+ fs.mkdirSync(projDir, { recursive: true });
352
+
353
+ const sessionId = 'gone-session';
354
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
355
+ fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
356
+ fs.writeFileSync(
357
+ path.join(sessionsDir, '66001.json'),
358
+ JSON.stringify({ pid: 66001, sessionId, cwd: '/project/gone', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
359
+ );
360
+
361
+ (adapter as any).sessionsDir = sessionsDir;
362
+ (adapter as any).projectsDir = projectsDir;
363
+
364
+ // Simulate JSONL disappearing between existence check and read
365
+ jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
243
366
 
244
367
  const agents = await adapter.detectAgents();
368
+
369
+ // matchedPids.delete called → process falls back to IDLE
245
370
  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
- });
371
+ expect(agents[0].sessionId).toBe('pid-66001');
372
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
373
+
374
+ fs.rmSync(tmpDir, { recursive: true, force: true });
375
+ jest.restoreAllMocks();
254
376
  });
255
377
 
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
- },
378
+ it('should fall back to process-only when legacy-matched JSONL becomes unreadable', async () => {
379
+ const startTime = new Date();
380
+ const processes: ProcessInfo[] = [
381
+ { pid: 66002, command: 'claude', cwd: '/project/legacy-gone', tty: 'ttys001', startTime },
382
+ ];
383
+ mockedListAgentProcesses.mockReturnValue(processes);
384
+ mockedEnrichProcesses.mockReturnValue(processes);
385
+
386
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-lgone-'));
387
+ const projectsDir = path.join(tmpDir, 'projects');
388
+ const projDir = path.join(projectsDir, '-project-legacy-gone');
389
+ fs.mkdirSync(projDir, { recursive: true });
390
+
391
+ const sessionId = 'legacy-gone-session';
392
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
393
+ fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
394
+
395
+ // No PID file → process goes to legacy fallback
396
+ (adapter as any).sessionsDir = path.join(tmpDir, 'no-sessions');
397
+ (adapter as any).projectsDir = projectsDir;
398
+
399
+ const legacySessionFile = {
400
+ sessionId,
401
+ filePath: jsonlPath,
402
+ projectDir: projDir,
403
+ birthtimeMs: startTime.getTime(),
404
+ resolvedCwd: '/project/legacy-gone',
405
+ };
406
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
407
+ mockedMatchProcessesToSessions.mockReturnValue([
408
+ { process: processes[0], session: legacySessionFile, deltaMs: 500 },
275
409
  ]);
276
410
 
411
+ // Simulate JSONL disappearing between match and read
412
+ jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
413
+
277
414
  const agents = await adapter.detectAgents();
415
+
278
416
  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
- });
417
+ expect(agents[0].sessionId).toBe('pid-66002');
418
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
419
+
420
+ fs.rmSync(tmpDir, { recursive: true, force: true });
421
+ jest.restoreAllMocks();
286
422
  });
287
423
 
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
- },
424
+ it('should mix direct PID-file matches and legacy matches across processes', async () => {
425
+ const startTime = new Date();
426
+ const processes: ProcessInfo[] = [
427
+ { pid: 55002, command: 'claude', cwd: '/project/alpha', tty: 'ttys001', startTime },
428
+ { pid: 55003, command: 'claude', cwd: '/project/beta', tty: 'ttys002', startTime },
429
+ ];
430
+ mockedListAgentProcesses.mockReturnValue(processes);
431
+ mockedEnrichProcesses.mockReturnValue(processes);
432
+
433
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-mix-test-'));
434
+ const sessionsDir = path.join(tmpDir, 'sessions');
435
+ const projectsDir = path.join(tmpDir, 'projects');
436
+ const projAlpha = path.join(projectsDir, '-project-alpha');
437
+ const projBeta = path.join(projectsDir, '-project-beta');
438
+ fs.mkdirSync(sessionsDir, { recursive: true });
439
+ fs.mkdirSync(projAlpha, { recursive: true });
440
+ fs.mkdirSync(projBeta, { recursive: true });
441
+
442
+ // PID file only for process 55002
443
+ const directSessionId = 'direct-session';
444
+ const directJsonl = path.join(projAlpha, `${directSessionId}.jsonl`);
445
+ fs.writeFileSync(directJsonl, [
446
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/alpha', message: { content: 'direct question' } }),
447
+ JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
448
+ ].join('\n'));
449
+ fs.writeFileSync(
450
+ path.join(sessionsDir, '55002.json'),
451
+ JSON.stringify({ pid: 55002, sessionId: directSessionId, cwd: '/project/alpha', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
452
+ );
453
+
454
+ // Legacy session file for process 55003
455
+ const legacySessionId = 'legacy-session';
456
+ const legacyJsonl = path.join(projBeta, `${legacySessionId}.jsonl`);
457
+ fs.writeFileSync(legacyJsonl, [
458
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/beta', message: { content: 'legacy question' } }),
459
+ JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
460
+ ].join('\n'));
461
+
462
+ (adapter as any).sessionsDir = sessionsDir;
463
+ (adapter as any).projectsDir = projectsDir;
464
+
465
+ // Mock legacy matching for process 55003
466
+ const legacySessionFile = {
467
+ sessionId: legacySessionId,
468
+ filePath: legacyJsonl,
469
+ projectDir: projBeta,
470
+ birthtimeMs: startTime.getTime(),
471
+ resolvedCwd: '/project/beta',
472
+ };
473
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
474
+ mockedMatchProcessesToSessions.mockReturnValue([
475
+ { process: processes[1], session: legacySessionFile, deltaMs: 1000 },
312
476
  ]);
313
477
 
314
-
315
478
  const agents = await adapter.detectAgents();
479
+
480
+ // Legacy matching called only for fallback process (55003)
481
+ expect(mockedMatchProcessesToSessions).toHaveBeenCalledTimes(1);
482
+ expect(mockedMatchProcessesToSessions.mock.calls[0][0]).toEqual([processes[1]]);
483
+
316
484
  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
- });
485
+ const alpha = agents.find(a => a.pid === 55002);
486
+ const beta = agents.find(a => a.pid === 55003);
487
+ expect(alpha?.sessionId).toBe(directSessionId);
488
+ expect(beta?.sessionId).toBe(legacySessionId);
489
+
490
+ fs.rmSync(tmpDir, { recursive: true, force: true });
329
491
  });
492
+ });
330
493
 
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([]);
494
+ describe('discoverSessions', () => {
495
+ let tmpDir: string;
341
496
 
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
- });
497
+ beforeEach(() => {
498
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
350
499
  });
351
500
 
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
- },
501
+ afterEach(() => {
502
+ fs.rmSync(tmpDir, { recursive: true, force: true });
503
+ });
504
+
505
+ it('should return empty when projects dir does not exist', () => {
506
+ (adapter as any).projectsDir = path.join(tmpDir, 'nonexistent');
507
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
508
+
509
+ const result = discoverSessions([
510
+ { pid: 1, command: 'claude', cwd: '/test', tty: '' },
361
511
  ]);
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
- },
512
+ expect(result).toEqual([]);
513
+ });
514
+
515
+ it('should scan only directories matching process CWDs', () => {
516
+ const projectsDir = path.join(tmpDir, 'projects');
517
+ (adapter as any).projectsDir = projectsDir;
518
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
519
+
520
+ // /my/project → -my-project (encoded dir)
521
+ const encodedDir = path.join(projectsDir, '-my-project');
522
+ fs.mkdirSync(encodedDir, { recursive: true });
523
+
524
+ // Also create another dir that should NOT be scanned
525
+ const otherDir = path.join(projectsDir, '-other-project');
526
+ fs.mkdirSync(otherDir, { recursive: true });
527
+
528
+ const mockFiles: SessionFile[] = [
371
529
  {
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,
530
+ sessionId: 's1',
531
+ filePath: path.join(encodedDir, 's1.jsonl'),
532
+ projectDir: encodedDir,
533
+ birthtimeMs: 1710800324000,
534
+ resolvedCwd: '',
378
535
  },
536
+ ];
537
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles);
538
+
539
+ const processes = [
540
+ { pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
541
+ ];
542
+
543
+ const result = discoverSessions(processes);
544
+ expect(result).toHaveLength(1);
545
+ expect(result[0].resolvedCwd).toBe('/my/project');
546
+ // batchGetSessionFileBirthtimes called once with all dirs
547
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
548
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
549
+ });
550
+
551
+ it('should return empty when encoded dir does not exist', () => {
552
+ const projectsDir = path.join(tmpDir, 'projects');
553
+ fs.mkdirSync(projectsDir, { recursive: true });
554
+ (adapter as any).projectsDir = projectsDir;
555
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
556
+
557
+ // Process CWD /test encodes to -test, but that dir doesn't exist
558
+ const result = discoverSessions([
559
+ { pid: 1, command: 'claude', cwd: '/test', tty: '' },
379
560
  ]);
561
+ expect(result).toEqual([]);
562
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
563
+ });
380
564
 
565
+ it('should deduplicate when multiple processes share same CWD', () => {
566
+ const projectsDir = path.join(tmpDir, 'projects');
567
+ (adapter as any).projectsDir = projectsDir;
568
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
381
569
 
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
- });
570
+ const encodedDir = path.join(projectsDir, '-my-project');
571
+ fs.mkdirSync(encodedDir, { recursive: true });
572
+
573
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
574
+ { sessionId: 's1', filePath: path.join(encodedDir, 's1.jsonl'), projectDir: encodedDir, birthtimeMs: 1710800324000, resolvedCwd: '' },
575
+ ]);
576
+
577
+ const processes = [
578
+ { pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
579
+ { pid: 2, command: 'claude', cwd: '/my/project', tty: '' },
580
+ ];
581
+
582
+ const result = discoverSessions(processes);
583
+ // Should only call batch once with deduplicated dir
584
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
585
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
586
+ expect(result).toHaveLength(1);
587
+ });
588
+
589
+ it('should skip processes with empty cwd', () => {
590
+ const projectsDir = path.join(tmpDir, 'projects');
591
+ fs.mkdirSync(projectsDir, { recursive: true });
592
+ (adapter as any).projectsDir = projectsDir;
593
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
594
+
595
+ const result = discoverSessions([
596
+ { pid: 1, command: 'claude', cwd: '', tty: '' },
597
+ ]);
598
+ expect(result).toEqual([]);
388
599
  });
389
600
  });
390
601
 
391
602
  describe('helper methods', () => {
392
603
  describe('determineStatus', () => {
393
604
  it('should return "unknown" for sessions with no last entry type', () => {
394
- const adapter = new ClaudeCodeAdapter();
395
605
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
396
606
 
397
607
  const session = {
@@ -402,12 +612,10 @@ describe('ClaudeCodeAdapter', () => {
402
612
  isInterrupted: false,
403
613
  };
404
614
 
405
- const status = determineStatus(session);
406
- expect(status).toBe(AgentStatus.UNKNOWN);
615
+ expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
407
616
  });
408
617
 
409
618
  it('should return "waiting" for assistant entries', () => {
410
- const adapter = new ClaudeCodeAdapter();
411
619
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
412
620
 
413
621
  const session = {
@@ -419,12 +627,10 @@ describe('ClaudeCodeAdapter', () => {
419
627
  isInterrupted: false,
420
628
  };
421
629
 
422
- const status = determineStatus(session);
423
- expect(status).toBe(AgentStatus.WAITING);
630
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
424
631
  });
425
632
 
426
633
  it('should return "waiting" for user interruption', () => {
427
- const adapter = new ClaudeCodeAdapter();
428
634
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
429
635
 
430
636
  const session = {
@@ -436,12 +642,10 @@ describe('ClaudeCodeAdapter', () => {
436
642
  isInterrupted: true,
437
643
  };
438
644
 
439
- const status = determineStatus(session);
440
- expect(status).toBe(AgentStatus.WAITING);
645
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
441
646
  });
442
647
 
443
648
  it('should return "running" for user/progress entries', () => {
444
- const adapter = new ClaudeCodeAdapter();
445
649
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
446
650
 
447
651
  const session = {
@@ -453,16 +657,13 @@ describe('ClaudeCodeAdapter', () => {
453
657
  isInterrupted: false,
454
658
  };
455
659
 
456
- const status = determineStatus(session);
457
- expect(status).toBe(AgentStatus.RUNNING);
660
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
458
661
  });
459
662
 
460
663
  it('should not override status based on age (process is running)', () => {
461
- const adapter = new ClaudeCodeAdapter();
462
664
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
463
665
 
464
666
  const oldDate = new Date(Date.now() - 10 * 60 * 1000);
465
-
466
667
  const session = {
467
668
  sessionId: 'test',
468
669
  projectPath: '/test',
@@ -472,14 +673,10 @@ describe('ClaudeCodeAdapter', () => {
472
673
  isInterrupted: false,
473
674
  };
474
675
 
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);
676
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
479
677
  });
480
678
 
481
679
  it('should return "idle" for system entries', () => {
482
- const adapter = new ClaudeCodeAdapter();
483
680
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
484
681
 
485
682
  const session = {
@@ -491,12 +688,10 @@ describe('ClaudeCodeAdapter', () => {
491
688
  isInterrupted: false,
492
689
  };
493
690
 
494
- const status = determineStatus(session);
495
- expect(status).toBe(AgentStatus.IDLE);
691
+ expect(determineStatus(session)).toBe(AgentStatus.IDLE);
496
692
  });
497
693
 
498
694
  it('should return "running" for thinking entries', () => {
499
- const adapter = new ClaudeCodeAdapter();
500
695
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
501
696
 
502
697
  const session = {
@@ -508,12 +703,10 @@ describe('ClaudeCodeAdapter', () => {
508
703
  isInterrupted: false,
509
704
  };
510
705
 
511
- const status = determineStatus(session);
512
- expect(status).toBe(AgentStatus.RUNNING);
706
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
513
707
  });
514
708
 
515
709
  it('should return "running" for progress entries', () => {
516
- const adapter = new ClaudeCodeAdapter();
517
710
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
518
711
 
519
712
  const session = {
@@ -525,12 +718,10 @@ describe('ClaudeCodeAdapter', () => {
525
718
  isInterrupted: false,
526
719
  };
527
720
 
528
- const status = determineStatus(session);
529
- expect(status).toBe(AgentStatus.RUNNING);
721
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
530
722
  });
531
723
 
532
724
  it('should return "unknown" for unrecognized entry types', () => {
533
- const adapter = new ClaudeCodeAdapter();
534
725
  const determineStatus = (adapter as any).determineStatus.bind(adapter);
535
726
 
536
727
  const session = {
@@ -542,380 +733,17 @@ describe('ClaudeCodeAdapter', () => {
542
733
  isInterrupted: false,
543
734
  };
544
735
 
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);
736
+ expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
906
737
  });
907
738
  });
908
739
 
909
740
  describe('extractUserMessageText', () => {
910
741
  it('should extract plain string content', () => {
911
- const adapter = new ClaudeCodeAdapter();
912
742
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
913
-
914
743
  expect(extract('hello world')).toBe('hello world');
915
744
  });
916
745
 
917
746
  it('should extract text from array content blocks', () => {
918
- const adapter = new ClaudeCodeAdapter();
919
747
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
920
748
 
921
749
  const content = [
@@ -926,7 +754,6 @@ describe('ClaudeCodeAdapter', () => {
926
754
  });
927
755
 
928
756
  it('should return undefined for empty/null content', () => {
929
- const adapter = new ClaudeCodeAdapter();
930
757
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
931
758
 
932
759
  expect(extract(undefined)).toBeUndefined();
@@ -935,7 +762,6 @@ describe('ClaudeCodeAdapter', () => {
935
762
  });
936
763
 
937
764
  it('should parse command-message tags', () => {
938
- const adapter = new ClaudeCodeAdapter();
939
765
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
940
766
 
941
767
  const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
@@ -943,7 +769,6 @@ describe('ClaudeCodeAdapter', () => {
943
769
  });
944
770
 
945
771
  it('should parse command-message without args', () => {
946
- const adapter = new ClaudeCodeAdapter();
947
772
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
948
773
 
949
774
  const msg = '<command-message><command-name>help</command-name></command-message>';
@@ -951,7 +776,6 @@ describe('ClaudeCodeAdapter', () => {
951
776
  });
952
777
 
953
778
  it('should extract ARGUMENTS from skill expansion', () => {
954
- const adapter = new ClaudeCodeAdapter();
955
779
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
956
780
 
957
781
  const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
@@ -959,7 +783,6 @@ describe('ClaudeCodeAdapter', () => {
959
783
  });
960
784
 
961
785
  it('should return undefined for skill expansion without ARGUMENTS', () => {
962
- const adapter = new ClaudeCodeAdapter();
963
786
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
964
787
 
965
788
  const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
@@ -967,7 +790,6 @@ describe('ClaudeCodeAdapter', () => {
967
790
  });
968
791
 
969
792
  it('should filter noise messages', () => {
970
- const adapter = new ClaudeCodeAdapter();
971
793
  const extract = (adapter as any).extractUserMessageText.bind(adapter);
972
794
 
973
795
  expect(extract('[Request interrupted by user]')).toBeUndefined();
@@ -978,114 +800,184 @@ describe('ClaudeCodeAdapter', () => {
978
800
 
979
801
  describe('parseCommandMessage', () => {
980
802
  it('should return undefined for malformed command-message', () => {
981
- const adapter = new ClaudeCodeAdapter();
982
803
  const parse = (adapter as any).parseCommandMessage.bind(adapter);
983
-
984
804
  expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
985
805
  });
986
806
  });
987
807
  });
988
808
 
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
- ];
809
+ describe('file I/O methods', () => {
810
+ let tmpDir: string;
1008
811
 
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();
812
+ beforeEach(() => {
813
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
814
+ });
815
+
816
+ afterEach(() => {
817
+ fs.rmSync(tmpDir, { recursive: true, force: true });
818
+ });
819
+
820
+ describe('tryPidFileMatching', () => {
821
+ let sessionsDir: string;
822
+ let projectsDir: string;
1012
823
 
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');
824
+ beforeEach(() => {
825
+ sessionsDir = path.join(tmpDir, 'sessions');
826
+ projectsDir = path.join(tmpDir, 'projects');
827
+ fs.mkdirSync(sessionsDir, { recursive: true });
828
+ (adapter as any).sessionsDir = sessionsDir;
829
+ (adapter as any).projectsDir = projectsDir;
1017
830
  });
1018
831
 
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
- ];
832
+ const makeProc = (pid: number, cwd = '/project/test', startTime?: Date): ProcessInfo => ({
833
+ pid, command: 'claude', cwd, tty: 'ttys001', startTime,
834
+ });
835
+
836
+ const writePidFile = (pid: number, sessionId: string, cwd: string, startedAt: number) => {
837
+ fs.writeFileSync(
838
+ path.join(sessionsDir, `${pid}.json`),
839
+ JSON.stringify({ pid, sessionId, cwd, startedAt, kind: 'interactive', entrypoint: 'cli' }),
840
+ );
841
+ };
842
+
843
+ const writeJsonl = (cwd: string, sessionId: string) => {
844
+ const encoded = cwd.replace(/\//g, '-');
845
+ const projDir = path.join(projectsDir, encoded);
846
+ fs.mkdirSync(projDir, { recursive: true });
847
+ const filePath = path.join(projDir, `${sessionId}.jsonl`);
848
+ fs.writeFileSync(filePath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
849
+ return filePath;
850
+ };
1044
851
 
1045
- const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd');
1046
- expect(result).toBeDefined();
1047
- expect(result.sessionId).toBe('newer');
852
+ it('should return direct match when PID file and JSONL both exist within time tolerance', () => {
853
+ const startTime = new Date();
854
+ const proc = makeProc(1001, '/project/test', startTime);
855
+ writePidFile(1001, 'session-abc', '/project/test', startTime.getTime());
856
+ writeJsonl('/project/test', 'session-abc');
857
+
858
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
859
+ const { direct, fallback } = tryMatch([proc]);
860
+
861
+ expect(direct).toHaveLength(1);
862
+ expect(fallback).toHaveLength(0);
863
+ expect(direct[0].sessionFile.sessionId).toBe('session-abc');
864
+ expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test');
865
+ expect(direct[0].process.pid).toBe(1001);
1048
866
  });
1049
867
 
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
- ];
868
+ it('should fall back when PID file exists but JSONL is missing', () => {
869
+ const startTime = new Date();
870
+ const proc = makeProc(1002, '/project/test', startTime);
871
+ writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime());
872
+ // No JSONL file written
1068
873
 
1069
- const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd');
1070
- expect(result).toBeDefined();
1071
- expect(result.sessionId).toBe('fresh-exact-cwd');
874
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
875
+ const { direct, fallback } = tryMatch([proc]);
876
+
877
+ expect(direct).toHaveLength(0);
878
+ expect(fallback).toHaveLength(1);
879
+ expect(fallback[0].pid).toBe(1002);
1072
880
  });
1073
- });
1074
881
 
1075
- describe('file I/O methods', () => {
1076
- let tmpDir: string;
882
+ it('should fall back when startedAt is stale (>60s from proc.startTime)', () => {
883
+ const startTime = new Date();
884
+ const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier
885
+ const proc = makeProc(1003, '/project/test', startTime);
886
+ writePidFile(1003, 'stale-session', '/project/test', staleTime);
887
+ writeJsonl('/project/test', 'stale-session');
1077
888
 
1078
- beforeEach(() => {
1079
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
1080
- });
889
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
890
+ const { direct, fallback } = tryMatch([proc]);
1081
891
 
1082
- afterEach(() => {
1083
- fs.rmSync(tmpDir, { recursive: true, force: true });
892
+ expect(direct).toHaveLength(0);
893
+ expect(fallback).toHaveLength(1);
894
+ });
895
+
896
+ it('should accept PID file when startedAt is within 60s tolerance', () => {
897
+ const startTime = new Date();
898
+ const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance
899
+ const proc = makeProc(1004, '/project/test', startTime);
900
+ writePidFile(1004, 'close-session', '/project/test', closeTime);
901
+ writeJsonl('/project/test', 'close-session');
902
+
903
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
904
+ const { direct, fallback } = tryMatch([proc]);
905
+
906
+ expect(direct).toHaveLength(1);
907
+ expect(fallback).toHaveLength(0);
908
+ });
909
+
910
+ it('should fall back when PID file is absent', () => {
911
+ const proc = makeProc(1005, '/project/test', new Date());
912
+ // No PID file written
913
+
914
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
915
+ const { direct, fallback } = tryMatch([proc]);
916
+
917
+ expect(direct).toHaveLength(0);
918
+ expect(fallback).toHaveLength(1);
919
+ });
920
+
921
+ it('should fall back when PID file contains malformed JSON', () => {
922
+ const proc = makeProc(1006, '/project/test', new Date());
923
+ fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{');
924
+
925
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
926
+ expect(() => {
927
+ const { direct, fallback } = tryMatch([proc]);
928
+ expect(direct).toHaveLength(0);
929
+ expect(fallback).toHaveLength(1);
930
+ }).not.toThrow();
931
+ });
932
+
933
+ it('should fall back for all processes when sessions dir does not exist', () => {
934
+ (adapter as any).sessionsDir = path.join(tmpDir, 'nonexistent-sessions');
935
+ const processes = [makeProc(2001, '/a', new Date()), makeProc(2002, '/b', new Date())];
936
+
937
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
938
+ const { direct, fallback } = tryMatch(processes);
939
+
940
+ expect(direct).toHaveLength(0);
941
+ expect(fallback).toHaveLength(2);
942
+ });
943
+
944
+ it('should correctly split mixed processes (some with PID files, some without)', () => {
945
+ const startTime = new Date();
946
+ const proc1 = makeProc(3001, '/project/one', startTime);
947
+ const proc2 = makeProc(3002, '/project/two', startTime);
948
+ const proc3 = makeProc(3003, '/project/three', startTime);
949
+
950
+ writePidFile(3001, 'session-one', '/project/one', startTime.getTime());
951
+ writeJsonl('/project/one', 'session-one');
952
+ writePidFile(3003, 'session-three', '/project/three', startTime.getTime());
953
+ writeJsonl('/project/three', 'session-three');
954
+ // proc2 has no PID file
955
+
956
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
957
+ const { direct, fallback } = tryMatch([proc1, proc2, proc3]);
958
+
959
+ expect(direct).toHaveLength(2);
960
+ expect(fallback).toHaveLength(1);
961
+ expect(direct.map((d: any) => d.process.pid).sort()).toEqual([3001, 3003]);
962
+ expect(fallback[0].pid).toBe(3002);
963
+ });
964
+
965
+ it('should skip stale-file check when proc.startTime is undefined', () => {
966
+ const proc = makeProc(4001, '/project/test', undefined); // no startTime
967
+ writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999);
968
+ writeJsonl('/project/test', 'no-time-session');
969
+
970
+ const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
971
+ const { direct, fallback } = tryMatch([proc]);
972
+
973
+ // startTime undefined → stale check skipped → direct match
974
+ expect(direct).toHaveLength(1);
975
+ expect(fallback).toHaveLength(0);
976
+ });
1084
977
  });
1085
978
 
1086
979
  describe('readSession', () => {
1087
980
  it('should parse session file with timestamps, slug, cwd, and entry type', () => {
1088
- const adapter = new ClaudeCodeAdapter();
1089
981
  const readSession = (adapter as any).readSession.bind(adapter);
1090
982
 
1091
983
  const filePath = path.join(tmpDir, 'test-session.jsonl');
@@ -1109,7 +1001,6 @@ describe('ClaudeCodeAdapter', () => {
1109
1001
  });
1110
1002
 
1111
1003
  it('should detect user interruption', () => {
1112
- const adapter = new ClaudeCodeAdapter();
1113
1004
  const readSession = (adapter as any).readSession.bind(adapter);
1114
1005
 
1115
1006
  const filePath = path.join(tmpDir, 'interrupted.jsonl');
@@ -1130,28 +1021,23 @@ describe('ClaudeCodeAdapter', () => {
1130
1021
  });
1131
1022
 
1132
1023
  it('should return session with defaults for empty file', () => {
1133
- const adapter = new ClaudeCodeAdapter();
1134
1024
  const readSession = (adapter as any).readSession.bind(adapter);
1135
1025
 
1136
1026
  const filePath = path.join(tmpDir, 'empty.jsonl');
1137
1027
  fs.writeFileSync(filePath, '');
1138
1028
 
1139
1029
  const session = readSession(filePath, '/test');
1140
- // Empty file content trims to '' which splits to [''] — no valid entries parsed
1141
1030
  expect(session).not.toBeNull();
1142
1031
  expect(session.lastEntryType).toBeUndefined();
1143
1032
  expect(session.slug).toBeUndefined();
1144
1033
  });
1145
1034
 
1146
1035
  it('should return null for non-existent file', () => {
1147
- const adapter = new ClaudeCodeAdapter();
1148
1036
  const readSession = (adapter as any).readSession.bind(adapter);
1149
-
1150
1037
  expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
1151
1038
  });
1152
1039
 
1153
1040
  it('should skip metadata entry types for lastEntryType', () => {
1154
- const adapter = new ClaudeCodeAdapter();
1155
1041
  const readSession = (adapter as any).readSession.bind(adapter);
1156
1042
 
1157
1043
  const filePath = path.join(tmpDir, 'metadata-test.jsonl');
@@ -1164,12 +1050,10 @@ describe('ClaudeCodeAdapter', () => {
1164
1050
  fs.writeFileSync(filePath, lines.join('\n'));
1165
1051
 
1166
1052
  const session = readSession(filePath, '/test');
1167
- // lastEntryType should be 'assistant', not 'last-prompt' or 'file-history-snapshot'
1168
1053
  expect(session.lastEntryType).toBe('assistant');
1169
1054
  });
1170
1055
 
1171
1056
  it('should parse snapshot.timestamp from file-history-snapshot first entry', () => {
1172
- const adapter = new ClaudeCodeAdapter();
1173
1057
  const readSession = (adapter as any).readSession.bind(adapter);
1174
1058
 
1175
1059
  const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
@@ -1184,13 +1068,11 @@ describe('ClaudeCodeAdapter', () => {
1184
1068
  fs.writeFileSync(filePath, lines.join('\n'));
1185
1069
 
1186
1070
  const session = readSession(filePath, '/test');
1187
- // sessionStart should come from snapshot.timestamp, not lastActive
1188
1071
  expect(session.sessionStart.toISOString()).toBe('2026-03-10T09:55:00.000Z');
1189
1072
  expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
1190
1073
  });
1191
1074
 
1192
1075
  it('should extract lastUserMessage from session entries', () => {
1193
- const adapter = new ClaudeCodeAdapter();
1194
1076
  const readSession = (adapter as any).readSession.bind(adapter);
1195
1077
 
1196
1078
  const filePath = path.join(tmpDir, 'user-msg.jsonl');
@@ -1203,12 +1085,10 @@ describe('ClaudeCodeAdapter', () => {
1203
1085
  fs.writeFileSync(filePath, lines.join('\n'));
1204
1086
 
1205
1087
  const session = readSession(filePath, '/test');
1206
- // Last user message should be the most recent one
1207
1088
  expect(session.lastUserMessage).toBe('second question');
1208
1089
  });
1209
1090
 
1210
1091
  it('should use lastCwd as projectPath when projectPath is empty', () => {
1211
- const adapter = new ClaudeCodeAdapter();
1212
1092
  const readSession = (adapter as any).readSession.bind(adapter);
1213
1093
 
1214
1094
  const filePath = path.join(tmpDir, 'no-project.jsonl');
@@ -1222,7 +1102,6 @@ describe('ClaudeCodeAdapter', () => {
1222
1102
  });
1223
1103
 
1224
1104
  it('should handle malformed JSON lines gracefully', () => {
1225
- const adapter = new ClaudeCodeAdapter();
1226
1105
  const readSession = (adapter as any).readSession.bind(adapter);
1227
1106
 
1228
1107
  const filePath = path.join(tmpDir, 'malformed.jsonl');
@@ -1237,131 +1116,5 @@ describe('ClaudeCodeAdapter', () => {
1237
1116
  expect(session.lastEntryType).toBe('assistant');
1238
1117
  });
1239
1118
  });
1240
-
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);
1246
-
1247
- expect(findSessionFiles(10)).toEqual([]);
1248
- });
1249
-
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);
1255
-
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
- );
1263
-
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
- });
1279
-
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);
1285
-
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
- );
1292
-
1293
- for (let i = 0; i < 5; i++) {
1294
- fs.writeFileSync(path.join(projDir, `session-${i}.jsonl`), '{}');
1295
- }
1296
-
1297
- const files = findSessionFiles(3);
1298
- expect(files).toHaveLength(3);
1299
- });
1300
-
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);
1306
-
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'), '{}');
1314
-
1315
- const files = findSessionFiles(10);
1316
- expect(files).toEqual([]);
1317
- });
1318
-
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);
1324
-
1325
- const projDir = path.join(projectsDir, 'no-index');
1326
- fs.mkdirSync(projDir, { recursive: true });
1327
- fs.writeFileSync(path.join(projDir, 'session.jsonl'), '{}');
1328
-
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
- });
1334
- });
1335
-
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);
1342
-
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
- );
1349
-
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
- });
1365
- });
1366
1119
  });
1367
1120
  });