@ai-devkit/agent-manager 0.15.0 → 0.17.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 (112) hide show
  1. package/.swcrc +27 -0
  2. package/dist/AgentManager.d.ts +4 -0
  3. package/dist/AgentManager.d.ts.map +1 -1
  4. package/dist/AgentManager.js +97 -64
  5. package/dist/AgentManager.js.map +1 -1
  6. package/dist/__tests__/AgentManager.test.js +564 -0
  7. package/dist/__tests__/AgentManager.test.js.map +1 -0
  8. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +2157 -0
  9. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -0
  10. package/dist/__tests__/adapters/CodexAdapter.test.js +1354 -0
  11. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -0
  12. package/dist/__tests__/adapters/CopilotAdapter.test.js +500 -0
  13. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -0
  14. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +1229 -0
  15. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -0
  16. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +626 -0
  17. package/dist/__tests__/adapters/OpenCodeAdapter.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 -1
  39. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  40. package/dist/adapters/CodexAdapter.js +208 -132
  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 +6 -1
  47. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  48. package/dist/adapters/GeminiCliAdapter.js +160 -149
  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/index.d.ts +1 -0
  53. package/dist/adapters/index.d.ts.map +1 -1
  54. package/dist/adapters/index.js +2 -0
  55. package/dist/adapters/index.js.map +1 -1
  56. package/dist/index.d.ts +6 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +5 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/terminal/TerminalFocusManager.js +62 -49
  61. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  62. package/dist/terminal/TmuxManager.d.ts +28 -0
  63. package/dist/terminal/TmuxManager.d.ts.map +1 -0
  64. package/dist/terminal/TmuxManager.js +137 -0
  65. package/dist/terminal/TmuxManager.js.map +1 -0
  66. package/dist/terminal/TtyWriter.js +37 -16
  67. package/dist/terminal/TtyWriter.js.map +1 -1
  68. package/dist/terminal/index.js +1 -0
  69. package/dist/terminal/index.js.map +1 -1
  70. package/dist/utils/AgentRegistry.d.ts +35 -0
  71. package/dist/utils/AgentRegistry.d.ts.map +1 -0
  72. package/dist/utils/AgentRegistry.js +123 -0
  73. package/dist/utils/AgentRegistry.js.map +1 -0
  74. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  75. package/dist/utils/ClaudeSessionParser.js +62 -93
  76. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  77. package/dist/utils/agents.d.ts +15 -0
  78. package/dist/utils/agents.d.ts.map +1 -0
  79. package/dist/utils/agents.js +41 -0
  80. package/dist/utils/agents.js.map +1 -0
  81. package/dist/utils/applescript.js +3 -6
  82. package/dist/utils/applescript.js.map +1 -1
  83. package/dist/utils/index.js +1 -0
  84. package/dist/utils/index.js.map +1 -1
  85. package/dist/utils/matching.js +21 -29
  86. package/dist/utils/matching.js.map +1 -1
  87. package/dist/utils/process.js +77 -56
  88. package/dist/utils/process.js.map +1 -1
  89. package/dist/utils/session.js +20 -34
  90. package/dist/utils/session.js.map +1 -1
  91. package/dist/utils/sortAgents.js +6 -8
  92. package/dist/utils/sortAgents.js.map +1 -1
  93. package/package.json +11 -7
  94. package/src/AgentManager.ts +40 -0
  95. package/src/__tests__/AgentManager.test.ts +135 -0
  96. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +3 -2
  97. package/src/__tests__/adapters/CodexAdapter.test.ts +199 -0
  98. package/src/__tests__/adapters/CopilotAdapter.test.ts +411 -0
  99. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +185 -1
  100. package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
  101. package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
  102. package/src/__tests__/utils/ClaudeSessionParser.test.ts +71 -0
  103. package/src/adapters/AgentAdapter.ts +1 -1
  104. package/src/adapters/CodexAdapter.ts +155 -8
  105. package/src/adapters/CopilotAdapter.ts +453 -0
  106. package/src/adapters/GeminiCliAdapter.ts +84 -6
  107. package/src/adapters/index.ts +1 -0
  108. package/src/index.ts +7 -0
  109. package/src/terminal/TmuxManager.ts +118 -0
  110. package/src/utils/AgentRegistry.ts +139 -0
  111. package/src/utils/ClaudeSessionParser.ts +21 -10
  112. package/src/utils/agents.ts +41 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Tests for CopilotAdapter
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 { CopilotAdapter } from '../../adapters/CopilotAdapter.js';
11
+ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
+ import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
14
+ import { generateAgentName } from '../../utils/matching.js';
15
+
16
+ vi.mock('../../utils/process.js', () => ({
17
+ listAgentProcesses: vi.fn(),
18
+ enrichProcesses: vi.fn(),
19
+ }));
20
+
21
+ vi.mock('../../utils/matching.js', () => ({
22
+ generateAgentName: vi.fn(),
23
+ }));
24
+
25
+ const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
26
+ const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
27
+ const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
28
+
29
+ describe('CopilotAdapter', () => {
30
+ let adapter: CopilotAdapter;
31
+ let tmpDir: string;
32
+ let sessionStateDir: string;
33
+
34
+ beforeEach(() => {
35
+ adapter = new CopilotAdapter();
36
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-test-'));
37
+ sessionStateDir = path.join(tmpDir, 'session-state');
38
+ fs.mkdirSync(sessionStateDir, { recursive: true });
39
+ (adapter as any).sessionStateDir = sessionStateDir;
40
+
41
+ mockedListAgentProcesses.mockReset();
42
+ mockedEnrichProcesses.mockReset();
43
+ mockedGenerateAgentName.mockReset();
44
+ mockedEnrichProcesses.mockImplementation((procs) => procs);
45
+ mockedGenerateAgentName.mockImplementation((cwd, pid) => `${path.basename(cwd) || 'unknown'} (${pid})`);
46
+ });
47
+
48
+ afterEach(() => {
49
+ fs.rmSync(tmpDir, { recursive: true, force: true });
50
+ });
51
+
52
+ function writeSession(
53
+ sessionId: string,
54
+ options: {
55
+ events?: Array<object | string>;
56
+ workspace?: Record<string, string>;
57
+ lockPid?: number | string;
58
+ },
59
+ ): string {
60
+ const sessionDir = path.join(sessionStateDir, sessionId);
61
+ fs.mkdirSync(sessionDir, { recursive: true });
62
+
63
+ if (options.events) {
64
+ const lines = options.events.map((entry) => (
65
+ typeof entry === 'string' ? entry : JSON.stringify(entry)
66
+ ));
67
+ fs.writeFileSync(path.join(sessionDir, 'events.jsonl'), lines.join('\n'));
68
+ }
69
+
70
+ if (options.workspace) {
71
+ const lines = Object.entries(options.workspace).map(([key, value]) => `${key}: ${value}`);
72
+ fs.writeFileSync(path.join(sessionDir, 'workspace.yaml'), lines.join('\n'));
73
+ }
74
+
75
+ if (options.lockPid !== undefined) {
76
+ fs.writeFileSync(path.join(sessionDir, `inuse.${options.lockPid}.lock`), '');
77
+ }
78
+
79
+ return sessionDir;
80
+ }
81
+
82
+ function sessionStart(sessionId: string, cwd: string, startTime = '2026-06-09T09:50:00.000Z'): object {
83
+ return {
84
+ type: 'session.start',
85
+ data: {
86
+ sessionId,
87
+ startTime,
88
+ context: {
89
+ cwd,
90
+ gitRoot: cwd,
91
+ branch: 'main',
92
+ },
93
+ },
94
+ timestamp: startTime,
95
+ };
96
+ }
97
+
98
+ describe('initialization', () => {
99
+ it('exposes copilot type', () => {
100
+ expect(adapter.type).toBe('copilot');
101
+ });
102
+ });
103
+
104
+ describe('canHandle', () => {
105
+ it('returns true for copilot commands', () => {
106
+ expect(adapter.canHandle({ pid: 1, command: 'copilot', cwd: '/repo', tty: 'ttys001' })).toBe(true);
107
+ });
108
+
109
+ it('returns true for full-path Homebrew copilot commands', () => {
110
+ expect(adapter.canHandle({
111
+ pid: 2,
112
+ command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot',
113
+ cwd: '/repo',
114
+ tty: 'ttys002',
115
+ })).toBe(true);
116
+ });
117
+
118
+ it('returns true for copilot.exe commands', () => {
119
+ expect(adapter.canHandle({ pid: 3, command: '/usr/bin/copilot.exe', cwd: '/repo', tty: 'ttys003' })).toBe(true);
120
+ });
121
+
122
+ it('returns false when copilot appears only in an argument', () => {
123
+ expect(adapter.canHandle({
124
+ pid: 4,
125
+ command: 'node /repo/copilot-plugin/index.js',
126
+ cwd: '/repo',
127
+ tty: 'ttys004',
128
+ })).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe('detectAgents', () => {
133
+ it('returns empty list when no copilot process is running', async () => {
134
+ mockedListAgentProcesses.mockReturnValue([]);
135
+
136
+ const agents = await adapter.detectAgents();
137
+
138
+ expect(agents).toEqual([]);
139
+ expect(mockedListAgentProcesses).toHaveBeenCalledWith('copilot');
140
+ });
141
+
142
+ it('maps matching inuse lock and events to an active agent', async () => {
143
+ const processes: ProcessInfo[] = [
144
+ { pid: 14096, command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot', cwd: '/repo', tty: 'ttys001' },
145
+ ];
146
+ mockedListAgentProcesses.mockReturnValue(processes);
147
+ mockedEnrichProcesses.mockReturnValue(processes);
148
+ writeSession('sess-a', {
149
+ lockPid: 14096,
150
+ events: [
151
+ sessionStart('sess-a', '/repo', '2026-06-09T09:50:00.000Z'),
152
+ { type: 'user.message', data: { content: 'Build the Copilot adapter' }, timestamp: new Date().toISOString() },
153
+ { type: 'assistant.message', data: { content: 'I will inspect the code.' }, timestamp: new Date().toISOString() },
154
+ ],
155
+ workspace: {
156
+ id: 'sess-a',
157
+ cwd: '/fallback',
158
+ name: 'Fallback Name',
159
+ created_at: '2026-06-09T09:49:00.000Z',
160
+ updated_at: '2026-06-09T09:51:00.000Z',
161
+ },
162
+ });
163
+
164
+ const agents = await adapter.detectAgents();
165
+
166
+ expect(agents).toHaveLength(1);
167
+ expect(agents[0]).toMatchObject({
168
+ type: 'copilot',
169
+ status: AgentStatus.WAITING,
170
+ pid: 14096,
171
+ projectPath: '/repo',
172
+ sessionId: 'sess-a',
173
+ summary: 'Build the Copilot adapter',
174
+ sessionFilePath: path.join(sessionStateDir, 'sess-a', 'events.jsonl'),
175
+ });
176
+ });
177
+
178
+ it('ignores invalid and unmatched lock PIDs', async () => {
179
+ const processes: ProcessInfo[] = [
180
+ { pid: 100, command: 'copilot', cwd: '/repo', tty: 'ttys001' },
181
+ ];
182
+ mockedListAgentProcesses.mockReturnValue(processes);
183
+ mockedEnrichProcesses.mockReturnValue(processes);
184
+ writeSession('invalid', {
185
+ lockPid: 'not-a-pid',
186
+ events: [sessionStart('invalid', '/invalid')],
187
+ });
188
+ writeSession('unmatched', {
189
+ lockPid: 999999,
190
+ events: [sessionStart('unmatched', '/unmatched')],
191
+ });
192
+
193
+ const agents = await adapter.detectAgents();
194
+
195
+ expect(agents).toHaveLength(1);
196
+ expect(agents[0]).toMatchObject({
197
+ pid: 100,
198
+ sessionId: 'pid-100',
199
+ summary: 'Copilot process running',
200
+ });
201
+ });
202
+
203
+ it('falls back to process-only agent when no session lock matches', async () => {
204
+ const processes: ProcessInfo[] = [
205
+ { pid: 200, command: 'copilot', cwd: '/repo-b', tty: 'ttys002' },
206
+ ];
207
+ mockedListAgentProcesses.mockReturnValue(processes);
208
+ mockedEnrichProcesses.mockReturnValue(processes);
209
+
210
+ const agents = await adapter.detectAgents();
211
+
212
+ expect(agents).toHaveLength(1);
213
+ expect(agents[0]).toMatchObject({
214
+ type: 'copilot',
215
+ status: AgentStatus.RUNNING,
216
+ pid: 200,
217
+ projectPath: '/repo-b',
218
+ sessionId: 'pid-200',
219
+ summary: 'Copilot process running',
220
+ });
221
+ });
222
+
223
+ it('does not add duplicate process-only agent for wrapper process in the same terminal', async () => {
224
+ const processes: ProcessInfo[] = [
225
+ { pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001' },
226
+ { pid: 14096, command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot', cwd: '/repo', tty: 'ttys001' },
227
+ ];
228
+ mockedListAgentProcesses.mockReturnValue(processes);
229
+ mockedEnrichProcesses.mockReturnValue(processes);
230
+ writeSession('sess-wrapper', {
231
+ lockPid: 14096,
232
+ events: [
233
+ sessionStart('sess-wrapper', '/repo', '2026-06-09T09:50:00.000Z'),
234
+ { type: 'user.message', data: { content: 'hello' }, timestamp: new Date().toISOString() },
235
+ ],
236
+ });
237
+
238
+ const agents = await adapter.detectAgents();
239
+
240
+ expect(agents).toHaveLength(1);
241
+ expect(agents[0]).toMatchObject({
242
+ pid: 14096,
243
+ sessionId: 'sess-wrapper',
244
+ });
245
+ });
246
+
247
+ it('uses workspace metadata when events are missing', async () => {
248
+ const processes: ProcessInfo[] = [
249
+ { pid: 300, command: 'copilot', cwd: '/proc-cwd', tty: 'ttys003' },
250
+ ];
251
+ mockedListAgentProcesses.mockReturnValue(processes);
252
+ mockedEnrichProcesses.mockReturnValue(processes);
253
+ writeSession('workspace-only', {
254
+ lockPid: 300,
255
+ workspace: {
256
+ id: 'workspace-only',
257
+ cwd: '/workspace-cwd',
258
+ name: 'Workspace Session',
259
+ created_at: '2026-06-09T09:00:00.000Z',
260
+ updated_at: '2026-06-09T09:10:00.000Z',
261
+ },
262
+ });
263
+
264
+ const agents = await adapter.detectAgents();
265
+
266
+ expect(agents).toHaveLength(1);
267
+ expect(agents[0]).toMatchObject({
268
+ pid: 300,
269
+ projectPath: '/workspace-cwd',
270
+ sessionId: 'workspace-only',
271
+ summary: 'Workspace Session',
272
+ });
273
+ });
274
+ });
275
+
276
+ describe('getConversation', () => {
277
+ it('parses user and assistant message events', () => {
278
+ const sessionDir = writeSession('conv', {
279
+ events: [
280
+ sessionStart('conv', '/repo'),
281
+ { type: 'user.message', data: { content: 'hello' }, timestamp: '2026-06-09T09:50:01.000Z' },
282
+ { type: 'assistant.message', data: { content: 'Hi there' }, timestamp: '2026-06-09T09:50:02.000Z' },
283
+ ],
284
+ });
285
+
286
+ const messages = adapter.getConversation(path.join(sessionDir, 'events.jsonl'));
287
+
288
+ expect(messages).toEqual([
289
+ { role: 'user', content: 'hello', timestamp: '2026-06-09T09:50:01.000Z' },
290
+ { role: 'assistant', content: 'Hi there', timestamp: '2026-06-09T09:50:02.000Z' },
291
+ ]);
292
+ });
293
+
294
+ it('includes system/info/warning messages only in verbose mode', () => {
295
+ const sessionDir = writeSession('verbose', {
296
+ events: [
297
+ { type: 'session.warning', data: { message: 'MCP failed' }, timestamp: '2026-06-09T09:50:01.000Z' },
298
+ { type: 'tool.execution_complete', data: { result: { content: 'Tool result' } }, timestamp: '2026-06-09T09:50:02.000Z' },
299
+ ],
300
+ });
301
+ const filePath = path.join(sessionDir, 'events.jsonl');
302
+
303
+ expect(adapter.getConversation(filePath)).toEqual([]);
304
+ expect(adapter.getConversation(filePath, { verbose: true })).toEqual([
305
+ { role: 'system', content: 'MCP failed', timestamp: '2026-06-09T09:50:01.000Z' },
306
+ { role: 'system', content: 'Tool result', timestamp: '2026-06-09T09:50:02.000Z' },
307
+ ]);
308
+ });
309
+
310
+ it('skips malformed JSONL lines and missing text', () => {
311
+ const sessionDir = writeSession('malformed', {
312
+ events: [
313
+ 'not json',
314
+ { type: 'user.message', data: { content: 'valid' }, timestamp: '2026-06-09T09:50:01.000Z' },
315
+ { type: 'assistant.message', data: {}, timestamp: '2026-06-09T09:50:02.000Z' },
316
+ ],
317
+ });
318
+
319
+ const messages = adapter.getConversation(path.join(sessionDir, 'events.jsonl'));
320
+
321
+ expect(messages).toEqual([
322
+ { role: 'user', content: 'valid', timestamp: '2026-06-09T09:50:01.000Z' },
323
+ ]);
324
+ });
325
+
326
+ it('returns empty array for missing file', () => {
327
+ expect(adapter.getConversation(path.join(tmpDir, 'missing.jsonl'))).toEqual([]);
328
+ });
329
+ });
330
+
331
+ describe('listSessions', () => {
332
+ it('returns historical sessions without active locks', async () => {
333
+ writeSession('history-a', {
334
+ events: [
335
+ sessionStart('history-a', '/repo-a', '2026-06-09T09:00:00.000Z'),
336
+ { type: 'user.message', data: { content: 'first user' }, timestamp: '2026-06-09T09:00:01.000Z' },
337
+ { type: 'assistant.message', data: { content: 'answer' }, timestamp: '2026-06-09T09:00:02.000Z' },
338
+ ],
339
+ });
340
+ writeSession('history-b', {
341
+ events: [
342
+ sessionStart('history-b', '/repo-b', '2026-06-09T10:00:00.000Z'),
343
+ { type: 'user.message', data: { content: 'other user' }, timestamp: '2026-06-09T10:00:01.000Z' },
344
+ ],
345
+ });
346
+
347
+ const sessions = await adapter.listSessions();
348
+
349
+ expect(sessions).toHaveLength(2);
350
+ const byId = Object.fromEntries(sessions.map((session) => [session.sessionId, session]));
351
+ expect(byId['history-a']).toMatchObject({
352
+ type: 'copilot',
353
+ cwd: '/repo-a',
354
+ firstUserMessage: 'first user',
355
+ sessionFilePath: path.join(sessionStateDir, 'history-a', 'events.jsonl'),
356
+ });
357
+ expect(byId['history-b']).toMatchObject({
358
+ type: 'copilot',
359
+ cwd: '/repo-b',
360
+ firstUserMessage: 'other user',
361
+ });
362
+ });
363
+
364
+ it('applies strict cwd filter', async () => {
365
+ writeSession('keep', { events: [sessionStart('keep', '/repo')] });
366
+ writeSession('drop', { events: [sessionStart('drop', '/other')] });
367
+
368
+ const sessions = await adapter.listSessions({ cwd: '/repo' });
369
+
370
+ expect(sessions).toHaveLength(1);
371
+ expect(sessions[0].sessionId).toBe('keep');
372
+ });
373
+
374
+ it('uses workspace fallback for historical sessions without events', async () => {
375
+ writeSession('workspace-history', {
376
+ workspace: {
377
+ id: 'workspace-history',
378
+ cwd: '/workspace-repo',
379
+ name: 'Workspace History',
380
+ created_at: '2026-06-09T08:00:00.000Z',
381
+ updated_at: '2026-06-09T08:10:00.000Z',
382
+ },
383
+ });
384
+
385
+ const sessions = await adapter.listSessions();
386
+
387
+ expect(sessions).toHaveLength(1);
388
+ expect(sessions[0]).toMatchObject({
389
+ type: 'copilot',
390
+ sessionId: 'workspace-history',
391
+ cwd: '/workspace-repo',
392
+ firstUserMessage: '',
393
+ sessionFilePath: path.join(sessionStateDir, 'workspace-history', 'events.jsonl'),
394
+ });
395
+ });
396
+
397
+ it('skips session directories without events or workspace metadata', async () => {
398
+ fs.mkdirSync(path.join(sessionStateDir, 'empty'), { recursive: true });
399
+
400
+ const sessions = await adapter.listSessions();
401
+
402
+ expect(sessions).toEqual([]);
403
+ });
404
+
405
+ it('returns empty when session-state directory does not exist', async () => {
406
+ (adapter as any).sessionStateDir = path.join(tmpDir, 'missing');
407
+
408
+ await expect(adapter.listSessions()).resolves.toEqual([]);
409
+ });
410
+ });
411
+ });
@@ -10,6 +10,7 @@ import * as path from 'path';
10
10
  import { GeminiCliAdapter } from '../../adapters/GeminiCliAdapter.js';
11
11
  import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
12
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
+ import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js';
13
14
  import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
14
15
  import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
15
16
  import * as crypto from 'crypto';
@@ -37,7 +38,7 @@ describe('GeminiCliAdapter', () => {
37
38
  tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-adapter-test-'));
38
39
  process.env.HOME = tmpHome;
39
40
 
40
- adapter = new GeminiCliAdapter();
41
+ adapter = new GeminiCliAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json')));
41
42
  mockedListAgentProcesses.mockReset();
42
43
  mockedEnrichProcesses.mockReset();
43
44
  mockedMatchProcessesToSessions.mockReset();
@@ -213,6 +214,79 @@ describe('GeminiCliAdapter', () => {
213
214
  expect(agents[0].summary).toContain('hello gemini');
214
215
  });
215
216
 
217
+ it('should collapse Gemini wrapper and child processes matched to the same session', async () => {
218
+ const cwd = '/repo/project-a';
219
+ const projectHash = hashProjectRoot(cwd);
220
+ const shortId = 'abc123';
221
+ const chatsDir = path.join(tmpHome, '.gemini', 'tmp', shortId, 'chats');
222
+ fs.mkdirSync(chatsDir, { recursive: true });
223
+ const sessionPath = path.join(chatsDir, 'session-2026-04-18T00-00-session1.json');
224
+ const sessionStart = new Date('2026-04-18T00:00:00Z').toISOString();
225
+ fs.writeFileSync(
226
+ sessionPath,
227
+ JSON.stringify({
228
+ sessionId: 'session1',
229
+ projectHash,
230
+ startTime: sessionStart,
231
+ lastUpdated: sessionStart,
232
+ kind: 'main',
233
+ directories: [cwd],
234
+ messages: [
235
+ { id: 'm1', timestamp: sessionStart, type: 'user', content: 'hello gemini' },
236
+ ],
237
+ }),
238
+ );
239
+
240
+ const wrapperProc: ProcessInfo = {
241
+ pid: 88394,
242
+ command: 'node /Users/foo/.nvm/versions/node/v23.9.0/bin/gemini',
243
+ cwd,
244
+ tty: 'ttys015',
245
+ startTime: new Date('2026-04-18T00:00:00Z'),
246
+ };
247
+ const childProc: ProcessInfo = {
248
+ pid: 88460,
249
+ command: '/Users/foo/.nvm/versions/node/v24.13.1/bin/node /Users/foo/.nvm/versions/node/v23.9.0/bin/gemini',
250
+ cwd,
251
+ tty: 'ttys015',
252
+ startTime: new Date('2026-04-18T00:00:01Z'),
253
+ };
254
+ mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
255
+ mockedMatchProcessesToSessions.mockReturnValue([
256
+ {
257
+ process: wrapperProc,
258
+ session: {
259
+ sessionId: 'session1',
260
+ filePath: sessionPath,
261
+ projectDir: chatsDir,
262
+ birthtimeMs: Date.now(),
263
+ resolvedCwd: cwd,
264
+ },
265
+ deltaMs: 0,
266
+ },
267
+ {
268
+ process: childProc,
269
+ session: {
270
+ sessionId: 'session1',
271
+ filePath: sessionPath,
272
+ projectDir: chatsDir,
273
+ birthtimeMs: Date.now(),
274
+ resolvedCwd: cwd,
275
+ },
276
+ deltaMs: 0,
277
+ },
278
+ ]);
279
+
280
+ const agents = await adapter.detectAgents();
281
+
282
+ expect(agents).toHaveLength(1);
283
+ expect(agents[0]).toMatchObject({
284
+ pid: 88460,
285
+ sessionId: 'session1',
286
+ sessionFilePath: sessionPath,
287
+ });
288
+ });
289
+
216
290
  it('should not match sessions from other projects', async () => {
217
291
  const procCwd = '/repo/project-a';
218
292
  const otherCwd = '/repo/project-b';
@@ -251,6 +325,116 @@ describe('GeminiCliAdapter', () => {
251
325
  });
252
326
  });
253
327
 
328
+ describe('detectAgents — registry cache short-circuit', () => {
329
+ let regPath: string;
330
+ let registry: AgentRegistry;
331
+ let cachedAdapter: GeminiCliAdapter;
332
+ let sessionFilePath: string;
333
+
334
+ function registerEntry(over: Partial<RegistryEntry> = {}): void {
335
+ registry.register({
336
+ name: 'gemini-100',
337
+ type: 'gemini_cli',
338
+ pid: 100,
339
+ tmuxSession: '',
340
+ cwd: '/repo-a',
341
+ startedAt: '2026-05-30T00:00:00.000Z',
342
+ sessionId: 's-cached',
343
+ sessionFilePath,
344
+ ...over,
345
+ });
346
+ }
347
+
348
+ beforeEach(() => {
349
+ regPath = path.join(tmpHome, 'agents.json');
350
+ registry = new AgentRegistry(regPath);
351
+ cachedAdapter = new GeminiCliAdapter(registry);
352
+
353
+ const now = new Date().toISOString();
354
+ sessionFilePath = path.join(tmpHome, 'gemini-session.json');
355
+ fs.writeFileSync(sessionFilePath, JSON.stringify({
356
+ sessionId: 's-cached',
357
+ projectHash: 'h',
358
+ startTime: now,
359
+ lastUpdated: now,
360
+ directories: ['/repo-a'],
361
+ messages: [
362
+ { id: 'm1', timestamp: now, type: 'user', content: 'Hello from gemini cache' },
363
+ ],
364
+ }));
365
+ });
366
+
367
+ it('short-circuits matching when registry has a valid entry', async () => {
368
+ registerEntry();
369
+ const proc: ProcessInfo = {
370
+ pid: 100,
371
+ command: 'node /path/to/gemini --help',
372
+ cwd: '/repo-a',
373
+ tty: 'ttys001',
374
+ startTime: new Date(),
375
+ };
376
+ mockedListAgentProcesses.mockReturnValue([proc]);
377
+
378
+ const agents = await cachedAdapter.detectAgents();
379
+
380
+ expect(agents).toHaveLength(1);
381
+ expect(agents[0]).toMatchObject({
382
+ type: 'gemini_cli',
383
+ pid: 100,
384
+ sessionId: 's-cached',
385
+ summary: 'Hello from gemini cache',
386
+ });
387
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
388
+ });
389
+
390
+ it('falls through when no registry entry exists for the pid', async () => {
391
+ const proc: ProcessInfo = {
392
+ pid: 100,
393
+ command: 'node /path/to/gemini --help',
394
+ cwd: '/repo-a',
395
+ tty: 'ttys001',
396
+ startTime: new Date(),
397
+ };
398
+ mockedListAgentProcesses.mockReturnValue([proc]);
399
+
400
+ const agents = await cachedAdapter.detectAgents();
401
+
402
+ expect(agents[0].sessionId).toBe('pid-100');
403
+ });
404
+
405
+ it('falls through when registry entry type does not match', async () => {
406
+ registerEntry({ type: 'claude' });
407
+ const proc: ProcessInfo = {
408
+ pid: 100,
409
+ command: 'node /path/to/gemini --help',
410
+ cwd: '/repo-a',
411
+ tty: 'ttys001',
412
+ startTime: new Date(),
413
+ };
414
+ mockedListAgentProcesses.mockReturnValue([proc]);
415
+
416
+ const agents = await cachedAdapter.detectAgents();
417
+
418
+ expect(agents[0].sessionId).toBe('pid-100');
419
+ });
420
+
421
+ it('falls through when the cached session file no longer exists', async () => {
422
+ registerEntry({ sessionFilePath: path.join(tmpHome, 'deleted.json') });
423
+ const proc: ProcessInfo = {
424
+ pid: 100,
425
+ command: 'node /path/to/gemini --help',
426
+ cwd: '/repo-a',
427
+ tty: 'ttys001',
428
+ startTime: new Date(),
429
+ };
430
+ mockedListAgentProcesses.mockReturnValue([proc]);
431
+
432
+ const agents = await cachedAdapter.detectAgents();
433
+
434
+ expect(agents[0].sessionId).toBe('pid-100');
435
+ });
436
+ });
437
+
254
438
  describe('discoverSessions', () => {
255
439
  it('should return empty when ~/.gemini/tmp does not exist', () => {
256
440
  const proc: ProcessInfo = {