@ai-devkit/agent-manager 0.26.1 → 0.26.3

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 (116) hide show
  1. package/dist/AgentManager.d.ts +5 -2
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +21 -4
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/__tests__/AgentManager.test.js +184 -0
  6. package/dist/__tests__/AgentManager.test.js.map +1 -1
  7. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +13 -4
  8. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -1
  9. package/dist/__tests__/adapters/CodexAdapter.test.js +13 -4
  10. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
  11. package/dist/__tests__/adapters/CopilotAdapter.test.js +7 -2
  12. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -1
  13. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +18 -5
  14. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -1
  15. package/dist/__tests__/adapters/GrokCliAdapter.test.js +7 -2
  16. package/dist/__tests__/adapters/GrokCliAdapter.test.js.map +1 -1
  17. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +13 -4
  18. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +1 -1
  19. package/dist/__tests__/adapters/PiAdapter.test.js +32 -4
  20. package/dist/__tests__/adapters/PiAdapter.test.js.map +1 -1
  21. package/dist/__tests__/utils/AgentRegistry.test.js +68 -46
  22. package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -1
  23. package/dist/__tests__/utils/process.test.js +90 -2
  24. package/dist/__tests__/utils/process.test.js.map +1 -1
  25. package/dist/adapters/AgentAdapter.d.ts +9 -3
  26. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  27. package/dist/adapters/AgentAdapter.js.map +1 -1
  28. package/dist/adapters/ClaudeCodeAdapter.d.ts +5 -4
  29. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  30. package/dist/adapters/ClaudeCodeAdapter.js +11 -7
  31. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  32. package/dist/adapters/CodexAdapter.d.ts +5 -4
  33. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  34. package/dist/adapters/CodexAdapter.js +11 -7
  35. package/dist/adapters/CodexAdapter.js.map +1 -1
  36. package/dist/adapters/CopilotAdapter.d.ts +5 -4
  37. package/dist/adapters/CopilotAdapter.d.ts.map +1 -1
  38. package/dist/adapters/CopilotAdapter.js +11 -7
  39. package/dist/adapters/CopilotAdapter.js.map +1 -1
  40. package/dist/adapters/GeminiCliAdapter.d.ts +6 -5
  41. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  42. package/dist/adapters/GeminiCliAdapter.js +12 -8
  43. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  44. package/dist/adapters/GrokCliAdapter.d.ts +3 -2
  45. package/dist/adapters/GrokCliAdapter.d.ts.map +1 -1
  46. package/dist/adapters/GrokCliAdapter.js +10 -6
  47. package/dist/adapters/GrokCliAdapter.js.map +1 -1
  48. package/dist/adapters/OpenCodeAdapter.d.ts +5 -4
  49. package/dist/adapters/OpenCodeAdapter.d.ts.map +1 -1
  50. package/dist/adapters/OpenCodeAdapter.js +11 -7
  51. package/dist/adapters/OpenCodeAdapter.js.map +1 -1
  52. package/dist/adapters/PiAdapter.d.ts +3 -2
  53. package/dist/adapters/PiAdapter.d.ts.map +1 -1
  54. package/dist/adapters/PiAdapter.js +12 -9
  55. package/dist/adapters/PiAdapter.js.map +1 -1
  56. package/dist/adapters/index.d.ts +1 -1
  57. package/dist/adapters/index.d.ts.map +1 -1
  58. package/dist/adapters/index.js.map +1 -1
  59. package/dist/database/connection.d.ts +22 -0
  60. package/dist/database/connection.d.ts.map +1 -0
  61. package/dist/database/connection.js +58 -0
  62. package/dist/database/connection.js.map +1 -0
  63. package/dist/database/index.d.ts +4 -0
  64. package/dist/database/index.d.ts.map +1 -0
  65. package/dist/database/index.js +4 -0
  66. package/dist/database/index.js.map +1 -0
  67. package/dist/database/migrations/001_initial.sql +12 -0
  68. package/dist/database/schema.d.ts +4 -0
  69. package/dist/database/schema.d.ts.map +1 -0
  70. package/dist/database/schema.js +47 -0
  71. package/dist/database/schema.js.map +1 -0
  72. package/dist/index.d.ts +2 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +1 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/utils/AgentRegistry.d.ts +7 -3
  77. package/dist/utils/AgentRegistry.d.ts.map +1 -1
  78. package/dist/utils/AgentRegistry.js +108 -57
  79. package/dist/utils/AgentRegistry.js.map +1 -1
  80. package/dist/utils/index.d.ts +1 -1
  81. package/dist/utils/index.d.ts.map +1 -1
  82. package/dist/utils/index.js +1 -1
  83. package/dist/utils/index.js.map +1 -1
  84. package/dist/utils/process.d.ts +9 -1
  85. package/dist/utils/process.d.ts.map +1 -1
  86. package/dist/utils/process.js +155 -10
  87. package/dist/utils/process.js.map +1 -1
  88. package/package.json +2 -2
  89. package/src/AgentManager.ts +28 -4
  90. package/src/__tests__/AgentManager.test.ts +126 -0
  91. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +16 -5
  92. package/src/__tests__/adapters/CodexAdapter.test.ts +16 -5
  93. package/src/__tests__/adapters/CopilotAdapter.test.ts +8 -1
  94. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +20 -4
  95. package/src/__tests__/adapters/GrokCliAdapter.test.ts +8 -1
  96. package/src/__tests__/adapters/OpenCodeAdapter.test.ts +16 -5
  97. package/src/__tests__/adapters/PiAdapter.test.ts +28 -5
  98. package/src/__tests__/utils/AgentRegistry.test.ts +48 -40
  99. package/src/__tests__/utils/process.test.ts +72 -1
  100. package/src/adapters/AgentAdapter.ts +11 -3
  101. package/src/adapters/ClaudeCodeAdapter.ts +10 -7
  102. package/src/adapters/CodexAdapter.ts +10 -7
  103. package/src/adapters/CopilotAdapter.ts +16 -7
  104. package/src/adapters/GeminiCliAdapter.ts +17 -8
  105. package/src/adapters/GrokCliAdapter.ts +9 -6
  106. package/src/adapters/OpenCodeAdapter.ts +10 -7
  107. package/src/adapters/PiAdapter.ts +10 -9
  108. package/src/adapters/index.ts +1 -1
  109. package/src/database/connection.ts +74 -0
  110. package/src/database/index.ts +7 -0
  111. package/src/database/migrations/001_initial.sql +12 -0
  112. package/src/database/schema.ts +62 -0
  113. package/src/index.ts +2 -0
  114. package/src/utils/AgentRegistry.ts +109 -49
  115. package/src/utils/index.ts +1 -1
  116. package/src/utils/process.ts +160 -10
@@ -10,7 +10,7 @@ import * as path from 'path';
10
10
  import { CopilotAdapter } from '../../adapters/CopilotAdapter.js';
11
11
  import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
12
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
- import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
13
+ import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js';
14
14
  import { generateAgentName } from '../../utils/matching.js';
15
15
  import { AgentRegistry } from '../../utils/AgentRegistry.js';
16
16
 
@@ -20,6 +20,7 @@ vi.mock('../../utils/process.js', async (importOriginal) => {
20
20
  ...actual,
21
21
  listAgentProcesses: vi.fn(),
22
22
  enrichProcesses: vi.fn(),
23
+ captureProcessSnapshot: vi.fn(),
23
24
  };
24
25
  });
25
26
 
@@ -29,6 +30,7 @@ vi.mock('../../utils/matching.js', () => ({
29
30
 
30
31
  const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
31
32
  const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
33
+ const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction<typeof captureProcessSnapshot>;
32
34
  const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
33
35
 
34
36
  describe('CopilotAdapter', () => {
@@ -45,8 +47,13 @@ describe('CopilotAdapter', () => {
45
47
 
46
48
  mockedListAgentProcesses.mockReset();
47
49
  mockedEnrichProcesses.mockReset();
50
+ mockedCaptureProcessSnapshot.mockReset();
48
51
  mockedGenerateAgentName.mockReset();
49
52
  mockedEnrichProcesses.mockImplementation((procs) => procs);
53
+ // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name.
54
+ mockedCaptureProcessSnapshot.mockImplementation(async (names) => (
55
+ enrichProcesses(names.flatMap((name) => listAgentProcesses(name)))
56
+ ));
50
57
  mockedGenerateAgentName.mockImplementation((cwd, pid) => `${path.basename(cwd) || 'unknown'} (${pid})`);
51
58
  });
52
59
 
@@ -11,7 +11,7 @@ import { GeminiCliAdapter } from '../../adapters/GeminiCliAdapter.js';
11
11
  import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
12
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
13
  import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js';
14
- import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
14
+ import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js';
15
15
  import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
16
16
  import * as crypto from 'crypto';
17
17
 
@@ -21,6 +21,7 @@ vi.mock('../../utils/process.js', async (importOriginal) => {
21
21
  ...actual,
22
22
  listAgentProcesses: vi.fn(),
23
23
  enrichProcesses: vi.fn(),
24
+ captureProcessSnapshot: vi.fn(),
24
25
  };
25
26
  });
26
27
 
@@ -31,6 +32,7 @@ vi.mock('../../utils/matching.js', () => ({
31
32
 
32
33
  const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
33
34
  const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
35
+ const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction<typeof captureProcessSnapshot>;
34
36
  const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction<typeof matchProcessesToSessions>;
35
37
  const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
36
38
 
@@ -45,10 +47,15 @@ describe('GeminiCliAdapter', () => {
45
47
  adapter = new GeminiCliAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json')));
46
48
  mockedListAgentProcesses.mockReset();
47
49
  mockedEnrichProcesses.mockReset();
50
+ mockedCaptureProcessSnapshot.mockReset();
48
51
  mockedMatchProcessesToSessions.mockReset();
49
52
  mockedGenerateAgentName.mockReset();
50
53
 
51
54
  mockedEnrichProcesses.mockImplementation((procs) => procs);
55
+ // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name.
56
+ mockedCaptureProcessSnapshot.mockImplementation(async (names) => (
57
+ enrichProcesses(names.flatMap((name) => listAgentProcesses(name)))
58
+ ));
52
59
  mockedMatchProcessesToSessions.mockReturnValue([]);
53
60
  mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => {
54
61
  const folder = path.basename(cwd) || 'unknown';
@@ -110,6 +117,15 @@ describe('GeminiCliAdapter', () => {
110
117
  tty: 'ttys006',
111
118
  })).toBe(true);
112
119
  });
120
+
121
+ it('should recognize Windows executable and entrypoint paths', () => {
122
+ expect(adapter.canHandle({
123
+ pid: 7,
124
+ command: 'C:\\tools\\node.exe C:\\lib\\gemini.js',
125
+ cwd: 'C:\\repo',
126
+ tty: '',
127
+ })).toBe(true);
128
+ });
113
129
  });
114
130
 
115
131
  describe('detectAgents', () => {
@@ -144,7 +160,7 @@ describe('GeminiCliAdapter', () => {
144
160
  it('should return process-only agents when no session files exist for the process', async () => {
145
161
  const proc: ProcessInfo = {
146
162
  pid: 1234,
147
- command: 'gemini',
163
+ command: 'node /usr/local/bin/gemini',
148
164
  cwd: '/repo',
149
165
  tty: 'ttys001',
150
166
  startTime: new Date('2026-04-18T00:00:00Z'),
@@ -257,7 +273,7 @@ describe('GeminiCliAdapter', () => {
257
273
 
258
274
  const proc: ProcessInfo = {
259
275
  pid: 42,
260
- command: 'gemini',
276
+ command: 'node /usr/local/bin/gemini',
261
277
  cwd,
262
278
  tty: 'ttys001',
263
279
  startTime: new Date('2026-04-18T00:00:00Z'),
@@ -383,7 +399,7 @@ describe('GeminiCliAdapter', () => {
383
399
 
384
400
  const proc: ProcessInfo = {
385
401
  pid: 7,
386
- command: 'gemini',
402
+ command: 'node /usr/local/bin/gemini',
387
403
  cwd: procCwd,
388
404
  tty: 'ttys001',
389
405
  startTime: new Date(),
@@ -13,7 +13,7 @@ import * as path from 'path';
13
13
  import { GrokCliAdapter } from '../../adapters/GrokCliAdapter.js';
14
14
  import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
15
15
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
16
- import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
16
+ import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js';
17
17
  import { generateAgentName } from '../../utils/matching.js';
18
18
 
19
19
  vi.mock('../../utils/process.js', async (importOriginal) => {
@@ -22,6 +22,7 @@ vi.mock('../../utils/process.js', async (importOriginal) => {
22
22
  ...actual,
23
23
  listAgentProcesses: vi.fn(),
24
24
  enrichProcesses: vi.fn(),
25
+ captureProcessSnapshot: vi.fn(),
25
26
  };
26
27
  });
27
28
 
@@ -35,6 +36,7 @@ vi.mock('../../utils/matching.js', async (importOriginal) => {
35
36
 
36
37
  const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
37
38
  const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
39
+ const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction<typeof captureProcessSnapshot>;
38
40
  const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
39
41
 
40
42
  const SESSION_ID = '019f16c3-5d5d-7dc3-85d1-bc629416ca2d';
@@ -64,9 +66,14 @@ describe('GrokCliAdapter', () => {
64
66
 
65
67
  mockedListAgentProcesses.mockReset();
66
68
  mockedEnrichProcesses.mockReset();
69
+ mockedCaptureProcessSnapshot.mockReset();
67
70
  mockedGenerateAgentName.mockReset();
68
71
 
69
72
  mockedEnrichProcesses.mockImplementation((procs) => procs);
73
+ // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name.
74
+ mockedCaptureProcessSnapshot.mockImplementation(async (names) => (
75
+ enrichProcesses(names.flatMap((name) => listAgentProcesses(name)))
76
+ ));
70
77
  mockedGenerateAgentName.mockImplementation((c: string, pid: number) => `${path.basename(c) || 'unknown'}-${pid}`);
71
78
  });
72
79
 
@@ -9,14 +9,19 @@ import * as path from 'path';
9
9
  import { OpenCodeAdapter } from '../../adapters/OpenCodeAdapter.js';
10
10
  import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
11
11
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
12
- import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
12
+ import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js';
13
13
  import { generateAgentName } from '../../utils/matching.js';
14
14
  import * as os from 'os';
15
15
 
16
- vi.mock('../../utils/process.js', () => ({
17
- listAgentProcesses: vi.fn(),
18
- enrichProcesses: vi.fn(),
19
- }));
16
+ vi.mock('../../utils/process.js', async (importOriginal) => {
17
+ const actual = await importOriginal() as typeof import('../../utils/process.js');
18
+ return {
19
+ ...actual,
20
+ listAgentProcesses: vi.fn(),
21
+ enrichProcesses: vi.fn(),
22
+ captureProcessSnapshot: vi.fn(),
23
+ };
24
+ });
20
25
 
21
26
  vi.mock('../../utils/matching.js', () => ({
22
27
  generateAgentName: vi.fn(),
@@ -25,6 +30,7 @@ vi.mock('../../utils/matching.js', () => ({
25
30
 
26
31
  const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
27
32
  const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
33
+ const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction<typeof captureProcessSnapshot>;
28
34
  const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
29
35
 
30
36
  function makeDb(queries: {
@@ -104,9 +110,14 @@ describe('OpenCodeAdapter', () => {
104
110
 
105
111
  mockedListAgentProcesses.mockReset();
106
112
  mockedEnrichProcesses.mockReset();
113
+ mockedCaptureProcessSnapshot.mockReset();
107
114
  mockedGenerateAgentName.mockReset();
108
115
 
109
116
  mockedEnrichProcesses.mockImplementation((procs) => procs);
117
+ // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name.
118
+ mockedCaptureProcessSnapshot.mockImplementation(async (names) => (
119
+ enrichProcesses(names.flatMap((name) => listAgentProcesses(name)))
120
+ ));
110
121
  mockedGenerateAgentName.mockImplementation((cwd, pid) => {
111
122
  const folder = path.basename(cwd) || 'unknown';
112
123
  return `${folder}-${pid}`;
@@ -11,13 +11,18 @@ import { PiAdapter } from '../../adapters/PiAdapter.js';
11
11
  import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
12
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
13
  import { AgentRegistry } from '../../utils/AgentRegistry.js';
14
- import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
14
+ import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js';
15
15
  import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
16
16
 
17
- vi.mock('../../utils/process.js', () => ({
18
- listAgentProcesses: vi.fn(),
19
- enrichProcesses: vi.fn(),
20
- }));
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
+ captureProcessSnapshot: vi.fn(),
24
+ };
25
+ });
21
26
 
22
27
  vi.mock('../../utils/matching.js', () => ({
23
28
  matchProcessesToSessions: vi.fn(),
@@ -26,6 +31,7 @@ vi.mock('../../utils/matching.js', () => ({
26
31
 
27
32
  const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
28
33
  const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
34
+ const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction<typeof captureProcessSnapshot>;
29
35
  const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction<typeof matchProcessesToSessions>;
30
36
  const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
31
37
 
@@ -43,10 +49,15 @@ describe('PiAdapter', () => {
43
49
  adapter = new PiAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json')));
44
50
  mockedListAgentProcesses.mockReset();
45
51
  mockedEnrichProcesses.mockReset();
52
+ mockedCaptureProcessSnapshot.mockReset();
46
53
  mockedMatchProcessesToSessions.mockReset();
47
54
  mockedGenerateAgentName.mockReset();
48
55
 
49
56
  mockedEnrichProcesses.mockImplementation((procs) => procs);
57
+ // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name.
58
+ mockedCaptureProcessSnapshot.mockImplementation(async (names) => (
59
+ enrichProcesses(names.flatMap((name) => listAgentProcesses(name)))
60
+ ));
50
61
  mockedMatchProcessesToSessions.mockReturnValue([]);
51
62
  mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => {
52
63
  const folder = path.basename(cwd) || 'unknown';
@@ -67,6 +78,18 @@ describe('PiAdapter', () => {
67
78
  expect(adapter.canHandle({ pid: 2, command: '/usr/local/bin/PI --model x', cwd: '/repo', tty: 'ttys002' })).toBe(true);
68
79
  expect(adapter.canHandle({ pid: 3, command: 'node /opt/pi/bin/pi.js', cwd: '/repo', tty: 'ttys003' })).toBe(true);
69
80
  expect(adapter.canHandle({ pid: 4, command: 'node /repo/feature-pi-adapter/script.js', cwd: '/repo', tty: 'ttys004' })).toBe(false);
81
+ expect(adapter.canHandle({ pid: 5, command: 'C:\\tools\\node.exe C:\\lib\\pi.js', cwd: '/repo', tty: 'ttys005' })).toBe(true);
82
+ });
83
+
84
+ it('rejects foreign executables from a hand-built snapshot context', async () => {
85
+ const foreign: ProcessInfo = {
86
+ pid: 6,
87
+ command: 'claude --resume /Users/x/repos/pi/session.jsonl',
88
+ cwd: '/repo',
89
+ tty: 'ttys006',
90
+ };
91
+
92
+ expect(await adapter.detectAgents({ processes: [foreign] })).toEqual([]);
70
93
  });
71
94
 
72
95
  it('maps a running Pi process to the tracker session for its PID', async () => {
@@ -33,29 +33,28 @@ describe('AgentRegistry', () => {
33
33
  });
34
34
 
35
35
  describe('register', () => {
36
- it('creates the file and parent directory if missing', () => {
36
+ it('creates the SQLite database and parent directory if missing', () => {
37
37
  registry.register(makeEntry());
38
- expect(fs.existsSync(regPath)).toBe(true);
39
- const parsed = JSON.parse(fs.readFileSync(regPath, 'utf8'));
40
- expect(parsed.entries).toHaveLength(1);
41
- expect(parsed.entries[0].name).toBe('agent1');
38
+ expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true);
39
+ expect(registry.list()[0].name).toBe('agent1');
42
40
  });
43
41
 
44
42
  it('appends a new entry when name is unique', () => {
45
43
  registry.register(makeEntry({ name: 'a' }));
46
- registry.register(makeEntry({ name: 'b' }));
44
+ registry.register(makeEntry({ name: 'b', pid: process.ppid }));
47
45
  expect(registry.list()).toHaveLength(2);
48
46
  });
49
47
 
50
- it('upserts in place when name already exists', () => {
51
- registry.register(makeEntry({ name: 'a', pid: 100 }));
52
- registry.register(makeEntry({ name: 'a', pid: 200 }));
48
+ it('upserts in place when type and pid already exist', () => {
49
+ registry.register(makeEntry({ name: 'a', pid: process.pid }));
50
+ registry.register(makeEntry({ name: 'fallback', pid: process.pid, tmuxSession: '' }));
53
51
  const all = registry.list();
54
52
  expect(all).toHaveLength(1);
55
- expect(all[0].pid).toBe(200);
53
+ expect(all[0].pid).toBe(process.pid);
54
+ expect(all[0].name).toBe('a');
56
55
  });
57
56
 
58
- it('writes atomically (no leftover .tmp on success)', () => {
57
+ it('does not write through the legacy fixed .tmp path', () => {
59
58
  registry.register(makeEntry());
60
59
  expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
61
60
  });
@@ -69,16 +68,18 @@ describe('AgentRegistry', () => {
69
68
 
70
69
  it('preserves existing tmuxSession when incoming is empty string', () => {
71
70
  registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));
72
- registry.register(makeEntry({ name: 'a', tmuxSession: '', pid: 999 }));
71
+ registry.register(makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid }));
73
72
  const saved = registry.lookup('a');
74
73
  expect(saved?.tmuxSession).toBe('pinned');
75
- expect(saved?.pid).toBe(999);
74
+ expect(saved?.pid).toBe(process.pid);
76
75
  });
77
76
 
78
- it('replaces tmuxSession when incoming is non-empty', () => {
79
- registry.register(makeEntry({ name: 'a', tmuxSession: 'old' }));
80
- registry.register(makeEntry({ name: 'a', tmuxSession: 'new' }));
81
- expect(registry.lookup('a')?.tmuxSession).toBe('new');
77
+ it('lets a managed start entry replace a generated fallback for the same pid', () => {
78
+ registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
79
+ registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' }));
80
+ expect(registry.lookup('custom-name')?.tmuxSession).toBe('custom-name');
81
+ expect(registry.lookup(`ai-devkit-${process.pid}`)).toBeNull();
82
+ expect(registry.list()).toHaveLength(1);
82
83
  });
83
84
  });
84
85
 
@@ -88,28 +89,35 @@ describe('AgentRegistry', () => {
88
89
  expect(fs.existsSync(regPath)).toBe(false);
89
90
  });
90
91
 
91
- it('upserts multiple entries with a single write', () => {
92
- const writeSpy = vi.spyOn(fs, 'writeFileSync');
92
+ it('upserts multiple entries in a single batch', () => {
93
93
  registry.registerBatch([
94
94
  makeEntry({ name: 'a' }),
95
- makeEntry({ name: 'b' }),
96
- makeEntry({ name: 'c' }),
95
+ makeEntry({ name: 'b', pid: process.pid + 1 }),
96
+ makeEntry({ name: 'c', pid: process.pid + 2 }),
97
97
  ]);
98
- expect(writeSpy).toHaveBeenCalledTimes(1);
99
- writeSpy.mockRestore();
100
98
  expect(registry.list()).toHaveLength(3);
101
99
  });
102
100
 
103
101
  it('applies the tmuxSession merge per entry', () => {
104
102
  registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));
105
103
  registry.registerBatch([
106
- makeEntry({ name: 'a', tmuxSession: '', pid: 7 }),
107
- makeEntry({ name: 'b', tmuxSession: '' }),
104
+ makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid }),
105
+ makeEntry({ name: 'b', tmuxSession: '', pid: process.pid + 1 }),
108
106
  ]);
109
107
  expect(registry.lookup('a')?.tmuxSession).toBe('pinned');
110
- expect(registry.lookup('a')?.pid).toBe(7);
108
+ expect(registry.lookup('a')?.pid).toBe(process.pid);
111
109
  expect(registry.lookup('b')?.tmuxSession).toBe('');
112
110
  });
111
+
112
+ it('handles concurrent registry instances without duplicate pid rows', () => {
113
+ const other = new AgentRegistry(regPath);
114
+ registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
115
+ other.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' }));
116
+ registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
117
+
118
+ expect(registry.list()).toHaveLength(1);
119
+ expect(registry.lookup('custom-name')?.pid).toBe(process.pid);
120
+ });
113
121
  });
114
122
 
115
123
  describe('lookup', () => {
@@ -124,20 +132,20 @@ describe('AgentRegistry', () => {
124
132
  });
125
133
 
126
134
  describe('list', () => {
127
- it('returns empty array when file does not exist', () => {
135
+ it('returns empty array when database does not contain entries', () => {
128
136
  expect(registry.list()).toEqual([]);
129
137
  });
130
138
 
131
- it('returns empty array when file is malformed', () => {
139
+ it('ignores existing legacy agents.json entries', () => {
140
+ const legacyEntry = makeEntry({ name: 'legacy', tmuxSession: 'legacy' });
132
141
  fs.mkdirSync(path.dirname(regPath), { recursive: true });
133
- fs.writeFileSync(regPath, 'not json', 'utf8');
134
- expect(registry.list()).toEqual([]);
135
- });
142
+ fs.writeFileSync(regPath, JSON.stringify({ entries: [legacyEntry] }), 'utf8');
136
143
 
137
- it('coerces non-array entries to []', () => {
138
- fs.mkdirSync(path.dirname(regPath), { recursive: true });
139
- fs.writeFileSync(regPath, JSON.stringify({ entries: 'oops' }), 'utf8');
140
- expect(registry.list()).toEqual([]);
144
+ const legacyRegistry = new AgentRegistry(regPath);
145
+
146
+ expect(legacyRegistry.lookup('legacy')).toBeNull();
147
+ expect(legacyRegistry.list()).toEqual([]);
148
+ expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true);
141
149
  });
142
150
  });
143
151
 
@@ -163,10 +171,10 @@ describe('AgentRegistry', () => {
163
171
 
164
172
  it('is a no-op when all entries are alive', () => {
165
173
  registry.register(makeEntry({ pid: process.pid }));
166
- const before = fs.readFileSync(regPath, 'utf8');
174
+ const before = registry.list();
167
175
  registry.prune();
168
- const after = fs.readFileSync(regPath, 'utf8');
169
- expect(after).toBe(before);
176
+ const after = registry.list();
177
+ expect(after).toEqual(before);
170
178
  });
171
179
 
172
180
  it('does nothing when file is missing', () => {
@@ -203,7 +211,7 @@ describe('AgentRegistry', () => {
203
211
 
204
212
  it('throws RenameConflictError when new name is already in use by a live entry', () => {
205
213
  registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
206
- registry.register(makeEntry({ name: 'agent-b', pid: process.pid }));
214
+ registry.register(makeEntry({ name: 'agent-b', pid: process.ppid }));
207
215
  expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
208
216
  });
209
217
 
@@ -214,7 +222,7 @@ describe('AgentRegistry', () => {
214
222
  expect(registry.lookup('agent-b')?.pid).toBe(process.pid);
215
223
  });
216
224
 
217
- it('writes atomically (no leftover .tmp on success)', () => {
225
+ it('does not create the legacy fixed .tmp path on rename', () => {
218
226
  registry.register(makeEntry({ name: 'old-name', pid: process.pid }));
219
227
  registry.rename('old-name', 'new-name');
220
228
  expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { MockedFunction } from 'vitest';
6
6
 
7
- import { execFileSync } from 'child_process';
7
+ import { execFile, execFileSync } from 'child_process';
8
8
  import {
9
9
  listAgentProcesses,
10
10
  batchGetProcessCwds,
@@ -12,13 +12,84 @@ import {
12
12
  enrichProcesses,
13
13
  findWrapperProcess,
14
14
  findWrapperProcessPids,
15
+ captureProcessSnapshot,
16
+ filterByProcessNames,
15
17
  } from '../../utils/process.js';
16
18
 
17
19
  vi.mock('child_process', () => ({
20
+ execFile: vi.fn(),
18
21
  execFileSync: vi.fn(),
19
22
  }));
20
23
 
21
24
  const mockedExecFileSync = execFileSync as MockedFunction<typeof execFileSync>;
25
+ const mockedExecFile = execFile as unknown as MockedFunction<(
26
+ file: string,
27
+ args: readonly string[],
28
+ options: object,
29
+ callback: (error: Error | null, stdout: string, stderr: string) => void,
30
+ ) => void>;
31
+
32
+ describe('captureProcessSnapshot', () => {
33
+ beforeEach(() => {
34
+ mockedExecFile.mockReset();
35
+ mockedExecFileSync.mockReset();
36
+ });
37
+
38
+ it('captures and enriches relevant processes without synchronous scans', async () => {
39
+ mockedExecFile.mockImplementation((file, args, _options, callback) => {
40
+ queueMicrotask(() => {
41
+ if (file === 'ps' && args.includes('-axo')) {
42
+ callback(null,
43
+ '100 1 s001 /usr/bin/claude --resume abc\n' +
44
+ '200 1 s002 C:\\\\tools\\\\node.exe C:\\\\bin\\\\pi.js\n' +
45
+ '300 1 s003 /usr/bin/unrelated\n',
46
+ '');
47
+ return;
48
+ }
49
+ if (file === 'lsof') {
50
+ callback(null, 'p100\nn/projects/claude\np200\nn/projects/pi\n', '');
51
+ return;
52
+ }
53
+ if (file === 'ps' && args.some((arg) => arg.includes('lstart='))) {
54
+ callback(null,
55
+ '100 Wed Mar 18 23:18:01 2026\n' +
56
+ '200 Thu Mar 19 10:00:00 2026\n',
57
+ '');
58
+ return;
59
+ }
60
+ callback(new Error(`unexpected command: ${file} ${args.join(' ')}`), '', '');
61
+ });
62
+ });
63
+
64
+ const snapshot = await captureProcessSnapshot(['claude', 'node']);
65
+
66
+ expect(snapshot.map((process) => process.pid)).toEqual([100, 200]);
67
+ expect(snapshot.map((process) => process.cwd)).toEqual(['/projects/claude', '/projects/pi']);
68
+ expect(snapshot.map((process) => process.startTime)).toEqual([
69
+ expect.any(Date),
70
+ expect.any(Date),
71
+ ]);
72
+ expect(mockedExecFileSync).not.toHaveBeenCalled();
73
+ expect(mockedExecFile.mock.calls.filter(([, args]) => args.includes('-axo'))).toHaveLength(1);
74
+ for (const [, , options] of mockedExecFile.mock.calls) {
75
+ expect(options).toMatchObject({ encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
76
+ expect(options).not.toHaveProperty('stdio');
77
+ }
78
+ });
79
+ });
80
+
81
+ describe('filterByProcessNames', () => {
82
+ it('matches only argv[0] and normalizes Windows paths and executable suffixes', () => {
83
+ const processes: ProcessInfo[] = [
84
+ { pid: 1, command: 'C:\\tools\\node.exe C:\\bin\\pi.js', cwd: '', tty: '' },
85
+ { pid: 2, command: '/usr/local/bin/node /opt/gemini.js', cwd: '', tty: '' },
86
+ { pid: 3, command: 'codex exec --cd C:\\repos\\node', cwd: '', tty: '' },
87
+ ];
88
+
89
+ expect(filterByProcessNames(processes, ['node'])).toEqual([processes[0], processes[1]]);
90
+ expect(filterByProcessNames(processes, ['node.exe'])).toEqual([processes[0], processes[1]]);
91
+ });
92
+ });
22
93
 
23
94
  describe('listAgentProcesses', () => {
24
95
  beforeEach(() => {
@@ -59,7 +59,7 @@ export interface ProcessInfo {
59
59
  /** Process ID */
60
60
  pid: number;
61
61
 
62
- /** Parent process ID, populated by listAgentProcesses when available */
62
+ /** Parent process ID, populated by process discovery when available */
63
63
  ppid?: number;
64
64
 
65
65
  /** Process command */
@@ -71,7 +71,7 @@ export interface ProcessInfo {
71
71
  /** Terminal TTY (e.g., "ttys030") */
72
72
  tty: string;
73
73
 
74
- /** Process start time, populated by enrichProcesses */
74
+ /** Process start time, populated by process enrichment */
75
75
  startTime?: Date;
76
76
  }
77
77
 
@@ -148,6 +148,11 @@ export interface ListSessionsOptions {
148
148
  type?: AgentType;
149
149
  }
150
150
 
151
+ export interface AgentDetectionContext {
152
+ /** One enriched process snapshot shared across this manager refresh. */
153
+ readonly processes: readonly ProcessInfo[];
154
+ }
155
+
151
156
  /**
152
157
  * Agent Adapter Interface
153
158
  *
@@ -157,11 +162,14 @@ export interface AgentAdapter {
157
162
  /** Type of agent this adapter handles */
158
163
  readonly type: AgentType;
159
164
 
165
+ /** Executable basenames required for shared process discovery. */
166
+ readonly processNames?: readonly string[];
167
+
160
168
  /**
161
169
  * Detect running agents of this type
162
170
  * @returns List of detected agents
163
171
  */
164
- detectAgents(): Promise<AgentInfo[]>;
172
+ detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]>;
165
173
 
166
174
  /**
167
175
  * Check if this adapter can handle the given process
@@ -7,9 +7,10 @@ import type {
7
7
  ConversationMessage,
8
8
  SessionSummary,
9
9
  ListSessionsOptions,
10
+ AgentDetectionContext,
10
11
  } from './AgentAdapter.js';
11
12
  import { AgentStatus } from './AgentAdapter.js';
12
- import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
13
+ import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js';
13
14
  import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session.js';
14
15
  import type { SessionFile } from '../utils/session.js';
15
16
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
@@ -60,14 +61,15 @@ const PID_FILE_STALENESS_MS = 60000;
60
61
  * Claude Code Adapter
61
62
  *
62
63
  * Detects Claude Code agents by:
63
- * 1. Finding running claude processes via shared listAgentProcesses()
64
- * 2. Enriching with CWD and start times via shared enrichProcesses()
64
+ * 1. Filtering Claude processes from a shared asynchronous process snapshot
65
+ * 2. Using snapshot CWD and start-time enrichment
65
66
  * 3. Attempting authoritative PID-file matching via ~/.claude/sessions/<pid>.json
66
67
  * 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file
67
68
  * 5. Extracting summary from last user message in session JSONL
68
69
  */
69
70
  export class ClaudeCodeAdapter implements AgentAdapter {
70
71
  readonly type = 'claude' as const;
72
+ readonly processNames = ['claude'] as const;
71
73
 
72
74
  private projectsDir: string;
73
75
  private sessionsDir: string;
@@ -85,13 +87,14 @@ export class ClaudeCodeAdapter implements AgentAdapter {
85
87
  }
86
88
 
87
89
  private isClaudeExecutable(command: string): boolean {
88
- const executable = command.trim().split(/\s+/)[0] || '';
89
- const base = path.basename(executable).toLowerCase();
90
+ const base = executableBasename(command);
90
91
  return base === 'claude' || base === 'claude.exe';
91
92
  }
92
93
 
93
- async detectAgents(): Promise<AgentInfo[]> {
94
- const processes = enrichProcesses(listAgentProcesses('claude'));
94
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
95
+ const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames);
96
+ const relevant = filterByProcessNames(snapshot, this.processNames);
97
+ const processes = relevant.filter((process) => this.canHandle(process));
95
98
  if (processes.length === 0) {
96
99
  return [];
97
100
  }