@ai-devkit/agent-manager 0.16.0 → 0.18.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 (105) hide show
  1. package/.swcrc +27 -0
  2. package/dist/AgentManager.js +72 -74
  3. package/dist/AgentManager.js.map +1 -1
  4. package/dist/__tests__/AgentManager.test.js +564 -0
  5. package/dist/__tests__/AgentManager.test.js.map +1 -0
  6. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +2157 -0
  7. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -0
  8. package/dist/__tests__/adapters/CodexAdapter.test.js +1419 -0
  9. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -0
  10. package/dist/__tests__/adapters/CopilotAdapter.test.js +500 -0
  11. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -0
  12. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +1229 -0
  13. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -0
  14. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +626 -0
  15. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +1 -0
  16. package/dist/__tests__/adapters/PiAdapter.test.js +590 -0
  17. package/dist/__tests__/adapters/PiAdapter.test.js.map +1 -0
  18. package/dist/__tests__/terminal/TmuxManager.test.js +159 -0
  19. package/dist/__tests__/terminal/TmuxManager.test.js.map +1 -0
  20. package/dist/__tests__/terminal/TtyWriter.test.js +162 -0
  21. package/dist/__tests__/terminal/TtyWriter.test.js.map +1 -0
  22. package/dist/__tests__/utils/AgentRegistry.test.js +282 -0
  23. package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -0
  24. package/dist/__tests__/utils/ClaudeSessionParser.test.js +312 -0
  25. package/dist/__tests__/utils/ClaudeSessionParser.test.js.map +1 -0
  26. package/dist/__tests__/utils/matching.test.js +324 -0
  27. package/dist/__tests__/utils/matching.test.js.map +1 -0
  28. package/dist/__tests__/utils/process.test.js +177 -0
  29. package/dist/__tests__/utils/process.test.js.map +1 -0
  30. package/dist/__tests__/utils/session.test.js +158 -0
  31. package/dist/__tests__/utils/session.test.js.map +1 -0
  32. package/dist/adapters/AgentAdapter.d.ts +1 -1
  33. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  34. package/dist/adapters/AgentAdapter.js +8 -7
  35. package/dist/adapters/AgentAdapter.js.map +1 -1
  36. package/dist/adapters/ClaudeCodeAdapter.js +66 -77
  37. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  38. package/dist/adapters/CodexAdapter.d.ts +13 -0
  39. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  40. package/dist/adapters/CodexAdapter.js +225 -141
  41. package/dist/adapters/CodexAdapter.js.map +1 -1
  42. package/dist/adapters/CopilotAdapter.d.ts +44 -0
  43. package/dist/adapters/CopilotAdapter.d.ts.map +1 -0
  44. package/dist/adapters/CopilotAdapter.js +314 -0
  45. package/dist/adapters/CopilotAdapter.js.map +1 -0
  46. package/dist/adapters/GeminiCliAdapter.d.ts +2 -0
  47. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  48. package/dist/adapters/GeminiCliAdapter.js +138 -156
  49. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  50. package/dist/adapters/OpenCodeAdapter.js +60 -69
  51. package/dist/adapters/OpenCodeAdapter.js.map +1 -1
  52. package/dist/adapters/PiAdapter.d.ts +62 -0
  53. package/dist/adapters/PiAdapter.d.ts.map +1 -0
  54. package/dist/adapters/PiAdapter.js +450 -0
  55. package/dist/adapters/PiAdapter.js.map +1 -0
  56. package/dist/adapters/index.d.ts +2 -0
  57. package/dist/adapters/index.d.ts.map +1 -1
  58. package/dist/adapters/index.js +3 -0
  59. package/dist/adapters/index.js.map +1 -1
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/terminal/TerminalFocusManager.js +62 -49
  65. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  66. package/dist/terminal/TmuxManager.js +59 -34
  67. package/dist/terminal/TmuxManager.js.map +1 -1
  68. package/dist/terminal/TtyWriter.js +37 -16
  69. package/dist/terminal/TtyWriter.js.map +1 -1
  70. package/dist/terminal/index.js +1 -0
  71. package/dist/terminal/index.js.map +1 -1
  72. package/dist/utils/AgentRegistry.js +41 -32
  73. package/dist/utils/AgentRegistry.js.map +1 -1
  74. package/dist/utils/ClaudeSessionParser.js +46 -87
  75. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  76. package/dist/utils/agents.d.ts +1 -1
  77. package/dist/utils/agents.d.ts.map +1 -1
  78. package/dist/utils/agents.js +22 -11
  79. package/dist/utils/agents.js.map +1 -1
  80. package/dist/utils/applescript.js +3 -6
  81. package/dist/utils/applescript.js.map +1 -1
  82. package/dist/utils/index.js +1 -0
  83. package/dist/utils/index.js.map +1 -1
  84. package/dist/utils/matching.js +21 -29
  85. package/dist/utils/matching.js.map +1 -1
  86. package/dist/utils/process.js +77 -56
  87. package/dist/utils/process.js.map +1 -1
  88. package/dist/utils/session.js +20 -34
  89. package/dist/utils/session.js.map +1 -1
  90. package/dist/utils/sortAgents.js +6 -8
  91. package/dist/utils/sortAgents.js.map +1 -1
  92. package/package.json +11 -7
  93. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +2 -0
  94. package/src/__tests__/adapters/CodexAdapter.test.ts +145 -0
  95. package/src/__tests__/adapters/CopilotAdapter.test.ts +411 -0
  96. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +74 -1
  97. package/src/__tests__/adapters/PiAdapter.test.ts +435 -0
  98. package/src/adapters/AgentAdapter.ts +1 -1
  99. package/src/adapters/CodexAdapter.ts +164 -6
  100. package/src/adapters/CopilotAdapter.ts +453 -0
  101. package/src/adapters/GeminiCliAdapter.ts +42 -3
  102. package/src/adapters/PiAdapter.ts +597 -0
  103. package/src/adapters/index.ts +2 -0
  104. package/src/index.ts +2 -0
  105. package/src/utils/agents.ts +1 -1
@@ -0,0 +1,2157 @@
1
+ /**
2
+ * Tests for ClaudeCodeAdapter
3
+ */ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter.js';
6
+ import { AgentStatus } from '../../adapters/AgentAdapter.js';
7
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
8
+ import { batchGetSessionFileBirthtimes } from '../../utils/session.js';
9
+ import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
10
+ import * as os from 'os';
11
+ vi.mock('../../utils/process.js', ()=>({
12
+ listAgentProcesses: vi.fn(),
13
+ enrichProcesses: vi.fn()
14
+ }));
15
+ vi.mock('../../utils/session.js', async ()=>{
16
+ const actual = await vi.importActual('../../utils/session');
17
+ return {
18
+ ...actual,
19
+ batchGetSessionFileBirthtimes: vi.fn()
20
+ };
21
+ });
22
+ vi.mock('../../utils/matching.js', ()=>({
23
+ matchProcessesToSessions: vi.fn(),
24
+ generateAgentName: vi.fn()
25
+ }));
26
+ const mockedListAgentProcesses = listAgentProcesses;
27
+ const mockedEnrichProcesses = enrichProcesses;
28
+ const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes;
29
+ const mockedMatchProcessesToSessions = matchProcessesToSessions;
30
+ const mockedGenerateAgentName = generateAgentName;
31
+ describe('ClaudeCodeAdapter', ()=>{
32
+ let adapter;
33
+ beforeEach(()=>{
34
+ adapter = new ClaudeCodeAdapter();
35
+ mockedListAgentProcesses.mockReset();
36
+ mockedEnrichProcesses.mockReset();
37
+ mockedBatchGetSessionFileBirthtimes.mockReset();
38
+ mockedMatchProcessesToSessions.mockReset();
39
+ mockedGenerateAgentName.mockReset();
40
+ // Default: enrichProcesses returns what it receives
41
+ mockedEnrichProcesses.mockImplementation((procs)=>procs);
42
+ // Default: generateAgentName returns "folder (pid)"
43
+ mockedGenerateAgentName.mockImplementation((cwd, pid)=>{
44
+ const folder = path.basename(cwd) || 'unknown';
45
+ return `${folder} (${pid})`;
46
+ });
47
+ });
48
+ describe('initialization', ()=>{
49
+ it('should create adapter with correct type', ()=>{
50
+ expect(adapter.type).toBe('claude');
51
+ });
52
+ });
53
+ describe('canHandle', ()=>{
54
+ it('should return true for claude processes', ()=>{
55
+ const processInfo = {
56
+ pid: 12345,
57
+ command: 'claude',
58
+ cwd: '/test',
59
+ tty: 'ttys001'
60
+ };
61
+ expect(adapter.canHandle(processInfo)).toBe(true);
62
+ });
63
+ it('should return true for claude executable with full path', ()=>{
64
+ const processInfo = {
65
+ pid: 12345,
66
+ command: '/usr/local/bin/claude --some-flag',
67
+ cwd: '/test',
68
+ tty: 'ttys001'
69
+ };
70
+ expect(adapter.canHandle(processInfo)).toBe(true);
71
+ });
72
+ it('should return true for CLAUDE (case-insensitive)', ()=>{
73
+ const processInfo = {
74
+ pid: 12345,
75
+ command: '/usr/local/bin/CLAUDE --continue',
76
+ cwd: '/test',
77
+ tty: 'ttys001'
78
+ };
79
+ expect(adapter.canHandle(processInfo)).toBe(true);
80
+ });
81
+ it('should return false for non-claude processes', ()=>{
82
+ const processInfo = {
83
+ pid: 12345,
84
+ command: 'node',
85
+ cwd: '/test',
86
+ tty: 'ttys001'
87
+ };
88
+ expect(adapter.canHandle(processInfo)).toBe(false);
89
+ });
90
+ it('should return false for processes with "claude" only in path arguments', ()=>{
91
+ const processInfo = {
92
+ pid: 12345,
93
+ command: '/usr/local/bin/node /path/to/claude-worktree/node_modules/nx/start.js',
94
+ cwd: '/test',
95
+ tty: 'ttys001'
96
+ };
97
+ expect(adapter.canHandle(processInfo)).toBe(false);
98
+ });
99
+ });
100
+ describe('detectAgents', ()=>{
101
+ it('should return empty array if no claude processes running', async ()=>{
102
+ mockedListAgentProcesses.mockReturnValue([]);
103
+ const agents = await adapter.detectAgents();
104
+ expect(agents).toEqual([]);
105
+ expect(mockedListAgentProcesses).toHaveBeenCalledWith('claude');
106
+ });
107
+ it('should return process-only agents when no sessions discovered', async ()=>{
108
+ const processes = [
109
+ {
110
+ pid: 777,
111
+ command: 'claude',
112
+ cwd: '/project/app',
113
+ tty: 'ttys001'
114
+ }
115
+ ];
116
+ mockedListAgentProcesses.mockReturnValue(processes);
117
+ mockedEnrichProcesses.mockReturnValue(processes);
118
+ // No projects dir → discoverSessions returns []
119
+ adapter.projectsDir = '/nonexistent/path';
120
+ const agents = await adapter.detectAgents();
121
+ expect(agents).toHaveLength(1);
122
+ expect(agents[0]).toMatchObject({
123
+ type: 'claude',
124
+ status: AgentStatus.IDLE,
125
+ pid: 777,
126
+ projectPath: '/project/app',
127
+ sessionId: 'pid-777',
128
+ summary: 'Unknown'
129
+ });
130
+ });
131
+ it('should detect agents with matched sessions', async ()=>{
132
+ const processes = [
133
+ {
134
+ pid: 12345,
135
+ command: 'claude',
136
+ cwd: '/Users/test/my-project',
137
+ tty: 'ttys001',
138
+ startTime: new Date('2026-03-18T23:18:01.000Z')
139
+ }
140
+ ];
141
+ mockedListAgentProcesses.mockReturnValue(processes);
142
+ mockedEnrichProcesses.mockReturnValue(processes);
143
+ // Set up projects dir with encoded directory name
144
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
145
+ const projectsDir = path.join(tmpDir, 'projects');
146
+ // Claude encodes /Users/test/my-project → -Users-test-my-project
147
+ const projDir = path.join(projectsDir, '-Users-test-my-project');
148
+ fs.mkdirSync(projDir, {
149
+ recursive: true
150
+ });
151
+ // Create session file
152
+ const sessionFile = path.join(projDir, 'session-1.jsonl');
153
+ fs.writeFileSync(sessionFile, [
154
+ JSON.stringify({
155
+ type: 'user',
156
+ timestamp: '2026-03-18T23:18:44Z',
157
+ cwd: '/Users/test/my-project',
158
+ message: {
159
+ content: 'Investigate failing tests'
160
+ }
161
+ }),
162
+ JSON.stringify({
163
+ type: 'assistant',
164
+ timestamp: '2026-03-18T23:19:00Z'
165
+ })
166
+ ].join('\n'));
167
+ adapter.projectsDir = projectsDir;
168
+ const sessionFiles = [
169
+ {
170
+ sessionId: 'session-1',
171
+ filePath: sessionFile,
172
+ projectDir: projDir,
173
+ birthtimeMs: new Date('2026-03-18T23:18:44Z').getTime(),
174
+ resolvedCwd: ''
175
+ }
176
+ ];
177
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
178
+ const matches = [
179
+ {
180
+ process: processes[0],
181
+ session: {
182
+ ...sessionFiles[0],
183
+ resolvedCwd: '/Users/test/my-project'
184
+ },
185
+ deltaMs: 43000
186
+ }
187
+ ];
188
+ mockedMatchProcessesToSessions.mockReturnValue(matches);
189
+ const agents = await adapter.detectAgents();
190
+ expect(agents).toHaveLength(1);
191
+ expect(agents[0]).toMatchObject({
192
+ type: 'claude',
193
+ status: AgentStatus.WAITING,
194
+ pid: 12345,
195
+ projectPath: '/Users/test/my-project',
196
+ sessionId: 'session-1'
197
+ });
198
+ expect(agents[0].summary).toContain('Investigate failing tests');
199
+ fs.rmSync(tmpDir, {
200
+ recursive: true,
201
+ force: true
202
+ });
203
+ });
204
+ it('should fall back to process-only for unmatched processes', async ()=>{
205
+ const processes = [
206
+ {
207
+ pid: 100,
208
+ command: 'claude',
209
+ cwd: '/project-a',
210
+ tty: 'ttys001',
211
+ startTime: new Date()
212
+ },
213
+ {
214
+ pid: 200,
215
+ command: 'claude',
216
+ cwd: '/project-b',
217
+ tty: 'ttys002',
218
+ startTime: new Date()
219
+ }
220
+ ];
221
+ mockedListAgentProcesses.mockReturnValue(processes);
222
+ mockedEnrichProcesses.mockReturnValue(processes);
223
+ // Set up projects dir with encoded directory names
224
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
225
+ const projectsDir = path.join(tmpDir, 'projects');
226
+ // /project-a → -project-a, /project-b → -project-b
227
+ const projDirA = path.join(projectsDir, '-project-a');
228
+ const projDirB = path.join(projectsDir, '-project-b');
229
+ fs.mkdirSync(projDirA, {
230
+ recursive: true
231
+ });
232
+ fs.mkdirSync(projDirB, {
233
+ recursive: true
234
+ });
235
+ const sessionFile = path.join(projDirA, 'only-session.jsonl');
236
+ fs.writeFileSync(sessionFile, JSON.stringify({
237
+ type: 'assistant',
238
+ timestamp: '2026-03-18T23:19:00Z'
239
+ }));
240
+ adapter.projectsDir = projectsDir;
241
+ const sessionFiles = [
242
+ {
243
+ sessionId: 'only-session',
244
+ filePath: sessionFile,
245
+ projectDir: projDirA,
246
+ birthtimeMs: Date.now(),
247
+ resolvedCwd: ''
248
+ }
249
+ ];
250
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
251
+ // Only process 100 matches
252
+ const matches = [
253
+ {
254
+ process: processes[0],
255
+ session: {
256
+ ...sessionFiles[0],
257
+ resolvedCwd: '/project-a'
258
+ },
259
+ deltaMs: 5000
260
+ }
261
+ ];
262
+ mockedMatchProcessesToSessions.mockReturnValue(matches);
263
+ const agents = await adapter.detectAgents();
264
+ expect(agents).toHaveLength(2);
265
+ const matched = agents.find((a)=>a.pid === 100);
266
+ const unmatched = agents.find((a)=>a.pid === 200);
267
+ expect(matched?.sessionId).toBe('only-session');
268
+ expect(unmatched?.sessionId).toBe('pid-200');
269
+ expect(unmatched?.status).toBe(AgentStatus.IDLE);
270
+ fs.rmSync(tmpDir, {
271
+ recursive: true,
272
+ force: true
273
+ });
274
+ });
275
+ it('should handle process with empty cwd in process-only fallback', async ()=>{
276
+ const processes = [
277
+ {
278
+ pid: 300,
279
+ command: 'claude',
280
+ cwd: '',
281
+ tty: 'ttys003'
282
+ }
283
+ ];
284
+ mockedListAgentProcesses.mockReturnValue(processes);
285
+ mockedEnrichProcesses.mockReturnValue(processes);
286
+ adapter.projectsDir = '/nonexistent';
287
+ const agents = await adapter.detectAgents();
288
+ expect(agents).toHaveLength(1);
289
+ expect(agents[0]).toMatchObject({
290
+ pid: 300,
291
+ sessionId: 'pid-300',
292
+ summary: 'Unknown',
293
+ projectPath: ''
294
+ });
295
+ });
296
+ it('should match via --resume <uuid> in command line and skip PID-file/legacy', async ()=>{
297
+ const sessionId = '0555f803-7eca-4fc6-a1e0-34dbf86b33b2';
298
+ const processes = [
299
+ {
300
+ pid: 41920,
301
+ command: `claude --resume ${sessionId}`,
302
+ cwd: '/project/resumed',
303
+ tty: 'ttys001',
304
+ startTime: new Date()
305
+ }
306
+ ];
307
+ mockedListAgentProcesses.mockReturnValue(processes);
308
+ mockedEnrichProcesses.mockReturnValue(processes);
309
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-resume-'));
310
+ const projectsDir = path.join(tmpDir, 'projects');
311
+ const projDir = path.join(projectsDir, '-project-resumed');
312
+ fs.mkdirSync(projDir, {
313
+ recursive: true
314
+ });
315
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
316
+ fs.writeFileSync(jsonlPath, [
317
+ JSON.stringify({
318
+ type: 'user',
319
+ timestamp: new Date().toISOString(),
320
+ cwd: '/project/resumed',
321
+ message: {
322
+ content: 'resumed conversation'
323
+ }
324
+ }),
325
+ JSON.stringify({
326
+ type: 'assistant',
327
+ timestamp: new Date().toISOString()
328
+ })
329
+ ].join('\n'));
330
+ adapter.projectsDir = projectsDir;
331
+ adapter.sessionsDir = path.join(tmpDir, 'sessions'); // empty — no PID file
332
+ const agents = await adapter.detectAgents();
333
+ // Legacy matching helpers must NOT have been consulted — resume match was authoritative
334
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
335
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
336
+ expect(agents).toHaveLength(1);
337
+ expect(agents[0]).toMatchObject({
338
+ type: 'claude',
339
+ pid: 41920,
340
+ sessionId,
341
+ projectPath: '/project/resumed'
342
+ });
343
+ expect(agents[0].summary).toContain('resumed conversation');
344
+ fs.rmSync(tmpDir, {
345
+ recursive: true,
346
+ force: true
347
+ });
348
+ });
349
+ it('should fall through when --resume points to a JSONL that does not exist', async ()=>{
350
+ const processes = [
351
+ {
352
+ pid: 41921,
353
+ command: 'claude --resume aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
354
+ cwd: '/project/missing',
355
+ tty: 'ttys001',
356
+ startTime: new Date()
357
+ }
358
+ ];
359
+ mockedListAgentProcesses.mockReturnValue(processes);
360
+ mockedEnrichProcesses.mockReturnValue(processes);
361
+ // No projects dir, no PID file → both matchers fail → process-only
362
+ adapter.projectsDir = '/nonexistent';
363
+ adapter.sessionsDir = '/nonexistent';
364
+ const agents = await adapter.detectAgents();
365
+ expect(agents).toHaveLength(1);
366
+ expect(agents[0].sessionId).toBe('pid-41921');
367
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
368
+ });
369
+ it('should use PID file for direct match and skip legacy matching for that process', async ()=>{
370
+ const startTime = new Date();
371
+ const processes = [
372
+ {
373
+ pid: 55001,
374
+ command: 'claude',
375
+ cwd: '/project/direct',
376
+ tty: 'ttys001',
377
+ startTime
378
+ }
379
+ ];
380
+ mockedListAgentProcesses.mockReturnValue(processes);
381
+ mockedEnrichProcesses.mockReturnValue(processes);
382
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pid-test-'));
383
+ const sessionsDir = path.join(tmpDir, 'sessions');
384
+ const projectsDir = path.join(tmpDir, 'projects');
385
+ const projDir = path.join(projectsDir, '-project-direct');
386
+ fs.mkdirSync(sessionsDir, {
387
+ recursive: true
388
+ });
389
+ fs.mkdirSync(projDir, {
390
+ recursive: true
391
+ });
392
+ const sessionId = 'pid-file-session';
393
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
394
+ fs.writeFileSync(jsonlPath, [
395
+ JSON.stringify({
396
+ type: 'user',
397
+ timestamp: new Date().toISOString(),
398
+ cwd: '/project/direct',
399
+ message: {
400
+ content: 'hello from pid file'
401
+ }
402
+ }),
403
+ JSON.stringify({
404
+ type: 'assistant',
405
+ timestamp: new Date().toISOString()
406
+ })
407
+ ].join('\n'));
408
+ fs.writeFileSync(path.join(sessionsDir, '55001.json'), JSON.stringify({
409
+ pid: 55001,
410
+ sessionId,
411
+ cwd: '/project/direct',
412
+ startedAt: startTime.getTime(),
413
+ kind: 'interactive',
414
+ entrypoint: 'cli'
415
+ }));
416
+ adapter.sessionsDir = sessionsDir;
417
+ adapter.projectsDir = projectsDir;
418
+ const agents = await adapter.detectAgents();
419
+ // Legacy matching utilities should NOT have been called (all processes matched via PID file)
420
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
421
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
422
+ expect(agents).toHaveLength(1);
423
+ expect(agents[0]).toMatchObject({
424
+ type: 'claude',
425
+ pid: 55001,
426
+ sessionId,
427
+ projectPath: '/project/direct',
428
+ status: AgentStatus.WAITING
429
+ });
430
+ expect(agents[0].summary).toContain('hello from pid file');
431
+ fs.rmSync(tmpDir, {
432
+ recursive: true,
433
+ force: true
434
+ });
435
+ });
436
+ it('should prefer PID-file live status over JSONL-derived status and surface waitingFor in summary', async ()=>{
437
+ const startTime = new Date();
438
+ const processes = [
439
+ {
440
+ pid: 55050,
441
+ command: 'claude',
442
+ cwd: '/project/wait',
443
+ tty: 'ttys001',
444
+ startTime
445
+ }
446
+ ];
447
+ mockedListAgentProcesses.mockReturnValue(processes);
448
+ mockedEnrichProcesses.mockReturnValue(processes);
449
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pid-wait-'));
450
+ const sessionsDir = path.join(tmpDir, 'sessions');
451
+ const projectsDir = path.join(tmpDir, 'projects');
452
+ const projDir = path.join(projectsDir, '-project-wait');
453
+ fs.mkdirSync(sessionsDir, {
454
+ recursive: true
455
+ });
456
+ fs.mkdirSync(projDir, {
457
+ recursive: true
458
+ });
459
+ const sessionId = 'wait-session';
460
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
461
+ // PID file's live status must win over JSONL-derived status.
462
+ fs.writeFileSync(jsonlPath, [
463
+ JSON.stringify({
464
+ type: 'user',
465
+ timestamp: new Date().toISOString(),
466
+ cwd: '/project/wait',
467
+ message: {
468
+ content: '/reddit-commenter'
469
+ }
470
+ }),
471
+ JSON.stringify({
472
+ type: 'permission-mode',
473
+ timestamp: new Date().toISOString(),
474
+ permissionMode: 'default'
475
+ })
476
+ ].join('\n'));
477
+ fs.writeFileSync(path.join(sessionsDir, '55050.json'), JSON.stringify({
478
+ pid: 55050,
479
+ sessionId,
480
+ cwd: '/project/wait',
481
+ startedAt: startTime.getTime(),
482
+ kind: 'interactive',
483
+ entrypoint: 'cli',
484
+ status: 'waiting',
485
+ waitingFor: 'approve Read'
486
+ }));
487
+ adapter.sessionsDir = sessionsDir;
488
+ adapter.projectsDir = projectsDir;
489
+ const agents = await adapter.detectAgents();
490
+ expect(agents).toHaveLength(1);
491
+ expect(agents[0].status).toBe(AgentStatus.WAITING);
492
+ expect(agents[0].summary).toContain('/reddit-commenter');
493
+ expect(agents[0].summary).toContain('waiting for approve Read');
494
+ fs.rmSync(tmpDir, {
495
+ recursive: true,
496
+ force: true
497
+ });
498
+ });
499
+ it('should resolve PID-file status: "idle" to AgentStatus.IDLE even when JSONL would say UNKNOWN', async ()=>{
500
+ const startTime = new Date();
501
+ const processes = [
502
+ {
503
+ pid: 55051,
504
+ command: 'claude',
505
+ cwd: '/project/idle',
506
+ tty: 'ttys001',
507
+ startTime
508
+ }
509
+ ];
510
+ mockedListAgentProcesses.mockReturnValue(processes);
511
+ mockedEnrichProcesses.mockReturnValue(processes);
512
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pid-idle-'));
513
+ const sessionsDir = path.join(tmpDir, 'sessions');
514
+ const projectsDir = path.join(tmpDir, 'projects');
515
+ const projDir = path.join(projectsDir, '-project-idle');
516
+ fs.mkdirSync(sessionsDir, {
517
+ recursive: true
518
+ });
519
+ fs.mkdirSync(projDir, {
520
+ recursive: true
521
+ });
522
+ const sessionId = 'idle-session';
523
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
524
+ fs.writeFileSync(jsonlPath, [
525
+ JSON.stringify({
526
+ type: 'user',
527
+ timestamp: new Date().toISOString(),
528
+ cwd: '/project/idle',
529
+ message: {
530
+ content: 'hello'
531
+ }
532
+ }),
533
+ JSON.stringify({
534
+ type: 'permission-mode',
535
+ timestamp: new Date().toISOString(),
536
+ permissionMode: 'default'
537
+ })
538
+ ].join('\n'));
539
+ fs.writeFileSync(path.join(sessionsDir, '55051.json'), JSON.stringify({
540
+ pid: 55051,
541
+ sessionId,
542
+ cwd: '/project/idle',
543
+ startedAt: startTime.getTime(),
544
+ kind: 'interactive',
545
+ entrypoint: 'cli',
546
+ status: 'idle'
547
+ }));
548
+ adapter.sessionsDir = sessionsDir;
549
+ adapter.projectsDir = projectsDir;
550
+ const agents = await adapter.detectAgents();
551
+ expect(agents).toHaveLength(1);
552
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
553
+ // No waitingFor in PID file → summary is just the last user message
554
+ expect(agents[0].summary).toBe('hello');
555
+ fs.rmSync(tmpDir, {
556
+ recursive: true,
557
+ force: true
558
+ });
559
+ });
560
+ it('should fall back to JSONL-derived status when PID-file status is missing or unrecognized', async ()=>{
561
+ const startTime = new Date();
562
+ const processes = [
563
+ {
564
+ pid: 55052,
565
+ command: 'claude',
566
+ cwd: '/project/legacy',
567
+ tty: 'ttys001',
568
+ startTime
569
+ }
570
+ ];
571
+ mockedListAgentProcesses.mockReturnValue(processes);
572
+ mockedEnrichProcesses.mockReturnValue(processes);
573
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pid-nostat-'));
574
+ const sessionsDir = path.join(tmpDir, 'sessions');
575
+ const projectsDir = path.join(tmpDir, 'projects');
576
+ const projDir = path.join(projectsDir, '-project-legacy');
577
+ fs.mkdirSync(sessionsDir, {
578
+ recursive: true
579
+ });
580
+ fs.mkdirSync(projDir, {
581
+ recursive: true
582
+ });
583
+ const sessionId = 'legacy-session';
584
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
585
+ // Last entry is assistant → JSONL parser yields WAITING.
586
+ fs.writeFileSync(jsonlPath, [
587
+ JSON.stringify({
588
+ type: 'user',
589
+ timestamp: new Date().toISOString(),
590
+ cwd: '/project/legacy',
591
+ message: {
592
+ content: 'do the thing'
593
+ }
594
+ }),
595
+ JSON.stringify({
596
+ type: 'assistant',
597
+ timestamp: new Date().toISOString()
598
+ })
599
+ ].join('\n'));
600
+ // PID file with unrecognized status string — adapter must ignore it and use parser
601
+ fs.writeFileSync(path.join(sessionsDir, '55052.json'), JSON.stringify({
602
+ pid: 55052,
603
+ sessionId,
604
+ cwd: '/project/legacy',
605
+ startedAt: startTime.getTime(),
606
+ kind: 'interactive',
607
+ entrypoint: 'cli',
608
+ status: 'fantastical-future-state'
609
+ }));
610
+ adapter.sessionsDir = sessionsDir;
611
+ adapter.projectsDir = projectsDir;
612
+ const agents = await adapter.detectAgents();
613
+ expect(agents).toHaveLength(1);
614
+ expect(agents[0].status).toBe(AgentStatus.WAITING);
615
+ fs.rmSync(tmpDir, {
616
+ recursive: true,
617
+ force: true
618
+ });
619
+ });
620
+ it('should pick up live status from PID file even when matched via --resume', async ()=>{
621
+ const sessionId = 'aaaaaaaa-1111-2222-3333-444444444444';
622
+ const startTime = new Date();
623
+ const processes = [
624
+ {
625
+ pid: 55053,
626
+ command: `claude --resume ${sessionId}`,
627
+ cwd: '/project/resume-wait',
628
+ tty: 'ttys001',
629
+ startTime
630
+ }
631
+ ];
632
+ mockedListAgentProcesses.mockReturnValue(processes);
633
+ mockedEnrichProcesses.mockReturnValue(processes);
634
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-resume-wait-'));
635
+ const sessionsDir = path.join(tmpDir, 'sessions');
636
+ const projectsDir = path.join(tmpDir, 'projects');
637
+ const projDir = path.join(projectsDir, '-project-resume-wait');
638
+ fs.mkdirSync(sessionsDir, {
639
+ recursive: true
640
+ });
641
+ fs.mkdirSync(projDir, {
642
+ recursive: true
643
+ });
644
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
645
+ fs.writeFileSync(jsonlPath, [
646
+ JSON.stringify({
647
+ type: 'user',
648
+ timestamp: new Date().toISOString(),
649
+ cwd: '/project/resume-wait',
650
+ message: {
651
+ content: 'resumed work'
652
+ }
653
+ }),
654
+ JSON.stringify({
655
+ type: 'permission-mode',
656
+ timestamp: new Date().toISOString(),
657
+ permissionMode: 'default'
658
+ })
659
+ ].join('\n'));
660
+ fs.writeFileSync(path.join(sessionsDir, '55053.json'), JSON.stringify({
661
+ pid: 55053,
662
+ sessionId,
663
+ cwd: '/project/resume-wait',
664
+ startedAt: startTime.getTime(),
665
+ kind: 'interactive',
666
+ entrypoint: 'cli',
667
+ status: 'waiting',
668
+ waitingFor: 'approve Bash'
669
+ }));
670
+ adapter.sessionsDir = sessionsDir;
671
+ adapter.projectsDir = projectsDir;
672
+ const agents = await adapter.detectAgents();
673
+ expect(agents).toHaveLength(1);
674
+ expect(agents[0].status).toBe(AgentStatus.WAITING);
675
+ expect(agents[0].summary).toContain('resumed work');
676
+ expect(agents[0].summary).toContain('waiting for approve Bash');
677
+ fs.rmSync(tmpDir, {
678
+ recursive: true,
679
+ force: true
680
+ });
681
+ });
682
+ it('should fall back to process-only when direct-matched JSONL becomes unreadable', async ()=>{
683
+ const startTime = new Date();
684
+ const processes = [
685
+ {
686
+ pid: 66001,
687
+ command: 'claude',
688
+ cwd: '/project/gone',
689
+ tty: 'ttys001',
690
+ startTime
691
+ }
692
+ ];
693
+ mockedListAgentProcesses.mockReturnValue(processes);
694
+ mockedEnrichProcesses.mockReturnValue(processes);
695
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-gone-'));
696
+ const sessionsDir = path.join(tmpDir, 'sessions');
697
+ const projectsDir = path.join(tmpDir, 'projects');
698
+ const projDir = path.join(projectsDir, '-project-gone');
699
+ fs.mkdirSync(sessionsDir, {
700
+ recursive: true
701
+ });
702
+ fs.mkdirSync(projDir, {
703
+ recursive: true
704
+ });
705
+ const sessionId = 'gone-session';
706
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
707
+ fs.writeFileSync(jsonlPath, JSON.stringify({
708
+ type: 'assistant',
709
+ timestamp: new Date().toISOString()
710
+ }));
711
+ fs.writeFileSync(path.join(sessionsDir, '66001.json'), JSON.stringify({
712
+ pid: 66001,
713
+ sessionId,
714
+ cwd: '/project/gone',
715
+ startedAt: startTime.getTime(),
716
+ kind: 'interactive',
717
+ entrypoint: 'cli'
718
+ }));
719
+ adapter.sessionsDir = sessionsDir;
720
+ adapter.projectsDir = projectsDir;
721
+ // Simulate JSONL disappearing between existence check and read
722
+ vi.spyOn(adapter.parser, 'readSession').mockReturnValueOnce(null);
723
+ const agents = await adapter.detectAgents();
724
+ // matchedPids.delete called → process falls back to IDLE
725
+ expect(agents).toHaveLength(1);
726
+ expect(agents[0].sessionId).toBe('pid-66001');
727
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
728
+ fs.rmSync(tmpDir, {
729
+ recursive: true,
730
+ force: true
731
+ });
732
+ vi.restoreAllMocks();
733
+ });
734
+ it('should fall back to process-only when legacy-matched JSONL becomes unreadable', async ()=>{
735
+ const startTime = new Date();
736
+ const processes = [
737
+ {
738
+ pid: 66002,
739
+ command: 'claude',
740
+ cwd: '/project/legacy-gone',
741
+ tty: 'ttys001',
742
+ startTime
743
+ }
744
+ ];
745
+ mockedListAgentProcesses.mockReturnValue(processes);
746
+ mockedEnrichProcesses.mockReturnValue(processes);
747
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-lgone-'));
748
+ const projectsDir = path.join(tmpDir, 'projects');
749
+ const projDir = path.join(projectsDir, '-project-legacy-gone');
750
+ fs.mkdirSync(projDir, {
751
+ recursive: true
752
+ });
753
+ const sessionId = 'legacy-gone-session';
754
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
755
+ fs.writeFileSync(jsonlPath, JSON.stringify({
756
+ type: 'assistant',
757
+ timestamp: new Date().toISOString()
758
+ }));
759
+ // No PID file → process goes to legacy fallback
760
+ adapter.sessionsDir = path.join(tmpDir, 'no-sessions');
761
+ adapter.projectsDir = projectsDir;
762
+ const legacySessionFile = {
763
+ sessionId,
764
+ filePath: jsonlPath,
765
+ projectDir: projDir,
766
+ birthtimeMs: startTime.getTime(),
767
+ resolvedCwd: '/project/legacy-gone'
768
+ };
769
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
770
+ legacySessionFile
771
+ ]);
772
+ mockedMatchProcessesToSessions.mockReturnValue([
773
+ {
774
+ process: processes[0],
775
+ session: legacySessionFile,
776
+ deltaMs: 500
777
+ }
778
+ ]);
779
+ // Simulate JSONL disappearing between match and read
780
+ vi.spyOn(adapter.parser, 'readSession').mockReturnValueOnce(null);
781
+ const agents = await adapter.detectAgents();
782
+ expect(agents).toHaveLength(1);
783
+ expect(agents[0].sessionId).toBe('pid-66002');
784
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
785
+ fs.rmSync(tmpDir, {
786
+ recursive: true,
787
+ force: true
788
+ });
789
+ vi.restoreAllMocks();
790
+ });
791
+ it('should mix direct PID-file matches and legacy matches across processes', async ()=>{
792
+ const startTime = new Date();
793
+ const processes = [
794
+ {
795
+ pid: 55002,
796
+ command: 'claude',
797
+ cwd: '/project/alpha',
798
+ tty: 'ttys001',
799
+ startTime
800
+ },
801
+ {
802
+ pid: 55003,
803
+ command: 'claude',
804
+ cwd: '/project/beta',
805
+ tty: 'ttys002',
806
+ startTime
807
+ }
808
+ ];
809
+ mockedListAgentProcesses.mockReturnValue(processes);
810
+ mockedEnrichProcesses.mockReturnValue(processes);
811
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-mix-test-'));
812
+ const sessionsDir = path.join(tmpDir, 'sessions');
813
+ const projectsDir = path.join(tmpDir, 'projects');
814
+ const projAlpha = path.join(projectsDir, '-project-alpha');
815
+ const projBeta = path.join(projectsDir, '-project-beta');
816
+ fs.mkdirSync(sessionsDir, {
817
+ recursive: true
818
+ });
819
+ fs.mkdirSync(projAlpha, {
820
+ recursive: true
821
+ });
822
+ fs.mkdirSync(projBeta, {
823
+ recursive: true
824
+ });
825
+ // PID file only for process 55002
826
+ const directSessionId = 'direct-session';
827
+ const directJsonl = path.join(projAlpha, `${directSessionId}.jsonl`);
828
+ fs.writeFileSync(directJsonl, [
829
+ JSON.stringify({
830
+ type: 'user',
831
+ timestamp: new Date().toISOString(),
832
+ cwd: '/project/alpha',
833
+ message: {
834
+ content: 'direct question'
835
+ }
836
+ }),
837
+ JSON.stringify({
838
+ type: 'assistant',
839
+ timestamp: new Date().toISOString()
840
+ })
841
+ ].join('\n'));
842
+ fs.writeFileSync(path.join(sessionsDir, '55002.json'), JSON.stringify({
843
+ pid: 55002,
844
+ sessionId: directSessionId,
845
+ cwd: '/project/alpha',
846
+ startedAt: startTime.getTime(),
847
+ kind: 'interactive',
848
+ entrypoint: 'cli'
849
+ }));
850
+ // Legacy session file for process 55003
851
+ const legacySessionId = 'legacy-session';
852
+ const legacyJsonl = path.join(projBeta, `${legacySessionId}.jsonl`);
853
+ fs.writeFileSync(legacyJsonl, [
854
+ JSON.stringify({
855
+ type: 'user',
856
+ timestamp: new Date().toISOString(),
857
+ cwd: '/project/beta',
858
+ message: {
859
+ content: 'legacy question'
860
+ }
861
+ }),
862
+ JSON.stringify({
863
+ type: 'assistant',
864
+ timestamp: new Date().toISOString()
865
+ })
866
+ ].join('\n'));
867
+ adapter.sessionsDir = sessionsDir;
868
+ adapter.projectsDir = projectsDir;
869
+ // Mock legacy matching for process 55003
870
+ const legacySessionFile = {
871
+ sessionId: legacySessionId,
872
+ filePath: legacyJsonl,
873
+ projectDir: projBeta,
874
+ birthtimeMs: startTime.getTime(),
875
+ resolvedCwd: '/project/beta'
876
+ };
877
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
878
+ legacySessionFile
879
+ ]);
880
+ mockedMatchProcessesToSessions.mockReturnValue([
881
+ {
882
+ process: processes[1],
883
+ session: legacySessionFile,
884
+ deltaMs: 1000
885
+ }
886
+ ]);
887
+ const agents = await adapter.detectAgents();
888
+ // Legacy matching called only for fallback process (55003)
889
+ expect(mockedMatchProcessesToSessions).toHaveBeenCalledTimes(1);
890
+ expect(mockedMatchProcessesToSessions.mock.calls[0][0]).toEqual([
891
+ processes[1]
892
+ ]);
893
+ expect(agents).toHaveLength(2);
894
+ const alpha = agents.find((a)=>a.pid === 55002);
895
+ const beta = agents.find((a)=>a.pid === 55003);
896
+ expect(alpha?.sessionId).toBe(directSessionId);
897
+ expect(beta?.sessionId).toBe(legacySessionId);
898
+ fs.rmSync(tmpDir, {
899
+ recursive: true,
900
+ force: true
901
+ });
902
+ });
903
+ });
904
+ describe('discoverSessions', ()=>{
905
+ let tmpDir;
906
+ beforeEach(()=>{
907
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
908
+ adapter.projectsDir = path.join(tmpDir, 'projects');
909
+ });
910
+ afterEach(()=>{
911
+ fs.rmSync(tmpDir, {
912
+ recursive: true,
913
+ force: true
914
+ });
915
+ });
916
+ it('should return empty when projects dir does not exist', ()=>{
917
+ adapter.projectsDir = path.join(tmpDir, 'nonexistent');
918
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
919
+ const result = discoverSessions([
920
+ {
921
+ pid: 1,
922
+ command: 'claude',
923
+ cwd: '/test',
924
+ tty: ''
925
+ }
926
+ ]);
927
+ expect(result).toEqual([]);
928
+ });
929
+ it('should scan only directories matching process CWDs', ()=>{
930
+ const projectsDir = path.join(tmpDir, 'projects');
931
+ adapter.projectsDir = projectsDir;
932
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
933
+ // /my/project → -my-project (encoded dir)
934
+ const encodedDir = path.join(projectsDir, '-my-project');
935
+ fs.mkdirSync(encodedDir, {
936
+ recursive: true
937
+ });
938
+ // Also create another dir that should NOT be scanned
939
+ const otherDir = path.join(projectsDir, '-other-project');
940
+ fs.mkdirSync(otherDir, {
941
+ recursive: true
942
+ });
943
+ const mockFiles = [
944
+ {
945
+ sessionId: 's1',
946
+ filePath: path.join(encodedDir, 's1.jsonl'),
947
+ projectDir: encodedDir,
948
+ birthtimeMs: 1710800324000,
949
+ resolvedCwd: ''
950
+ }
951
+ ];
952
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles);
953
+ const processes = [
954
+ {
955
+ pid: 1,
956
+ command: 'claude',
957
+ cwd: '/my/project',
958
+ tty: ''
959
+ }
960
+ ];
961
+ const result = discoverSessions(processes);
962
+ expect(result).toHaveLength(1);
963
+ expect(result[0].resolvedCwd).toBe('/my/project');
964
+ // batchGetSessionFileBirthtimes called once with all dirs
965
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
966
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([
967
+ encodedDir
968
+ ]);
969
+ });
970
+ it('should return empty when encoded dir does not exist', ()=>{
971
+ const projectsDir = path.join(tmpDir, 'projects');
972
+ fs.mkdirSync(projectsDir, {
973
+ recursive: true
974
+ });
975
+ adapter.projectsDir = projectsDir;
976
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
977
+ // Process CWD /test encodes to -test, but that dir doesn't exist
978
+ const result = discoverSessions([
979
+ {
980
+ pid: 1,
981
+ command: 'claude',
982
+ cwd: '/test',
983
+ tty: ''
984
+ }
985
+ ]);
986
+ expect(result).toEqual([]);
987
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
988
+ });
989
+ it('should deduplicate when multiple processes share same CWD', ()=>{
990
+ const projectsDir = path.join(tmpDir, 'projects');
991
+ adapter.projectsDir = projectsDir;
992
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
993
+ const encodedDir = path.join(projectsDir, '-my-project');
994
+ fs.mkdirSync(encodedDir, {
995
+ recursive: true
996
+ });
997
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
998
+ {
999
+ sessionId: 's1',
1000
+ filePath: path.join(encodedDir, 's1.jsonl'),
1001
+ projectDir: encodedDir,
1002
+ birthtimeMs: 1710800324000,
1003
+ resolvedCwd: ''
1004
+ }
1005
+ ]);
1006
+ const processes = [
1007
+ {
1008
+ pid: 1,
1009
+ command: 'claude',
1010
+ cwd: '/my/project',
1011
+ tty: ''
1012
+ },
1013
+ {
1014
+ pid: 2,
1015
+ command: 'claude',
1016
+ cwd: '/my/project',
1017
+ tty: ''
1018
+ }
1019
+ ];
1020
+ const result = discoverSessions(processes);
1021
+ // Should only call batch once with deduplicated dir
1022
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
1023
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([
1024
+ encodedDir
1025
+ ]);
1026
+ expect(result).toHaveLength(1);
1027
+ });
1028
+ it('should skip processes with empty cwd', ()=>{
1029
+ const projectsDir = path.join(tmpDir, 'projects');
1030
+ fs.mkdirSync(projectsDir, {
1031
+ recursive: true
1032
+ });
1033
+ adapter.projectsDir = projectsDir;
1034
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
1035
+ const result = discoverSessions([
1036
+ {
1037
+ pid: 1,
1038
+ command: 'claude',
1039
+ cwd: '',
1040
+ tty: ''
1041
+ }
1042
+ ]);
1043
+ expect(result).toEqual([]);
1044
+ });
1045
+ });
1046
+ describe('helper methods', ()=>{
1047
+ describe('determineStatus', ()=>{
1048
+ it('should return "unknown" for sessions with no last entry type', ()=>{
1049
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1050
+ const session = {
1051
+ sessionId: 'test',
1052
+ projectPath: '/test',
1053
+ sessionStart: new Date(),
1054
+ lastActive: new Date(),
1055
+ isInterrupted: false
1056
+ };
1057
+ expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
1058
+ });
1059
+ it('should return "waiting" for assistant entries', ()=>{
1060
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1061
+ const session = {
1062
+ sessionId: 'test',
1063
+ projectPath: '/test',
1064
+ sessionStart: new Date(),
1065
+ lastActive: new Date(),
1066
+ lastEntryType: 'assistant',
1067
+ isInterrupted: false
1068
+ };
1069
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
1070
+ });
1071
+ it('should return "waiting" for user interruption', ()=>{
1072
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1073
+ const session = {
1074
+ sessionId: 'test',
1075
+ projectPath: '/test',
1076
+ sessionStart: new Date(),
1077
+ lastActive: new Date(),
1078
+ lastEntryType: 'user',
1079
+ isInterrupted: true
1080
+ };
1081
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
1082
+ });
1083
+ it('should return "running" for user/progress entries', ()=>{
1084
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1085
+ const session = {
1086
+ sessionId: 'test',
1087
+ projectPath: '/test',
1088
+ sessionStart: new Date(),
1089
+ lastActive: new Date(),
1090
+ lastEntryType: 'user',
1091
+ isInterrupted: false
1092
+ };
1093
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
1094
+ });
1095
+ it('should not override status based on age (process is running)', ()=>{
1096
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1097
+ const oldDate = new Date(Date.now() - 10 * 60 * 1000);
1098
+ const session = {
1099
+ sessionId: 'test',
1100
+ projectPath: '/test',
1101
+ sessionStart: oldDate,
1102
+ lastActive: oldDate,
1103
+ lastEntryType: 'assistant',
1104
+ isInterrupted: false
1105
+ };
1106
+ expect(determineStatus(session)).toBe(AgentStatus.WAITING);
1107
+ });
1108
+ it('should return "idle" for system entries', ()=>{
1109
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1110
+ const session = {
1111
+ sessionId: 'test',
1112
+ projectPath: '/test',
1113
+ sessionStart: new Date(),
1114
+ lastActive: new Date(),
1115
+ lastEntryType: 'system',
1116
+ isInterrupted: false
1117
+ };
1118
+ expect(determineStatus(session)).toBe(AgentStatus.IDLE);
1119
+ });
1120
+ it('should return "running" for thinking entries', ()=>{
1121
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1122
+ const session = {
1123
+ sessionId: 'test',
1124
+ projectPath: '/test',
1125
+ sessionStart: new Date(),
1126
+ lastActive: new Date(),
1127
+ lastEntryType: 'thinking',
1128
+ isInterrupted: false
1129
+ };
1130
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
1131
+ });
1132
+ it('should return "running" for progress entries', ()=>{
1133
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1134
+ const session = {
1135
+ sessionId: 'test',
1136
+ projectPath: '/test',
1137
+ sessionStart: new Date(),
1138
+ lastActive: new Date(),
1139
+ lastEntryType: 'progress',
1140
+ isInterrupted: false
1141
+ };
1142
+ expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
1143
+ });
1144
+ it('should return "unknown" for unrecognized entry types', ()=>{
1145
+ const determineStatus = adapter.parser.determineStatus.bind(adapter.parser);
1146
+ const session = {
1147
+ sessionId: 'test',
1148
+ projectPath: '/test',
1149
+ sessionStart: new Date(),
1150
+ lastActive: new Date(),
1151
+ lastEntryType: 'some_other_type',
1152
+ isInterrupted: false
1153
+ };
1154
+ expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
1155
+ });
1156
+ });
1157
+ describe('extractUserMessageText', ()=>{
1158
+ it('should extract plain string content', ()=>{
1159
+ const extract = adapter.parser['extractUserMessageText'].bind(adapter.parser);
1160
+ expect(extract('hello world')).toBe('hello world');
1161
+ });
1162
+ it('should extract text from array content blocks', ()=>{
1163
+ const extract = adapter.parser['extractUserMessageText'].bind(adapter.parser);
1164
+ const content = [
1165
+ {
1166
+ type: 'tool_result',
1167
+ content: 'some result'
1168
+ },
1169
+ {
1170
+ type: 'text',
1171
+ text: 'user question'
1172
+ }
1173
+ ];
1174
+ expect(extract(content)).toBe('user question');
1175
+ });
1176
+ it('should return undefined for empty/null content', ()=>{
1177
+ const extract = adapter.parser['extractUserMessageText'].bind(adapter.parser);
1178
+ expect(extract(undefined)).toBeUndefined();
1179
+ expect(extract('')).toBeUndefined();
1180
+ expect(extract([])).toBeUndefined();
1181
+ });
1182
+ it('should parse command-message tags', ()=>{
1183
+ const extract = adapter.parser['extractUserMessageText'].bind(adapter.parser);
1184
+ const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
1185
+ expect(extract(msg)).toBe('commit fix bug');
1186
+ });
1187
+ it('should parse command-message without args', ()=>{
1188
+ const extract = adapter.parser['extractUserMessageText'].bind(adapter.parser);
1189
+ const msg = '<command-message><command-name>help</command-name></command-message>';
1190
+ expect(extract(msg)).toBe('help');
1191
+ });
1192
+ it('should extract ARGUMENTS from skill expansion', ()=>{
1193
+ const extract = adapter.parser['extractUserMessageText'].bind(adapter.parser);
1194
+ const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
1195
+ expect(extract(msg)).toBe('implement the feature');
1196
+ });
1197
+ it('should return undefined for skill expansion without ARGUMENTS', ()=>{
1198
+ const extract = adapter.parser['extractUserMessageText'].bind(adapter.parser);
1199
+ const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
1200
+ expect(extract(msg)).toBeUndefined();
1201
+ });
1202
+ it('should filter noise messages', ()=>{
1203
+ const extract = adapter.parser['extractUserMessageText'].bind(adapter.parser);
1204
+ expect(extract('[Request interrupted by user]')).toBeUndefined();
1205
+ expect(extract('Tool loaded.')).toBeUndefined();
1206
+ expect(extract('This session is being continued from a previous conversation')).toBeUndefined();
1207
+ });
1208
+ });
1209
+ describe('parseCommandMessage', ()=>{
1210
+ it('should return undefined for malformed command-message', ()=>{
1211
+ const parse = adapter.parser['parseCommandMessage'].bind(adapter.parser);
1212
+ expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
1213
+ });
1214
+ });
1215
+ });
1216
+ describe('file I/O methods', ()=>{
1217
+ let tmpDir;
1218
+ beforeEach(()=>{
1219
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
1220
+ adapter.projectsDir = path.join(tmpDir, 'projects');
1221
+ });
1222
+ afterEach(()=>{
1223
+ fs.rmSync(tmpDir, {
1224
+ recursive: true,
1225
+ force: true
1226
+ });
1227
+ });
1228
+ describe('tryPidFileMatching', ()=>{
1229
+ let sessionsDir;
1230
+ let projectsDir;
1231
+ beforeEach(()=>{
1232
+ sessionsDir = path.join(tmpDir, 'sessions');
1233
+ projectsDir = path.join(tmpDir, 'projects');
1234
+ fs.mkdirSync(sessionsDir, {
1235
+ recursive: true
1236
+ });
1237
+ adapter.sessionsDir = sessionsDir;
1238
+ adapter.projectsDir = projectsDir;
1239
+ });
1240
+ const makeProc = (pid, cwd = '/project/test', startTime)=>({
1241
+ pid,
1242
+ command: 'claude',
1243
+ cwd,
1244
+ tty: 'ttys001',
1245
+ startTime
1246
+ });
1247
+ const writePidFile = (pid, sessionId, cwd, startedAt)=>{
1248
+ fs.writeFileSync(path.join(sessionsDir, `${pid}.json`), JSON.stringify({
1249
+ pid,
1250
+ sessionId,
1251
+ cwd,
1252
+ startedAt,
1253
+ kind: 'interactive',
1254
+ entrypoint: 'cli'
1255
+ }));
1256
+ };
1257
+ const writeJsonl = (cwd, sessionId)=>{
1258
+ const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-');
1259
+ const projDir = path.join(projectsDir, encoded);
1260
+ fs.mkdirSync(projDir, {
1261
+ recursive: true
1262
+ });
1263
+ const filePath = path.join(projDir, `${sessionId}.jsonl`);
1264
+ fs.writeFileSync(filePath, JSON.stringify({
1265
+ type: 'assistant',
1266
+ timestamp: new Date().toISOString()
1267
+ }));
1268
+ return filePath;
1269
+ };
1270
+ it('should return direct match when PID file and JSONL both exist within time tolerance', ()=>{
1271
+ const startTime = new Date();
1272
+ const proc = makeProc(1001, '/project/test', startTime);
1273
+ writePidFile(1001, 'session-abc', '/project/test', startTime.getTime());
1274
+ writeJsonl('/project/test', 'session-abc');
1275
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1276
+ const { direct, fallback } = tryMatch([
1277
+ proc
1278
+ ]);
1279
+ expect(direct).toHaveLength(1);
1280
+ expect(fallback).toHaveLength(0);
1281
+ expect(direct[0].sessionFile.sessionId).toBe('session-abc');
1282
+ expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test');
1283
+ expect(direct[0].process.pid).toBe(1001);
1284
+ });
1285
+ it('should fall back when PID file exists but JSONL is missing', ()=>{
1286
+ const startTime = new Date();
1287
+ const proc = makeProc(1002, '/project/test', startTime);
1288
+ writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime());
1289
+ // No JSONL file written
1290
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1291
+ const { direct, fallback } = tryMatch([
1292
+ proc
1293
+ ]);
1294
+ expect(direct).toHaveLength(0);
1295
+ expect(fallback).toHaveLength(1);
1296
+ expect(fallback[0].pid).toBe(1002);
1297
+ });
1298
+ it('should fall back when startedAt is stale (>60s from proc.startTime)', ()=>{
1299
+ const startTime = new Date();
1300
+ const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier
1301
+ const proc = makeProc(1003, '/project/test', startTime);
1302
+ writePidFile(1003, 'stale-session', '/project/test', staleTime);
1303
+ writeJsonl('/project/test', 'stale-session');
1304
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1305
+ const { direct, fallback } = tryMatch([
1306
+ proc
1307
+ ]);
1308
+ expect(direct).toHaveLength(0);
1309
+ expect(fallback).toHaveLength(1);
1310
+ });
1311
+ it('should accept PID file when startedAt is within 60s tolerance', ()=>{
1312
+ const startTime = new Date();
1313
+ const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance
1314
+ const proc = makeProc(1004, '/project/test', startTime);
1315
+ writePidFile(1004, 'close-session', '/project/test', closeTime);
1316
+ writeJsonl('/project/test', 'close-session');
1317
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1318
+ const { direct, fallback } = tryMatch([
1319
+ proc
1320
+ ]);
1321
+ expect(direct).toHaveLength(1);
1322
+ expect(fallback).toHaveLength(0);
1323
+ });
1324
+ it('should fall back when PID file is absent', ()=>{
1325
+ const proc = makeProc(1005, '/project/test', new Date());
1326
+ // No PID file written
1327
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1328
+ const { direct, fallback } = tryMatch([
1329
+ proc
1330
+ ]);
1331
+ expect(direct).toHaveLength(0);
1332
+ expect(fallback).toHaveLength(1);
1333
+ });
1334
+ it('should fall back when PID file contains malformed JSON', ()=>{
1335
+ const proc = makeProc(1006, '/project/test', new Date());
1336
+ fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{');
1337
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1338
+ expect(()=>{
1339
+ const { direct, fallback } = tryMatch([
1340
+ proc
1341
+ ]);
1342
+ expect(direct).toHaveLength(0);
1343
+ expect(fallback).toHaveLength(1);
1344
+ }).not.toThrow();
1345
+ });
1346
+ it('should fall back for all processes when sessions dir does not exist', ()=>{
1347
+ adapter.sessionsDir = path.join(tmpDir, 'nonexistent-sessions');
1348
+ const processes = [
1349
+ makeProc(2001, '/a', new Date()),
1350
+ makeProc(2002, '/b', new Date())
1351
+ ];
1352
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1353
+ const { direct, fallback } = tryMatch(processes);
1354
+ expect(direct).toHaveLength(0);
1355
+ expect(fallback).toHaveLength(2);
1356
+ });
1357
+ it('should correctly split mixed processes (some with PID files, some without)', ()=>{
1358
+ const startTime = new Date();
1359
+ const proc1 = makeProc(3001, '/project/one', startTime);
1360
+ const proc2 = makeProc(3002, '/project/two', startTime);
1361
+ const proc3 = makeProc(3003, '/project/three', startTime);
1362
+ writePidFile(3001, 'session-one', '/project/one', startTime.getTime());
1363
+ writeJsonl('/project/one', 'session-one');
1364
+ writePidFile(3003, 'session-three', '/project/three', startTime.getTime());
1365
+ writeJsonl('/project/three', 'session-three');
1366
+ // proc2 has no PID file
1367
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1368
+ const { direct, fallback } = tryMatch([
1369
+ proc1,
1370
+ proc2,
1371
+ proc3
1372
+ ]);
1373
+ expect(direct).toHaveLength(2);
1374
+ expect(fallback).toHaveLength(1);
1375
+ expect(direct.map((d)=>d.process.pid).sort()).toEqual([
1376
+ 3001,
1377
+ 3003
1378
+ ]);
1379
+ expect(fallback[0].pid).toBe(3002);
1380
+ });
1381
+ it('should skip stale-file check when proc.startTime is undefined', ()=>{
1382
+ const proc = makeProc(4001, '/project/test', undefined); // no startTime
1383
+ writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999);
1384
+ writeJsonl('/project/test', 'no-time-session');
1385
+ const tryMatch = adapter.tryPidFileMatching.bind(adapter);
1386
+ const { direct, fallback } = tryMatch([
1387
+ proc
1388
+ ]);
1389
+ // startTime undefined → stale check skipped → direct match
1390
+ expect(direct).toHaveLength(1);
1391
+ expect(fallback).toHaveLength(0);
1392
+ });
1393
+ });
1394
+ describe('getProjectDir', ()=>{
1395
+ const encode = (cwd)=>adapter.getProjectDir(cwd);
1396
+ it('should replace path separators with hyphens', ()=>{
1397
+ const expected = path.join(adapter.projectsDir, '-Users-foo-bar');
1398
+ expect(encode('/Users/foo/bar')).toBe(expected);
1399
+ });
1400
+ it('should encode underscores as hyphens (matches Claude Code CLI)', ()=>{
1401
+ const expected = path.join(adapter.projectsDir, '-Users-foo-my-project');
1402
+ expect(encode('/Users/foo/my_project')).toBe(expected);
1403
+ });
1404
+ it('should encode dots as hyphens', ()=>{
1405
+ const expected = path.join(adapter.projectsDir, '-Users-foo--worktrees-x');
1406
+ expect(encode('/Users/foo/.worktrees/x')).toBe(expected);
1407
+ });
1408
+ it('should collide paths that differ only in non-alphanumeric chars', ()=>{
1409
+ // The encoding is intentionally lossy — callers must
1410
+ // disambiguate via session JSONL contents, not dir name.
1411
+ expect(encode('/a/b_c')).toBe(encode('/a/b-c'));
1412
+ expect(encode('/a/b_c')).toBe(encode('/a/b.c'));
1413
+ });
1414
+ it('should resolve to a real session dir when cwd contains underscores', ()=>{
1415
+ const cwd = '/Users/foo/my_project';
1416
+ const projectsDir = adapter.projectsDir;
1417
+ const expectedDir = path.join(projectsDir, '-Users-foo-my-project');
1418
+ fs.mkdirSync(expectedDir, {
1419
+ recursive: true
1420
+ });
1421
+ const sessionFile = path.join(expectedDir, 'session-underscore.jsonl');
1422
+ fs.writeFileSync(sessionFile, '');
1423
+ expect(encode(cwd)).toBe(expectedDir);
1424
+ expect(fs.existsSync(path.join(encode(cwd), 'session-underscore.jsonl'))).toBe(true);
1425
+ });
1426
+ });
1427
+ describe('readSession', ()=>{
1428
+ it('should parse session file with timestamps, cwd, and entry type', ()=>{
1429
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1430
+ const filePath = path.join(tmpDir, 'test-session.jsonl');
1431
+ const lines = [
1432
+ JSON.stringify({
1433
+ type: 'user',
1434
+ timestamp: '2026-03-10T10:00:00Z',
1435
+ cwd: '/my/project'
1436
+ }),
1437
+ JSON.stringify({
1438
+ type: 'assistant',
1439
+ timestamp: '2026-03-10T10:01:00Z'
1440
+ })
1441
+ ];
1442
+ fs.writeFileSync(filePath, lines.join('\n'));
1443
+ const session = readSession(filePath, '/my/project');
1444
+ expect(session).toMatchObject({
1445
+ sessionId: 'test-session',
1446
+ projectPath: '/my/project',
1447
+ lastCwd: '/my/project',
1448
+ lastEntryType: 'assistant',
1449
+ isInterrupted: false
1450
+ });
1451
+ expect(session.sessionStart.toISOString()).toBe('2026-03-10T10:00:00.000Z');
1452
+ expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
1453
+ });
1454
+ it('should detect user interruption', ()=>{
1455
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1456
+ const filePath = path.join(tmpDir, 'interrupted.jsonl');
1457
+ const lines = [
1458
+ JSON.stringify({
1459
+ type: 'user',
1460
+ timestamp: '2026-03-10T10:00:00Z',
1461
+ message: {
1462
+ content: [
1463
+ {
1464
+ type: 'text',
1465
+ text: '[Request interrupted by user for tool use]'
1466
+ }
1467
+ ]
1468
+ }
1469
+ })
1470
+ ];
1471
+ fs.writeFileSync(filePath, lines.join('\n'));
1472
+ const session = readSession(filePath, '/test');
1473
+ expect(session.isInterrupted).toBe(true);
1474
+ expect(session.lastEntryType).toBe('user');
1475
+ });
1476
+ it('should return session with defaults for empty file', ()=>{
1477
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1478
+ const filePath = path.join(tmpDir, 'empty.jsonl');
1479
+ fs.writeFileSync(filePath, '');
1480
+ const session = readSession(filePath, '/test');
1481
+ expect(session).not.toBeNull();
1482
+ expect(session.lastEntryType).toBeUndefined();
1483
+ });
1484
+ it('should return null for non-existent file', ()=>{
1485
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1486
+ expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
1487
+ });
1488
+ it('should skip metadata entry types for lastEntryType', ()=>{
1489
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1490
+ const filePath = path.join(tmpDir, 'metadata-test.jsonl');
1491
+ const lines = [
1492
+ JSON.stringify({
1493
+ type: 'user',
1494
+ timestamp: '2026-03-10T10:00:00Z',
1495
+ message: {
1496
+ content: 'hello'
1497
+ }
1498
+ }),
1499
+ JSON.stringify({
1500
+ type: 'assistant',
1501
+ timestamp: '2026-03-10T10:01:00Z'
1502
+ }),
1503
+ JSON.stringify({
1504
+ type: 'last-prompt',
1505
+ timestamp: '2026-03-10T10:02:00Z'
1506
+ }),
1507
+ JSON.stringify({
1508
+ type: 'file-history-snapshot',
1509
+ timestamp: '2026-03-10T10:03:00Z'
1510
+ })
1511
+ ];
1512
+ fs.writeFileSync(filePath, lines.join('\n'));
1513
+ const session = readSession(filePath, '/test');
1514
+ expect(session.lastEntryType).toBe('assistant');
1515
+ });
1516
+ it('should parse snapshot.timestamp from file-history-snapshot first entry', ()=>{
1517
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1518
+ const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
1519
+ const lines = [
1520
+ JSON.stringify({
1521
+ type: 'file-history-snapshot',
1522
+ snapshot: {
1523
+ timestamp: '2026-03-10T09:55:00Z',
1524
+ files: []
1525
+ }
1526
+ }),
1527
+ JSON.stringify({
1528
+ type: 'user',
1529
+ timestamp: '2026-03-10T10:00:00Z',
1530
+ message: {
1531
+ content: 'test'
1532
+ }
1533
+ }),
1534
+ JSON.stringify({
1535
+ type: 'assistant',
1536
+ timestamp: '2026-03-10T10:01:00Z'
1537
+ })
1538
+ ];
1539
+ fs.writeFileSync(filePath, lines.join('\n'));
1540
+ const session = readSession(filePath, '/test');
1541
+ expect(session.sessionStart.toISOString()).toBe('2026-03-10T09:55:00.000Z');
1542
+ expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
1543
+ });
1544
+ it('should extract lastUserMessage from session entries', ()=>{
1545
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1546
+ const filePath = path.join(tmpDir, 'user-msg.jsonl');
1547
+ const lines = [
1548
+ JSON.stringify({
1549
+ type: 'user',
1550
+ timestamp: '2026-03-10T10:00:00Z',
1551
+ message: {
1552
+ content: 'first question'
1553
+ }
1554
+ }),
1555
+ JSON.stringify({
1556
+ type: 'assistant',
1557
+ timestamp: '2026-03-10T10:01:00Z'
1558
+ }),
1559
+ JSON.stringify({
1560
+ type: 'user',
1561
+ timestamp: '2026-03-10T10:02:00Z',
1562
+ message: {
1563
+ content: [
1564
+ {
1565
+ type: 'text',
1566
+ text: 'second question'
1567
+ }
1568
+ ]
1569
+ }
1570
+ }),
1571
+ JSON.stringify({
1572
+ type: 'assistant',
1573
+ timestamp: '2026-03-10T10:03:00Z'
1574
+ })
1575
+ ];
1576
+ fs.writeFileSync(filePath, lines.join('\n'));
1577
+ const session = readSession(filePath, '/test');
1578
+ expect(session.lastUserMessage).toBe('second question');
1579
+ });
1580
+ it('should use lastCwd as projectPath when projectPath is empty', ()=>{
1581
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1582
+ const filePath = path.join(tmpDir, 'no-project.jsonl');
1583
+ const lines = [
1584
+ JSON.stringify({
1585
+ type: 'user',
1586
+ timestamp: '2026-03-10T10:00:00Z',
1587
+ cwd: '/derived/path',
1588
+ message: {
1589
+ content: 'test'
1590
+ }
1591
+ })
1592
+ ];
1593
+ fs.writeFileSync(filePath, lines.join('\n'));
1594
+ const session = readSession(filePath, '');
1595
+ expect(session.projectPath).toBe('/derived/path');
1596
+ });
1597
+ it('should handle malformed JSON lines gracefully', ()=>{
1598
+ const readSession = adapter.parser.readSession.bind(adapter.parser);
1599
+ const filePath = path.join(tmpDir, 'malformed.jsonl');
1600
+ const lines = [
1601
+ 'not json',
1602
+ JSON.stringify({
1603
+ type: 'assistant',
1604
+ timestamp: '2026-03-10T10:00:00Z'
1605
+ })
1606
+ ];
1607
+ fs.writeFileSync(filePath, lines.join('\n'));
1608
+ const session = readSession(filePath, '/test');
1609
+ expect(session).not.toBeNull();
1610
+ expect(session.lastEntryType).toBe('assistant');
1611
+ });
1612
+ });
1613
+ });
1614
+ describe('getConversation', ()=>{
1615
+ let tmpDir;
1616
+ beforeEach(()=>{
1617
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-conv-'));
1618
+ });
1619
+ afterEach(()=>{
1620
+ fs.rmSync(tmpDir, {
1621
+ recursive: true,
1622
+ force: true
1623
+ });
1624
+ });
1625
+ function writeJsonl(lines) {
1626
+ const filePath = path.join(tmpDir, 'session.jsonl');
1627
+ fs.writeFileSync(filePath, lines.map((l)=>JSON.stringify(l)).join('\n'));
1628
+ return filePath;
1629
+ }
1630
+ it('should parse user and assistant text messages', ()=>{
1631
+ const filePath = writeJsonl([
1632
+ {
1633
+ type: 'user',
1634
+ timestamp: '2026-03-27T10:00:00Z',
1635
+ message: {
1636
+ content: 'Hello'
1637
+ }
1638
+ },
1639
+ {
1640
+ type: 'assistant',
1641
+ timestamp: '2026-03-27T10:00:05Z',
1642
+ message: {
1643
+ content: [
1644
+ {
1645
+ type: 'text',
1646
+ text: 'Hi there!'
1647
+ }
1648
+ ]
1649
+ }
1650
+ }
1651
+ ]);
1652
+ const messages = adapter.getConversation(filePath);
1653
+ expect(messages).toHaveLength(2);
1654
+ expect(messages[0]).toEqual({
1655
+ role: 'user',
1656
+ content: 'Hello',
1657
+ timestamp: '2026-03-27T10:00:00Z'
1658
+ });
1659
+ expect(messages[1]).toEqual({
1660
+ role: 'assistant',
1661
+ content: 'Hi there!',
1662
+ timestamp: '2026-03-27T10:00:05Z'
1663
+ });
1664
+ });
1665
+ it('should skip metadata entry types', ()=>{
1666
+ const filePath = writeJsonl([
1667
+ {
1668
+ type: 'file-history-snapshot',
1669
+ timestamp: '2026-03-27T10:00:00Z',
1670
+ snapshot: {}
1671
+ },
1672
+ {
1673
+ type: 'last-prompt',
1674
+ timestamp: '2026-03-27T10:00:00Z'
1675
+ },
1676
+ {
1677
+ type: 'user',
1678
+ timestamp: '2026-03-27T10:00:01Z',
1679
+ message: {
1680
+ content: 'Fix bug'
1681
+ }
1682
+ }
1683
+ ]);
1684
+ const messages = adapter.getConversation(filePath);
1685
+ expect(messages).toHaveLength(1);
1686
+ expect(messages[0].content).toBe('Fix bug');
1687
+ });
1688
+ it('should skip progress and thinking entries', ()=>{
1689
+ const filePath = writeJsonl([
1690
+ {
1691
+ type: 'user',
1692
+ timestamp: '2026-03-27T10:00:00Z',
1693
+ message: {
1694
+ content: 'Hello'
1695
+ }
1696
+ },
1697
+ {
1698
+ type: 'progress',
1699
+ timestamp: '2026-03-27T10:00:01Z',
1700
+ data: {}
1701
+ },
1702
+ {
1703
+ type: 'thinking',
1704
+ timestamp: '2026-03-27T10:00:02Z'
1705
+ },
1706
+ {
1707
+ type: 'assistant',
1708
+ timestamp: '2026-03-27T10:00:03Z',
1709
+ message: {
1710
+ content: [
1711
+ {
1712
+ type: 'text',
1713
+ text: 'Done'
1714
+ }
1715
+ ]
1716
+ }
1717
+ }
1718
+ ]);
1719
+ const messages = adapter.getConversation(filePath);
1720
+ expect(messages).toHaveLength(2);
1721
+ expect(messages[0].role).toBe('user');
1722
+ expect(messages[1].role).toBe('assistant');
1723
+ });
1724
+ it('should include system messages', ()=>{
1725
+ const filePath = writeJsonl([
1726
+ {
1727
+ type: 'system',
1728
+ timestamp: '2026-03-27T10:00:00Z',
1729
+ message: {
1730
+ content: 'System initialized'
1731
+ }
1732
+ }
1733
+ ]);
1734
+ const messages = adapter.getConversation(filePath);
1735
+ expect(messages).toHaveLength(1);
1736
+ expect(messages[0]).toEqual({
1737
+ role: 'system',
1738
+ content: 'System initialized',
1739
+ timestamp: '2026-03-27T10:00:00Z'
1740
+ });
1741
+ });
1742
+ it('should skip tool_use and tool_result blocks in default mode', ()=>{
1743
+ const filePath = writeJsonl([
1744
+ {
1745
+ type: 'assistant',
1746
+ timestamp: '2026-03-27T10:00:00Z',
1747
+ message: {
1748
+ content: [
1749
+ {
1750
+ type: 'text',
1751
+ text: 'Let me read the file.'
1752
+ },
1753
+ {
1754
+ type: 'tool_use',
1755
+ name: 'Read',
1756
+ input: {
1757
+ file_path: '/src/app.ts'
1758
+ }
1759
+ }
1760
+ ]
1761
+ }
1762
+ },
1763
+ {
1764
+ type: 'user',
1765
+ timestamp: '2026-03-27T10:00:01Z',
1766
+ message: {
1767
+ content: [
1768
+ {
1769
+ type: 'tool_result',
1770
+ tool_use_id: 'toolu_1',
1771
+ content: 'file contents here'
1772
+ }
1773
+ ]
1774
+ }
1775
+ }
1776
+ ]);
1777
+ const messages = adapter.getConversation(filePath);
1778
+ expect(messages).toHaveLength(1);
1779
+ expect(messages[0].content).toBe('Let me read the file.');
1780
+ });
1781
+ it('should include tool_use and tool_result blocks in verbose mode', ()=>{
1782
+ const filePath = writeJsonl([
1783
+ {
1784
+ type: 'assistant',
1785
+ timestamp: '2026-03-27T10:00:00Z',
1786
+ message: {
1787
+ content: [
1788
+ {
1789
+ type: 'text',
1790
+ text: 'Let me read the file.'
1791
+ },
1792
+ {
1793
+ type: 'tool_use',
1794
+ name: 'Read',
1795
+ input: {
1796
+ file_path: '/src/app.ts'
1797
+ }
1798
+ }
1799
+ ]
1800
+ }
1801
+ },
1802
+ {
1803
+ type: 'user',
1804
+ timestamp: '2026-03-27T10:00:01Z',
1805
+ message: {
1806
+ content: [
1807
+ {
1808
+ type: 'tool_result',
1809
+ tool_use_id: 'toolu_1',
1810
+ content: 'file contents here'
1811
+ }
1812
+ ]
1813
+ }
1814
+ }
1815
+ ]);
1816
+ const messages = adapter.getConversation(filePath, {
1817
+ verbose: true
1818
+ });
1819
+ expect(messages).toHaveLength(2);
1820
+ expect(messages[0].content).toContain('[Tool: Read]');
1821
+ expect(messages[0].content).toContain('/src/app.ts');
1822
+ expect(messages[1].content).toContain('[Tool Result]');
1823
+ });
1824
+ it('should handle tool_result errors in verbose mode', ()=>{
1825
+ const filePath = writeJsonl([
1826
+ {
1827
+ type: 'user',
1828
+ timestamp: '2026-03-27T10:00:00Z',
1829
+ message: {
1830
+ content: [
1831
+ {
1832
+ type: 'tool_result',
1833
+ tool_use_id: 'toolu_1',
1834
+ content: 'Something went wrong',
1835
+ is_error: true
1836
+ }
1837
+ ]
1838
+ }
1839
+ }
1840
+ ]);
1841
+ const messages = adapter.getConversation(filePath, {
1842
+ verbose: true
1843
+ });
1844
+ expect(messages).toHaveLength(1);
1845
+ expect(messages[0].content).toContain('[Tool Error]');
1846
+ });
1847
+ it('should handle malformed JSON lines gracefully', ()=>{
1848
+ const filePath = path.join(tmpDir, 'malformed.jsonl');
1849
+ fs.writeFileSync(filePath, [
1850
+ JSON.stringify({
1851
+ type: 'user',
1852
+ timestamp: '2026-03-27T10:00:00Z',
1853
+ message: {
1854
+ content: 'Hello'
1855
+ }
1856
+ }),
1857
+ 'this is not valid json',
1858
+ JSON.stringify({
1859
+ type: 'assistant',
1860
+ timestamp: '2026-03-27T10:00:01Z',
1861
+ message: {
1862
+ content: [
1863
+ {
1864
+ type: 'text',
1865
+ text: 'World'
1866
+ }
1867
+ ]
1868
+ }
1869
+ })
1870
+ ].join('\n'));
1871
+ const messages = adapter.getConversation(filePath);
1872
+ expect(messages).toHaveLength(2);
1873
+ });
1874
+ it('should return empty array for missing file', ()=>{
1875
+ const messages = adapter.getConversation('/nonexistent/path.jsonl');
1876
+ expect(messages).toEqual([]);
1877
+ });
1878
+ it('should return empty array for empty file', ()=>{
1879
+ const filePath = path.join(tmpDir, 'empty.jsonl');
1880
+ fs.writeFileSync(filePath, '');
1881
+ const messages = adapter.getConversation(filePath);
1882
+ expect(messages).toEqual([]);
1883
+ });
1884
+ it('should filter noise messages from user entries', ()=>{
1885
+ const filePath = writeJsonl([
1886
+ {
1887
+ type: 'user',
1888
+ timestamp: '2026-03-27T10:00:00Z',
1889
+ message: {
1890
+ content: [
1891
+ {
1892
+ type: 'text',
1893
+ text: '[Request interrupted by user]'
1894
+ }
1895
+ ]
1896
+ }
1897
+ },
1898
+ {
1899
+ type: 'user',
1900
+ timestamp: '2026-03-27T10:00:01Z',
1901
+ message: {
1902
+ content: 'Tool loaded.'
1903
+ }
1904
+ },
1905
+ {
1906
+ type: 'user',
1907
+ timestamp: '2026-03-27T10:00:02Z',
1908
+ message: {
1909
+ content: 'Real question'
1910
+ }
1911
+ }
1912
+ ]);
1913
+ const messages = adapter.getConversation(filePath);
1914
+ expect(messages).toHaveLength(1);
1915
+ expect(messages[0].content).toBe('Real question');
1916
+ });
1917
+ });
1918
+ describe('listSessions', ()=>{
1919
+ let tmpDir;
1920
+ let projectsDir;
1921
+ beforeEach(()=>{
1922
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-list-'));
1923
+ projectsDir = path.join(tmpDir, 'projects');
1924
+ fs.mkdirSync(projectsDir, {
1925
+ recursive: true
1926
+ });
1927
+ adapter.projectsDir = projectsDir;
1928
+ });
1929
+ afterEach(()=>{
1930
+ fs.rmSync(tmpDir, {
1931
+ recursive: true,
1932
+ force: true
1933
+ });
1934
+ });
1935
+ function writeSession(projectDir, sessionId, lines) {
1936
+ fs.mkdirSync(projectDir, {
1937
+ recursive: true
1938
+ });
1939
+ const filePath = path.join(projectDir, `${sessionId}.jsonl`);
1940
+ fs.writeFileSync(filePath, lines.map((l)=>JSON.stringify(l)).join('\n'));
1941
+ return filePath;
1942
+ }
1943
+ it('returns empty when projects dir does not exist', async ()=>{
1944
+ fs.rmSync(projectsDir, {
1945
+ recursive: true,
1946
+ force: true
1947
+ });
1948
+ const result = await adapter.listSessions();
1949
+ expect(result).toEqual([]);
1950
+ });
1951
+ it('returns sessions from a single cwd-scoped project dir', async ()=>{
1952
+ const cwd = '/Users/test/proj';
1953
+ const projDir = path.join(projectsDir, '-Users-test-proj');
1954
+ const filePath = writeSession(projDir, 'sess-1', [
1955
+ {
1956
+ type: 'user',
1957
+ timestamp: '2025-01-01T00:00:00Z',
1958
+ cwd,
1959
+ message: {
1960
+ content: 'first prompt'
1961
+ }
1962
+ },
1963
+ {
1964
+ type: 'assistant',
1965
+ timestamp: '2025-01-01T00:01:00Z'
1966
+ }
1967
+ ]);
1968
+ const result = await adapter.listSessions({
1969
+ cwd
1970
+ });
1971
+ expect(result).toHaveLength(1);
1972
+ expect(result[0]).toMatchObject({
1973
+ type: 'claude',
1974
+ sessionId: 'sess-1',
1975
+ cwd,
1976
+ firstUserMessage: 'first prompt',
1977
+ sessionFilePath: filePath
1978
+ });
1979
+ expect(result[0].lastActive).toBeInstanceOf(Date);
1980
+ expect(result[0].startedAt).toBeInstanceOf(Date);
1981
+ });
1982
+ it('lists sessions from all project dirs when no cwd filter', async ()=>{
1983
+ const cwdA = '/Users/test/proj-a';
1984
+ const cwdB = '/Users/test/proj-b';
1985
+ writeSession(path.join(projectsDir, '-Users-test-proj-a'), 'a', [
1986
+ {
1987
+ type: 'user',
1988
+ timestamp: '2025-01-01T00:00:00Z',
1989
+ cwd: cwdA,
1990
+ message: {
1991
+ content: 'msg-a'
1992
+ }
1993
+ }
1994
+ ]);
1995
+ writeSession(path.join(projectsDir, '-Users-test-proj-b'), 'b', [
1996
+ {
1997
+ type: 'user',
1998
+ timestamp: '2025-01-02T00:00:00Z',
1999
+ cwd: cwdB,
2000
+ message: {
2001
+ content: 'msg-b'
2002
+ }
2003
+ }
2004
+ ]);
2005
+ const result = await adapter.listSessions();
2006
+ expect(result).toHaveLength(2);
2007
+ expect(result.map((r)=>r.sessionId).sort()).toEqual([
2008
+ 'a',
2009
+ 'b'
2010
+ ]);
2011
+ const cwds = result.map((r)=>r.cwd).sort();
2012
+ expect(cwds).toEqual([
2013
+ cwdA,
2014
+ cwdB
2015
+ ]);
2016
+ });
2017
+ it('drops sessions whose recorded cwd does not match opts.cwd (strict equality)', async ()=>{
2018
+ const cwdReal = '/Users/test/foo';
2019
+ const cwdRequested = '/Users/test/foo/sub';
2020
+ writeSession(path.join(projectsDir, '-Users-test-foo'), 's', [
2021
+ {
2022
+ type: 'user',
2023
+ timestamp: '2025-01-01T00:00:00Z',
2024
+ cwd: cwdReal,
2025
+ message: {
2026
+ content: 'hi'
2027
+ }
2028
+ }
2029
+ ]);
2030
+ // Encoded dir for the requested cwd doesn't exist → return []
2031
+ const result = await adapter.listSessions({
2032
+ cwd: cwdRequested
2033
+ });
2034
+ expect(result).toEqual([]);
2035
+ });
2036
+ it('drops sessions whose recorded cwd disagrees with the encoded dir', async ()=>{
2037
+ // Edge case: encoded dir lookup matches, but session content
2038
+ // records a different cwd. Strict-equality filter must reject.
2039
+ const requested = '/Users/test/proj';
2040
+ const projDir = path.join(projectsDir, '-Users-test-proj');
2041
+ writeSession(projDir, 's', [
2042
+ {
2043
+ type: 'user',
2044
+ timestamp: '2025-01-01T00:00:00Z',
2045
+ cwd: '/different/path',
2046
+ message: {
2047
+ content: 'mismatch'
2048
+ }
2049
+ }
2050
+ ]);
2051
+ const result = await adapter.listSessions({
2052
+ cwd: requested
2053
+ });
2054
+ expect(result).toEqual([]);
2055
+ });
2056
+ it('finds sessions whose recorded cwd lives in a different encoded dir (worktree case)', async ()=>{
2057
+ // Real-world case: Claude Code is launched in /repo, then chdirs into
2058
+ // /repo/.worktrees/feature. The session file is stored under the
2059
+ // ENCODED launch dir, but its content records the worktree path.
2060
+ // listSessions({ cwd: worktree }) must still find it.
2061
+ const launchDir = path.join(projectsDir, '-repo');
2062
+ const worktreeCwd = '/repo/.worktrees/feature';
2063
+ writeSession(launchDir, 'wt', [
2064
+ {
2065
+ type: 'user',
2066
+ timestamp: '2025-01-01T00:00:00Z',
2067
+ cwd: worktreeCwd,
2068
+ message: {
2069
+ content: 'in worktree'
2070
+ }
2071
+ }
2072
+ ]);
2073
+ const result = await adapter.listSessions({
2074
+ cwd: worktreeCwd
2075
+ });
2076
+ expect(result).toHaveLength(1);
2077
+ expect(result[0]).toMatchObject({
2078
+ sessionId: 'wt',
2079
+ cwd: worktreeCwd,
2080
+ firstUserMessage: 'in worktree'
2081
+ });
2082
+ });
2083
+ it('skips malformed session files', async ()=>{
2084
+ const cwd = '/Users/test/p';
2085
+ const projDir = path.join(projectsDir, '-Users-test-p');
2086
+ fs.mkdirSync(projDir, {
2087
+ recursive: true
2088
+ });
2089
+ fs.writeFileSync(path.join(projDir, 'bad.jsonl'), 'not valid json');
2090
+ writeSession(projDir, 'good', [
2091
+ {
2092
+ type: 'user',
2093
+ timestamp: '2025-01-01T00:00:00Z',
2094
+ cwd,
2095
+ message: {
2096
+ content: 'ok'
2097
+ }
2098
+ }
2099
+ ]);
2100
+ const result = await adapter.listSessions({
2101
+ cwd
2102
+ });
2103
+ expect(result).toHaveLength(1);
2104
+ expect(result[0].sessionId).toBe('good');
2105
+ });
2106
+ it('captures first user message after filtering noise', async ()=>{
2107
+ const cwd = '/Users/test/q';
2108
+ writeSession(path.join(projectsDir, '-Users-test-q'), 's', [
2109
+ {
2110
+ type: 'user',
2111
+ timestamp: '2025-01-01T00:00:00Z',
2112
+ cwd,
2113
+ message: {
2114
+ content: 'Tool loaded.'
2115
+ }
2116
+ },
2117
+ {
2118
+ type: 'user',
2119
+ timestamp: '2025-01-01T00:00:01Z',
2120
+ cwd,
2121
+ message: {
2122
+ content: 'real first prompt'
2123
+ }
2124
+ },
2125
+ {
2126
+ type: 'user',
2127
+ timestamp: '2025-01-01T00:00:02Z',
2128
+ cwd,
2129
+ message: {
2130
+ content: 'second prompt'
2131
+ }
2132
+ }
2133
+ ]);
2134
+ const result = await adapter.listSessions({
2135
+ cwd
2136
+ });
2137
+ expect(result).toHaveLength(1);
2138
+ expect(result[0].firstUserMessage).toBe('real first prompt');
2139
+ });
2140
+ it('returns empty firstUserMessage when no user message exists', async ()=>{
2141
+ const cwd = '/Users/test/empty';
2142
+ writeSession(path.join(projectsDir, '-Users-test-empty'), 's', [
2143
+ {
2144
+ type: 'assistant',
2145
+ timestamp: '2025-01-01T00:00:00Z'
2146
+ }
2147
+ ]);
2148
+ const result = await adapter.listSessions({
2149
+ cwd
2150
+ });
2151
+ expect(result).toHaveLength(1);
2152
+ expect(result[0].firstUserMessage).toBe('');
2153
+ });
2154
+ });
2155
+ });
2156
+
2157
+ //# sourceMappingURL=ClaudeCodeAdapter.test.js.map