@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,435 @@
1
+ /**
2
+ * Tests for PiAdapter
3
+ */
4
+
5
+ import type { MockedFunction } from 'vitest';
6
+ import * as fs from 'fs';
7
+ import * as os from 'os';
8
+ import * as path from 'path';
9
+
10
+ import { PiAdapter } from '../../adapters/PiAdapter.js';
11
+ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
+ import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
+ import { AgentRegistry } from '../../utils/AgentRegistry.js';
14
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
15
+ import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
16
+
17
+ vi.mock('../../utils/process.js', () => ({
18
+ listAgentProcesses: vi.fn(),
19
+ enrichProcesses: vi.fn(),
20
+ }));
21
+
22
+ vi.mock('../../utils/matching.js', () => ({
23
+ matchProcessesToSessions: vi.fn(),
24
+ generateAgentName: vi.fn(),
25
+ }));
26
+
27
+ const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
28
+ const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
29
+ const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction<typeof matchProcessesToSessions>;
30
+ const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
31
+
32
+ describe('PiAdapter', () => {
33
+ let adapter: PiAdapter;
34
+ let tmpHome: string;
35
+ let sessionsDir: string;
36
+
37
+ beforeEach(() => {
38
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-adapter-test-'));
39
+ process.env.HOME = tmpHome;
40
+ sessionsDir = path.join(tmpHome, '.pi', 'agent', 'sessions');
41
+ fs.mkdirSync(sessionsDir, { recursive: true });
42
+
43
+ adapter = new PiAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json')));
44
+ mockedListAgentProcesses.mockReset();
45
+ mockedEnrichProcesses.mockReset();
46
+ mockedMatchProcessesToSessions.mockReset();
47
+ mockedGenerateAgentName.mockReset();
48
+
49
+ mockedEnrichProcesses.mockImplementation((procs) => procs);
50
+ mockedMatchProcessesToSessions.mockReturnValue([]);
51
+ mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => {
52
+ const folder = path.basename(cwd) || 'unknown';
53
+ return `${folder} (${pid})`;
54
+ });
55
+ });
56
+
57
+ afterEach(() => {
58
+ fs.rmSync(tmpHome, { recursive: true, force: true });
59
+ });
60
+
61
+ it('exposes pi type', () => {
62
+ expect(adapter.type).toBe('pi');
63
+ });
64
+
65
+ it('identifies Pi commands without matching unrelated paths', () => {
66
+ expect(adapter.canHandle({ pid: 1, command: 'pi', cwd: '/repo', tty: 'ttys001' })).toBe(true);
67
+ expect(adapter.canHandle({ pid: 2, command: '/usr/local/bin/PI --model x', cwd: '/repo', tty: 'ttys002' })).toBe(true);
68
+ expect(adapter.canHandle({ pid: 3, command: 'node /opt/pi/bin/pi.js', cwd: '/repo', tty: 'ttys003' })).toBe(true);
69
+ expect(adapter.canHandle({ pid: 4, command: 'node /repo/feature-pi-adapter/script.js', cwd: '/repo', tty: 'ttys004' })).toBe(false);
70
+ });
71
+
72
+ it('maps a running Pi process to the tracker session for its PID', async () => {
73
+ const cwd = '/repo/project-a';
74
+ const proc = makeProcess({ pid: 101, cwd });
75
+ const sessionFile = writePiSession(cwd, [
76
+ { type: 'session_meta', timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-101', cwd },
77
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'implement Pi adapter' },
78
+ { role: 'assistant', timestamp: new Date().toISOString(), content: 'working on it' },
79
+ ]);
80
+ fs.writeFileSync(
81
+ path.join(tmpHome, '.pi', 'agent', 'sessions.json'),
82
+ JSON.stringify({ 101: sessionFile }),
83
+ );
84
+ mockedListAgentProcesses.mockReturnValue([proc]);
85
+
86
+ const agents = await adapter.detectAgents();
87
+
88
+ expect(agents).toHaveLength(1);
89
+ expect(agents[0]).toMatchObject({
90
+ type: 'pi',
91
+ pid: 101,
92
+ projectPath: cwd,
93
+ sessionId: 'sess-101',
94
+ summary: 'implement Pi adapter',
95
+ status: AgentStatus.WAITING,
96
+ sessionFilePath: sessionFile,
97
+ });
98
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it('truncates long user prompts in detected agent summaries', async () => {
102
+ const cwd = '/repo/project-long-summary';
103
+ const proc = makeProcess({ pid: 112, cwd });
104
+ const longPrompt = 'x'.repeat(140);
105
+ const sessionFile = writePiSession(cwd, [
106
+ { type: 'session', timestamp: '2026-06-10T08:58:20.754Z', id: 'sess-long', cwd },
107
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: longPrompt },
108
+ ]);
109
+ fs.writeFileSync(
110
+ path.join(tmpHome, '.pi', 'agent', 'sessions.json'),
111
+ JSON.stringify({ 112: sessionFile }),
112
+ );
113
+ mockedListAgentProcesses.mockReturnValue([proc]);
114
+
115
+ const agents = await adapter.detectAgents();
116
+
117
+ expect(agents[0].summary).toHaveLength(120);
118
+ expect(agents[0].summary.endsWith('...')).toBe(true);
119
+ });
120
+
121
+ it('uses the filename session id fallback and reports running when the latest message is from the user', async () => {
122
+ const cwd = '/repo/project-filename-fallback';
123
+ const proc = makeProcess({ pid: 113, cwd });
124
+ const sessionFile = writePiSessionWithFileName(cwd, 'plain-session.jsonl', [
125
+ { role: 'user', timestamp: new Date().toISOString(), content: 'still working' },
126
+ ]);
127
+ fs.writeFileSync(
128
+ path.join(tmpHome, '.pi', 'agent', 'sessions.json'),
129
+ JSON.stringify({ 113: sessionFile }),
130
+ );
131
+ mockedListAgentProcesses.mockReturnValue([proc]);
132
+
133
+ const agents = await adapter.detectAgents();
134
+
135
+ expect(agents[0]).toMatchObject({
136
+ sessionId: 'plain-session',
137
+ summary: 'still working',
138
+ status: AgentStatus.RUNNING,
139
+ });
140
+ });
141
+
142
+ it('falls back to legacy matching when sessions.json is missing', async () => {
143
+ const cwd = '/repo/project-b';
144
+ const proc = makeProcess({ pid: 202, cwd });
145
+ const sessionFile = writePiSession(cwd, [
146
+ { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-202' },
147
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'fallback matching please' },
148
+ ]);
149
+ mockedListAgentProcesses.mockReturnValue([proc]);
150
+ mockedMatchProcessesToSessions.mockReturnValue([
151
+ {
152
+ process: proc,
153
+ session: {
154
+ sessionId: 'sess-202',
155
+ filePath: sessionFile,
156
+ projectDir: path.dirname(sessionFile),
157
+ birthtimeMs: Date.now(),
158
+ resolvedCwd: cwd,
159
+ },
160
+ deltaMs: 0,
161
+ },
162
+ ]);
163
+
164
+ const agents = await adapter.detectAgents();
165
+
166
+ expect(agents).toHaveLength(1);
167
+ expect(agents[0]).toMatchObject({
168
+ type: 'pi',
169
+ pid: 202,
170
+ projectPath: cwd,
171
+ sessionId: 'sess-202',
172
+ summary: 'fallback matching please',
173
+ });
174
+ expect(mockedMatchProcessesToSessions).toHaveBeenCalledWith(
175
+ [proc],
176
+ expect.arrayContaining([
177
+ expect.objectContaining({ filePath: sessionFile, resolvedCwd: cwd }),
178
+ ]),
179
+ );
180
+ });
181
+
182
+ it('ignores malformed tracker metadata and still falls back to legacy matching', async () => {
183
+ const cwd = '/repo/project-c';
184
+ const proc = makeProcess({ pid: 303, cwd });
185
+ const sessionFile = writePiSession(cwd, [
186
+ { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-303', cwd },
187
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'recover from bad tracker' },
188
+ ]);
189
+ fs.writeFileSync(path.join(tmpHome, '.pi', 'agent', 'sessions.json'), '{bad json');
190
+ mockedListAgentProcesses.mockReturnValue([proc]);
191
+ mockedMatchProcessesToSessions.mockReturnValue([
192
+ {
193
+ process: proc,
194
+ session: {
195
+ sessionId: 'sess-303',
196
+ filePath: sessionFile,
197
+ projectDir: path.dirname(sessionFile),
198
+ birthtimeMs: Date.now(),
199
+ resolvedCwd: cwd,
200
+ },
201
+ deltaMs: 0,
202
+ },
203
+ ]);
204
+
205
+ const agents = await adapter.detectAgents();
206
+
207
+ expect(agents).toHaveLength(1);
208
+ expect(agents[0].sessionId).toBe('sess-303');
209
+ });
210
+
211
+ it('falls back to legacy matching when a trusted tracker session is unparseable', async () => {
212
+ const cwd = '/repo/project-bad-tracker-session';
213
+ const proc = makeProcess({ pid: 304, cwd });
214
+ const badSessionFile = writePiSessionWithFileName(cwd, 'bad.jsonl', ['{not json']);
215
+ const fallbackSessionFile = writePiSession(cwd, [
216
+ { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-304', cwd },
217
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'fallback after bad tracker session' },
218
+ ]);
219
+ fs.writeFileSync(
220
+ path.join(tmpHome, '.pi', 'agent', 'sessions.json'),
221
+ JSON.stringify({ 304: badSessionFile }),
222
+ );
223
+ mockedListAgentProcesses.mockReturnValue([proc]);
224
+ mockedMatchProcessesToSessions.mockReturnValue([
225
+ {
226
+ process: proc,
227
+ session: {
228
+ sessionId: 'sess-304',
229
+ filePath: fallbackSessionFile,
230
+ projectDir: path.dirname(fallbackSessionFile),
231
+ birthtimeMs: Date.now(),
232
+ resolvedCwd: cwd,
233
+ },
234
+ deltaMs: 0,
235
+ },
236
+ ]);
237
+
238
+ const agents = await adapter.detectAgents();
239
+
240
+ expect(agents).toHaveLength(1);
241
+ expect(agents[0]).toMatchObject({
242
+ sessionId: 'sess-304',
243
+ summary: 'fallback after bad tracker session',
244
+ sessionFilePath: fallbackSessionFile,
245
+ });
246
+ expect(mockedMatchProcessesToSessions).toHaveBeenCalled();
247
+ });
248
+
249
+ it('does not trust tracker paths outside the Pi sessions directory', async () => {
250
+ const cwd = '/repo/project-d';
251
+ const proc = makeProcess({ pid: 404, cwd });
252
+ const outside = path.join(tmpHome, 'outside.jsonl');
253
+ fs.writeFileSync(outside, JSON.stringify({ role: 'user', content: 'nope' }));
254
+ fs.writeFileSync(
255
+ path.join(tmpHome, '.pi', 'agent', 'sessions.json'),
256
+ JSON.stringify({ 404: outside }),
257
+ );
258
+ mockedListAgentProcesses.mockReturnValue([proc]);
259
+
260
+ const agents = await adapter.detectAgents();
261
+
262
+ expect(agents).toHaveLength(1);
263
+ expect(agents[0]).toMatchObject({
264
+ type: 'pi',
265
+ pid: 404,
266
+ sessionId: 'pid-404',
267
+ summary: 'Pi process running',
268
+ });
269
+ });
270
+
271
+ it('returns a process-only agent when no session can be matched', async () => {
272
+ const proc = makeProcess({ pid: 505, cwd: '/repo/project-e' });
273
+ mockedListAgentProcesses.mockReturnValue([proc]);
274
+
275
+ const agents = await adapter.detectAgents();
276
+
277
+ expect(agents).toHaveLength(1);
278
+ expect(agents[0]).toMatchObject({
279
+ type: 'pi',
280
+ status: AgentStatus.RUNNING,
281
+ pid: 505,
282
+ projectPath: '/repo/project-e',
283
+ sessionId: 'pid-505',
284
+ summary: 'Pi process running',
285
+ });
286
+ });
287
+
288
+ it('reads user and assistant conversation messages from JSONL', () => {
289
+ const cwd = '/repo/project-f';
290
+ const sessionFile = writePiSession(cwd, [
291
+ { role: 'system', timestamp: '2026-06-10T08:58:20.000Z', content: 'hidden' },
292
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'hello pi' },
293
+ { type: 'assistant', timestamp: '2026-06-10T08:58:22.000Z', message: { content: 'hello human' } },
294
+ '{not json',
295
+ ]);
296
+
297
+ expect(adapter.getConversation(sessionFile)).toEqual([
298
+ { role: 'user', content: 'hello pi', timestamp: '2026-06-10T08:58:21.000Z' },
299
+ { role: 'assistant', content: 'hello human', timestamp: '2026-06-10T08:58:22.000Z' },
300
+ ]);
301
+ });
302
+
303
+ it('includes system entries only in verbose conversation mode', () => {
304
+ const cwd = '/repo/project-verbose';
305
+ const sessionFile = writePiSession(cwd, [
306
+ { role: 'system', timestamp: '2026-06-10T08:58:20.000Z', content: 'model changed' },
307
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'visible' },
308
+ ]);
309
+
310
+ expect(adapter.getConversation(sessionFile)).toEqual([
311
+ { role: 'user', content: 'visible', timestamp: '2026-06-10T08:58:21.000Z' },
312
+ ]);
313
+ expect(adapter.getConversation(sessionFile, { verbose: true })).toEqual([
314
+ { role: 'system', content: 'model changed', timestamp: '2026-06-10T08:58:20.000Z' },
315
+ { role: 'user', content: 'visible', timestamp: '2026-06-10T08:58:21.000Z' },
316
+ ]);
317
+ });
318
+
319
+ it('reads real Pi message entries with nested role and text parts', async () => {
320
+ const cwd = '/repo/project-real';
321
+ const proc = makeProcess({ pid: 606, cwd });
322
+ const sessionFile = writePiSession(cwd, [
323
+ { type: 'session', version: 3, id: 'sess-real', timestamp: '2026-06-10T13:27:17.581Z', cwd },
324
+ { type: 'model_change', id: 'model-1', timestamp: '2026-06-10T13:27:17.655Z', modelId: 'claude-sonnet-4-6' },
325
+ {
326
+ type: 'message',
327
+ id: 'msg-user',
328
+ timestamp: '2026-06-10T13:27:37.975Z',
329
+ message: {
330
+ role: 'user',
331
+ content: [{ type: 'text', text: 'hello' }],
332
+ timestamp: 1781098057974,
333
+ },
334
+ },
335
+ {
336
+ type: 'message',
337
+ id: 'msg-assistant',
338
+ timestamp: '2026-06-10T13:27:40.161Z',
339
+ message: {
340
+ role: 'assistant',
341
+ content: [{ type: 'text', text: 'Hello! How can I help you today?' }],
342
+ provider: 'anthropic',
343
+ model: 'claude-sonnet-4-6',
344
+ timestamp: 1781098058012,
345
+ },
346
+ },
347
+ ]);
348
+ fs.writeFileSync(
349
+ path.join(tmpHome, '.pi', 'agent', 'sessions.json'),
350
+ JSON.stringify({ 606: sessionFile }),
351
+ );
352
+ mockedListAgentProcesses.mockReturnValue([proc]);
353
+
354
+ expect(adapter.getConversation(sessionFile)).toEqual([
355
+ { role: 'user', content: 'hello', timestamp: '2026-06-10T13:27:37.975Z' },
356
+ { role: 'assistant', content: 'Hello! How can I help you today?', timestamp: '2026-06-10T13:27:40.161Z' },
357
+ ]);
358
+
359
+ const agents = await adapter.detectAgents();
360
+ expect(agents[0]).toMatchObject({
361
+ sessionId: 'sess-real',
362
+ summary: 'hello',
363
+ lastActive: new Date('2026-06-10T13:27:40.161Z'),
364
+ });
365
+ });
366
+
367
+ it('lists historical sessions and applies cwd filtering', async () => {
368
+ const matchingCwd = '/repo/project-g';
369
+ const otherCwd = '/repo/project-h';
370
+ const matchingSession = writePiSession(matchingCwd, [
371
+ { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-g', cwd: matchingCwd },
372
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'first matching message' },
373
+ ]);
374
+ writePiSession(otherCwd, [
375
+ { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-h', cwd: otherCwd },
376
+ { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'other message' },
377
+ ]);
378
+
379
+ const sessions = await adapter.listSessions({ cwd: matchingCwd });
380
+
381
+ expect(sessions).toEqual([
382
+ expect.objectContaining({
383
+ type: 'pi',
384
+ sessionId: 'sess-g',
385
+ cwd: matchingCwd,
386
+ firstUserMessage: 'first matching message',
387
+ sessionFilePath: matchingSession,
388
+ }),
389
+ ]);
390
+ });
391
+
392
+ function makeProcess(overrides: Partial<ProcessInfo>): ProcessInfo {
393
+ return {
394
+ pid: 1,
395
+ command: 'pi',
396
+ cwd: '/repo',
397
+ tty: 'ttys001',
398
+ startTime: new Date('2026-06-10T08:58:20.000Z'),
399
+ ...overrides,
400
+ };
401
+ }
402
+
403
+ function writePiSession(cwd: string, entries: Array<Record<string, unknown> | string>): string {
404
+ const projectDir = path.join(sessionsDir, encodeProjectDir(cwd));
405
+ fs.mkdirSync(projectDir, { recursive: true });
406
+ const sessionId = entries
407
+ .map((entry) => typeof entry === 'string' ? undefined : entry.sessionId)
408
+ .find((value): value is string => typeof value === 'string') ?? cryptoRandomSessionId();
409
+ const filePath = path.join(projectDir, `2026-06-10T08-58-20-754Z_${sessionId}.jsonl`);
410
+ fs.writeFileSync(
411
+ filePath,
412
+ entries.map((entry) => typeof entry === 'string' ? entry : JSON.stringify(entry)).join('\n'),
413
+ );
414
+ return filePath;
415
+ }
416
+
417
+ function writePiSessionWithFileName(cwd: string, fileName: string, entries: Array<Record<string, unknown> | string>): string {
418
+ const projectDir = path.join(sessionsDir, encodeProjectDir(cwd));
419
+ fs.mkdirSync(projectDir, { recursive: true });
420
+ const filePath = path.join(projectDir, fileName);
421
+ fs.writeFileSync(
422
+ filePath,
423
+ entries.map((entry) => typeof entry === 'string' ? entry : JSON.stringify(entry)).join('\n'),
424
+ );
425
+ return filePath;
426
+ }
427
+
428
+ function encodeProjectDir(cwd: string): string {
429
+ return cwd.replace(/\//g, '-').replace(/^-?/, '--') + '--';
430
+ }
431
+
432
+ function cryptoRandomSessionId(): string {
433
+ return `019eb0c1-06d2-71ed-90ee-${Math.random().toString(16).slice(2, 14).padEnd(12, '0')}`;
434
+ }
435
+ });
@@ -8,7 +8,7 @@
8
8
  /**
9
9
  * Type of AI agent
10
10
  */
11
- export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'other';
11
+ export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'copilot' | 'pi' | 'other';
12
12
 
13
13
  /**
14
14
  * Current status of an agent
@@ -48,6 +48,16 @@ interface CodexSession {
48
48
  lastPayloadType?: string;
49
49
  }
50
50
 
51
+ interface DirectMatch {
52
+ process: ProcessInfo;
53
+ sessionFile: SessionFile;
54
+ }
55
+
56
+ interface DirectMatchResult {
57
+ agents: AgentInfo[];
58
+ failedProcesses: ProcessInfo[];
59
+ }
60
+
51
61
  export class CodexAdapter implements AgentAdapter {
52
62
  readonly type = 'codex' as const;
53
63
 
@@ -78,14 +88,24 @@ export class CodexAdapter implements AgentAdapter {
78
88
  const { cachedAgents, remaining } = this.tryRegistryCache(processes);
79
89
  if (remaining.length === 0) return cachedAgents;
80
90
 
81
- const { sessions, contentCache } = this.discoverSessions(remaining);
91
+ const { direct, fallback } = this.tryResumeMatching(remaining);
92
+ const directResult = this.mapDirectMatches(direct);
93
+ const { sessions, contentCache } = this.discoverSessions(fallback);
82
94
  if (sessions.length === 0) {
83
- return [...cachedAgents, ...remaining.map((p) => this.mapProcessOnlyAgent(p))];
95
+ return [
96
+ ...cachedAgents,
97
+ ...directResult.agents,
98
+ ...directResult.failedProcesses.map((p) => this.mapProcessOnlyAgent(p)),
99
+ ...fallback.map((p) => this.mapProcessOnlyAgent(p)),
100
+ ];
84
101
  }
85
102
 
86
- const matches = matchProcessesToSessions(remaining, sessions);
87
- const matchedPids = new Set(matches.map((m) => m.process.pid));
88
- const agents: AgentInfo[] = [];
103
+ const matches = matchProcessesToSessions(fallback, sessions);
104
+ const matchedPids = new Set([
105
+ ...directResult.agents.map((a) => a.pid),
106
+ ...matches.map((m) => m.process.pid),
107
+ ]);
108
+ const agents: AgentInfo[] = [...directResult.agents];
89
109
 
90
110
  for (const match of matches) {
91
111
  const cachedContent = contentCache.get(match.session.filePath);
@@ -97,12 +117,16 @@ export class CodexAdapter implements AgentAdapter {
97
117
  }
98
118
  }
99
119
 
100
- for (const proc of remaining) {
120
+ for (const proc of fallback) {
101
121
  if (!matchedPids.has(proc.pid)) {
102
122
  agents.push(this.mapProcessOnlyAgent(proc));
103
123
  }
104
124
  }
105
125
 
126
+ for (const proc of directResult.failedProcesses) {
127
+ agents.push(this.mapProcessOnlyAgent(proc));
128
+ }
129
+
106
130
  return [...cachedAgents, ...agents];
107
131
  }
108
132
 
@@ -139,6 +163,101 @@ export class CodexAdapter implements AgentAdapter {
139
163
  return { cachedAgents, remaining };
140
164
  }
141
165
 
166
+ /**
167
+ * Match processes via `codex resume <uuid>` in their command line.
168
+ * Resumed sessions predate the process, so birth-time proximity cannot
169
+ * reliably pair them with the running PID.
170
+ */
171
+ private tryResumeMatching(processes: ProcessInfo[]): {
172
+ direct: DirectMatch[];
173
+ fallback: ProcessInfo[];
174
+ } {
175
+ const direct: DirectMatch[] = [];
176
+ const fallback: ProcessInfo[] = [];
177
+
178
+ for (const proc of processes) {
179
+ const sessionId = this.extractResumeSessionId(proc.command);
180
+ if (!sessionId) {
181
+ fallback.push(proc);
182
+ continue;
183
+ }
184
+
185
+ const sessionFile = this.findSessionFileById(sessionId);
186
+ if (!sessionFile) {
187
+ fallback.push(proc);
188
+ continue;
189
+ }
190
+
191
+ direct.push({ process: proc, sessionFile });
192
+ }
193
+
194
+ return { direct, fallback };
195
+ }
196
+
197
+ private extractResumeSessionId(command: string): string | null {
198
+ const match = command.match(/(?:^|\s)resume\s+([0-9a-f-]{36})(?:\s|$)/i);
199
+ return match?.[1] ?? null;
200
+ }
201
+
202
+ private findSessionFileById(sessionId: string): SessionFile | null {
203
+ for (const filePath of this.getCandidateSessionFiles(sessionId)) {
204
+ if (!path.basename(filePath).includes(sessionId)) continue;
205
+
206
+ const content = safeReadFile(filePath);
207
+ const firstLine = content?.split('\n')[0]?.trim();
208
+ if (!firstLine) continue;
209
+
210
+ try {
211
+ const parsed = JSON.parse(firstLine) as CodexEventEntry;
212
+ if (parsed.type !== 'session_meta' || parsed.payload?.id !== sessionId) {
213
+ continue;
214
+ }
215
+
216
+ const stat = safeStat(filePath);
217
+ if (!stat) continue;
218
+
219
+ return {
220
+ sessionId,
221
+ filePath,
222
+ projectDir: path.dirname(filePath),
223
+ birthtimeMs: stat.birthtimeMs,
224
+ resolvedCwd: parsed.payload.cwd || '',
225
+ };
226
+ } catch {
227
+ continue;
228
+ }
229
+ }
230
+
231
+ return null;
232
+ }
233
+
234
+ private getCandidateSessionFiles(sessionId: string): string[] {
235
+ // Codex currently writes UUIDv7 session IDs, so the ID can narrow lookup
236
+ // to the creation-date directory. Fall back for older or changed formats.
237
+ const sessionDate = this.tryParseUuidV7Date(sessionId);
238
+ if (!sessionDate) return this.collectAllSessionFiles();
239
+
240
+ return this.collectSessionFilesInDateDirs(
241
+ this.getDateDirsAroundDate(sessionDate, CodexAdapter.PROCESS_START_DAY_WINDOW_DAYS),
242
+ );
243
+ }
244
+
245
+ private mapDirectMatches(matches: DirectMatch[]): DirectMatchResult {
246
+ const agents: AgentInfo[] = [];
247
+ const failedProcesses: ProcessInfo[] = [];
248
+
249
+ for (const match of matches) {
250
+ const sessionData = this.parseSession(undefined, match.sessionFile.filePath);
251
+ if (sessionData) {
252
+ agents.push(this.mapSessionToAgent(sessionData, match.process, match.sessionFile.filePath));
253
+ } else {
254
+ failedProcesses.push(match.process);
255
+ }
256
+ }
257
+
258
+ return { agents, failedProcesses };
259
+ }
260
+
142
261
  /**
143
262
  * Discover session files for the given processes.
144
263
  *
@@ -213,6 +332,21 @@ export class CodexAdapter implements AgentAdapter {
213
332
  return dirs;
214
333
  }
215
334
 
335
+ private getDateDirsAroundDate(date: Date, windowDays: number): string[] {
336
+ const dirs: string[] = [];
337
+
338
+ for (let offset = -windowDays; offset <= windowDays; offset++) {
339
+ const day = new Date(date.getTime());
340
+ day.setDate(day.getDate() + offset);
341
+ const dayDir = path.join(this.codexSessionsDir, this.toSessionDayKey(day));
342
+ if (isDirectory(dayDir)) {
343
+ dirs.push(dayDir);
344
+ }
345
+ }
346
+
347
+ return dirs;
348
+ }
349
+
216
350
  private toSessionDayKey(date: Date): string {
217
351
  const yyyy = String(date.getFullYear()).padStart(4, '0');
218
352
  const mm = String(date.getMonth() + 1).padStart(2, '0');
@@ -220,6 +354,17 @@ export class CodexAdapter implements AgentAdapter {
220
354
  return path.join(yyyy, mm, dd);
221
355
  }
222
356
 
357
+ private tryParseUuidV7Date(sessionId: string): Date | null {
358
+ const match = sessionId.match(/^([0-9a-f]{8})-([0-9a-f]{4})-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
359
+ if (!match) return null;
360
+
361
+ const timestampMs = Number.parseInt(`${match[1]}${match[2]}`, 16);
362
+ if (!Number.isSafeInteger(timestampMs) || timestampMs <= 0) return null;
363
+
364
+ const date = new Date(timestampMs);
365
+ return Number.isNaN(date.getTime()) ? null : date;
366
+ }
367
+
223
368
  /**
224
369
  * Parse session file content into CodexSession.
225
370
  * Uses cached content if available, otherwise reads from disk.
@@ -462,6 +607,19 @@ export class CodexAdapter implements AgentAdapter {
462
607
  return out;
463
608
  }
464
609
 
610
+ private collectSessionFilesInDateDirs(dateDirs: string[]): string[] {
611
+ const out: string[] = [];
612
+
613
+ for (const dayDir of dateDirs) {
614
+ for (const fileEntry of safeReaddir(dayDir)) {
615
+ if (!fileEntry.endsWith('.jsonl')) continue;
616
+ out.push(path.join(dayDir, fileEntry));
617
+ }
618
+ }
619
+
620
+ return out;
621
+ }
622
+
465
623
  /**
466
624
  * Read a Codex session JSONL file and produce a {@link SessionSummary}.
467
625
  * Returns null when the file is unreadable, has no `session_meta`, or