@ai-devkit/agent-manager 0.14.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 (111) hide show
  1. package/.eslintrc.json +1 -1
  2. package/dist/AgentManager.d.ts +14 -12
  3. package/dist/AgentManager.d.ts.map +1 -1
  4. package/dist/AgentManager.js +40 -33
  5. package/dist/AgentManager.js.map +1 -1
  6. package/dist/adapters/AgentAdapter.js +2 -5
  7. package/dist/adapters/AgentAdapter.js.map +1 -1
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  10. package/dist/adapters/ClaudeCodeAdapter.js +29 -63
  11. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  12. package/dist/adapters/CodexAdapter.d.ts +5 -2
  13. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  14. package/dist/adapters/CodexAdapter.js +66 -73
  15. package/dist/adapters/CodexAdapter.js.map +1 -1
  16. package/dist/adapters/GeminiCliAdapter.d.ts +5 -2
  17. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  18. package/dist/adapters/GeminiCliAdapter.js +63 -70
  19. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  20. package/dist/adapters/OpenCodeAdapter.d.ts +1 -1
  21. package/dist/adapters/OpenCodeAdapter.d.ts.map +1 -1
  22. package/dist/adapters/OpenCodeAdapter.js +20 -59
  23. package/dist/adapters/OpenCodeAdapter.js.map +1 -1
  24. package/dist/adapters/index.d.ts +6 -6
  25. package/dist/adapters/index.d.ts.map +1 -1
  26. package/dist/adapters/index.js +5 -13
  27. package/dist/adapters/index.js.map +1 -1
  28. package/dist/index.d.ts +18 -11
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +12 -22
  31. package/dist/index.js.map +1 -1
  32. package/dist/terminal/TerminalFocusManager.js +13 -17
  33. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  34. package/dist/terminal/TmuxManager.d.ts +28 -0
  35. package/dist/terminal/TmuxManager.d.ts.map +1 -0
  36. package/dist/terminal/TmuxManager.js +112 -0
  37. package/dist/terminal/TmuxManager.js.map +1 -0
  38. package/dist/terminal/TtyWriter.d.ts +1 -1
  39. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  40. package/dist/terminal/TtyWriter.js +11 -15
  41. package/dist/terminal/TtyWriter.js.map +1 -1
  42. package/dist/terminal/index.d.ts +4 -4
  43. package/dist/terminal/index.d.ts.map +1 -1
  44. package/dist/terminal/index.js +3 -9
  45. package/dist/terminal/index.js.map +1 -1
  46. package/dist/utils/AgentRegistry.d.ts +35 -0
  47. package/dist/utils/AgentRegistry.d.ts.map +1 -0
  48. package/dist/utils/AgentRegistry.js +114 -0
  49. package/dist/utils/AgentRegistry.js.map +1 -0
  50. package/dist/utils/ClaudeSessionParser.d.ts +2 -2
  51. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  52. package/dist/utils/ClaudeSessionParser.js +32 -59
  53. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  54. package/dist/utils/agents.d.ts +15 -0
  55. package/dist/utils/agents.d.ts.map +1 -0
  56. package/dist/utils/agents.js +30 -0
  57. package/dist/utils/agents.js.map +1 -0
  58. package/dist/utils/applescript.js +1 -4
  59. package/dist/utils/applescript.js.map +1 -1
  60. package/dist/utils/index.d.ts +6 -6
  61. package/dist/utils/index.d.ts.map +1 -1
  62. package/dist/utils/index.js +4 -15
  63. package/dist/utils/index.js.map +1 -1
  64. package/dist/utils/matching.d.ts +2 -2
  65. package/dist/utils/matching.d.ts.map +1 -1
  66. package/dist/utils/matching.js +3 -40
  67. package/dist/utils/matching.js.map +1 -1
  68. package/dist/utils/process.d.ts +1 -1
  69. package/dist/utils/process.d.ts.map +1 -1
  70. package/dist/utils/process.js +12 -52
  71. package/dist/utils/process.js.map +1 -1
  72. package/dist/utils/session.js +8 -49
  73. package/dist/utils/session.js.map +1 -1
  74. package/dist/utils/sortAgents.d.ts +4 -0
  75. package/dist/utils/sortAgents.d.ts.map +1 -0
  76. package/dist/utils/sortAgents.js +48 -0
  77. package/dist/utils/sortAgents.js.map +1 -0
  78. package/package.json +9 -8
  79. package/src/AgentManager.ts +53 -28
  80. package/src/__tests__/AgentManager.test.ts +140 -5
  81. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +45 -44
  82. package/src/__tests__/adapters/CodexAdapter.test.ts +137 -30
  83. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +130 -18
  84. package/src/__tests__/adapters/OpenCodeAdapter.test.ts +23 -21
  85. package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
  86. package/src/__tests__/terminal/TtyWriter.test.ts +10 -9
  87. package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
  88. package/src/__tests__/utils/ClaudeSessionParser.test.ts +73 -2
  89. package/src/__tests__/utils/matching.test.ts +4 -4
  90. package/src/__tests__/utils/process.test.ts +6 -5
  91. package/src/__tests__/utils/session.test.ts +8 -7
  92. package/src/adapters/ClaudeCodeAdapter.ts +8 -8
  93. package/src/adapters/CodexAdapter.ts +51 -12
  94. package/src/adapters/GeminiCliAdapter.ts +51 -12
  95. package/src/adapters/OpenCodeAdapter.ts +4 -4
  96. package/src/adapters/index.ts +6 -6
  97. package/src/index.ts +19 -11
  98. package/src/terminal/TerminalFocusManager.ts +2 -2
  99. package/src/terminal/TmuxManager.ts +118 -0
  100. package/src/terminal/TtyWriter.ts +3 -3
  101. package/src/terminal/index.ts +4 -4
  102. package/src/utils/AgentRegistry.ts +139 -0
  103. package/src/utils/ClaudeSessionParser.ts +23 -12
  104. package/src/utils/agents.ts +41 -0
  105. package/src/utils/index.ts +6 -6
  106. package/src/utils/matching.ts +2 -2
  107. package/src/utils/process.ts +1 -1
  108. package/src/utils/sortAgents.ts +52 -0
  109. package/tsconfig.json +2 -2
  110. package/vitest.config.ts +20 -0
  111. package/jest.config.js +0 -21
@@ -2,30 +2,33 @@
2
2
  * Tests for GeminiCliAdapter
3
3
  */
4
4
 
5
+ import type { MockedFunction } from 'vitest';
5
6
  import * as fs from 'fs';
6
7
  import * as os from 'os';
7
8
  import * as path from 'path';
8
- import { beforeEach, afterEach, describe, expect, it, jest } from '@jest/globals';
9
- import { GeminiCliAdapter } from '../../adapters/GeminiCliAdapter';
10
- import type { ProcessInfo } from '../../adapters/AgentAdapter';
11
- import { AgentStatus } from '../../adapters/AgentAdapter';
12
- import { listAgentProcesses, enrichProcesses } from '../../utils/process';
13
- import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
14
-
15
- jest.mock('../../utils/process', () => ({
16
- listAgentProcesses: jest.fn(),
17
- enrichProcesses: jest.fn(),
9
+
10
+ import { GeminiCliAdapter } from '../../adapters/GeminiCliAdapter.js';
11
+ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
+ import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
+ import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js';
14
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
15
+ import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
16
+ import * as crypto from 'crypto';
17
+
18
+ vi.mock('../../utils/process.js', () => ({
19
+ listAgentProcesses: vi.fn(),
20
+ enrichProcesses: vi.fn(),
18
21
  }));
19
22
 
20
- jest.mock('../../utils/matching', () => ({
21
- matchProcessesToSessions: jest.fn(),
22
- generateAgentName: jest.fn(),
23
+ vi.mock('../../utils/matching.js', () => ({
24
+ matchProcessesToSessions: vi.fn(),
25
+ generateAgentName: vi.fn(),
23
26
  }));
24
27
 
25
- const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction<typeof listAgentProcesses>;
26
- const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction<typeof enrichProcesses>;
27
- const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction<typeof matchProcessesToSessions>;
28
- const mockedGenerateAgentName = generateAgentName as jest.MockedFunction<typeof generateAgentName>;
28
+ const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
29
+ const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
30
+ const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction<typeof matchProcessesToSessions>;
31
+ const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
29
32
 
30
33
  describe('GeminiCliAdapter', () => {
31
34
  let adapter: GeminiCliAdapter;
@@ -249,6 +252,116 @@ describe('GeminiCliAdapter', () => {
249
252
  });
250
253
  });
251
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
+
252
365
  describe('discoverSessions', () => {
253
366
  it('should return empty when ~/.gemini/tmp does not exist', () => {
254
367
  const proc: ProcessInfo = {
@@ -918,6 +1031,5 @@ function writeSession(
918
1031
  */
919
1032
  function hashProjectRoot(projectRoot: string): string {
920
1033
  // eslint-disable-next-line @typescript-eslint/no-require-imports
921
- const crypto = require('crypto');
922
1034
  return crypto.createHash('sha256').update(projectRoot).digest('hex');
923
1035
  }
@@ -2,28 +2,30 @@
2
2
  * Tests for OpenCodeAdapter
3
3
  */
4
4
 
5
+ import type { MockedFunction } from 'vitest';
5
6
  import * as fs from 'fs';
6
7
  import * as path from 'path';
7
- import { beforeEach, afterEach, describe, expect, it, jest } from '@jest/globals';
8
- import { OpenCodeAdapter } from '../../adapters/OpenCodeAdapter';
9
- import type { ProcessInfo } from '../../adapters/AgentAdapter';
10
- import { AgentStatus } from '../../adapters/AgentAdapter';
11
- import { listAgentProcesses, enrichProcesses } from '../../utils/process';
12
- import { generateAgentName } from '../../utils/matching';
13
-
14
- jest.mock('../../utils/process', () => ({
15
- listAgentProcesses: jest.fn(),
16
- enrichProcesses: jest.fn(),
8
+
9
+ import { OpenCodeAdapter } from '../../adapters/OpenCodeAdapter.js';
10
+ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
11
+ import { AgentStatus } from '../../adapters/AgentAdapter.js';
12
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
13
+ import { generateAgentName } from '../../utils/matching.js';
14
+ import * as os from 'os';
15
+
16
+ vi.mock('../../utils/process.js', () => ({
17
+ listAgentProcesses: vi.fn(),
18
+ enrichProcesses: vi.fn(),
17
19
  }));
18
20
 
19
- jest.mock('../../utils/matching', () => ({
20
- generateAgentName: jest.fn(),
21
- matchProcessesToSessions: jest.fn(),
21
+ vi.mock('../../utils/matching.js', () => ({
22
+ generateAgentName: vi.fn(),
23
+ matchProcessesToSessions: vi.fn(),
22
24
  }));
23
25
 
24
- const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction<typeof listAgentProcesses>;
25
- const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction<typeof enrichProcesses>;
26
- const mockedGenerateAgentName = generateAgentName as jest.MockedFunction<typeof generateAgentName>;
26
+ const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
27
+ const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
28
+ const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
27
29
 
28
30
  function makeDb(queries: {
29
31
  session?: Array<{ id: string; directory: string; time_created: number }>;
@@ -85,11 +87,11 @@ function makeDb(queries: {
85
87
  return { all: () => [], get: () => undefined };
86
88
  };
87
89
 
88
- return { prepare: prepareImpl, close: jest.fn() };
90
+ return { prepare: prepareImpl, close: vi.fn() };
89
91
  }
90
92
 
91
93
  function makeDbConstructor(db: ReturnType<typeof makeDb>) {
92
- return jest.fn().mockReturnValue(db);
94
+ return vi.fn().mockReturnValue(db);
93
95
  }
94
96
 
95
97
  describe('OpenCodeAdapter', () => {
@@ -110,7 +112,7 @@ describe('OpenCodeAdapter', () => {
110
112
  return `${folder}-${pid}`;
111
113
  });
112
114
 
113
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'opencode-test-'));
115
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-test-'));
114
116
  dbPath = path.join(tmpDir, 'opencode.db');
115
117
  (adapter as any).dbPath = dbPath;
116
118
  (adapter as any).db = null;
@@ -118,7 +120,7 @@ describe('OpenCodeAdapter', () => {
118
120
 
119
121
  afterEach(() => {
120
122
  fs.rmSync(tmpDir, { recursive: true, force: true });
121
- jest.restoreAllMocks();
123
+ vi.restoreAllMocks();
122
124
  });
123
125
 
124
126
  describe('type', () => {
@@ -193,7 +195,7 @@ describe('OpenCodeAdapter', () => {
193
195
 
194
196
  fs.writeFileSync(dbPath, ''); // file exists but empty → sqlite throws
195
197
  const db = makeDb({ session: [] }); // no matching session
196
- jest.mock('better-sqlite3', () => makeDbConstructor(db));
198
+ vi.doMock('better-sqlite3', () => makeDbConstructor(db));
197
199
  (adapter as any).db = db;
198
200
 
199
201
  const agents = await adapter.detectAgents();
@@ -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
+ });
@@ -1,18 +1,19 @@
1
- import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
- import { TtyWriter } from '../../terminal/TtyWriter';
3
- import { TerminalType } from '../../terminal/TerminalFocusManager';
4
- import type { TerminalLocation } from '../../terminal/TerminalFocusManager';
1
+ import type { Mock } from 'vitest';
2
+
3
+ import { TtyWriter } from '../../terminal/TtyWriter.js';
4
+ import { TerminalType } from '../../terminal/TerminalFocusManager.js';
5
+ import type { TerminalLocation } from '../../terminal/TerminalFocusManager.js';
5
6
  import { execFile } from 'child_process';
6
7
 
7
- jest.mock('child_process', () => {
8
- const actual = jest.requireActual<typeof import('child_process')>('child_process');
8
+ vi.mock('child_process', async () => {
9
+ const actual = await vi.importActual<typeof import('child_process')>('child_process');
9
10
  return {
10
11
  ...actual,
11
- execFile: jest.fn(),
12
+ execFile: vi.fn(),
12
13
  };
13
14
  });
14
15
 
15
- const mockedExecFile = execFile as unknown as jest.Mock;
16
+ const mockedExecFile = execFile as unknown as Mock;
16
17
 
17
18
  function mockExecFileSuccess(stdout = '') {
18
19
  mockedExecFile.mockImplementation((...args: unknown[]) => {
@@ -30,7 +31,7 @@ function mockExecFileError(message: string) {
30
31
 
31
32
  describe('TtyWriter', () => {
32
33
  beforeEach(() => {
33
- jest.clearAllMocks();
34
+ vi.clearAllMocks();
34
35
  });
35
36
 
36
37
  describe('tmux', () => {