@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,16 +2,20 @@
2
2
  * Tests for AgentManager
3
3
  */
4
4
 
5
- import { describe, it, expect, beforeEach } from '@jest/globals';
6
- import { AgentManager } from '../AgentManager';
5
+
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import { AgentManager } from '../AgentManager.js';
7
10
  import type {
8
11
  AgentAdapter,
9
12
  AgentInfo,
10
13
  AgentType,
11
14
  ConversationMessage,
12
15
  SessionSummary,
13
- } from '../adapters/AgentAdapter';
14
- import { AgentStatus } from '../adapters/AgentAdapter';
16
+ } from '../adapters/AgentAdapter.js';
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'));
@@ -333,7 +468,7 @@ describe('AgentManager', () => {
333
468
  new MockAdapter('codex', [], false, [], true /* failListSessions */),
334
469
  );
335
470
 
336
- const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
471
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
337
472
 
338
473
  try {
339
474
  const result = await manager.listSessions();
@@ -2,40 +2,42 @@
2
2
  * Tests for ClaudeCodeAdapter
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 { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
8
- import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter';
9
- import type { ProcessInfo } from '../../adapters/AgentAdapter';
10
- import { AgentStatus } from '../../adapters/AgentAdapter';
11
- import { listAgentProcesses, enrichProcesses } from '../../utils/process';
12
- import { batchGetSessionFileBirthtimes } from '../../utils/session';
13
- import type { SessionFile } from '../../utils/session';
14
- import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
15
- import type { MatchResult } from '../../utils/matching';
16
- jest.mock('../../utils/process', () => ({
17
- listAgentProcesses: jest.fn(),
18
- enrichProcesses: jest.fn(),
8
+
9
+ import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter.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 { batchGetSessionFileBirthtimes } from '../../utils/session.js';
14
+ import type { SessionFile } from '../../utils/session.js';
15
+ import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
16
+ import type { MatchResult } from '../../utils/matching.js';
17
+ import * as os from 'os';
18
+ vi.mock('../../utils/process.js', () => ({
19
+ listAgentProcesses: vi.fn(),
20
+ enrichProcesses: vi.fn(),
19
21
  }));
20
22
 
21
- jest.mock('../../utils/session', () => {
22
- const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session');
23
+ vi.mock('../../utils/session.js', async () => {
24
+ const actual = await vi.importActual('../../utils/session') as typeof import('../../utils/session');
23
25
  return {
24
26
  ...actual,
25
- batchGetSessionFileBirthtimes: jest.fn(),
27
+ batchGetSessionFileBirthtimes: vi.fn(),
26
28
  };
27
29
  });
28
30
 
29
- jest.mock('../../utils/matching', () => ({
30
- matchProcessesToSessions: jest.fn(),
31
- generateAgentName: jest.fn(),
31
+ vi.mock('../../utils/matching.js', () => ({
32
+ matchProcessesToSessions: vi.fn(),
33
+ generateAgentName: vi.fn(),
32
34
  }));
33
35
 
34
- const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction<typeof listAgentProcesses>;
35
- const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction<typeof enrichProcesses>;
36
- const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as jest.MockedFunction<typeof batchGetSessionFileBirthtimes>;
37
- const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction<typeof matchProcessesToSessions>;
38
- const mockedGenerateAgentName = generateAgentName as jest.MockedFunction<typeof generateAgentName>;
36
+ const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
37
+ const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
38
+ const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as MockedFunction<typeof batchGetSessionFileBirthtimes>;
39
+ const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction<typeof matchProcessesToSessions>;
40
+ const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
39
41
  describe('ClaudeCodeAdapter', () => {
40
42
  let adapter: ClaudeCodeAdapter;
41
43
 
@@ -163,7 +165,7 @@ describe('ClaudeCodeAdapter', () => {
163
165
  mockedEnrichProcesses.mockReturnValue(processes);
164
166
 
165
167
  // Set up projects dir with encoded directory name
166
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
168
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
167
169
  const projectsDir = path.join(tmpDir, 'projects');
168
170
  // Claude encodes /Users/test/my-project → -Users-test-my-project
169
171
  const projDir = path.join(projectsDir, '-Users-test-my-project');
@@ -222,7 +224,7 @@ describe('ClaudeCodeAdapter', () => {
222
224
  mockedEnrichProcesses.mockReturnValue(processes);
223
225
 
224
226
  // Set up projects dir with encoded directory names
225
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
227
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
226
228
  const projectsDir = path.join(tmpDir, 'projects');
227
229
  // /project-a → -project-a, /project-b → -project-b
228
230
  const projDirA = path.join(projectsDir, '-project-a');
@@ -303,7 +305,7 @@ describe('ClaudeCodeAdapter', () => {
303
305
  mockedListAgentProcesses.mockReturnValue(processes);
304
306
  mockedEnrichProcesses.mockReturnValue(processes);
305
307
 
306
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-resume-'));
308
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-resume-'));
307
309
  const projectsDir = path.join(tmpDir, 'projects');
308
310
  const projDir = path.join(projectsDir, '-project-resumed');
309
311
  fs.mkdirSync(projDir, { recursive: true });
@@ -367,7 +369,7 @@ describe('ClaudeCodeAdapter', () => {
367
369
  mockedListAgentProcesses.mockReturnValue(processes);
368
370
  mockedEnrichProcesses.mockReturnValue(processes);
369
371
 
370
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-test-'));
372
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pid-test-'));
371
373
  const sessionsDir = path.join(tmpDir, 'sessions');
372
374
  const projectsDir = path.join(tmpDir, 'projects');
373
375
  const projDir = path.join(projectsDir, '-project-direct');
@@ -416,7 +418,7 @@ describe('ClaudeCodeAdapter', () => {
416
418
  mockedListAgentProcesses.mockReturnValue(processes);
417
419
  mockedEnrichProcesses.mockReturnValue(processes);
418
420
 
419
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-wait-'));
421
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pid-wait-'));
420
422
  const sessionsDir = path.join(tmpDir, 'sessions');
421
423
  const projectsDir = path.join(tmpDir, 'projects');
422
424
  const projDir = path.join(projectsDir, '-project-wait');
@@ -425,8 +427,7 @@ describe('ClaudeCodeAdapter', () => {
425
427
 
426
428
  const sessionId = 'wait-session';
427
429
  const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
428
- // JSONL trails with permission-mode parser would resolve to UNKNOWN.
429
- // PID file's live status must win.
430
+ // PID file's live status must win over JSONL-derived status.
430
431
  fs.writeFileSync(jsonlPath, [
431
432
  JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/wait', message: { content: '/reddit-commenter' } }),
432
433
  JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
@@ -463,7 +464,7 @@ describe('ClaudeCodeAdapter', () => {
463
464
  mockedListAgentProcesses.mockReturnValue(processes);
464
465
  mockedEnrichProcesses.mockReturnValue(processes);
465
466
 
466
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-idle-'));
467
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pid-idle-'));
467
468
  const sessionsDir = path.join(tmpDir, 'sessions');
468
469
  const projectsDir = path.join(tmpDir, 'projects');
469
470
  const projDir = path.join(projectsDir, '-project-idle');
@@ -508,7 +509,7 @@ describe('ClaudeCodeAdapter', () => {
508
509
  mockedListAgentProcesses.mockReturnValue(processes);
509
510
  mockedEnrichProcesses.mockReturnValue(processes);
510
511
 
511
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-nostat-'));
512
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-pid-nostat-'));
512
513
  const sessionsDir = path.join(tmpDir, 'sessions');
513
514
  const projectsDir = path.join(tmpDir, 'projects');
514
515
  const projDir = path.join(projectsDir, '-project-legacy');
@@ -560,7 +561,7 @@ describe('ClaudeCodeAdapter', () => {
560
561
  mockedListAgentProcesses.mockReturnValue(processes);
561
562
  mockedEnrichProcesses.mockReturnValue(processes);
562
563
 
563
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-resume-wait-'));
564
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-resume-wait-'));
564
565
  const sessionsDir = path.join(tmpDir, 'sessions');
565
566
  const projectsDir = path.join(tmpDir, 'projects');
566
567
  const projDir = path.join(projectsDir, '-project-resume-wait');
@@ -604,7 +605,7 @@ describe('ClaudeCodeAdapter', () => {
604
605
  mockedListAgentProcesses.mockReturnValue(processes);
605
606
  mockedEnrichProcesses.mockReturnValue(processes);
606
607
 
607
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-gone-'));
608
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-gone-'));
608
609
  const sessionsDir = path.join(tmpDir, 'sessions');
609
610
  const projectsDir = path.join(tmpDir, 'projects');
610
611
  const projDir = path.join(projectsDir, '-project-gone');
@@ -623,7 +624,7 @@ describe('ClaudeCodeAdapter', () => {
623
624
  (adapter as any).projectsDir = projectsDir;
624
625
 
625
626
  // Simulate JSONL disappearing between existence check and read
626
- jest.spyOn((adapter as any).parser, 'readSession').mockReturnValueOnce(null);
627
+ vi.spyOn((adapter as any).parser, 'readSession').mockReturnValueOnce(null);
627
628
 
628
629
  const agents = await adapter.detectAgents();
629
630
 
@@ -633,7 +634,7 @@ describe('ClaudeCodeAdapter', () => {
633
634
  expect(agents[0].status).toBe(AgentStatus.IDLE);
634
635
 
635
636
  fs.rmSync(tmpDir, { recursive: true, force: true });
636
- jest.restoreAllMocks();
637
+ vi.restoreAllMocks();
637
638
  });
638
639
 
639
640
  it('should fall back to process-only when legacy-matched JSONL becomes unreadable', async () => {
@@ -644,7 +645,7 @@ describe('ClaudeCodeAdapter', () => {
644
645
  mockedListAgentProcesses.mockReturnValue(processes);
645
646
  mockedEnrichProcesses.mockReturnValue(processes);
646
647
 
647
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-lgone-'));
648
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-lgone-'));
648
649
  const projectsDir = path.join(tmpDir, 'projects');
649
650
  const projDir = path.join(projectsDir, '-project-legacy-gone');
650
651
  fs.mkdirSync(projDir, { recursive: true });
@@ -670,7 +671,7 @@ describe('ClaudeCodeAdapter', () => {
670
671
  ]);
671
672
 
672
673
  // Simulate JSONL disappearing between match and read
673
- jest.spyOn((adapter as any).parser, 'readSession').mockReturnValueOnce(null);
674
+ vi.spyOn((adapter as any).parser, 'readSession').mockReturnValueOnce(null);
674
675
 
675
676
  const agents = await adapter.detectAgents();
676
677
 
@@ -679,7 +680,7 @@ describe('ClaudeCodeAdapter', () => {
679
680
  expect(agents[0].status).toBe(AgentStatus.IDLE);
680
681
 
681
682
  fs.rmSync(tmpDir, { recursive: true, force: true });
682
- jest.restoreAllMocks();
683
+ vi.restoreAllMocks();
683
684
  });
684
685
 
685
686
  it('should mix direct PID-file matches and legacy matches across processes', async () => {
@@ -691,7 +692,7 @@ describe('ClaudeCodeAdapter', () => {
691
692
  mockedListAgentProcesses.mockReturnValue(processes);
692
693
  mockedEnrichProcesses.mockReturnValue(processes);
693
694
 
694
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-mix-test-'));
695
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-mix-test-'));
695
696
  const sessionsDir = path.join(tmpDir, 'sessions');
696
697
  const projectsDir = path.join(tmpDir, 'projects');
697
698
  const projAlpha = path.join(projectsDir, '-project-alpha');
@@ -756,7 +757,7 @@ describe('ClaudeCodeAdapter', () => {
756
757
  let tmpDir: string;
757
758
 
758
759
  beforeEach(() => {
759
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
760
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
760
761
  });
761
762
 
762
763
  afterEach(() => {
@@ -1071,7 +1072,7 @@ describe('ClaudeCodeAdapter', () => {
1071
1072
  let tmpDir: string;
1072
1073
 
1073
1074
  beforeEach(() => {
1074
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
1075
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
1075
1076
  });
1076
1077
 
1077
1078
  afterEach(() => {
@@ -1419,7 +1420,7 @@ describe('ClaudeCodeAdapter', () => {
1419
1420
  let tmpDir: string;
1420
1421
 
1421
1422
  beforeEach(() => {
1422
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-conv-'));
1423
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-conv-'));
1423
1424
  });
1424
1425
 
1425
1426
  afterEach(() => {
@@ -1594,7 +1595,7 @@ describe('ClaudeCodeAdapter', () => {
1594
1595
  let projectsDir: string;
1595
1596
 
1596
1597
  beforeEach(() => {
1597
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-list-'));
1598
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-list-'));
1598
1599
  projectsDir = path.join(tmpDir, 'projects');
1599
1600
  fs.mkdirSync(projectsDir, { recursive: true });
1600
1601
  (adapter as any).projectsDir = projectsDir;
@@ -2,41 +2,44 @@
2
2
  * Tests for CodexAdapter
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 { CodexAdapter } from '../../adapters/CodexAdapter';
9
- import type { ProcessInfo } from '../../adapters/AgentAdapter';
10
- import { AgentStatus } from '../../adapters/AgentAdapter';
11
- import { listAgentProcesses, enrichProcesses } from '../../utils/process';
12
- import { batchGetSessionFileBirthtimes } from '../../utils/session';
13
- import type { SessionFile } from '../../utils/session';
14
- import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
15
- import type { MatchResult } from '../../utils/matching';
16
-
17
- jest.mock('../../utils/process', () => ({
18
- listAgentProcesses: jest.fn(),
19
- enrichProcesses: jest.fn(),
8
+
9
+ import { CodexAdapter } from '../../adapters/CodexAdapter.js';
10
+ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
11
+ import { AgentStatus } from '../../adapters/AgentAdapter.js';
12
+ import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js';
13
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
14
+ import { batchGetSessionFileBirthtimes } from '../../utils/session.js';
15
+ import type { SessionFile } from '../../utils/session.js';
16
+ import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
17
+ import type { MatchResult } from '../../utils/matching.js';
18
+ import * as os from 'os';
19
+
20
+ vi.mock('../../utils/process.js', () => ({
21
+ listAgentProcesses: vi.fn(),
22
+ enrichProcesses: vi.fn(),
20
23
  }));
21
24
 
22
- jest.mock('../../utils/session', () => {
23
- const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session');
25
+ vi.mock('../../utils/session.js', async () => {
26
+ const actual = await vi.importActual('../../utils/session') as typeof import('../../utils/session');
24
27
  return {
25
28
  ...actual,
26
- batchGetSessionFileBirthtimes: jest.fn(),
29
+ batchGetSessionFileBirthtimes: vi.fn(),
27
30
  };
28
31
  });
29
32
 
30
- jest.mock('../../utils/matching', () => ({
31
- matchProcessesToSessions: jest.fn(),
32
- generateAgentName: jest.fn(),
33
+ vi.mock('../../utils/matching.js', () => ({
34
+ matchProcessesToSessions: vi.fn(),
35
+ generateAgentName: vi.fn(),
33
36
  }));
34
37
 
35
- const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction<typeof listAgentProcesses>;
36
- const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction<typeof enrichProcesses>;
37
- const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as jest.MockedFunction<typeof batchGetSessionFileBirthtimes>;
38
- const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction<typeof matchProcessesToSessions>;
39
- const mockedGenerateAgentName = generateAgentName as jest.MockedFunction<typeof generateAgentName>;
38
+ const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
39
+ const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
40
+ const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as MockedFunction<typeof batchGetSessionFileBirthtimes>;
41
+ const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction<typeof matchProcessesToSessions>;
42
+ const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
40
43
 
41
44
  describe('CodexAdapter', () => {
42
45
  let adapter: CodexAdapter;
@@ -136,7 +139,7 @@ describe('CodexAdapter', () => {
136
139
  mockedEnrichProcesses.mockReturnValue(processes);
137
140
 
138
141
  // Set up sessions dir with date directory
139
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-'));
142
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
140
143
  const sessionsDir = path.join(tmpDir, 'sessions');
141
144
  const dateDir = path.join(sessionsDir, '2026', '03', '18');
142
145
  fs.mkdirSync(dateDir, { recursive: true });
@@ -195,7 +198,7 @@ describe('CodexAdapter', () => {
195
198
  mockedListAgentProcesses.mockReturnValue(processes);
196
199
  mockedEnrichProcesses.mockReturnValue(processes);
197
200
 
198
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-'));
201
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
199
202
  const sessionsDir = path.join(tmpDir, 'sessions');
200
203
  const now = new Date();
201
204
  const dateDir = path.join(
@@ -247,11 +250,115 @@ describe('CodexAdapter', () => {
247
250
  });
248
251
  });
249
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
+
250
357
  describe('discoverSessions', () => {
251
358
  let tmpDir: string;
252
359
 
253
360
  beforeEach(() => {
254
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-'));
361
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
255
362
  });
256
363
 
257
364
  afterEach(() => {
@@ -399,7 +506,7 @@ describe('CodexAdapter', () => {
399
506
  let tmpDir: string;
400
507
 
401
508
  beforeEach(() => {
402
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-'));
509
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
403
510
  });
404
511
 
405
512
  afterEach(() => {
@@ -524,7 +631,7 @@ describe('CodexAdapter', () => {
524
631
  let tmpDir: string;
525
632
 
526
633
  beforeEach(() => {
527
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-conv-'));
634
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-conv-'));
528
635
  });
529
636
 
530
637
  afterEach(() => {
@@ -640,7 +747,7 @@ describe('CodexAdapter', () => {
640
747
  let sessionsDir: string;
641
748
 
642
749
  beforeEach(() => {
643
- tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-list-'));
750
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-list-'));
644
751
  sessionsDir = path.join(tmpDir, 'sessions');
645
752
  fs.mkdirSync(sessionsDir, { recursive: true });
646
753
  (adapter as any).codexSessionsDir = sessionsDir;