@ai-devkit/agent-manager 0.9.0 → 0.11.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 (63) hide show
  1. package/dist/AgentManager.d.ts +21 -1
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +47 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/AgentAdapter.d.ts +66 -0
  6. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/ClaudeCodeAdapter.d.ts +14 -43
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.js +60 -275
  10. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  11. package/dist/adapters/CodexAdapter.d.ts +14 -1
  12. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  13. package/dist/adapters/CodexAdapter.js +105 -6
  14. package/dist/adapters/CodexAdapter.js.map +1 -1
  15. package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
  16. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  17. package/dist/adapters/GeminiCliAdapter.js +77 -6
  18. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  23. package/dist/terminal/TerminalFocusManager.js +36 -27
  24. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  25. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  26. package/dist/terminal/TtyWriter.js +3 -12
  27. package/dist/terminal/TtyWriter.js.map +1 -1
  28. package/dist/utils/ClaudeSessionParser.d.ts +114 -0
  29. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -0
  30. package/dist/utils/ClaudeSessionParser.js +377 -0
  31. package/dist/utils/ClaudeSessionParser.js.map +1 -0
  32. package/dist/utils/applescript.d.ts +6 -0
  33. package/dist/utils/applescript.d.ts.map +1 -0
  34. package/dist/utils/applescript.js +14 -0
  35. package/dist/utils/applescript.js.map +1 -0
  36. package/dist/utils/process.d.ts +3 -4
  37. package/dist/utils/process.d.ts.map +1 -1
  38. package/dist/utils/process.js +11 -15
  39. package/dist/utils/process.js.map +1 -1
  40. package/dist/utils/session.d.ts +34 -5
  41. package/dist/utils/session.d.ts.map +1 -1
  42. package/dist/utils/session.js +90 -44
  43. package/dist/utils/session.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/AgentManager.ts +66 -4
  46. package/src/__tests__/AgentManager.test.ts +134 -2
  47. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +188 -32
  48. package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
  49. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
  50. package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
  51. package/src/__tests__/utils/process.test.ts +27 -27
  52. package/src/__tests__/utils/session.test.ts +79 -43
  53. package/src/adapters/AgentAdapter.ts +76 -0
  54. package/src/adapters/ClaudeCodeAdapter.ts +82 -356
  55. package/src/adapters/CodexAdapter.ts +126 -8
  56. package/src/adapters/GeminiCliAdapter.ts +102 -7
  57. package/src/index.ts +9 -1
  58. package/src/terminal/TerminalFocusManager.ts +35 -26
  59. package/src/terminal/TtyWriter.ts +1 -11
  60. package/src/utils/ClaudeSessionParser.ts +437 -0
  61. package/src/utils/applescript.ts +10 -0
  62. package/src/utils/process.ts +21 -24
  63. package/src/utils/session.ts +86 -45
@@ -18,9 +18,13 @@ jest.mock('../../utils/process', () => ({
18
18
  enrichProcesses: jest.fn(),
19
19
  }));
20
20
 
21
- jest.mock('../../utils/session', () => ({
22
- batchGetSessionFileBirthtimes: jest.fn(),
23
- }));
21
+ jest.mock('../../utils/session', () => {
22
+ const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session');
23
+ return {
24
+ ...actual,
25
+ batchGetSessionFileBirthtimes: jest.fn(),
26
+ };
27
+ });
24
28
 
25
29
  jest.mock('../../utils/matching', () => ({
26
30
  matchProcessesToSessions: jest.fn(),
@@ -361,7 +365,7 @@ describe('ClaudeCodeAdapter', () => {
361
365
  (adapter as any).projectsDir = projectsDir;
362
366
 
363
367
  // Simulate JSONL disappearing between existence check and read
364
- jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
368
+ jest.spyOn((adapter as any).parser, 'readSession').mockReturnValueOnce(null);
365
369
 
366
370
  const agents = await adapter.detectAgents();
367
371
 
@@ -408,7 +412,7 @@ describe('ClaudeCodeAdapter', () => {
408
412
  ]);
409
413
 
410
414
  // Simulate JSONL disappearing between match and read
411
- jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
415
+ jest.spyOn((adapter as any).parser, 'readSession').mockReturnValueOnce(null);
412
416
 
413
417
  const agents = await adapter.detectAgents();
414
418
 
@@ -601,7 +605,7 @@ describe('ClaudeCodeAdapter', () => {
601
605
  describe('helper methods', () => {
602
606
  describe('determineStatus', () => {
603
607
  it('should return "unknown" for sessions with no last entry type', () => {
604
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
608
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
605
609
 
606
610
  const session = {
607
611
  sessionId: 'test',
@@ -615,7 +619,7 @@ describe('ClaudeCodeAdapter', () => {
615
619
  });
616
620
 
617
621
  it('should return "waiting" for assistant entries', () => {
618
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
622
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
619
623
 
620
624
  const session = {
621
625
  sessionId: 'test',
@@ -630,7 +634,7 @@ describe('ClaudeCodeAdapter', () => {
630
634
  });
631
635
 
632
636
  it('should return "waiting" for user interruption', () => {
633
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
637
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
634
638
 
635
639
  const session = {
636
640
  sessionId: 'test',
@@ -645,7 +649,7 @@ describe('ClaudeCodeAdapter', () => {
645
649
  });
646
650
 
647
651
  it('should return "running" for user/progress entries', () => {
648
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
652
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
649
653
 
650
654
  const session = {
651
655
  sessionId: 'test',
@@ -660,7 +664,7 @@ describe('ClaudeCodeAdapter', () => {
660
664
  });
661
665
 
662
666
  it('should not override status based on age (process is running)', () => {
663
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
667
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
664
668
 
665
669
  const oldDate = new Date(Date.now() - 10 * 60 * 1000);
666
670
  const session = {
@@ -676,7 +680,7 @@ describe('ClaudeCodeAdapter', () => {
676
680
  });
677
681
 
678
682
  it('should return "idle" for system entries', () => {
679
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
683
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
680
684
 
681
685
  const session = {
682
686
  sessionId: 'test',
@@ -691,7 +695,7 @@ describe('ClaudeCodeAdapter', () => {
691
695
  });
692
696
 
693
697
  it('should return "running" for thinking entries', () => {
694
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
698
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
695
699
 
696
700
  const session = {
697
701
  sessionId: 'test',
@@ -706,7 +710,7 @@ describe('ClaudeCodeAdapter', () => {
706
710
  });
707
711
 
708
712
  it('should return "running" for progress entries', () => {
709
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
713
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
710
714
 
711
715
  const session = {
712
716
  sessionId: 'test',
@@ -721,7 +725,7 @@ describe('ClaudeCodeAdapter', () => {
721
725
  });
722
726
 
723
727
  it('should return "unknown" for unrecognized entry types', () => {
724
- const determineStatus = (adapter as any).determineStatus.bind(adapter);
728
+ const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
725
729
 
726
730
  const session = {
727
731
  sessionId: 'test',
@@ -738,12 +742,12 @@ describe('ClaudeCodeAdapter', () => {
738
742
 
739
743
  describe('extractUserMessageText', () => {
740
744
  it('should extract plain string content', () => {
741
- const extract = (adapter as any).extractUserMessageText.bind(adapter);
745
+ const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
742
746
  expect(extract('hello world')).toBe('hello world');
743
747
  });
744
748
 
745
749
  it('should extract text from array content blocks', () => {
746
- const extract = (adapter as any).extractUserMessageText.bind(adapter);
750
+ const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
747
751
 
748
752
  const content = [
749
753
  { type: 'tool_result', content: 'some result' },
@@ -753,7 +757,7 @@ describe('ClaudeCodeAdapter', () => {
753
757
  });
754
758
 
755
759
  it('should return undefined for empty/null content', () => {
756
- const extract = (adapter as any).extractUserMessageText.bind(adapter);
760
+ const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
757
761
 
758
762
  expect(extract(undefined)).toBeUndefined();
759
763
  expect(extract('')).toBeUndefined();
@@ -761,35 +765,35 @@ describe('ClaudeCodeAdapter', () => {
761
765
  });
762
766
 
763
767
  it('should parse command-message tags', () => {
764
- const extract = (adapter as any).extractUserMessageText.bind(adapter);
768
+ const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
765
769
 
766
770
  const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
767
771
  expect(extract(msg)).toBe('commit fix bug');
768
772
  });
769
773
 
770
774
  it('should parse command-message without args', () => {
771
- const extract = (adapter as any).extractUserMessageText.bind(adapter);
775
+ const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
772
776
 
773
777
  const msg = '<command-message><command-name>help</command-name></command-message>';
774
778
  expect(extract(msg)).toBe('help');
775
779
  });
776
780
 
777
781
  it('should extract ARGUMENTS from skill expansion', () => {
778
- const extract = (adapter as any).extractUserMessageText.bind(adapter);
782
+ const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
779
783
 
780
784
  const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
781
785
  expect(extract(msg)).toBe('implement the feature');
782
786
  });
783
787
 
784
788
  it('should return undefined for skill expansion without ARGUMENTS', () => {
785
- const extract = (adapter as any).extractUserMessageText.bind(adapter);
789
+ const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
786
790
 
787
791
  const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
788
792
  expect(extract(msg)).toBeUndefined();
789
793
  });
790
794
 
791
795
  it('should filter noise messages', () => {
792
- const extract = (adapter as any).extractUserMessageText.bind(adapter);
796
+ const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
793
797
 
794
798
  expect(extract('[Request interrupted by user]')).toBeUndefined();
795
799
  expect(extract('Tool loaded.')).toBeUndefined();
@@ -799,7 +803,7 @@ describe('ClaudeCodeAdapter', () => {
799
803
 
800
804
  describe('parseCommandMessage', () => {
801
805
  it('should return undefined for malformed command-message', () => {
802
- const parse = (adapter as any).parseCommandMessage.bind(adapter);
806
+ const parse = (adapter as any).parser['parseCommandMessage'].bind((adapter as any).parser);
803
807
  expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
804
808
  });
805
809
  });
@@ -977,7 +981,7 @@ describe('ClaudeCodeAdapter', () => {
977
981
 
978
982
  describe('readSession', () => {
979
983
  it('should parse session file with timestamps, cwd, and entry type', () => {
980
- const readSession = (adapter as any).readSession.bind(adapter);
984
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
981
985
 
982
986
  const filePath = path.join(tmpDir, 'test-session.jsonl');
983
987
  const lines = [
@@ -999,7 +1003,7 @@ describe('ClaudeCodeAdapter', () => {
999
1003
  });
1000
1004
 
1001
1005
  it('should detect user interruption', () => {
1002
- const readSession = (adapter as any).readSession.bind(adapter);
1006
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
1003
1007
 
1004
1008
  const filePath = path.join(tmpDir, 'interrupted.jsonl');
1005
1009
  const lines = [
@@ -1019,7 +1023,7 @@ describe('ClaudeCodeAdapter', () => {
1019
1023
  });
1020
1024
 
1021
1025
  it('should return session with defaults for empty file', () => {
1022
- const readSession = (adapter as any).readSession.bind(adapter);
1026
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
1023
1027
 
1024
1028
  const filePath = path.join(tmpDir, 'empty.jsonl');
1025
1029
  fs.writeFileSync(filePath, '');
@@ -1030,12 +1034,12 @@ describe('ClaudeCodeAdapter', () => {
1030
1034
  });
1031
1035
 
1032
1036
  it('should return null for non-existent file', () => {
1033
- const readSession = (adapter as any).readSession.bind(adapter);
1037
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
1034
1038
  expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
1035
1039
  });
1036
1040
 
1037
1041
  it('should skip metadata entry types for lastEntryType', () => {
1038
- const readSession = (adapter as any).readSession.bind(adapter);
1042
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
1039
1043
 
1040
1044
  const filePath = path.join(tmpDir, 'metadata-test.jsonl');
1041
1045
  const lines = [
@@ -1051,7 +1055,7 @@ describe('ClaudeCodeAdapter', () => {
1051
1055
  });
1052
1056
 
1053
1057
  it('should parse snapshot.timestamp from file-history-snapshot first entry', () => {
1054
- const readSession = (adapter as any).readSession.bind(adapter);
1058
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
1055
1059
 
1056
1060
  const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
1057
1061
  const lines = [
@@ -1070,7 +1074,7 @@ describe('ClaudeCodeAdapter', () => {
1070
1074
  });
1071
1075
 
1072
1076
  it('should extract lastUserMessage from session entries', () => {
1073
- const readSession = (adapter as any).readSession.bind(adapter);
1077
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
1074
1078
 
1075
1079
  const filePath = path.join(tmpDir, 'user-msg.jsonl');
1076
1080
  const lines = [
@@ -1086,7 +1090,7 @@ describe('ClaudeCodeAdapter', () => {
1086
1090
  });
1087
1091
 
1088
1092
  it('should use lastCwd as projectPath when projectPath is empty', () => {
1089
- const readSession = (adapter as any).readSession.bind(adapter);
1093
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
1090
1094
 
1091
1095
  const filePath = path.join(tmpDir, 'no-project.jsonl');
1092
1096
  const lines = [
@@ -1099,7 +1103,7 @@ describe('ClaudeCodeAdapter', () => {
1099
1103
  });
1100
1104
 
1101
1105
  it('should handle malformed JSON lines gracefully', () => {
1102
- const readSession = (adapter as any).readSession.bind(adapter);
1106
+ const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
1103
1107
 
1104
1108
  const filePath = path.join(tmpDir, 'malformed.jsonl');
1105
1109
  const lines = [
@@ -1288,4 +1292,156 @@ describe('ClaudeCodeAdapter', () => {
1288
1292
  expect(messages[0].content).toBe('Real question');
1289
1293
  });
1290
1294
  });
1295
+
1296
+ describe('listSessions', () => {
1297
+ let tmpDir: string;
1298
+ let projectsDir: string;
1299
+
1300
+ beforeEach(() => {
1301
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-list-'));
1302
+ projectsDir = path.join(tmpDir, 'projects');
1303
+ fs.mkdirSync(projectsDir, { recursive: true });
1304
+ (adapter as any).projectsDir = projectsDir;
1305
+ });
1306
+
1307
+ afterEach(() => {
1308
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1309
+ });
1310
+
1311
+ function writeSession(projectDir: string, sessionId: string, lines: object[]): string {
1312
+ fs.mkdirSync(projectDir, { recursive: true });
1313
+ const filePath = path.join(projectDir, `${sessionId}.jsonl`);
1314
+ fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n'));
1315
+ return filePath;
1316
+ }
1317
+
1318
+ it('returns empty when projects dir does not exist', async () => {
1319
+ fs.rmSync(projectsDir, { recursive: true, force: true });
1320
+ const result = await adapter.listSessions();
1321
+ expect(result).toEqual([]);
1322
+ });
1323
+
1324
+ it('returns sessions from a single cwd-scoped project dir', async () => {
1325
+ const cwd = '/Users/test/proj';
1326
+ const projDir = path.join(projectsDir, '-Users-test-proj');
1327
+ const filePath = writeSession(projDir, 'sess-1', [
1328
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'first prompt' } },
1329
+ { type: 'assistant', timestamp: '2025-01-01T00:01:00Z' },
1330
+ ]);
1331
+
1332
+ const result = await adapter.listSessions({ cwd });
1333
+
1334
+ expect(result).toHaveLength(1);
1335
+ expect(result[0]).toMatchObject({
1336
+ type: 'claude',
1337
+ sessionId: 'sess-1',
1338
+ cwd,
1339
+ firstUserMessage: 'first prompt',
1340
+ sessionFilePath: filePath,
1341
+ });
1342
+ expect(result[0].lastActive).toBeInstanceOf(Date);
1343
+ expect(result[0].startedAt).toBeInstanceOf(Date);
1344
+ });
1345
+
1346
+ it('lists sessions from all project dirs when no cwd filter', async () => {
1347
+ const cwdA = '/Users/test/proj-a';
1348
+ const cwdB = '/Users/test/proj-b';
1349
+ writeSession(path.join(projectsDir, '-Users-test-proj-a'), 'a', [
1350
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdA, message: { content: 'msg-a' } },
1351
+ ]);
1352
+ writeSession(path.join(projectsDir, '-Users-test-proj-b'), 'b', [
1353
+ { type: 'user', timestamp: '2025-01-02T00:00:00Z', cwd: cwdB, message: { content: 'msg-b' } },
1354
+ ]);
1355
+
1356
+ const result = await adapter.listSessions();
1357
+
1358
+ expect(result).toHaveLength(2);
1359
+ expect(result.map((r) => r.sessionId).sort()).toEqual(['a', 'b']);
1360
+ const cwds = result.map((r) => r.cwd).sort();
1361
+ expect(cwds).toEqual([cwdA, cwdB]);
1362
+ });
1363
+
1364
+ it('drops sessions whose recorded cwd does not match opts.cwd (strict equality)', async () => {
1365
+ const cwdReal = '/Users/test/foo';
1366
+ const cwdRequested = '/Users/test/foo/sub';
1367
+ writeSession(path.join(projectsDir, '-Users-test-foo'), 's', [
1368
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdReal, message: { content: 'hi' } },
1369
+ ]);
1370
+
1371
+ // Encoded dir for the requested cwd doesn't exist → return []
1372
+ const result = await adapter.listSessions({ cwd: cwdRequested });
1373
+ expect(result).toEqual([]);
1374
+ });
1375
+
1376
+ it('drops sessions whose recorded cwd disagrees with the encoded dir', async () => {
1377
+ // Edge case: encoded dir lookup matches, but session content
1378
+ // records a different cwd. Strict-equality filter must reject.
1379
+ const requested = '/Users/test/proj';
1380
+ const projDir = path.join(projectsDir, '-Users-test-proj');
1381
+ writeSession(projDir, 's', [
1382
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: '/different/path', message: { content: 'mismatch' } },
1383
+ ]);
1384
+
1385
+ const result = await adapter.listSessions({ cwd: requested });
1386
+ expect(result).toEqual([]);
1387
+ });
1388
+
1389
+ it('finds sessions whose recorded cwd lives in a different encoded dir (worktree case)', async () => {
1390
+ // Real-world case: Claude Code is launched in /repo, then chdirs into
1391
+ // /repo/.worktrees/feature. The session file is stored under the
1392
+ // ENCODED launch dir, but its content records the worktree path.
1393
+ // listSessions({ cwd: worktree }) must still find it.
1394
+ const launchDir = path.join(projectsDir, '-repo');
1395
+ const worktreeCwd = '/repo/.worktrees/feature';
1396
+ writeSession(launchDir, 'wt', [
1397
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: worktreeCwd, message: { content: 'in worktree' } },
1398
+ ]);
1399
+
1400
+ const result = await adapter.listSessions({ cwd: worktreeCwd });
1401
+ expect(result).toHaveLength(1);
1402
+ expect(result[0]).toMatchObject({
1403
+ sessionId: 'wt',
1404
+ cwd: worktreeCwd,
1405
+ firstUserMessage: 'in worktree',
1406
+ });
1407
+ });
1408
+
1409
+ it('skips malformed session files', async () => {
1410
+ const cwd = '/Users/test/p';
1411
+ const projDir = path.join(projectsDir, '-Users-test-p');
1412
+ fs.mkdirSync(projDir, { recursive: true });
1413
+ fs.writeFileSync(path.join(projDir, 'bad.jsonl'), 'not valid json');
1414
+ writeSession(projDir, 'good', [
1415
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'ok' } },
1416
+ ]);
1417
+
1418
+ const result = await adapter.listSessions({ cwd });
1419
+ expect(result).toHaveLength(1);
1420
+ expect(result[0].sessionId).toBe('good');
1421
+ });
1422
+
1423
+ it('captures first user message after filtering noise', async () => {
1424
+ const cwd = '/Users/test/q';
1425
+ writeSession(path.join(projectsDir, '-Users-test-q'), 's', [
1426
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'Tool loaded.' } },
1427
+ { type: 'user', timestamp: '2025-01-01T00:00:01Z', cwd, message: { content: 'real first prompt' } },
1428
+ { type: 'user', timestamp: '2025-01-01T00:00:02Z', cwd, message: { content: 'second prompt' } },
1429
+ ]);
1430
+
1431
+ const result = await adapter.listSessions({ cwd });
1432
+ expect(result).toHaveLength(1);
1433
+ expect(result[0].firstUserMessage).toBe('real first prompt');
1434
+ });
1435
+
1436
+ it('returns empty firstUserMessage when no user message exists', async () => {
1437
+ const cwd = '/Users/test/empty';
1438
+ writeSession(path.join(projectsDir, '-Users-test-empty'), 's', [
1439
+ { type: 'assistant', timestamp: '2025-01-01T00:00:00Z' },
1440
+ ]);
1441
+
1442
+ const result = await adapter.listSessions({ cwd });
1443
+ expect(result).toHaveLength(1);
1444
+ expect(result[0].firstUserMessage).toBe('');
1445
+ });
1446
+ });
1291
1447
  });
@@ -19,9 +19,13 @@ jest.mock('../../utils/process', () => ({
19
19
  enrichProcesses: jest.fn(),
20
20
  }));
21
21
 
22
- jest.mock('../../utils/session', () => ({
23
- batchGetSessionFileBirthtimes: jest.fn(),
24
- }));
22
+ jest.mock('../../utils/session', () => {
23
+ const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session');
24
+ return {
25
+ ...actual,
26
+ batchGetSessionFileBirthtimes: jest.fn(),
27
+ };
28
+ });
25
29
 
26
30
  jest.mock('../../utils/matching', () => ({
27
31
  matchProcessesToSessions: jest.fn(),
@@ -630,4 +634,120 @@ describe('CodexAdapter', () => {
630
634
  expect(messages[0].content).toBe('Response');
631
635
  });
632
636
  });
637
+
638
+ describe('listSessions', () => {
639
+ let tmpDir: string;
640
+ let sessionsDir: string;
641
+
642
+ beforeEach(() => {
643
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-list-'));
644
+ sessionsDir = path.join(tmpDir, 'sessions');
645
+ fs.mkdirSync(sessionsDir, { recursive: true });
646
+ (adapter as any).codexSessionsDir = sessionsDir;
647
+ });
648
+
649
+ afterEach(() => {
650
+ fs.rmSync(tmpDir, { recursive: true, force: true });
651
+ });
652
+
653
+ function writeCodexSession(dateDir: string, sessionId: string, lines: object[]): string {
654
+ fs.mkdirSync(dateDir, { recursive: true });
655
+ const filePath = path.join(dateDir, `${sessionId}.jsonl`);
656
+ fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n'));
657
+ return filePath;
658
+ }
659
+
660
+ it('returns empty when sessions dir does not exist', async () => {
661
+ fs.rmSync(sessionsDir, { recursive: true, force: true });
662
+ const result = await adapter.listSessions();
663
+ expect(result).toEqual([]);
664
+ });
665
+
666
+ it('walks every YYYY/MM/DD dir and returns all sessions', async () => {
667
+ const dayA = path.join(sessionsDir, '2025', '01', '01');
668
+ const dayB = path.join(sessionsDir, '2025', '02', '03');
669
+ writeCodexSession(dayA, 'sess-a', [
670
+ { type: 'session_meta', payload: { id: 'sess-a', cwd: '/repo-a', timestamp: '2025-01-01T00:00:00Z' } },
671
+ { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'msg-a' } },
672
+ ]);
673
+ writeCodexSession(dayB, 'sess-b', [
674
+ { type: 'session_meta', payload: { id: 'sess-b', cwd: '/repo-b', timestamp: '2025-02-03T00:00:00Z' } },
675
+ { type: 'event', timestamp: '2025-02-03T00:00:01Z', payload: { type: 'user_message', message: 'msg-b' } },
676
+ ]);
677
+
678
+ const result = await adapter.listSessions();
679
+
680
+ expect(result).toHaveLength(2);
681
+ const byId = Object.fromEntries(result.map((r) => [r.sessionId, r]));
682
+ expect(byId['sess-a']).toMatchObject({
683
+ type: 'codex',
684
+ cwd: '/repo-a',
685
+ firstUserMessage: 'msg-a',
686
+ });
687
+ expect(byId['sess-b']).toMatchObject({
688
+ type: 'codex',
689
+ cwd: '/repo-b',
690
+ firstUserMessage: 'msg-b',
691
+ });
692
+ });
693
+
694
+ it('applies strict-equality cwd filter against session_meta cwd', async () => {
695
+ const day = path.join(sessionsDir, '2025', '01', '01');
696
+ writeCodexSession(day, 'keep', [
697
+ { type: 'session_meta', payload: { id: 'keep', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } },
698
+ { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'yes' } },
699
+ ]);
700
+ writeCodexSession(day, 'drop', [
701
+ { type: 'session_meta', payload: { id: 'drop', cwd: '/other', timestamp: '2025-01-01T00:01:00Z' } },
702
+ { type: 'event', timestamp: '2025-01-01T00:01:01Z', payload: { type: 'user_message', message: 'no' } },
703
+ ]);
704
+
705
+ const result = await adapter.listSessions({ cwd: '/repo' });
706
+
707
+ expect(result).toHaveLength(1);
708
+ expect(result[0].sessionId).toBe('keep');
709
+ });
710
+
711
+ it('skips files without a session_meta first line', async () => {
712
+ const day = path.join(sessionsDir, '2025', '01', '01');
713
+ writeCodexSession(day, 'bad', [
714
+ { type: 'event', timestamp: '2025-01-01T00:00:00Z', payload: { type: 'user_message', message: 'orphan' } },
715
+ ]);
716
+ writeCodexSession(day, 'good', [
717
+ { type: 'session_meta', payload: { id: 'good', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } },
718
+ { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'ok' } },
719
+ ]);
720
+
721
+ const result = await adapter.listSessions();
722
+ expect(result).toHaveLength(1);
723
+ expect(result[0].sessionId).toBe('good');
724
+ });
725
+
726
+ it('captures the first user_message as firstUserMessage', async () => {
727
+ const day = path.join(sessionsDir, '2025', '01', '01');
728
+ writeCodexSession(day, 's', [
729
+ { type: 'session_meta', payload: { id: 's', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } },
730
+ { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'agent_message', message: 'preamble' } },
731
+ { type: 'event', timestamp: '2025-01-01T00:00:02Z', payload: { type: 'user_message', message: 'first user' } },
732
+ { type: 'event', timestamp: '2025-01-01T00:00:03Z', payload: { type: 'user_message', message: 'second user' } },
733
+ ]);
734
+
735
+ const result = await adapter.listSessions({ cwd: '/repo' });
736
+
737
+ expect(result).toHaveLength(1);
738
+ expect(result[0].firstUserMessage).toBe('first user');
739
+ });
740
+
741
+ it('returns empty firstUserMessage when no user_message exists', async () => {
742
+ const day = path.join(sessionsDir, '2025', '01', '01');
743
+ writeCodexSession(day, 's', [
744
+ { type: 'session_meta', payload: { id: 's', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } },
745
+ { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'agent_message', message: 'agent only' } },
746
+ ]);
747
+
748
+ const result = await adapter.listSessions({ cwd: '/repo' });
749
+ expect(result).toHaveLength(1);
750
+ expect(result[0].firstUserMessage).toBe('');
751
+ });
752
+ });
633
753
  });