@ai-devkit/agent-manager 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/adapters/AgentAdapter.d.ts +2 -0
  2. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  3. package/dist/adapters/ClaudeCodeAdapter.d.ts +49 -38
  4. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  5. package/dist/adapters/ClaudeCodeAdapter.js +286 -293
  6. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  7. package/dist/adapters/CodexAdapter.d.ts +32 -30
  8. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  9. package/dist/adapters/CodexAdapter.js +148 -284
  10. package/dist/adapters/CodexAdapter.js.map +1 -1
  11. package/dist/index.d.ts +1 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -10
  14. package/dist/index.js.map +1 -1
  15. package/dist/utils/index.d.ts +6 -3
  16. package/dist/utils/index.d.ts.map +1 -1
  17. package/dist/utils/index.js +12 -11
  18. package/dist/utils/index.js.map +1 -1
  19. package/dist/utils/matching.d.ts +39 -0
  20. package/dist/utils/matching.d.ts.map +1 -0
  21. package/dist/utils/matching.js +103 -0
  22. package/dist/utils/matching.js.map +1 -0
  23. package/dist/utils/process.d.ts +25 -40
  24. package/dist/utils/process.d.ts.map +1 -1
  25. package/dist/utils/process.js +151 -105
  26. package/dist/utils/process.js.map +1 -1
  27. package/dist/utils/session.d.ts +30 -0
  28. package/dist/utils/session.d.ts.map +1 -0
  29. package/dist/utils/session.js +101 -0
  30. package/dist/utils/session.js.map +1 -0
  31. package/package.json +2 -2
  32. package/src/__tests__/AgentManager.test.ts +0 -25
  33. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +921 -205
  34. package/src/__tests__/adapters/CodexAdapter.test.ts +468 -269
  35. package/src/__tests__/utils/matching.test.ts +191 -0
  36. package/src/__tests__/utils/process.test.ts +202 -0
  37. package/src/__tests__/utils/session.test.ts +117 -0
  38. package/src/adapters/AgentAdapter.ts +3 -0
  39. package/src/adapters/ClaudeCodeAdapter.ts +341 -418
  40. package/src/adapters/CodexAdapter.ts +155 -420
  41. package/src/index.ts +1 -3
  42. package/src/utils/index.ts +6 -3
  43. package/src/utils/matching.ts +92 -0
  44. package/src/utils/process.ts +133 -119
  45. package/src/utils/session.ts +92 -0
  46. package/dist/utils/file.d.ts +0 -52
  47. package/dist/utils/file.d.ts.map +0 -1
  48. package/dist/utils/file.js +0 -135
  49. package/dist/utils/file.js.map +0 -1
  50. package/src/utils/file.ts +0 -100
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Tests for utils/matching.ts
3
+ */
4
+
5
+ import { describe, it, expect } from '@jest/globals';
6
+ import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
7
+ import type { ProcessInfo } from '../../adapters/AgentAdapter';
8
+ import type { SessionFile } from '../../utils/session';
9
+
10
+ function makeProcess(overrides: Partial<ProcessInfo> & { pid: number }): ProcessInfo {
11
+ return {
12
+ command: 'claude',
13
+ cwd: '/projects/my-app',
14
+ tty: 'ttys001',
15
+ startTime: new Date('2026-03-18T23:18:01.000Z'),
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ function makeSession(overrides: Partial<SessionFile> & { sessionId: string }): SessionFile {
21
+ return {
22
+ filePath: `/home/.claude/projects/my-app/${overrides.sessionId}.jsonl`,
23
+ projectDir: '/home/.claude/projects/my-app',
24
+ birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime(),
25
+ resolvedCwd: '/projects/my-app',
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe('matchProcessesToSessions', () => {
31
+ it('should return empty array when no processes', () => {
32
+ const sessions = [makeSession({ sessionId: 's1' })];
33
+ expect(matchProcessesToSessions([], sessions)).toEqual([]);
34
+ });
35
+
36
+ it('should return empty array when no sessions', () => {
37
+ const processes = [makeProcess({ pid: 100 })];
38
+ expect(matchProcessesToSessions(processes, [])).toEqual([]);
39
+ });
40
+
41
+ it('should match a single process to closest session', () => {
42
+ const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
43
+ const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() });
44
+ const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:20:00.000Z').getTime() });
45
+
46
+ const results = matchProcessesToSessions([proc], [s1, s2]);
47
+ expect(results).toHaveLength(1);
48
+ expect(results[0].session.sessionId).toBe('s1');
49
+ expect(results[0].deltaMs).toBe(43000);
50
+ });
51
+
52
+ it('should enforce 1:1 constraint — each process matches only one session', () => {
53
+ const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
54
+ const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:18:30.000Z') });
55
+ const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:10.000Z').getTime() });
56
+ const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:18:35.000Z').getTime() });
57
+
58
+ const results = matchProcessesToSessions([p1, p2], [s1, s2]);
59
+ expect(results).toHaveLength(2);
60
+
61
+ const pids = results.map(r => r.process.pid).sort();
62
+ const sids = results.map(r => r.session.sessionId).sort();
63
+ expect(pids).toEqual([100, 200]);
64
+ expect(sids).toEqual(['s1', 's2']);
65
+ });
66
+
67
+ it('should disambiguate multiple processes with same CWD by birthtime', () => {
68
+ const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
69
+ const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-19T08:53:11.000Z') });
70
+ const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() });
71
+ const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-19T08:55:35.000Z').getTime() });
72
+
73
+ const results = matchProcessesToSessions([p1, p2], [s1, s2]);
74
+ expect(results).toHaveLength(2);
75
+
76
+ const match1 = results.find(r => r.process.pid === 100);
77
+ const match2 = results.find(r => r.process.pid === 200);
78
+ expect(match1?.session.sessionId).toBe('s1');
79
+ expect(match2?.session.sessionId).toBe('s2');
80
+ });
81
+
82
+ it('should exclude processes without startTime', () => {
83
+ const proc = makeProcess({ pid: 100, startTime: undefined });
84
+ const session = makeSession({ sessionId: 's1' });
85
+
86
+ const results = matchProcessesToSessions([proc], [session]);
87
+ expect(results).toEqual([]);
88
+ });
89
+
90
+ it('should exclude processes without cwd', () => {
91
+ const proc = makeProcess({ pid: 100, cwd: '' });
92
+ const session = makeSession({ sessionId: 's1' });
93
+
94
+ const results = matchProcessesToSessions([proc], [session]);
95
+ expect(results).toEqual([]);
96
+ });
97
+
98
+ it('should not match when CWD does not match resolvedCwd', () => {
99
+ const proc = makeProcess({ pid: 100, cwd: '/projects/app-a' });
100
+ const session = makeSession({ sessionId: 's1', resolvedCwd: '/projects/app-b' });
101
+
102
+ const results = matchProcessesToSessions([proc], [session]);
103
+ expect(results).toEqual([]);
104
+ });
105
+
106
+ it('should not match when delta exceeds 3-minute tolerance', () => {
107
+ const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
108
+ const session = makeSession({
109
+ sessionId: 's1',
110
+ birthtimeMs: new Date('2026-03-18T23:22:00.000Z').getTime(), // ~4 min later
111
+ });
112
+
113
+ const results = matchProcessesToSessions([proc], [session]);
114
+ expect(results).toEqual([]);
115
+ });
116
+
117
+ it('should match at exactly 3-minute boundary', () => {
118
+ const startTime = new Date('2026-03-18T23:18:00.000Z');
119
+ const proc = makeProcess({ pid: 100, startTime });
120
+ const session = makeSession({
121
+ sessionId: 's1',
122
+ birthtimeMs: startTime.getTime() + 180_000, // exactly 3 min
123
+ });
124
+
125
+ const results = matchProcessesToSessions([proc], [session]);
126
+ expect(results).toHaveLength(1);
127
+ });
128
+
129
+ it('should handle more sessions than processes', () => {
130
+ const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
131
+ const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() });
132
+ const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:19:00.000Z').getTime() });
133
+ const s3 = makeSession({ sessionId: 's3', birthtimeMs: new Date('2026-03-18T23:19:30.000Z').getTime() });
134
+
135
+ const results = matchProcessesToSessions([proc], [s1, s2, s3]);
136
+ expect(results).toHaveLength(1);
137
+ expect(results[0].session.sessionId).toBe('s1'); // closest
138
+ });
139
+
140
+ it('should handle more processes than sessions', () => {
141
+ const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') });
142
+ const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:20:01.000Z') });
143
+ const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() });
144
+
145
+ const results = matchProcessesToSessions([p1, p2], [s1]);
146
+ expect(results).toHaveLength(1);
147
+ expect(results[0].process.pid).toBe(100); // closest
148
+ });
149
+
150
+ it('should skip sessions with empty resolvedCwd', () => {
151
+ const proc = makeProcess({ pid: 100 });
152
+ const session = makeSession({ sessionId: 's1', resolvedCwd: '' });
153
+
154
+ const results = matchProcessesToSessions([proc], [session]);
155
+ expect(results).toEqual([]);
156
+ });
157
+
158
+ it('should prefer best match when greedy ordering matters', () => {
159
+ // p1 is 10s from s2, p2 is 5s from s2 — p2 should win s2, p1 gets s1
160
+ const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:00.000Z') });
161
+ const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:18:25.000Z') });
162
+ const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:08.000Z').getTime() });
163
+ const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:18:30.000Z').getTime() });
164
+
165
+ const results = matchProcessesToSessions([p1, p2], [s1, s2]);
166
+ expect(results).toHaveLength(2);
167
+
168
+ const match1 = results.find(r => r.process.pid === 200);
169
+ const match2 = results.find(r => r.process.pid === 100);
170
+ expect(match1?.session.sessionId).toBe('s2'); // 5s delta
171
+ expect(match2?.session.sessionId).toBe('s1'); // 8s delta
172
+ });
173
+ });
174
+
175
+ describe('generateAgentName', () => {
176
+ it('should return folderName (pid)', () => {
177
+ expect(generateAgentName('/projects/my-app', 12345)).toBe('my-app (12345)');
178
+ });
179
+
180
+ it('should handle root path', () => {
181
+ expect(generateAgentName('/', 100)).toBe('unknown (100)');
182
+ });
183
+
184
+ it('should handle empty cwd', () => {
185
+ expect(generateAgentName('', 100)).toBe('unknown (100)');
186
+ });
187
+
188
+ it('should handle nested paths', () => {
189
+ expect(generateAgentName('/home/user/projects/ai-devkit', 78070)).toBe('ai-devkit (78070)');
190
+ });
191
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tests for new functions in utils/process.ts
3
+ */
4
+
5
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
6
+ import { execSync } from 'child_process';
7
+ import {
8
+ listAgentProcesses,
9
+ batchGetProcessCwds,
10
+ batchGetProcessStartTimes,
11
+ enrichProcesses,
12
+ } from '../../utils/process';
13
+
14
+ jest.mock('child_process', () => ({
15
+ execSync: jest.fn(),
16
+ }));
17
+
18
+ const mockedExecSync = execSync as jest.MockedFunction<typeof execSync>;
19
+
20
+ describe('listAgentProcesses', () => {
21
+ beforeEach(() => {
22
+ mockedExecSync.mockReset();
23
+ });
24
+
25
+ it('should parse ps aux | grep output and post-filter by executable name', () => {
26
+ mockedExecSync.mockReturnValue(
27
+ 'user 78070 1.0 0.5 485636016 245952 s018 S+ 11:18PM 1:55.14 claude\n' +
28
+ 'user 55106 0.1 0.4 485620368 72496 s015 S+ 9Mar26 8:06.36 claude\n',
29
+ );
30
+
31
+ const processes = listAgentProcesses('claude');
32
+ expect(processes).toHaveLength(2);
33
+ expect(processes[0].pid).toBe(78070);
34
+ expect(processes[0].command).toBe('claude');
35
+ expect(processes[0].tty).toBe('s018');
36
+ expect(processes[0].cwd).toBe(''); // not populated yet
37
+ expect(processes[1].pid).toBe(55106);
38
+ });
39
+
40
+ it('should filter out non-matching executables', () => {
41
+ mockedExecSync.mockReturnValue(
42
+ 'user 100 0.0 0.0 0 0 s001 S 1:00PM 0:00 claude\n' +
43
+ 'user 200 0.0 0.0 0 0 s002 S 1:00PM 0:00 claude-helper --pid 100\n' +
44
+ 'user 300 0.0 0.0 0 0 s003 S 1:00PM 0:00 /usr/bin/claude\n',
45
+ );
46
+
47
+ const processes = listAgentProcesses('claude');
48
+ expect(processes).toHaveLength(2);
49
+ expect(processes.map(p => p.pid)).toEqual([100, 300]);
50
+ });
51
+
52
+ it('should return empty array on command failure', () => {
53
+ mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
54
+ expect(listAgentProcesses('claude')).toEqual([]);
55
+ });
56
+
57
+ it('should handle empty output', () => {
58
+ mockedExecSync.mockReturnValue('');
59
+ expect(listAgentProcesses('claude')).toEqual([]);
60
+ });
61
+
62
+ it('should reject empty pattern', () => {
63
+ expect(listAgentProcesses('')).toEqual([]);
64
+ expect(mockedExecSync).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('should reject patterns with shell injection characters', () => {
68
+ expect(listAgentProcesses('claude; rm -rf /')).toEqual([]);
69
+ expect(listAgentProcesses("claude' || true")).toEqual([]);
70
+ expect(listAgentProcesses('$(whoami)')).toEqual([]);
71
+ expect(mockedExecSync).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('should accept valid patterns with dashes and underscores', () => {
75
+ mockedExecSync.mockReturnValue('');
76
+ listAgentProcesses('claude-code');
77
+ expect(mockedExecSync).toHaveBeenCalled();
78
+
79
+ mockedExecSync.mockReset();
80
+ mockedExecSync.mockReturnValue('');
81
+ listAgentProcesses('my_agent');
82
+ expect(mockedExecSync).toHaveBeenCalled();
83
+ });
84
+ });
85
+
86
+ describe('batchGetProcessCwds', () => {
87
+ beforeEach(() => {
88
+ mockedExecSync.mockReset();
89
+ });
90
+
91
+ it('should parse batched lsof output', () => {
92
+ mockedExecSync.mockReturnValue(
93
+ 'p78070\nn/Users/user/ai-devkit\np55106\nn/Users/user/other-project\n',
94
+ );
95
+
96
+ const cwds = batchGetProcessCwds([78070, 55106]);
97
+ expect(cwds.get(78070)).toBe('/Users/user/ai-devkit');
98
+ expect(cwds.get(55106)).toBe('/Users/user/other-project');
99
+ });
100
+
101
+ it('should return empty map for empty pids', () => {
102
+ expect(batchGetProcessCwds([])).toEqual(new Map());
103
+ });
104
+
105
+ it('should return partial results when lsof succeeds for some PIDs', () => {
106
+ // lsof might not return entries for dead processes
107
+ mockedExecSync.mockReturnValue(
108
+ 'p78070\nn/Users/user/ai-devkit\n',
109
+ );
110
+
111
+ const cwds = batchGetProcessCwds([78070, 99999]);
112
+ expect(cwds.size).toBe(1);
113
+ expect(cwds.get(78070)).toBe('/Users/user/ai-devkit');
114
+ });
115
+
116
+ it('should return empty map on total failure', () => {
117
+ mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
118
+ const cwds = batchGetProcessCwds([78070]);
119
+ // Falls through to pwdx fallback which also fails
120
+ expect(cwds.size).toBe(0);
121
+ });
122
+ });
123
+
124
+ describe('batchGetProcessStartTimes', () => {
125
+ beforeEach(() => {
126
+ mockedExecSync.mockReset();
127
+ });
128
+
129
+ it('should parse ps lstart output', () => {
130
+ mockedExecSync.mockReturnValue(
131
+ ' 78070 Wed Mar 18 23:18:01 2026\n' +
132
+ ' 55106 Mon Mar 9 21:41:42 2026\n',
133
+ );
134
+
135
+ const times = batchGetProcessStartTimes([78070, 55106]);
136
+ expect(times.size).toBe(2);
137
+ expect(times.get(78070)?.getFullYear()).toBe(2026);
138
+ expect(times.get(55106)?.getMonth()).toBe(2); // March = 2
139
+ });
140
+
141
+ it('should return empty map for empty pids', () => {
142
+ expect(batchGetProcessStartTimes([])).toEqual(new Map());
143
+ });
144
+
145
+ it('should skip lines with unparseable dates', () => {
146
+ mockedExecSync.mockReturnValue(
147
+ ' 78070 Wed Mar 18 23:18:01 2026\n' +
148
+ ' 99999 INVALID_DATE\n',
149
+ );
150
+
151
+ const times = batchGetProcessStartTimes([78070, 99999]);
152
+ expect(times.size).toBe(1);
153
+ expect(times.has(78070)).toBe(true);
154
+ });
155
+
156
+ it('should return empty map on failure', () => {
157
+ mockedExecSync.mockImplementation(() => { throw new Error('fail'); });
158
+ expect(batchGetProcessStartTimes([78070])).toEqual(new Map());
159
+ });
160
+ });
161
+
162
+ describe('enrichProcesses', () => {
163
+ beforeEach(() => {
164
+ mockedExecSync.mockReset();
165
+ });
166
+
167
+ it('should populate cwd and startTime on processes', () => {
168
+ // First call: batchGetProcessCwds (lsof)
169
+ // Second call: batchGetProcessStartTimes (ps lstart)
170
+ mockedExecSync
171
+ .mockReturnValueOnce('p100\nn/projects/app\n')
172
+ .mockReturnValueOnce(' 100 Wed Mar 18 23:18:01 2026\n');
173
+
174
+ const processes = [
175
+ { pid: 100, command: 'claude', cwd: '', tty: 's001' },
176
+ ];
177
+
178
+ const enriched = enrichProcesses(processes);
179
+ expect(enriched[0].cwd).toBe('/projects/app');
180
+ expect(enriched[0].startTime).toBeDefined();
181
+ });
182
+
183
+ it('should return empty array for empty input', () => {
184
+ expect(enrichProcesses([])).toEqual([]);
185
+ expect(mockedExecSync).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it('should handle partial failures', () => {
189
+ // lsof succeeds, ps lstart fails
190
+ mockedExecSync
191
+ .mockReturnValueOnce('p100\nn/projects/app\n')
192
+ .mockImplementationOnce(() => { throw new Error('fail'); });
193
+
194
+ const processes = [
195
+ { pid: 100, command: 'claude', cwd: '', tty: 's001' },
196
+ ];
197
+
198
+ const enriched = enrichProcesses(processes);
199
+ expect(enriched[0].cwd).toBe('/projects/app');
200
+ expect(enriched[0].startTime).toBeUndefined();
201
+ });
202
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tests for utils/session.ts
3
+ */
4
+
5
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
6
+ import { execSync } from 'child_process';
7
+ import { batchGetSessionFileBirthtimes } from '../../utils/session';
8
+
9
+ jest.mock('child_process', () => ({
10
+ execSync: jest.fn(),
11
+ }));
12
+
13
+ const mockedExecSync = execSync as jest.MockedFunction<typeof execSync>;
14
+
15
+ describe('batchGetSessionFileBirthtimes', () => {
16
+ beforeEach(() => {
17
+ mockedExecSync.mockReset();
18
+ });
19
+
20
+ it('should parse stat output correctly', () => {
21
+ mockedExecSync.mockReturnValue(
22
+ '1710800324 /home/.claude/projects/my-app/abc123.jsonl\n' +
23
+ '1710800500 /home/.claude/projects/my-app/def456.jsonl\n',
24
+ );
25
+
26
+ const results = batchGetSessionFileBirthtimes(['/home/.claude/projects/my-app']);
27
+
28
+ expect(results).toHaveLength(2);
29
+ expect(results[0]).toEqual({
30
+ sessionId: 'abc123',
31
+ filePath: '/home/.claude/projects/my-app/abc123.jsonl',
32
+ projectDir: '/home/.claude/projects/my-app',
33
+ birthtimeMs: 1710800324000,
34
+ resolvedCwd: '',
35
+ });
36
+ expect(results[1].sessionId).toBe('def456');
37
+ expect(results[1].birthtimeMs).toBe(1710800500000);
38
+ });
39
+
40
+ it('should return empty array for empty dirs list', () => {
41
+ expect(batchGetSessionFileBirthtimes([])).toEqual([]);
42
+ expect(mockedExecSync).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it('should return empty array on command failure', () => {
46
+ mockedExecSync.mockImplementation(() => {
47
+ throw new Error('Command failed');
48
+ });
49
+
50
+ expect(batchGetSessionFileBirthtimes(['/some/dir'])).toEqual([]);
51
+ });
52
+
53
+ it('should skip lines with invalid epoch (0 or negative)', () => {
54
+ mockedExecSync.mockReturnValue(
55
+ '0 /dir/bad.jsonl\n' +
56
+ '-1 /dir/negative.jsonl\n' +
57
+ '1710800324 /dir/good.jsonl\n',
58
+ );
59
+
60
+ const results = batchGetSessionFileBirthtimes(['/dir']);
61
+ expect(results).toHaveLength(1);
62
+ expect(results[0].sessionId).toBe('good');
63
+ });
64
+
65
+ it('should skip non-jsonl files in output', () => {
66
+ mockedExecSync.mockReturnValue(
67
+ '1710800324 /dir/sessions-index.json\n' +
68
+ '1710800500 /dir/abc123.jsonl\n',
69
+ );
70
+
71
+ const results = batchGetSessionFileBirthtimes(['/dir']);
72
+ expect(results).toHaveLength(1);
73
+ expect(results[0].sessionId).toBe('abc123');
74
+ });
75
+
76
+ it('should handle empty output', () => {
77
+ mockedExecSync.mockReturnValue('');
78
+ expect(batchGetSessionFileBirthtimes(['/dir'])).toEqual([]);
79
+ });
80
+
81
+ it('should handle UUID session IDs', () => {
82
+ mockedExecSync.mockReturnValue(
83
+ '1710800324 /dir/068e7b1f-cff5-4c94-bf69-b9acd32d765c.jsonl\n',
84
+ );
85
+
86
+ const results = batchGetSessionFileBirthtimes(['/dir']);
87
+ expect(results).toHaveLength(1);
88
+ expect(results[0].sessionId).toBe('068e7b1f-cff5-4c94-bf69-b9acd32d765c');
89
+ });
90
+
91
+ it('should leave resolvedCwd empty', () => {
92
+ mockedExecSync.mockReturnValue('1710800324 /dir/abc.jsonl\n');
93
+
94
+ const results = batchGetSessionFileBirthtimes(['/dir']);
95
+ expect(results[0].resolvedCwd).toBe('');
96
+ });
97
+
98
+ it('should combine multiple directories into a single stat call', () => {
99
+ mockedExecSync.mockReturnValue(
100
+ '1710800324 /projects/app-a/sess1.jsonl\n' +
101
+ '1710800400 /projects/app-b/sess2.jsonl\n' +
102
+ '1710800500 /projects/app-a/sess3.jsonl\n',
103
+ );
104
+
105
+ const results = batchGetSessionFileBirthtimes(['/projects/app-a', '/projects/app-b']);
106
+
107
+ expect(mockedExecSync).toHaveBeenCalledTimes(1);
108
+ const cmd = mockedExecSync.mock.calls[0][0] as string;
109
+ expect(cmd).toContain('"/projects/app-a"/*.jsonl');
110
+ expect(cmd).toContain('"/projects/app-b"/*.jsonl');
111
+
112
+ expect(results).toHaveLength(3);
113
+ expect(results[0].projectDir).toBe('/projects/app-a');
114
+ expect(results[1].projectDir).toBe('/projects/app-b');
115
+ expect(results[2].projectDir).toBe('/projects/app-a');
116
+ });
117
+ });
@@ -68,6 +68,9 @@ export interface ProcessInfo {
68
68
 
69
69
  /** Terminal TTY (e.g., "ttys030") */
70
70
  tty: string;
71
+
72
+ /** Process start time, populated by enrichProcesses */
73
+ startTime?: Date;
71
74
  }
72
75
 
73
76
  /**