@ai-devkit/agent-manager 0.18.0 → 0.19.1

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 (45) hide show
  1. package/dist/__tests__/adapters/CopilotAdapter.test.js +127 -2
  2. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -1
  3. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +144 -2
  4. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -1
  5. package/dist/__tests__/terminal/TerminalFocusManager.test.js +73 -0
  6. package/dist/__tests__/terminal/TerminalFocusManager.test.js.map +1 -0
  7. package/dist/__tests__/utils/agents.test.js +17 -0
  8. package/dist/__tests__/utils/agents.test.js.map +1 -0
  9. package/dist/__tests__/utils/process.test.js +54 -5
  10. package/dist/__tests__/utils/process.test.js.map +1 -1
  11. package/dist/adapters/AgentAdapter.d.ts +2 -0
  12. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  13. package/dist/adapters/AgentAdapter.js.map +1 -1
  14. package/dist/adapters/CopilotAdapter.d.ts +4 -2
  15. package/dist/adapters/CopilotAdapter.d.ts.map +1 -1
  16. package/dist/adapters/CopilotAdapter.js +23 -12
  17. package/dist/adapters/CopilotAdapter.js.map +1 -1
  18. package/dist/adapters/GeminiCliAdapter.d.ts +1 -0
  19. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  20. package/dist/adapters/GeminiCliAdapter.js +36 -11
  21. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.d.ts +1 -0
  23. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  24. package/dist/terminal/TerminalFocusManager.js +10 -9
  25. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  26. package/dist/utils/agents.d.ts +1 -1
  27. package/dist/utils/agents.d.ts.map +1 -1
  28. package/dist/utils/agents.js +29 -3
  29. package/dist/utils/agents.js.map +1 -1
  30. package/dist/utils/process.d.ts +11 -2
  31. package/dist/utils/process.d.ts.map +1 -1
  32. package/dist/utils/process.js +44 -9
  33. package/dist/utils/process.js.map +1 -1
  34. package/package.json +6 -1
  35. package/src/__tests__/adapters/CopilotAdapter.test.ts +97 -5
  36. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +139 -4
  37. package/src/__tests__/terminal/TerminalFocusManager.test.ts +92 -0
  38. package/src/__tests__/utils/agents.test.ts +17 -0
  39. package/src/__tests__/utils/process.test.ts +31 -7
  40. package/src/adapters/AgentAdapter.ts +3 -0
  41. package/src/adapters/CopilotAdapter.ts +25 -14
  42. package/src/adapters/GeminiCliAdapter.ts +42 -11
  43. package/src/terminal/TerminalFocusManager.ts +11 -3
  44. package/src/utils/agents.ts +22 -2
  45. package/src/utils/process.ts +64 -9
@@ -12,11 +12,16 @@ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
12
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
13
  import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
14
14
  import { generateAgentName } from '../../utils/matching.js';
15
-
16
- vi.mock('../../utils/process.js', () => ({
17
- listAgentProcesses: vi.fn(),
18
- enrichProcesses: vi.fn(),
19
- }));
15
+ import { AgentRegistry } from '../../utils/AgentRegistry.js';
16
+
17
+ vi.mock('../../utils/process.js', async (importOriginal) => {
18
+ const actual = await importOriginal() as typeof import('../../utils/process.js');
19
+ return {
20
+ ...actual,
21
+ listAgentProcesses: vi.fn(),
22
+ enrichProcesses: vi.fn(),
23
+ };
24
+ });
20
25
 
21
26
  vi.mock('../../utils/matching.js', () => ({
22
27
  generateAgentName: vi.fn(),
@@ -220,6 +225,55 @@ describe('CopilotAdapter', () => {
220
225
  });
221
226
  });
222
227
 
228
+ it('suppresses wrapper process-only agents before the session lock exists', async () => {
229
+ const processes: ProcessInfo[] = [
230
+ { pid: 86800, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
231
+ { pid: 86810, command: '/custom/install/copilot', cwd: '/repo', tty: 'ttys001', ppid: 86800 },
232
+ ];
233
+ mockedListAgentProcesses.mockReturnValue(processes);
234
+ mockedEnrichProcesses.mockReturnValue(processes);
235
+
236
+ const agents = await adapter.detectAgents();
237
+
238
+ expect(agents).toHaveLength(1);
239
+ expect(agents[0]).toMatchObject({
240
+ pid: 86810,
241
+ sessionId: 'pid-86810',
242
+ summary: 'Copilot process running',
243
+ });
244
+ });
245
+
246
+ it('carries the managed wrapper name to a process-only child before the session lock exists', async () => {
247
+ const registry = new AgentRegistry(path.join(tmpDir, 'agents.json'));
248
+ adapter = new CopilotAdapter(registry);
249
+ (adapter as any).sessionStateDir = sessionStateDir;
250
+ const processes: ProcessInfo[] = [
251
+ { pid: 86800, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
252
+ { pid: 86810, command: '/custom/install/copilot', cwd: '/repo', tty: 'ttys001', ppid: 86800 },
253
+ ];
254
+ registry.register({
255
+ name: 'copilot-started',
256
+ type: 'copilot',
257
+ pid: 86800,
258
+ tmuxSession: 'copilot-started',
259
+ cwd: '/repo',
260
+ startedAt: '2026-06-13T19:15:16.211Z',
261
+ sessionId: 'pid-86800',
262
+ sessionFilePath: '',
263
+ });
264
+ mockedListAgentProcesses.mockReturnValue(processes);
265
+ mockedEnrichProcesses.mockReturnValue(processes);
266
+
267
+ const agents = await adapter.detectAgents();
268
+
269
+ expect(agents).toHaveLength(1);
270
+ expect(agents[0]).toMatchObject({
271
+ name: 'copilot-started',
272
+ pid: 86810,
273
+ sessionId: 'pid-86810',
274
+ });
275
+ });
276
+
223
277
  it('does not add duplicate process-only agent for wrapper process in the same terminal', async () => {
224
278
  const processes: ProcessInfo[] = [
225
279
  { pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001' },
@@ -244,6 +298,44 @@ describe('CopilotAdapter', () => {
244
298
  });
245
299
  });
246
300
 
301
+ it('carries the managed wrapper name to a lock-backed child process', async () => {
302
+ const registry = new AgentRegistry(path.join(tmpDir, 'agents.json'));
303
+ adapter = new CopilotAdapter(registry);
304
+ (adapter as any).sessionStateDir = sessionStateDir;
305
+ const processes: ProcessInfo[] = [
306
+ { pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
307
+ { pid: 14096, command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot', cwd: '/repo', tty: 'ttys001', ppid: 14095 },
308
+ ];
309
+ registry.register({
310
+ name: 'copilot-started',
311
+ type: 'copilot',
312
+ pid: 14095,
313
+ tmuxSession: 'copilot-started',
314
+ cwd: '/repo',
315
+ startedAt: '2026-06-13T19:15:16.211Z',
316
+ sessionId: 'pid-14095',
317
+ sessionFilePath: '',
318
+ });
319
+ mockedListAgentProcesses.mockReturnValue(processes);
320
+ mockedEnrichProcesses.mockReturnValue(processes);
321
+ writeSession('sess-wrapper', {
322
+ lockPid: 14096,
323
+ events: [
324
+ sessionStart('sess-wrapper', '/repo', '2026-06-09T09:50:00.000Z'),
325
+ { type: 'user.message', data: { content: 'hello' }, timestamp: new Date().toISOString() },
326
+ ],
327
+ });
328
+
329
+ const agents = await adapter.detectAgents();
330
+
331
+ expect(agents).toHaveLength(1);
332
+ expect(agents[0]).toMatchObject({
333
+ name: 'copilot-started',
334
+ pid: 14096,
335
+ sessionId: 'sess-wrapper',
336
+ });
337
+ });
338
+
247
339
  it('uses workspace metadata when events are missing', async () => {
248
340
  const processes: ProcessInfo[] = [
249
341
  { pid: 300, command: 'copilot', cwd: '/proc-cwd', tty: 'ttys003' },
@@ -15,10 +15,14 @@ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
15
15
  import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
16
16
  import * as crypto from 'crypto';
17
17
 
18
- vi.mock('../../utils/process.js', () => ({
19
- listAgentProcesses: vi.fn(),
20
- enrichProcesses: vi.fn(),
21
- }));
18
+ vi.mock('../../utils/process.js', async (importOriginal) => {
19
+ const actual = await importOriginal() as typeof import('../../utils/process.js');
20
+ return {
21
+ ...actual,
22
+ listAgentProcesses: vi.fn(),
23
+ enrichProcesses: vi.fn(),
24
+ };
25
+ });
22
26
 
23
27
  vi.mock('../../utils/matching.js', () => ({
24
28
  matchProcessesToSessions: vi.fn(),
@@ -158,6 +162,77 @@ describe('GeminiCliAdapter', () => {
158
162
  });
159
163
  });
160
164
 
165
+ it('should suppress Gemini wrapper process-only agents before a session file exists', async () => {
166
+ const wrapperProc: ProcessInfo = {
167
+ pid: 20452,
168
+ ppid: 17530,
169
+ command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
170
+ cwd: '/repo',
171
+ tty: 'ttys007',
172
+ startTime: new Date('2026-06-13T08:25:21Z'),
173
+ };
174
+ const childProc: ProcessInfo = {
175
+ pid: 21373,
176
+ ppid: 20452,
177
+ command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
178
+ cwd: '/repo',
179
+ tty: 'ttys007',
180
+ startTime: new Date('2026-06-13T08:25:26Z'),
181
+ };
182
+ mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
183
+
184
+ const agents = await adapter.detectAgents();
185
+
186
+ expect(agents).toHaveLength(1);
187
+ expect(agents[0]).toMatchObject({
188
+ pid: 21373,
189
+ sessionId: 'pid-21373',
190
+ summary: 'Gemini CLI process running',
191
+ });
192
+ });
193
+
194
+ it('should carry the managed wrapper name to a process-only child before a session file exists', async () => {
195
+ const regPath = path.join(tmpHome, 'agents.json');
196
+ const registry = new AgentRegistry(regPath);
197
+ const namedAdapter = new GeminiCliAdapter(registry);
198
+ const wrapperProc: ProcessInfo = {
199
+ pid: 35792,
200
+ ppid: 33068,
201
+ command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
202
+ cwd: '/repo',
203
+ tty: 'ttys002',
204
+ startTime: new Date('2026-06-13T19:15:16Z'),
205
+ };
206
+ const childProc: ProcessInfo = {
207
+ pid: 36514,
208
+ ppid: 35792,
209
+ command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
210
+ cwd: '/repo',
211
+ tty: 'ttys002',
212
+ startTime: new Date('2026-06-13T19:15:18Z'),
213
+ };
214
+ registry.register({
215
+ name: 'cli-mqcqj469',
216
+ type: 'gemini_cli',
217
+ pid: wrapperProc.pid,
218
+ tmuxSession: 'cli-mqcqj469',
219
+ cwd: wrapperProc.cwd,
220
+ startedAt: '2026-06-13T19:15:16.211Z',
221
+ sessionId: `pid-${wrapperProc.pid}`,
222
+ sessionFilePath: '',
223
+ });
224
+ mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
225
+
226
+ const agents = await namedAdapter.detectAgents();
227
+
228
+ expect(agents).toHaveLength(1);
229
+ expect(agents[0]).toMatchObject({
230
+ name: 'cli-mqcqj469',
231
+ pid: childProc.pid,
232
+ sessionId: `pid-${childProc.pid}`,
233
+ });
234
+ });
235
+
161
236
  it('should map a process to its matching session file via projectHash', async () => {
162
237
  const cwd = '/repo/project-a';
163
238
  const projectHash = hashProjectRoot(cwd);
@@ -433,6 +508,66 @@ describe('GeminiCliAdapter', () => {
433
508
 
434
509
  expect(agents[0].sessionId).toBe('pid-100');
435
510
  });
511
+
512
+ it('carries the managed wrapper name to the detected child process', async () => {
513
+ const wrapperProc: ProcessInfo = {
514
+ pid: 20339,
515
+ ppid: 17570,
516
+ command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
517
+ cwd: '/repo-a',
518
+ tty: 'ttys002',
519
+ startTime: new Date('2026-06-13T19:00:53Z'),
520
+ };
521
+ const childProc: ProcessInfo = {
522
+ pid: 21038,
523
+ ppid: 20339,
524
+ command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
525
+ cwd: '/repo-a',
526
+ tty: 'ttys002',
527
+ startTime: new Date('2026-06-13T19:00:57Z'),
528
+ };
529
+ const now = new Date().toISOString();
530
+ sessionFilePath = writeSession(tmpHome, 'cli-2', 'session-2026-06-13T19-00-s-cached', {
531
+ sessionId: 's-cached',
532
+ projectHash: hashProjectRoot('/repo-a'),
533
+ startTime: now,
534
+ lastUpdated: now,
535
+ directories: ['/repo-a'],
536
+ messages: [
537
+ { id: 'm1', timestamp: now, type: 'user', content: 'Hello from child process' },
538
+ ],
539
+ });
540
+ registerEntry({
541
+ name: 'cli-mqcq0mg5',
542
+ pid: wrapperProc.pid,
543
+ tmuxSession: 'cli-mqcq0mg5',
544
+ sessionId: 's-cached',
545
+ sessionFilePath,
546
+ });
547
+ mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
548
+ mockedMatchProcessesToSessions.mockReturnValue([
549
+ {
550
+ process: childProc,
551
+ session: {
552
+ sessionId: 's-cached',
553
+ filePath: sessionFilePath,
554
+ projectDir: path.dirname(sessionFilePath),
555
+ birthtimeMs: Date.now(),
556
+ resolvedCwd: '/repo-a',
557
+ },
558
+ deltaMs: 0,
559
+ },
560
+ ]);
561
+
562
+ const agents = await cachedAdapter.detectAgents();
563
+
564
+ expect(agents).toHaveLength(1);
565
+ expect(agents[0]).toMatchObject({
566
+ name: 'cli-mqcq0mg5',
567
+ pid: childProc.pid,
568
+ sessionId: 's-cached',
569
+ });
570
+ });
436
571
  });
437
572
 
438
573
  describe('discoverSessions', () => {
@@ -0,0 +1,92 @@
1
+ import { execFile } from 'child_process';
2
+ import type { MockedFunction } from 'vitest';
3
+
4
+ import { TerminalFocusManager, TerminalType } from '../../terminal/TerminalFocusManager.js';
5
+ import { getProcessTty } from '../../utils/process.js';
6
+
7
+ vi.mock('child_process', () => ({
8
+ execFile: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('../../utils/process.js', async () => {
12
+ const actual = await vi.importActual<typeof import('../../utils/process.js')>('../../utils/process.js');
13
+ return {
14
+ ...actual,
15
+ getProcessTty: vi.fn(),
16
+ };
17
+ });
18
+
19
+ type ExecFileCb = (err: Error | null, result?: { stdout: string; stderr: string }) => void;
20
+ const mockedExecFile = execFile as unknown as MockedFunction<
21
+ (cmd: string, args: string[], cb: ExecFileCb) => void
22
+ >;
23
+ const mockedGetProcessTty = getProcessTty as MockedFunction<typeof getProcessTty>;
24
+
25
+ function setExecFileHandler(handler: (cmd: string, args: string[]) => string | Error) {
26
+ mockedExecFile.mockImplementation((cmd, args, cb) => {
27
+ const result = handler(cmd, args);
28
+ if (result instanceof Error) cb(result);
29
+ else cb(null, { stdout: result, stderr: '' });
30
+ });
31
+ }
32
+
33
+ describe('TerminalFocusManager', () => {
34
+ beforeEach(() => {
35
+ mockedExecFile.mockReset();
36
+ mockedGetProcessTty.mockReset();
37
+ mockedGetProcessTty.mockReturnValue('ttys000');
38
+ });
39
+
40
+ it('finds iTerm2 when the process is listed by full app binary path', async () => {
41
+ setExecFileHandler((cmd, args) => {
42
+ if (cmd === 'tmux') return new Error('tmux not running');
43
+ if (cmd === 'pgrep') return new Error('pgrep did not match GUI app');
44
+ if (cmd === 'ps' && args.join(' ') === '-Axo comm') {
45
+ return '/Applications/iTerm.app/Contents/MacOS/iTerm2\n';
46
+ }
47
+ if (cmd === 'osascript') return 'found\n';
48
+ return '';
49
+ });
50
+
51
+ const location = await new TerminalFocusManager().findTerminal(123);
52
+
53
+ expect(location).toEqual({
54
+ type: TerminalType.ITERM2,
55
+ identifier: '/dev/ttys000',
56
+ tty: '/dev/ttys000',
57
+ });
58
+ expect(mockedExecFile).not.toHaveBeenCalledWith(
59
+ 'pgrep',
60
+ expect.any(Array),
61
+ expect.any(Function),
62
+ );
63
+ });
64
+
65
+ it('finds Terminal.app when the process is listed by app bundle path', async () => {
66
+ setExecFileHandler((cmd, args) => {
67
+ if (cmd === 'tmux') return new Error('tmux not running');
68
+ if (cmd === 'pgrep') return new Error('pgrep did not match GUI app');
69
+ if (cmd === 'ps' && args.join(' ') === '-Axo comm') {
70
+ return '/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal\n';
71
+ }
72
+ if (cmd === 'osascript') {
73
+ const script = args[1] ?? '';
74
+ return script.includes('tell application "Terminal"') ? 'found\n' : '';
75
+ }
76
+ return '';
77
+ });
78
+
79
+ const location = await new TerminalFocusManager().findTerminal(123);
80
+
81
+ expect(location).toEqual({
82
+ type: TerminalType.TERMINAL_APP,
83
+ identifier: '/dev/ttys000',
84
+ tty: '/dev/ttys000',
85
+ });
86
+ expect(mockedExecFile).not.toHaveBeenCalledWith(
87
+ 'pgrep',
88
+ expect.any(Array),
89
+ expect.any(Function),
90
+ );
91
+ });
92
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AGENTS } from '../../../src/utils/agents.js';
3
+
4
+ describe('AGENTS', () => {
5
+ it('includes Copilot as a startable agent', () => {
6
+ expect(AGENTS.copilot.command).toBe('copilot');
7
+ expect(AGENTS.copilot.matches('/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot')).toBe(true);
8
+ expect(AGENTS.copilot.matches('node /repo/feature-cli-copilot-cli/script.js')).toBe(false);
9
+ });
10
+
11
+ it('includes Pi as a startable agent', () => {
12
+ expect(AGENTS.pi.command).toBe('pi');
13
+ expect(AGENTS.pi.matches('pi')).toBe(true);
14
+ expect(AGENTS.pi.matches('/usr/local/bin/pi --model x')).toBe(true);
15
+ expect(AGENTS.pi.matches('node /repo/feature-pi-adapter/script.js')).toBe(false);
16
+ });
17
+ });
@@ -10,6 +10,8 @@ import {
10
10
  batchGetProcessCwds,
11
11
  batchGetProcessStartTimes,
12
12
  enrichProcesses,
13
+ findWrapperProcess,
14
+ findWrapperProcessPids,
13
15
  } from '../../utils/process.js';
14
16
 
15
17
  vi.mock('child_process', () => ({
@@ -23,26 +25,28 @@ describe('listAgentProcesses', () => {
23
25
  mockedExecFileSync.mockReset();
24
26
  });
25
27
 
26
- it('should parse ps aux | grep output and post-filter by executable name', () => {
28
+ it('should parse ps output and post-filter by executable name', () => {
27
29
  mockedExecFileSync.mockReturnValue(
28
- 'user 78070 1.0 0.5 485636016 245952 s018 S+ 11:18PM 1:55.14 claude\n' +
29
- 'user 55106 0.1 0.4 485620368 72496 s015 S+ 9Mar26 8:06.36 claude\n',
30
+ '78070 1 s018 claude\n' +
31
+ '55106 55100 s015 claude\n',
30
32
  );
31
33
 
32
34
  const processes = listAgentProcesses('claude');
33
35
  expect(processes).toHaveLength(2);
34
36
  expect(processes[0].pid).toBe(78070);
37
+ expect(processes[0].ppid).toBe(1);
35
38
  expect(processes[0].command).toBe('claude');
36
39
  expect(processes[0].tty).toBe('s018');
37
40
  expect(processes[0].cwd).toBe(''); // not populated yet
38
41
  expect(processes[1].pid).toBe(55106);
42
+ expect(processes[1].ppid).toBe(55100);
39
43
  });
40
44
 
41
45
  it('should filter out non-matching executables', () => {
42
46
  mockedExecFileSync.mockReturnValue(
43
- 'user 100 0.0 0.0 0 0 s001 S 1:00PM 0:00 claude\n' +
44
- 'user 200 0.0 0.0 0 0 s002 S 1:00PM 0:00 claude-helper --pid 100\n' +
45
- 'user 300 0.0 0.0 0 0 s003 S 1:00PM 0:00 /usr/bin/claude\n',
47
+ '100 1 s001 claude\n' +
48
+ '200 1 s002 claude-helper --pid 100\n' +
49
+ '300 1 s003 /usr/bin/claude\n',
46
50
  );
47
51
 
48
52
  const processes = listAgentProcesses('claude');
@@ -75,7 +79,11 @@ describe('listAgentProcesses', () => {
75
79
  it('should accept valid patterns with dashes and underscores', () => {
76
80
  mockedExecFileSync.mockReturnValue('');
77
81
  listAgentProcesses('claude-code');
78
- expect(mockedExecFileSync).toHaveBeenCalled();
82
+ expect(mockedExecFileSync).toHaveBeenCalledWith(
83
+ 'ps',
84
+ ['-axo', 'pid=,ppid=,tty=,command='],
85
+ { encoding: 'utf-8' },
86
+ );
79
87
 
80
88
  mockedExecFileSync.mockReset();
81
89
  mockedExecFileSync.mockReturnValue('');
@@ -201,3 +209,19 @@ describe('enrichProcesses', () => {
201
209
  expect(enriched[0].startTime).toBeUndefined();
202
210
  });
203
211
  });
212
+
213
+ describe('wrapper process detection', () => {
214
+ it('finds the parent wrapper process for a child in the same terminal and cwd', () => {
215
+ const wrapper = { pid: 100, ppid: 1, command: 'node /bin/gemini', cwd: '/repo', tty: 'ttys001' };
216
+ const child = { pid: 200, ppid: 100, command: 'node --max-old-space-size=8192 /bin/gemini', cwd: '/repo', tty: 'ttys001' };
217
+
218
+ expect(findWrapperProcess([wrapper, child], child)).toBe(wrapper);
219
+ expect(findWrapperProcessPids([wrapper, child])).toEqual(new Set([100]));
220
+ });
221
+
222
+ it('does not mark a matched child process as its own wrapper', () => {
223
+ const child = { pid: 200, ppid: 100, command: 'node --max-old-space-size=8192 /bin/gemini', cwd: '/repo', tty: 'ttys001' };
224
+
225
+ expect(findWrapperProcessPids([child], [child])).toEqual(new Set());
226
+ });
227
+ });
@@ -59,6 +59,9 @@ export interface ProcessInfo {
59
59
  /** Process ID */
60
60
  pid: number;
61
61
 
62
+ /** Parent process ID, populated by listAgentProcesses when available */
63
+ ppid?: number;
64
+
62
65
  /** Process command */
63
66
  command: string;
64
67
 
@@ -19,9 +19,10 @@ import type {
19
19
  SessionSummary,
20
20
  } from './AgentAdapter.js';
21
21
  import { AgentStatus } from './AgentAdapter.js';
22
- import { enrichProcesses, listAgentProcesses } from '../utils/process.js';
22
+ import { enrichProcesses, findWrapperProcess, findWrapperProcessPids, listAgentProcesses } from '../utils/process.js';
23
23
  import { generateAgentName } from '../utils/matching.js';
24
24
  import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
25
+ import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js';
25
26
 
26
27
  interface CopilotEventEntry {
27
28
  type?: string;
@@ -107,10 +108,12 @@ export class CopilotAdapter implements AgentAdapter {
107
108
  ]);
108
109
 
109
110
  private sessionStateDir: string;
111
+ private registry: AgentRegistry;
110
112
 
111
- constructor() {
113
+ constructor(registry: AgentRegistry = AgentRegistry.default()) {
112
114
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
113
115
  this.sessionStateDir = path.join(homeDir, '.copilot', 'session-state');
116
+ this.registry = registry;
114
117
  }
115
118
 
116
119
  canHandle(processInfo: ProcessInfo): boolean {
@@ -122,6 +125,7 @@ export class CopilotAdapter implements AgentAdapter {
122
125
  if (processes.length === 0) return [];
123
126
 
124
127
  const processByPid = new Map(processes.map((proc) => [proc.pid, proc]));
128
+ const registryEntriesByPid = new Map(this.registry.list().map((entry) => [entry.pid, entry]));
125
129
  const matchedPids = new Set<number>();
126
130
  const matchedProcesses: ProcessInfo[] = [];
127
131
  const agents: AgentInfo[] = [];
@@ -133,29 +137,36 @@ export class CopilotAdapter implements AgentAdapter {
133
137
  const session = this.readSessionDir(lock.sessionDir, lock.sessionId);
134
138
  if (!session) continue;
135
139
 
136
- agents.push(this.mapSessionToAgent(session, proc));
140
+ const agent = this.mapSessionToAgent(session, proc);
141
+ this.applyWrapperRegistryName(agent, proc, processes, registryEntriesByPid);
142
+ agents.push(agent);
137
143
  matchedPids.add(proc.pid);
138
144
  matchedProcesses.push(proc);
139
145
  }
140
146
 
147
+ const wrapperPids = findWrapperProcessPids(processes, matchedProcesses);
141
148
  for (const proc of processes) {
142
- if (!matchedPids.has(proc.pid) && !this.isDuplicateProcess(proc, matchedProcesses)) {
143
- agents.push(this.mapProcessOnlyAgent(proc));
149
+ if (!matchedPids.has(proc.pid) && !wrapperPids.has(proc.pid)) {
150
+ const agent = this.mapProcessOnlyAgent(proc);
151
+ this.applyWrapperRegistryName(agent, proc, processes, registryEntriesByPid);
152
+ agents.push(agent);
144
153
  }
145
154
  }
146
155
 
147
156
  return agents;
148
157
  }
149
158
 
150
- private isDuplicateProcess(proc: ProcessInfo, matchedProcesses: ProcessInfo[]): boolean {
151
- return matchedProcesses.some((matched) => {
152
- if (proc.pid === matched.pid) return true;
153
-
154
- const sameTty = proc.tty !== '' && proc.tty !== '?' && proc.tty === matched.tty;
155
- const sameCwd = proc.cwd !== '' && proc.cwd === matched.cwd;
156
-
157
- return sameTty && (sameCwd || proc.cwd === '' || matched.cwd === '');
158
- });
159
+ private applyWrapperRegistryName(
160
+ agent: AgentInfo,
161
+ processInfo: ProcessInfo,
162
+ processes: ProcessInfo[],
163
+ registryEntriesByPid: Map<number, RegistryEntry>,
164
+ ): void {
165
+ const wrapper = findWrapperProcess(processes, processInfo);
166
+ const wrapperEntry = wrapper ? registryEntriesByPid.get(wrapper.pid) : undefined;
167
+ if (wrapperEntry?.type === this.type) {
168
+ agent.name = wrapperEntry.name;
169
+ }
159
170
  }
160
171
 
161
172
  getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {