@ai-devkit/agent-manager 0.15.0 → 0.16.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 (47) hide show
  1. package/dist/AgentManager.d.ts +4 -0
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +35 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/CodexAdapter.d.ts +4 -1
  6. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  7. package/dist/adapters/CodexAdapter.js +35 -6
  8. package/dist/adapters/CodexAdapter.js.map +1 -1
  9. package/dist/adapters/GeminiCliAdapter.d.ts +4 -1
  10. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  11. package/dist/adapters/GeminiCliAdapter.js +35 -6
  12. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/terminal/TmuxManager.d.ts +28 -0
  18. package/dist/terminal/TmuxManager.d.ts.map +1 -0
  19. package/dist/terminal/TmuxManager.js +112 -0
  20. package/dist/terminal/TmuxManager.js.map +1 -0
  21. package/dist/utils/AgentRegistry.d.ts +35 -0
  22. package/dist/utils/AgentRegistry.d.ts.map +1 -0
  23. package/dist/utils/AgentRegistry.js +114 -0
  24. package/dist/utils/AgentRegistry.js.map +1 -0
  25. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  26. package/dist/utils/ClaudeSessionParser.js +21 -11
  27. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  28. package/dist/utils/agents.d.ts +15 -0
  29. package/dist/utils/agents.d.ts.map +1 -0
  30. package/dist/utils/agents.js +30 -0
  31. package/dist/utils/agents.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/AgentManager.ts +40 -0
  34. package/src/__tests__/AgentManager.test.ts +135 -0
  35. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +1 -2
  36. package/src/__tests__/adapters/CodexAdapter.test.ts +105 -0
  37. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +111 -0
  38. package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
  39. package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
  40. package/src/__tests__/utils/ClaudeSessionParser.test.ts +71 -0
  41. package/src/adapters/CodexAdapter.ts +45 -6
  42. package/src/adapters/GeminiCliAdapter.ts +45 -6
  43. package/src/index.ts +6 -0
  44. package/src/terminal/TmuxManager.ts +118 -0
  45. package/src/utils/AgentRegistry.ts +139 -0
  46. package/src/utils/ClaudeSessionParser.ts +21 -10
  47. package/src/utils/agents.ts +41 -0
@@ -3,6 +3,9 @@
3
3
  */
4
4
 
5
5
 
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
6
9
  import { AgentManager } from '../AgentManager.js';
7
10
  import type {
8
11
  AgentAdapter,
@@ -12,6 +15,7 @@ import type {
12
15
  SessionSummary,
13
16
  } from '../adapters/AgentAdapter.js';
14
17
  import { AgentStatus } from '../adapters/AgentAdapter.js';
18
+ import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js';
15
19
 
16
20
  // Mock adapter for testing
17
21
  class MockAdapter implements AgentAdapter {
@@ -240,6 +244,137 @@ describe('AgentManager', () => {
240
244
  });
241
245
  });
242
246
 
247
+ describe('listAgents — registry persistence', () => {
248
+ let tmpDir: string;
249
+ let regPath: string;
250
+ let registry: AgentRegistry;
251
+ let scopedManager: AgentManager;
252
+
253
+ beforeEach(() => {
254
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-manager-'));
255
+ regPath = path.join(tmpDir, 'agents.json');
256
+ registry = new AgentRegistry(regPath);
257
+ scopedManager = new AgentManager(registry);
258
+ });
259
+
260
+ afterEach(() => {
261
+ fs.rmSync(tmpDir, { recursive: true, force: true });
262
+ });
263
+
264
+ it('persists every detected agent to the registry', async () => {
265
+ scopedManager.registerAdapter(new MockAdapter('claude', [
266
+ createMockAgent({
267
+ name: 'a',
268
+ pid: process.pid,
269
+ sessionId: 'sid-a',
270
+ sessionFilePath: '/path/a.jsonl',
271
+ projectPath: '/cwd/a',
272
+ }),
273
+ ]));
274
+
275
+ await scopedManager.listAgents();
276
+
277
+ const entries = registry.list();
278
+ expect(entries).toHaveLength(1);
279
+ expect(entries[0]).toMatchObject({
280
+ name: 'a',
281
+ type: 'claude',
282
+ pid: process.pid,
283
+ cwd: '/cwd/a',
284
+ sessionId: 'sid-a',
285
+ sessionFilePath: '/path/a.jsonl',
286
+ tmuxSession: '',
287
+ });
288
+ expect(entries[0].startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
289
+ });
290
+
291
+ it('prunes entries for dead pids', async () => {
292
+ registry.register({
293
+ name: 'dead',
294
+ type: 'claude',
295
+ pid: 999999,
296
+ tmuxSession: '',
297
+ cwd: '/cwd/dead',
298
+ startedAt: '2026-05-30T00:00:00.000Z',
299
+ sessionId: 'sid-dead',
300
+ sessionFilePath: '/path/dead.jsonl',
301
+ });
302
+
303
+ scopedManager.registerAdapter(new MockAdapter('claude', [
304
+ createMockAgent({ name: 'live', pid: process.pid }),
305
+ ]));
306
+
307
+ await scopedManager.listAgents();
308
+
309
+ const entries = registry.list();
310
+ expect(entries.map((e) => e.name)).toEqual(['live']);
311
+ });
312
+
313
+ it('preserves an existing name (e.g. user-set "merry") across cycles', async () => {
314
+ registry.register({
315
+ name: 'merry',
316
+ type: 'claude',
317
+ pid: process.pid,
318
+ tmuxSession: 'merry',
319
+ cwd: '/cwd/merry',
320
+ startedAt: '2026-05-30T00:00:00.000Z',
321
+ sessionId: 'sid-merry',
322
+ sessionFilePath: '/path/merry.jsonl',
323
+ });
324
+
325
+ scopedManager.registerAdapter(new MockAdapter('claude', [
326
+ createMockAgent({ name: 'default-name', pid: process.pid }),
327
+ ]));
328
+
329
+ const agents = await scopedManager.listAgents();
330
+
331
+ expect(agents[0].name).toBe('merry');
332
+ expect(registry.list()[0].name).toBe('merry');
333
+ expect(registry.list()[0].tmuxSession).toBe('merry');
334
+ expect(registry.list()[0].startedAt).toBe('2026-05-30T00:00:00.000Z');
335
+ });
336
+
337
+ it('writes a fresh startedAt for new entries', async () => {
338
+ const before = new Date().toISOString();
339
+ scopedManager.registerAdapter(new MockAdapter('claude', [
340
+ createMockAgent({ name: 'new', pid: process.pid }),
341
+ ]));
342
+
343
+ await scopedManager.listAgents();
344
+
345
+ const entry = registry.list()[0];
346
+ expect(entry.startedAt >= before).toBe(true);
347
+ });
348
+
349
+ it('batches the write — a single registerBatch call per listAgents', async () => {
350
+ const spy = vi.spyOn(registry, 'registerBatch');
351
+
352
+ scopedManager.registerAdapter(new MockAdapter('claude', [
353
+ createMockAgent({ name: 'a', pid: process.pid }),
354
+ ]));
355
+ scopedManager.registerAdapter(new MockAdapter('codex', [
356
+ createMockAgent({ name: 'b', type: 'codex', pid: process.pid + 1 }),
357
+ ]));
358
+
359
+ await scopedManager.listAgents();
360
+
361
+ expect(spy).toHaveBeenCalledTimes(1);
362
+ expect((spy.mock.calls[0][0] as RegistryEntry[]).map((e) => e.name).sort())
363
+ .toEqual(['a', 'b']);
364
+ });
365
+
366
+ it('skips registerBatch when no agents detected (still calls prune)', async () => {
367
+ const writeSpy = vi.spyOn(registry, 'registerBatch');
368
+ const pruneSpy = vi.spyOn(registry, 'prune');
369
+
370
+ scopedManager.registerAdapter(new MockAdapter('claude', []));
371
+ await scopedManager.listAgents();
372
+
373
+ expect(writeSpy).not.toHaveBeenCalled();
374
+ expect(pruneSpy).toHaveBeenCalledTimes(1);
375
+ });
376
+ });
377
+
243
378
  describe('clear', () => {
244
379
  it('should remove all adapters', () => {
245
380
  manager.registerAdapter(new MockAdapter('claude'));
@@ -427,8 +427,7 @@ describe('ClaudeCodeAdapter', () => {
427
427
 
428
428
  const sessionId = 'wait-session';
429
429
  const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
430
- // JSONL trails with permission-mode parser would resolve to UNKNOWN.
431
- // PID file's live status must win.
430
+ // PID file's live status must win over JSONL-derived status.
432
431
  fs.writeFileSync(jsonlPath, [
433
432
  JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/wait', message: { content: '/reddit-commenter' } }),
434
433
  JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
@@ -9,6 +9,7 @@ import * as path from 'path';
9
9
  import { CodexAdapter } from '../../adapters/CodexAdapter.js';
10
10
  import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
11
11
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
12
+ import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js';
12
13
  import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
13
14
  import { batchGetSessionFileBirthtimes } from '../../utils/session.js';
14
15
  import type { SessionFile } from '../../utils/session.js';
@@ -249,6 +250,110 @@ describe('CodexAdapter', () => {
249
250
  });
250
251
  });
251
252
 
253
+ describe('detectAgents — registry cache short-circuit', () => {
254
+ let tmpDir: string;
255
+ let regPath: string;
256
+ let registry: AgentRegistry;
257
+ let cachedAdapter: CodexAdapter;
258
+ let sessionFilePath: string;
259
+
260
+ function registerEntry(over: Partial<RegistryEntry> = {}): void {
261
+ registry.register({
262
+ name: 'codex-100',
263
+ type: 'codex',
264
+ pid: 100,
265
+ tmuxSession: '',
266
+ cwd: '/repo-a',
267
+ startedAt: '2026-05-30T00:00:00.000Z',
268
+ sessionId: 'sess-cached',
269
+ sessionFilePath,
270
+ ...over,
271
+ });
272
+ }
273
+
274
+ beforeEach(() => {
275
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-cache-'));
276
+ regPath = path.join(tmpDir, 'agents.json');
277
+ registry = new AgentRegistry(regPath);
278
+ cachedAdapter = new CodexAdapter(registry);
279
+
280
+ const recentTs = new Date().toISOString();
281
+ sessionFilePath = path.join(tmpDir, 'sess-cached.jsonl');
282
+ fs.writeFileSync(sessionFilePath, [
283
+ JSON.stringify({ type: 'session_meta', payload: { id: 'sess-cached', timestamp: recentTs, cwd: '/repo-a' } }),
284
+ JSON.stringify({ type: 'event', timestamp: recentTs, payload: { type: 'token_count', message: 'Hello from cache' } }),
285
+ ].join('\n'));
286
+ });
287
+
288
+ afterEach(() => {
289
+ fs.rmSync(tmpDir, { recursive: true, force: true });
290
+ });
291
+
292
+ it('short-circuits matching when registry has a valid entry', async () => {
293
+ registerEntry();
294
+ const processes: ProcessInfo[] = [
295
+ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
296
+ ];
297
+ mockedListAgentProcesses.mockReturnValue(processes);
298
+ mockedEnrichProcesses.mockReturnValue(processes);
299
+
300
+ const agents = await cachedAdapter.detectAgents();
301
+
302
+ expect(agents).toHaveLength(1);
303
+ expect(agents[0]).toMatchObject({
304
+ type: 'codex',
305
+ pid: 100,
306
+ sessionId: 'sess-cached',
307
+ sessionFilePath,
308
+ summary: 'Hello from cache',
309
+ });
310
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
311
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
312
+ });
313
+
314
+ it('falls through when no registry entry exists for the pid', async () => {
315
+ const processes: ProcessInfo[] = [
316
+ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
317
+ ];
318
+ mockedListAgentProcesses.mockReturnValue(processes);
319
+ mockedEnrichProcesses.mockReturnValue(processes);
320
+ (cachedAdapter as any).codexSessionsDir = '/nonexistent';
321
+
322
+ const agents = await cachedAdapter.detectAgents();
323
+
324
+ expect(agents[0].sessionId).toBe('pid-100');
325
+ });
326
+
327
+ it('falls through when registry entry type does not match', async () => {
328
+ registerEntry({ type: 'claude' });
329
+ const processes: ProcessInfo[] = [
330
+ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
331
+ ];
332
+ mockedListAgentProcesses.mockReturnValue(processes);
333
+ mockedEnrichProcesses.mockReturnValue(processes);
334
+ (cachedAdapter as any).codexSessionsDir = '/nonexistent';
335
+
336
+ const agents = await cachedAdapter.detectAgents();
337
+
338
+ expect(agents[0].sessionId).toBe('pid-100');
339
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
340
+ });
341
+
342
+ it('falls through when the cached session file no longer exists', async () => {
343
+ registerEntry({ sessionFilePath: path.join(tmpDir, 'deleted.jsonl') });
344
+ const processes: ProcessInfo[] = [
345
+ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
346
+ ];
347
+ mockedListAgentProcesses.mockReturnValue(processes);
348
+ mockedEnrichProcesses.mockReturnValue(processes);
349
+ (cachedAdapter as any).codexSessionsDir = '/nonexistent';
350
+
351
+ const agents = await cachedAdapter.detectAgents();
352
+
353
+ expect(agents[0].sessionId).toBe('pid-100');
354
+ });
355
+ });
356
+
252
357
  describe('discoverSessions', () => {
253
358
  let tmpDir: string;
254
359
 
@@ -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';
@@ -251,6 +252,116 @@ describe('GeminiCliAdapter', () => {
251
252
  });
252
253
  });
253
254
 
255
+ describe('detectAgents — registry cache short-circuit', () => {
256
+ let regPath: string;
257
+ let registry: AgentRegistry;
258
+ let cachedAdapter: GeminiCliAdapter;
259
+ let sessionFilePath: string;
260
+
261
+ function registerEntry(over: Partial<RegistryEntry> = {}): void {
262
+ registry.register({
263
+ name: 'gemini-100',
264
+ type: 'gemini_cli',
265
+ pid: 100,
266
+ tmuxSession: '',
267
+ cwd: '/repo-a',
268
+ startedAt: '2026-05-30T00:00:00.000Z',
269
+ sessionId: 's-cached',
270
+ sessionFilePath,
271
+ ...over,
272
+ });
273
+ }
274
+
275
+ beforeEach(() => {
276
+ regPath = path.join(tmpHome, 'agents.json');
277
+ registry = new AgentRegistry(regPath);
278
+ cachedAdapter = new GeminiCliAdapter(registry);
279
+
280
+ const now = new Date().toISOString();
281
+ sessionFilePath = path.join(tmpHome, 'gemini-session.json');
282
+ fs.writeFileSync(sessionFilePath, JSON.stringify({
283
+ sessionId: 's-cached',
284
+ projectHash: 'h',
285
+ startTime: now,
286
+ lastUpdated: now,
287
+ directories: ['/repo-a'],
288
+ messages: [
289
+ { id: 'm1', timestamp: now, type: 'user', content: 'Hello from gemini cache' },
290
+ ],
291
+ }));
292
+ });
293
+
294
+ it('short-circuits matching when registry has a valid entry', async () => {
295
+ registerEntry();
296
+ const proc: ProcessInfo = {
297
+ pid: 100,
298
+ command: 'node /path/to/gemini --help',
299
+ cwd: '/repo-a',
300
+ tty: 'ttys001',
301
+ startTime: new Date(),
302
+ };
303
+ mockedListAgentProcesses.mockReturnValue([proc]);
304
+
305
+ const agents = await cachedAdapter.detectAgents();
306
+
307
+ expect(agents).toHaveLength(1);
308
+ expect(agents[0]).toMatchObject({
309
+ type: 'gemini_cli',
310
+ pid: 100,
311
+ sessionId: 's-cached',
312
+ summary: 'Hello from gemini cache',
313
+ });
314
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
315
+ });
316
+
317
+ it('falls through when no registry entry exists for the pid', async () => {
318
+ const proc: ProcessInfo = {
319
+ pid: 100,
320
+ command: 'node /path/to/gemini --help',
321
+ cwd: '/repo-a',
322
+ tty: 'ttys001',
323
+ startTime: new Date(),
324
+ };
325
+ mockedListAgentProcesses.mockReturnValue([proc]);
326
+
327
+ const agents = await cachedAdapter.detectAgents();
328
+
329
+ expect(agents[0].sessionId).toBe('pid-100');
330
+ });
331
+
332
+ it('falls through when registry entry type does not match', async () => {
333
+ registerEntry({ type: 'claude' });
334
+ const proc: ProcessInfo = {
335
+ pid: 100,
336
+ command: 'node /path/to/gemini --help',
337
+ cwd: '/repo-a',
338
+ tty: 'ttys001',
339
+ startTime: new Date(),
340
+ };
341
+ mockedListAgentProcesses.mockReturnValue([proc]);
342
+
343
+ const agents = await cachedAdapter.detectAgents();
344
+
345
+ expect(agents[0].sessionId).toBe('pid-100');
346
+ });
347
+
348
+ it('falls through when the cached session file no longer exists', async () => {
349
+ registerEntry({ sessionFilePath: path.join(tmpHome, 'deleted.json') });
350
+ const proc: ProcessInfo = {
351
+ pid: 100,
352
+ command: 'node /path/to/gemini --help',
353
+ cwd: '/repo-a',
354
+ tty: 'ttys001',
355
+ startTime: new Date(),
356
+ };
357
+ mockedListAgentProcesses.mockReturnValue([proc]);
358
+
359
+ const agents = await cachedAdapter.detectAgents();
360
+
361
+ expect(agents[0].sessionId).toBe('pid-100');
362
+ });
363
+ });
364
+
254
365
  describe('discoverSessions', () => {
255
366
  it('should return empty when ~/.gemini/tmp does not exist', () => {
256
367
  const proc: ProcessInfo = {
@@ -0,0 +1,175 @@
1
+ import { execFile } from 'child_process';
2
+ import type { MockedFunction } from 'vitest';
3
+ import { TmuxManager } from '../../terminal/TmuxManager.js';
4
+
5
+ vi.mock('child_process', () => ({
6
+ execFile: vi.fn(),
7
+ }));
8
+
9
+ type ExecFileCb = (err: Error | null, result?: { stdout: string; stderr: string }) => void;
10
+ const mockedExecFile = execFile as unknown as MockedFunction<
11
+ (cmd: string, args: string[], cb: ExecFileCb) => void
12
+ >;
13
+
14
+ /** Drive the promisified execFile mock with a per-call handler. */
15
+ function setExecFileHandler(handler: (cmd: string, args: string[]) => string | Error) {
16
+ mockedExecFile.mockImplementation((_cmd, _args, cb) => {
17
+ const result = handler(_cmd, _args);
18
+ if (result instanceof Error) cb(result);
19
+ else cb(null, { stdout: result, stderr: '' });
20
+ });
21
+ }
22
+
23
+ describe('TmuxManager', () => {
24
+ let tmux: TmuxManager;
25
+
26
+ beforeEach(() => {
27
+ tmux = new TmuxManager();
28
+ mockedExecFile.mockReset();
29
+ });
30
+
31
+ describe('isAvailable', () => {
32
+ it('returns true when `tmux -V` succeeds', async () => {
33
+ setExecFileHandler(() => 'tmux 3.4');
34
+ expect(await tmux.isAvailable()).toBe(true);
35
+ });
36
+
37
+ it('returns false when tmux is missing', async () => {
38
+ setExecFileHandler(() => new Error('ENOENT'));
39
+ expect(await tmux.isAvailable()).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('sessionExists', () => {
44
+ it('returns true when has-session succeeds', async () => {
45
+ setExecFileHandler(() => '');
46
+ expect(await tmux.sessionExists('foo')).toBe(true);
47
+ });
48
+
49
+ it('returns false when has-session fails', async () => {
50
+ setExecFileHandler(() => new Error('no session'));
51
+ expect(await tmux.sessionExists('foo')).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('createSession', () => {
56
+ it('issues `tmux new-session -d -s <name> -c <cwd>`', async () => {
57
+ setExecFileHandler(() => '');
58
+ await tmux.createSession('foo', '/work');
59
+ expect(mockedExecFile).toHaveBeenCalledWith(
60
+ 'tmux',
61
+ ['new-session', '-d', '-s', 'foo', '-c', '/work'],
62
+ expect.any(Function),
63
+ );
64
+ });
65
+ });
66
+
67
+ describe('sendKeys', () => {
68
+ it('appends Enter so the command runs', async () => {
69
+ setExecFileHandler(() => '');
70
+ await tmux.sendKeys('foo', 'claude');
71
+ expect(mockedExecFile).toHaveBeenCalledWith(
72
+ 'tmux',
73
+ ['send-keys', '-t', 'foo', 'claude', 'Enter'],
74
+ expect.any(Function),
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('killSession', () => {
80
+ it('swallows errors when session is already gone', async () => {
81
+ setExecFileHandler(() => new Error("can't find session"));
82
+ await expect(tmux.killSession('foo')).resolves.toBeUndefined();
83
+ });
84
+ });
85
+
86
+ describe('findAgentPid', () => {
87
+ const matchesClaude = (cmd: string) => cmd.split(/\s+/)[0]?.endsWith('claude') ?? false;
88
+
89
+ it('returns null when the session has no pane', async () => {
90
+ setExecFileHandler((cmd, args) => {
91
+ if (args[0] === 'list-panes') return new Error('no session');
92
+ return '';
93
+ });
94
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBeNull();
95
+ });
96
+
97
+ it('returns null when no descendant matches', async () => {
98
+ // pane PID 100; child 200 is "node /unrelated" — no match
99
+ setExecFileHandler((cmd, args) => {
100
+ if (cmd === 'tmux' && args[0] === 'list-panes') return '100\n';
101
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
102
+ if (cmd === 'pgrep') return new Error('no children');
103
+ if (cmd === 'ps' && args[1] === '200') return 'node /unrelated';
104
+ return '';
105
+ });
106
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBeNull();
107
+ });
108
+
109
+ it('returns the matching descendant when found', async () => {
110
+ // pane 100 → child 200 (claude) — no grandchildren
111
+ setExecFileHandler((cmd, args) => {
112
+ if (cmd === 'tmux') return '100\n';
113
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
114
+ if (cmd === 'pgrep') return new Error('no children');
115
+ if (cmd === 'ps' && args[1] === '200') return 'claude';
116
+ return '';
117
+ });
118
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(200);
119
+ });
120
+
121
+ it('prefers the deepest match (wrapper case)', async () => {
122
+ // pane 100 → 200 (claude wrapper, matches) → 300 (claude, matches, deeper)
123
+ setExecFileHandler((cmd, args) => {
124
+ if (cmd === 'tmux') return '100\n';
125
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
126
+ if (cmd === 'pgrep' && args[1] === '200') return '300\n';
127
+ if (cmd === 'pgrep') return new Error('no children');
128
+ if (cmd === 'ps' && args[1] === '200') return '/usr/local/bin/claude';
129
+ if (cmd === 'ps' && args[1] === '300') return '/usr/local/lib/claude';
130
+ return '';
131
+ });
132
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(300);
133
+ });
134
+
135
+ it('skips non-matching subprocesses (MCP child case)', async () => {
136
+ // pane 100 → 200 (claude, matches) → 300 (mcp-server, no match)
137
+ setExecFileHandler((cmd, args) => {
138
+ if (cmd === 'tmux') return '100\n';
139
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
140
+ if (cmd === 'pgrep' && args[1] === '200') return '300\n';
141
+ if (cmd === 'pgrep') return new Error('no children');
142
+ if (cmd === 'ps' && args[1] === '200') return 'claude';
143
+ if (cmd === 'ps' && args[1] === '300') return 'node mcp-server.js';
144
+ return '';
145
+ });
146
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(200);
147
+ });
148
+
149
+ it('handles the gemini Node-script shape via a token-scan matcher', async () => {
150
+ const matchesGemini = (cmd: string) =>
151
+ cmd.split(/\s+/).some((t) => t.endsWith('/gemini') || t === 'gemini');
152
+ setExecFileHandler((cmd, args) => {
153
+ if (cmd === 'tmux') return '100\n';
154
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
155
+ if (cmd === 'pgrep') return new Error('no children');
156
+ if (cmd === 'ps' && args[1] === '200') return 'node /opt/homebrew/bin/gemini --foo';
157
+ return '';
158
+ });
159
+ expect(await tmux.findAgentPid('foo', matchesGemini)).toBe(200);
160
+ });
161
+
162
+ it('handles multiple children at the same level', async () => {
163
+ // pane 100 → [200 (nope), 201 (claude)]
164
+ setExecFileHandler((cmd, args) => {
165
+ if (cmd === 'tmux') return '100\n';
166
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n201\n';
167
+ if (cmd === 'pgrep') return new Error('no children');
168
+ if (cmd === 'ps' && args[1] === '200') return 'bash';
169
+ if (cmd === 'ps' && args[1] === '201') return 'claude';
170
+ return '';
171
+ });
172
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(201);
173
+ });
174
+ });
175
+ });