@ai-devkit/agent-manager 0.26.2 → 0.26.4

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 (104) hide show
  1. package/dist/AgentManager.d.ts +5 -2
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +28 -10
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/__tests__/AgentManager.test.js +289 -4
  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 +140 -0
  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 +1 -1
  60. package/dist/database/connection.d.ts.map +1 -1
  61. package/dist/database/connection.js +1 -1
  62. package/dist/database/connection.js.map +1 -1
  63. package/dist/index.d.ts +3 -2
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/utils/AgentRegistry.d.ts +14 -1
  68. package/dist/utils/AgentRegistry.d.ts.map +1 -1
  69. package/dist/utils/AgentRegistry.js +65 -20
  70. package/dist/utils/AgentRegistry.js.map +1 -1
  71. package/dist/utils/index.d.ts +1 -1
  72. package/dist/utils/index.d.ts.map +1 -1
  73. package/dist/utils/index.js +1 -1
  74. package/dist/utils/index.js.map +1 -1
  75. package/dist/utils/process.d.ts +9 -1
  76. package/dist/utils/process.d.ts.map +1 -1
  77. package/dist/utils/process.js +155 -10
  78. package/dist/utils/process.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/AgentManager.ts +35 -8
  81. package/src/__tests__/AgentManager.test.ts +231 -4
  82. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +16 -5
  83. package/src/__tests__/adapters/CodexAdapter.test.ts +16 -5
  84. package/src/__tests__/adapters/CopilotAdapter.test.ts +8 -1
  85. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +20 -4
  86. package/src/__tests__/adapters/GrokCliAdapter.test.ts +8 -1
  87. package/src/__tests__/adapters/OpenCodeAdapter.test.ts +16 -5
  88. package/src/__tests__/adapters/PiAdapter.test.ts +28 -5
  89. package/src/__tests__/utils/AgentRegistry.test.ts +119 -0
  90. package/src/__tests__/utils/process.test.ts +72 -1
  91. package/src/adapters/AgentAdapter.ts +11 -3
  92. package/src/adapters/ClaudeCodeAdapter.ts +10 -7
  93. package/src/adapters/CodexAdapter.ts +10 -7
  94. package/src/adapters/CopilotAdapter.ts +16 -7
  95. package/src/adapters/GeminiCliAdapter.ts +17 -8
  96. package/src/adapters/GrokCliAdapter.ts +9 -6
  97. package/src/adapters/OpenCodeAdapter.ts +10 -7
  98. package/src/adapters/PiAdapter.ts +10 -9
  99. package/src/adapters/index.ts +1 -1
  100. package/src/database/connection.ts +4 -2
  101. package/src/index.ts +3 -1
  102. package/src/utils/AgentRegistry.ts +85 -17
  103. package/src/utils/index.ts +1 -1
  104. package/src/utils/process.ts +160 -10
@@ -2,9 +2,28 @@
2
2
  * Process Detection Utilities
3
3
  *
4
4
  * Shared shell command wrappers for detecting and inspecting running processes.
5
- * All execFileSync calls for process data live here — adapters must not call execFileSync directly.
5
+ * Built-in refresh discovery uses captureProcessSnapshot(); synchronous helpers
6
+ * remain exported for compatibility with existing consumers.
6
7
  */ import * as path from 'path';
7
- import { execFileSync } from 'child_process';
8
+ import { execFile, execFileSync } from 'child_process';
9
+ const PROCESS_EXEC_MAX_BUFFER = 10 * 1024 * 1024;
10
+ const VALID_EXECUTABLE_NAME = /^[a-zA-Z0-9_-]+$/;
11
+ export function executableBasename(command) {
12
+ const executable = command.trim().split(/\s+/)[0] || '';
13
+ return path.basename(executable.replace(/\\/g, '/')).toLowerCase();
14
+ }
15
+ function normalizeExecutableName(name) {
16
+ const lower = name.toLowerCase();
17
+ return lower.endsWith('.exe') ? lower.slice(0, -4) : lower;
18
+ }
19
+ function normalizedProcessNames(namePatterns) {
20
+ return new Set(namePatterns.filter(Boolean).map(normalizeExecutableName));
21
+ }
22
+ export function filterByProcessNames(processes, namePatterns) {
23
+ const names = normalizedProcessNames(namePatterns);
24
+ if (names.size === 0) return [];
25
+ return processes.filter((process)=>names.has(normalizeExecutableName(executableBasename(process.command))));
26
+ }
8
27
  /**
9
28
  * List running processes matching an agent executable name.
10
29
  *
@@ -15,7 +34,7 @@ import { execFileSync } from 'child_process';
15
34
  * cwd and startTime are NOT populated — call enrichProcesses() to fill them.
16
35
  */ export function listAgentProcesses(namePattern) {
17
36
  // Validate pattern contains only safe characters (alphanumeric, dash, underscore)
18
- if (!namePattern || !/^[a-zA-Z0-9_-]+$/.test(namePattern)) {
37
+ if (!namePattern || !VALID_EXECUTABLE_NAME.test(namePattern)) {
19
38
  return [];
20
39
  }
21
40
  try {
@@ -25,7 +44,9 @@ import { execFileSync } from 'child_process';
25
44
  ], {
26
45
  encoding: 'utf-8'
27
46
  });
28
- const lowerPattern = namePattern.toLowerCase();
47
+ const names = normalizedProcessNames([
48
+ namePattern
49
+ ]);
29
50
  const processes = [];
30
51
  for (const line of output.trim().split('\n')){
31
52
  if (!line.trim()) continue;
@@ -36,12 +57,7 @@ import { execFileSync } from 'child_process';
36
57
  if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
37
58
  const tty = match[3];
38
59
  const command = match[4];
39
- // Check that the executable basename matches exactly
40
- const executable = command.trim().split(/\s+/)[0] || '';
41
- const base = path.basename(executable).toLowerCase();
42
- if (base !== lowerPattern && base !== `${lowerPattern}.exe`) {
43
- continue;
44
- }
60
+ if (!names.has(normalizeExecutableName(executableBasename(command)))) continue;
45
61
  const ttyShort = tty.startsWith('/dev/') ? tty.slice(5) : tty;
46
62
  processes.push({
47
63
  pid,
@@ -56,6 +72,135 @@ import { execFileSync } from 'child_process';
56
72
  return [];
57
73
  }
58
74
  }
75
+ function execFileText(file, args) {
76
+ return new Promise((resolve, reject)=>{
77
+ execFile(file, args, {
78
+ encoding: 'utf-8',
79
+ maxBuffer: PROCESS_EXEC_MAX_BUFFER
80
+ }, (error, stdout)=>{
81
+ if (error) {
82
+ reject(error);
83
+ return;
84
+ }
85
+ resolve(stdout);
86
+ });
87
+ });
88
+ }
89
+ function parseProcessList(output, namePatterns) {
90
+ const processes = [];
91
+ for (const line of output.trim().split('\n')){
92
+ if (!line.trim()) continue;
93
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
94
+ if (!match) continue;
95
+ const pid = parseInt(match[1], 10);
96
+ const ppid = parseInt(match[2], 10);
97
+ if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
98
+ const tty = match[3];
99
+ const command = match[4];
100
+ const base = executableBasename(command);
101
+ const normalizedBase = normalizeExecutableName(base);
102
+ if (!namePatterns.has(normalizedBase)) continue;
103
+ processes.push({
104
+ pid,
105
+ ppid,
106
+ command,
107
+ cwd: '',
108
+ tty: tty.startsWith('/dev/') ? tty.slice(5) : tty
109
+ });
110
+ }
111
+ return processes;
112
+ }
113
+ async function batchGetProcessCwdsAsync(pids) {
114
+ const result = new Map();
115
+ if (pids.length === 0) return result;
116
+ try {
117
+ const output = await execFileText('lsof', [
118
+ '-a',
119
+ '-d',
120
+ 'cwd',
121
+ '-Fn',
122
+ '-p',
123
+ pids.join(',')
124
+ ]);
125
+ let currentPid = null;
126
+ for (const line of output.trim().split('\n')){
127
+ if (line.startsWith('p')) {
128
+ currentPid = parseInt(line.slice(1), 10);
129
+ } else if (line.startsWith('n') && currentPid !== null) {
130
+ result.set(currentPid, line.slice(1));
131
+ currentPid = null;
132
+ }
133
+ }
134
+ return result;
135
+ } catch {
136
+ const entries = await Promise.all(pids.map(async (pid)=>{
137
+ try {
138
+ const output = await execFileText('pwdx', [
139
+ String(pid)
140
+ ]);
141
+ const match = output.match(/^\d+:\s*(.+)$/);
142
+ return match ? [
143
+ pid,
144
+ match[1].trim()
145
+ ] : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }));
150
+ for (const entry of entries){
151
+ if (entry) result.set(entry[0], entry[1]);
152
+ }
153
+ return result;
154
+ }
155
+ }
156
+ async function batchGetProcessStartTimesAsync(pids) {
157
+ const result = new Map();
158
+ if (pids.length === 0) return result;
159
+ try {
160
+ const output = await execFileText('ps', [
161
+ '-o',
162
+ 'pid=,lstart=',
163
+ '-p',
164
+ pids.join(',')
165
+ ]);
166
+ for (const rawLine of output.split('\n')){
167
+ const match = rawLine.trim().match(/^(\d+)\s+(.+)$/);
168
+ if (!match) continue;
169
+ const pid = parseInt(match[1], 10);
170
+ const date = new Date(match[2].trim());
171
+ if (Number.isFinite(pid) && !Number.isNaN(date.getTime())) result.set(pid, date);
172
+ }
173
+ } catch {
174
+ // Return partial/empty data, matching the synchronous helper.
175
+ }
176
+ return result;
177
+ }
178
+ /**
179
+ * Capture and enrich relevant processes without blocking the event loop.
180
+ * One base process listing is shared by every requested executable name.
181
+ */ export async function captureProcessSnapshot(namePatterns) {
182
+ const names = normalizedProcessNames(namePatterns.filter((name)=>Boolean(name) && VALID_EXECUTABLE_NAME.test(name)));
183
+ if (names.size === 0) return [];
184
+ try {
185
+ const output = await execFileText('ps', [
186
+ '-axo',
187
+ 'pid=,ppid=,tty=,command='
188
+ ]);
189
+ const processes = parseProcessList(output, names);
190
+ const pids = processes.map((process)=>process.pid);
191
+ const [cwdMap, startTimeMap] = await Promise.all([
192
+ batchGetProcessCwdsAsync(pids),
193
+ batchGetProcessStartTimesAsync(pids)
194
+ ]);
195
+ return processes.map((process)=>({
196
+ ...process,
197
+ cwd: cwdMap.get(process.pid) || '',
198
+ startTime: startTimeMap.get(process.pid)
199
+ }));
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
59
204
  /**
60
205
  * Batch-get current working directories for multiple PIDs.
61
206
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/process.ts"],"sourcesContent":["/**\n * Process Detection Utilities\n *\n * Shared shell command wrappers for detecting and inspecting running processes.\n * All execFileSync calls for process data live here — adapters must not call execFileSync directly.\n */\n\nimport * as path from 'path';\nimport { execFileSync } from 'child_process';\nimport type { ProcessInfo } from '../adapters/AgentAdapter.js';\n\n/**\n * List running processes matching an agent executable name.\n *\n * Uses `ps -axo` then filters in JS for exact executable basename match.\n * This avoids shell pipelines and string interpolation.\n *\n * Returned ProcessInfo has pid, ppid, command, tty populated.\n * cwd and startTime are NOT populated — call enrichProcesses() to fill them.\n */\nexport function listAgentProcesses(namePattern: string): ProcessInfo[] {\n // Validate pattern contains only safe characters (alphanumeric, dash, underscore)\n if (!namePattern || !/^[a-zA-Z0-9_-]+$/.test(namePattern)) {\n return [];\n }\n\n try {\n const output = execFileSync('ps', ['-axo', 'pid=,ppid=,tty=,command='], { encoding: 'utf-8' });\n\n const lowerPattern = namePattern.toLowerCase();\n const processes: ProcessInfo[] = [];\n\n for (const line of output.trim().split('\\n')) {\n if (!line.trim()) continue;\n\n const match = line.match(/^\\s*(\\d+)\\s+(\\d+)\\s+(\\S+)\\s+(.+)$/);\n if (!match) continue;\n\n const pid = parseInt(match[1], 10);\n const ppid = parseInt(match[2], 10);\n if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;\n\n const tty = match[3];\n const command = match[4];\n\n // Check that the executable basename matches exactly\n const executable = command.trim().split(/\\s+/)[0] || '';\n const base = path.basename(executable).toLowerCase();\n if (base !== lowerPattern && base !== `${lowerPattern}.exe`) {\n continue;\n }\n\n const ttyShort = tty.startsWith('/dev/') ? tty.slice(5) : tty;\n\n processes.push({\n pid,\n ppid,\n command,\n cwd: '',\n tty: ttyShort,\n });\n }\n\n return processes;\n } catch {\n return [];\n }\n}\n\n/**\n * Batch-get current working directories for multiple PIDs.\n *\n * Single `lsof -a -d cwd -Fn -p PID1,PID2,...` call.\n * Returns partial results — if lsof fails for one PID, others still return.\n */\nexport function batchGetProcessCwds(pids: number[]): Map<number, string> {\n const result = new Map<number, string>();\n if (pids.length === 0) return result;\n\n try {\n const output = execFileSync(\n 'lsof', ['-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')],\n { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },\n );\n\n // lsof output format: p{PID}\\nn{path}\\np{PID}\\nn{path}...\n let currentPid: number | null = null;\n for (const line of output.trim().split('\\n')) {\n if (line.startsWith('p')) {\n currentPid = parseInt(line.slice(1), 10);\n } else if (line.startsWith('n') && currentPid !== null) {\n result.set(currentPid, line.slice(1));\n currentPid = null;\n }\n }\n } catch {\n // Try per-PID fallback with pwdx (Linux)\n for (const pid of pids) {\n try {\n const output = execFileSync(\n 'pwdx', [String(pid)],\n { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },\n );\n const match = output.match(/^\\d+:\\s*(.+)$/);\n if (match) {\n result.set(pid, match[1].trim());\n }\n } catch {\n // Skip this PID\n }\n }\n }\n\n return result;\n}\n\n/**\n * Batch-get process start times for multiple PIDs.\n *\n * Single `ps -o pid=,lstart= -p PID1,PID2,...` call.\n * Uses lstart format which gives full timestamp (e.g., \"Thu Feb 5 16:00:57 2026\").\n * Returns partial results.\n */\nexport function batchGetProcessStartTimes(pids: number[]): Map<number, Date> {\n const result = new Map<number, Date>();\n if (pids.length === 0) return result;\n\n try {\n const output = execFileSync(\n 'ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')],\n { encoding: 'utf-8' },\n );\n\n for (const rawLine of output.split('\\n')) {\n const line = rawLine.trim();\n if (!line) continue;\n\n // Format: \" PID DAY MON DD HH:MM:SS YYYY\"\n // e.g., \" 78070 Wed Mar 18 23:18:01 2026\"\n const match = line.match(/^\\s*(\\d+)\\s+(.+)$/);\n if (!match) continue;\n\n const pid = parseInt(match[1], 10);\n const dateStr = match[2].trim();\n\n if (!Number.isFinite(pid)) continue;\n\n const date = new Date(dateStr);\n if (!Number.isNaN(date.getTime())) {\n result.set(pid, date);\n }\n }\n } catch {\n // Return whatever we have\n }\n\n return result;\n}\n\n/**\n * Enrich ProcessInfo array with cwd and startTime.\n *\n * Calls batchGetProcessCwds and batchGetProcessStartTimes in batched shell calls,\n * then populates each ProcessInfo in-place. Returns partial results —\n * if a PID fails, that process keeps empty cwd / undefined startTime.\n */\nexport function enrichProcesses(processes: ProcessInfo[]): ProcessInfo[] {\n if (processes.length === 0) return processes;\n\n const pids = processes.map(p => p.pid);\n const cwdMap = batchGetProcessCwds(pids);\n const startTimeMap = batchGetProcessStartTimes(pids);\n\n for (const proc of processes) {\n proc.cwd = cwdMap.get(proc.pid) || '';\n proc.startTime = startTimeMap.get(proc.pid);\n }\n\n return processes;\n}\n\nfunction isSameTerminalProcess(proc: ProcessInfo, matched: ProcessInfo): boolean {\n const sameTty = proc.tty !== '' && proc.tty !== '?' && proc.tty === matched.tty;\n const sameCwd = proc.cwd !== '' && proc.cwd === matched.cwd;\n\n return sameTty && (sameCwd || proc.cwd === '' || matched.cwd === '');\n}\n\nfunction matchesProcessIdentity(proc: ProcessInfo, matched: ProcessInfo): boolean {\n return proc.pid === matched.pid || isSameTerminalProcess(proc, matched);\n}\n\nexport function findWrapperProcess(\n processes: ProcessInfo[],\n child: ProcessInfo,\n): ProcessInfo | undefined {\n return processes.find((proc) => (\n proc.pid !== child.pid &&\n child.ppid === proc.pid &&\n matchesProcessIdentity(proc, child)\n ));\n}\n\n/**\n * Find parent wrapper processes that should not be reported as separate agents.\n *\n * A process is considered a wrapper when it is the parent of another candidate\n * agent process in the same terminal/worktree, or when it points at the same\n * terminal/worktree as an already session-matched process.\n */\nexport function findWrapperProcessPids(\n processes: ProcessInfo[],\n matchedProcesses: ProcessInfo[] = [],\n): Set<number> {\n const wrappers = new Set<number>();\n\n for (const child of processes) {\n const wrapper = findWrapperProcess(processes, child);\n if (wrapper) {\n wrappers.add(wrapper.pid);\n }\n }\n\n for (const proc of processes) {\n if (matchedProcesses.some((matched) => (\n proc.pid !== matched.pid && isSameTerminalProcess(proc, matched)\n ))) {\n wrappers.add(proc.pid);\n }\n }\n\n return wrappers;\n}\n\n/**\n * Get the TTY device for a specific process\n */\nexport function getProcessTty(pid: number): string {\n try {\n const output = execFileSync(\n 'ps', ['-p', String(pid), '-o', 'tty='],\n { encoding: 'utf-8' },\n );\n\n const tty = output.trim();\n return tty.startsWith('/dev/') ? tty.slice(5) : tty;\n } catch {\n return '?';\n }\n}\n"],"names":["path","execFileSync","listAgentProcesses","namePattern","test","output","encoding","lowerPattern","toLowerCase","processes","line","trim","split","match","pid","parseInt","ppid","Number","isNaN","tty","command","executable","base","basename","ttyShort","startsWith","slice","push","cwd","batchGetProcessCwds","pids","result","Map","length","join","stdio","currentPid","set","String","batchGetProcessStartTimes","rawLine","dateStr","isFinite","date","Date","getTime","enrichProcesses","map","p","cwdMap","startTimeMap","proc","get","startTime","isSameTerminalProcess","matched","sameTty","sameCwd","matchesProcessIdentity","findWrapperProcess","child","find","findWrapperProcessPids","matchedProcesses","wrappers","Set","wrapper","add","some","getProcessTty"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/utils/process.ts"],"sourcesContent":["/**\n * Process Detection Utilities\n *\n * Shared shell command wrappers for detecting and inspecting running processes.\n * Built-in refresh discovery uses captureProcessSnapshot(); synchronous helpers\n * remain exported for compatibility with existing consumers.\n */\n\nimport * as path from 'path';\nimport { execFile, execFileSync } from 'child_process';\nimport type { ProcessInfo } from '../adapters/AgentAdapter.js';\n\nconst PROCESS_EXEC_MAX_BUFFER = 10 * 1024 * 1024;\nconst VALID_EXECUTABLE_NAME = /^[a-zA-Z0-9_-]+$/;\n\nexport function executableBasename(command: string): string {\n const executable = command.trim().split(/\\s+/)[0] || '';\n return path.basename(executable.replace(/\\\\/g, '/')).toLowerCase();\n}\n\nfunction normalizeExecutableName(name: string): string {\n const lower = name.toLowerCase();\n return lower.endsWith('.exe') ? lower.slice(0, -4) : lower;\n}\n\nfunction normalizedProcessNames(namePatterns: readonly string[]): Set<string> {\n return new Set(namePatterns.filter(Boolean).map(normalizeExecutableName));\n}\n\nexport function filterByProcessNames(\n processes: readonly ProcessInfo[],\n namePatterns: readonly string[],\n): ProcessInfo[] {\n const names = normalizedProcessNames(namePatterns);\n if (names.size === 0) return [];\n return processes.filter((process) => (\n names.has(normalizeExecutableName(executableBasename(process.command)))\n ));\n}\n\n/**\n * List running processes matching an agent executable name.\n *\n * Uses `ps -axo` then filters in JS for exact executable basename match.\n * This avoids shell pipelines and string interpolation.\n *\n * Returned ProcessInfo has pid, ppid, command, tty populated.\n * cwd and startTime are NOT populated — call enrichProcesses() to fill them.\n */\nexport function listAgentProcesses(namePattern: string): ProcessInfo[] {\n // Validate pattern contains only safe characters (alphanumeric, dash, underscore)\n if (!namePattern || !VALID_EXECUTABLE_NAME.test(namePattern)) {\n return [];\n }\n\n try {\n const output = execFileSync('ps', ['-axo', 'pid=,ppid=,tty=,command='], { encoding: 'utf-8' });\n\n const names = normalizedProcessNames([namePattern]);\n const processes: ProcessInfo[] = [];\n\n for (const line of output.trim().split('\\n')) {\n if (!line.trim()) continue;\n\n const match = line.match(/^\\s*(\\d+)\\s+(\\d+)\\s+(\\S+)\\s+(.+)$/);\n if (!match) continue;\n\n const pid = parseInt(match[1], 10);\n const ppid = parseInt(match[2], 10);\n if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;\n\n const tty = match[3];\n const command = match[4];\n\n if (!names.has(normalizeExecutableName(executableBasename(command)))) continue;\n\n const ttyShort = tty.startsWith('/dev/') ? tty.slice(5) : tty;\n\n processes.push({\n pid,\n ppid,\n command,\n cwd: '',\n tty: ttyShort,\n });\n }\n\n return processes;\n } catch {\n return [];\n }\n}\n\nfunction execFileText(\n file: string,\n args: readonly string[],\n): Promise<string> {\n return new Promise((resolve, reject) => {\n execFile(file, args, { encoding: 'utf-8', maxBuffer: PROCESS_EXEC_MAX_BUFFER }, (error, stdout) => {\n if (error) {\n reject(error);\n return;\n }\n resolve(stdout);\n });\n });\n}\n\nfunction parseProcessList(output: string, namePatterns: ReadonlySet<string>): ProcessInfo[] {\n const processes: ProcessInfo[] = [];\n\n for (const line of output.trim().split('\\n')) {\n if (!line.trim()) continue;\n\n const match = line.match(/^\\s*(\\d+)\\s+(\\d+)\\s+(\\S+)\\s+(.+)$/);\n if (!match) continue;\n\n const pid = parseInt(match[1], 10);\n const ppid = parseInt(match[2], 10);\n if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;\n\n const tty = match[3];\n const command = match[4];\n const base = executableBasename(command);\n const normalizedBase = normalizeExecutableName(base);\n if (!namePatterns.has(normalizedBase)) continue;\n\n processes.push({\n pid,\n ppid,\n command,\n cwd: '',\n tty: tty.startsWith('/dev/') ? tty.slice(5) : tty,\n });\n }\n\n return processes;\n}\n\nasync function batchGetProcessCwdsAsync(pids: number[]): Promise<Map<number, string>> {\n const result = new Map<number, string>();\n if (pids.length === 0) return result;\n\n try {\n const output = await execFileText('lsof', ['-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')]);\n let currentPid: number | null = null;\n for (const line of output.trim().split('\\n')) {\n if (line.startsWith('p')) {\n currentPid = parseInt(line.slice(1), 10);\n } else if (line.startsWith('n') && currentPid !== null) {\n result.set(currentPid, line.slice(1));\n currentPid = null;\n }\n }\n return result;\n } catch {\n const entries = await Promise.all(pids.map(async (pid) => {\n try {\n const output = await execFileText('pwdx', [String(pid)]);\n const match = output.match(/^\\d+:\\s*(.+)$/);\n return match ? [pid, match[1].trim()] as const : null;\n } catch {\n return null;\n }\n }));\n for (const entry of entries) {\n if (entry) result.set(entry[0], entry[1]);\n }\n return result;\n }\n}\n\nasync function batchGetProcessStartTimesAsync(pids: number[]): Promise<Map<number, Date>> {\n const result = new Map<number, Date>();\n if (pids.length === 0) return result;\n\n try {\n const output = await execFileText('ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')]);\n for (const rawLine of output.split('\\n')) {\n const match = rawLine.trim().match(/^(\\d+)\\s+(.+)$/);\n if (!match) continue;\n const pid = parseInt(match[1], 10);\n const date = new Date(match[2].trim());\n if (Number.isFinite(pid) && !Number.isNaN(date.getTime())) result.set(pid, date);\n }\n } catch {\n // Return partial/empty data, matching the synchronous helper.\n }\n return result;\n}\n\n/**\n * Capture and enrich relevant processes without blocking the event loop.\n * One base process listing is shared by every requested executable name.\n */\nexport async function captureProcessSnapshot(namePatterns: readonly string[]): Promise<ProcessInfo[]> {\n const names = normalizedProcessNames(\n namePatterns.filter((name) => Boolean(name) && VALID_EXECUTABLE_NAME.test(name)),\n );\n if (names.size === 0) return [];\n\n try {\n const output = await execFileText('ps', ['-axo', 'pid=,ppid=,tty=,command=']);\n const processes = parseProcessList(output, names);\n const pids = processes.map((process) => process.pid);\n const [cwdMap, startTimeMap] = await Promise.all([\n batchGetProcessCwdsAsync(pids),\n batchGetProcessStartTimesAsync(pids),\n ]);\n return processes.map((process) => ({\n ...process,\n cwd: cwdMap.get(process.pid) || '',\n startTime: startTimeMap.get(process.pid),\n }));\n } catch {\n return [];\n }\n}\n\n/**\n * Batch-get current working directories for multiple PIDs.\n *\n * Single `lsof -a -d cwd -Fn -p PID1,PID2,...` call.\n * Returns partial results — if lsof fails for one PID, others still return.\n */\nexport function batchGetProcessCwds(pids: number[]): Map<number, string> {\n const result = new Map<number, string>();\n if (pids.length === 0) return result;\n\n try {\n const output = execFileSync(\n 'lsof', ['-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')],\n { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },\n );\n\n // lsof output format: p{PID}\\nn{path}\\np{PID}\\nn{path}...\n let currentPid: number | null = null;\n for (const line of output.trim().split('\\n')) {\n if (line.startsWith('p')) {\n currentPid = parseInt(line.slice(1), 10);\n } else if (line.startsWith('n') && currentPid !== null) {\n result.set(currentPid, line.slice(1));\n currentPid = null;\n }\n }\n } catch {\n // Try per-PID fallback with pwdx (Linux)\n for (const pid of pids) {\n try {\n const output = execFileSync(\n 'pwdx', [String(pid)],\n { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },\n );\n const match = output.match(/^\\d+:\\s*(.+)$/);\n if (match) {\n result.set(pid, match[1].trim());\n }\n } catch {\n // Skip this PID\n }\n }\n }\n\n return result;\n}\n\n/**\n * Batch-get process start times for multiple PIDs.\n *\n * Single `ps -o pid=,lstart= -p PID1,PID2,...` call.\n * Uses lstart format which gives full timestamp (e.g., \"Thu Feb 5 16:00:57 2026\").\n * Returns partial results.\n */\nexport function batchGetProcessStartTimes(pids: number[]): Map<number, Date> {\n const result = new Map<number, Date>();\n if (pids.length === 0) return result;\n\n try {\n const output = execFileSync(\n 'ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')],\n { encoding: 'utf-8' },\n );\n\n for (const rawLine of output.split('\\n')) {\n const line = rawLine.trim();\n if (!line) continue;\n\n // Format: \" PID DAY MON DD HH:MM:SS YYYY\"\n // e.g., \" 78070 Wed Mar 18 23:18:01 2026\"\n const match = line.match(/^\\s*(\\d+)\\s+(.+)$/);\n if (!match) continue;\n\n const pid = parseInt(match[1], 10);\n const dateStr = match[2].trim();\n\n if (!Number.isFinite(pid)) continue;\n\n const date = new Date(dateStr);\n if (!Number.isNaN(date.getTime())) {\n result.set(pid, date);\n }\n }\n } catch {\n // Return whatever we have\n }\n\n return result;\n}\n\n/**\n * Enrich ProcessInfo array with cwd and startTime.\n *\n * Calls batchGetProcessCwds and batchGetProcessStartTimes in batched shell calls,\n * then populates each ProcessInfo in-place. Returns partial results —\n * if a PID fails, that process keeps empty cwd / undefined startTime.\n */\nexport function enrichProcesses(processes: ProcessInfo[]): ProcessInfo[] {\n if (processes.length === 0) return processes;\n\n const pids = processes.map(p => p.pid);\n const cwdMap = batchGetProcessCwds(pids);\n const startTimeMap = batchGetProcessStartTimes(pids);\n\n for (const proc of processes) {\n proc.cwd = cwdMap.get(proc.pid) || '';\n proc.startTime = startTimeMap.get(proc.pid);\n }\n\n return processes;\n}\n\nfunction isSameTerminalProcess(proc: ProcessInfo, matched: ProcessInfo): boolean {\n const sameTty = proc.tty !== '' && proc.tty !== '?' && proc.tty === matched.tty;\n const sameCwd = proc.cwd !== '' && proc.cwd === matched.cwd;\n\n return sameTty && (sameCwd || proc.cwd === '' || matched.cwd === '');\n}\n\nfunction matchesProcessIdentity(proc: ProcessInfo, matched: ProcessInfo): boolean {\n return proc.pid === matched.pid || isSameTerminalProcess(proc, matched);\n}\n\nexport function findWrapperProcess(\n processes: ProcessInfo[],\n child: ProcessInfo,\n): ProcessInfo | undefined {\n return processes.find((proc) => (\n proc.pid !== child.pid &&\n child.ppid === proc.pid &&\n matchesProcessIdentity(proc, child)\n ));\n}\n\n/**\n * Find parent wrapper processes that should not be reported as separate agents.\n *\n * A process is considered a wrapper when it is the parent of another candidate\n * agent process in the same terminal/worktree, or when it points at the same\n * terminal/worktree as an already session-matched process.\n */\nexport function findWrapperProcessPids(\n processes: ProcessInfo[],\n matchedProcesses: ProcessInfo[] = [],\n): Set<number> {\n const wrappers = new Set<number>();\n\n for (const child of processes) {\n const wrapper = findWrapperProcess(processes, child);\n if (wrapper) {\n wrappers.add(wrapper.pid);\n }\n }\n\n for (const proc of processes) {\n if (matchedProcesses.some((matched) => (\n proc.pid !== matched.pid && isSameTerminalProcess(proc, matched)\n ))) {\n wrappers.add(proc.pid);\n }\n }\n\n return wrappers;\n}\n\n/**\n * Get the TTY device for a specific process\n */\nexport function getProcessTty(pid: number): string {\n try {\n const output = execFileSync(\n 'ps', ['-p', String(pid), '-o', 'tty='],\n { encoding: 'utf-8' },\n );\n\n const tty = output.trim();\n return tty.startsWith('/dev/') ? tty.slice(5) : tty;\n } catch {\n return '?';\n }\n}\n"],"names":["path","execFile","execFileSync","PROCESS_EXEC_MAX_BUFFER","VALID_EXECUTABLE_NAME","executableBasename","command","executable","trim","split","basename","replace","toLowerCase","normalizeExecutableName","name","lower","endsWith","slice","normalizedProcessNames","namePatterns","Set","filter","Boolean","map","filterByProcessNames","processes","names","size","process","has","listAgentProcesses","namePattern","test","output","encoding","line","match","pid","parseInt","ppid","Number","isNaN","tty","ttyShort","startsWith","push","cwd","execFileText","file","args","Promise","resolve","reject","maxBuffer","error","stdout","parseProcessList","base","normalizedBase","batchGetProcessCwdsAsync","pids","result","Map","length","join","currentPid","set","entries","all","String","entry","batchGetProcessStartTimesAsync","rawLine","date","Date","isFinite","getTime","captureProcessSnapshot","cwdMap","startTimeMap","get","startTime","batchGetProcessCwds","stdio","batchGetProcessStartTimes","dateStr","enrichProcesses","p","proc","isSameTerminalProcess","matched","sameTty","sameCwd","matchesProcessIdentity","findWrapperProcess","child","find","findWrapperProcessPids","matchedProcesses","wrappers","wrapper","add","some","getProcessTty"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-devkit/agent-manager",
3
- "version": "0.26.2",
3
+ "version": "0.26.4",
4
4
  "type": "module",
5
5
  "description": "Standalone agent detection and management utilities for AI DevKit",
6
6
  "main": "dist/index.js",
@@ -10,9 +10,13 @@ import type {
10
10
  AgentInfo,
11
11
  SessionSummary,
12
12
  ListSessionsOptions,
13
+ ProcessInfo,
13
14
  } from './adapters/AgentAdapter.js';
14
15
  import { sortAgents, type AgentSortKey } from './utils/sortAgents.js';
15
16
  import { AgentRegistry, type RegistryEntry } from './utils/AgentRegistry.js';
17
+ import { captureProcessSnapshot, filterByProcessNames } from './utils/process.js';
18
+
19
+ type ProcessSnapshotCapture = (namePatterns: readonly string[]) => Promise<ProcessInfo[]>;
16
20
 
17
21
  export interface ListAgentsOptions {
18
22
  /**
@@ -41,7 +45,10 @@ export class AgentManager {
41
45
  private adapters: Map<string, AgentAdapter> = new Map();
42
46
  private registry: AgentRegistry;
43
47
 
44
- constructor(registry: AgentRegistry = AgentRegistry.default()) {
48
+ constructor(
49
+ registry: AgentRegistry = AgentRegistry.default(),
50
+ private readonly captureSnapshot: ProcessSnapshotCapture = captureProcessSnapshot,
51
+ ) {
45
52
  this.registry = registry;
46
53
  }
47
54
 
@@ -126,10 +133,27 @@ export class AgentManager {
126
133
  const allAgents: AgentInfo[] = [];
127
134
  const errors: Array<{ type: string; error: Error }> = [];
128
135
 
129
- // Query all adapters in parallel
130
- const adapterPromises = Array.from(this.adapters.values()).map(async (adapter) => {
136
+ const adapters = Array.from(this.adapters.values());
137
+ const processNames = Array.from(new Set(adapters.flatMap(
138
+ (adapter) => adapter.processNames ? [...adapter.processNames] : [],
139
+ )));
140
+ let processes: readonly ProcessInfo[] = [];
141
+ if (processNames.length > 0) {
142
+ try {
143
+ processes = await this.captureSnapshot(processNames);
144
+ } catch {
145
+ processes = [];
146
+ }
147
+ }
148
+
149
+ // Query all adapters in parallel using executable-scoped slices of the shared snapshot.
150
+ const adapterPromises = adapters.map(async (adapter) => {
131
151
  try {
132
- const agents = await adapter.detectAgents();
152
+ const agents = adapter.processNames
153
+ ? await adapter.detectAgents({
154
+ processes: filterByProcessNames(processes, adapter.processNames),
155
+ })
156
+ : await adapter.detectAgents();
133
157
  return { type: adapter.type, agents, error: null };
134
158
  } catch (error) {
135
159
  // Capture error but don't throw - allow other adapters to continue
@@ -156,15 +180,18 @@ export class AgentManager {
156
180
  });
157
181
  }
158
182
 
159
- const preExistingByPid = new Map(this.registry.list().map((e) => [e.pid, e]));
183
+ const identityKey = (type: string, pid: number): string => `${type}:${pid}`;
184
+ const preExistingByIdentity = new Map(
185
+ this.registry.list().map((entry) => [identityKey(entry.type, entry.pid), entry]),
186
+ );
160
187
  const entries = allAgents.map((agent) =>
161
- this.toRegistryEntry(agent, preExistingByPid.get(agent.pid)),
188
+ this.toRegistryEntry(agent, preExistingByIdentity.get(identityKey(agent.type, agent.pid))),
162
189
  );
163
190
  if (entries.length > 0) this.registry.registerBatch(entries);
164
- this.registry.prune();
191
+ this.registry.pruneIfDue();
165
192
 
166
193
  for (const agent of allAgents) {
167
- const entry = preExistingByPid.get(agent.pid);
194
+ const entry = preExistingByIdentity.get(identityKey(agent.type, agent.pid));
168
195
  if (entry) {
169
196
  agent.name = entry.name;
170
197
  }
@@ -13,6 +13,7 @@ import type {
13
13
  AgentType,
14
14
  ConversationMessage,
15
15
  SessionSummary,
16
+ ProcessInfo,
16
17
  } from '../adapters/AgentAdapter.js';
17
18
  import { AgentStatus } from '../adapters/AgentAdapter.js';
18
19
  import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js';
@@ -170,6 +171,94 @@ describe('AgentManager', () => {
170
171
  });
171
172
 
172
173
  describe('listAgents', () => {
174
+ it('shares one process capture while giving each adapter only its declared executables', async () => {
175
+ const processes: ProcessInfo[] = [
176
+ { pid: 101, command: 'claude', cwd: '/claude', tty: 's001' },
177
+ { pid: 202, command: 'node /bin/pi', cwd: '/pi', tty: 's002' },
178
+ ];
179
+ const captureSnapshot = vi.fn(async () => processes);
180
+ const createSnapshotAdapter = (type: AgentType, processNames: string[]) => ({
181
+ type,
182
+ processNames,
183
+ detectAgents: vi.fn(async () => []),
184
+ canHandle: () => true,
185
+ getConversation: () => [],
186
+ listSessions: async () => [],
187
+ });
188
+ const claude = createSnapshotAdapter('claude', ['claude']);
189
+ const pi = createSnapshotAdapter('pi', ['pi', 'node']);
190
+ const snapshotManager = new AgentManager(
191
+ new AgentRegistry(path.join(tmpDir, 'snapshot-agents.json')),
192
+ captureSnapshot,
193
+ );
194
+
195
+ snapshotManager.registerAdapter(claude as AgentAdapter);
196
+ snapshotManager.registerAdapter(pi as AgentAdapter);
197
+
198
+ await snapshotManager.listAgents();
199
+
200
+ expect(captureSnapshot).toHaveBeenCalledTimes(1);
201
+ expect(captureSnapshot).toHaveBeenCalledWith(['claude', 'pi', 'node']);
202
+ expect(claude.detectAgents).toHaveBeenCalledWith({ processes: [processes[0]] });
203
+ expect(pi.detectAgents).toHaveBeenCalledWith({ processes: [processes[1]] });
204
+ });
205
+
206
+ it('does not expose foreign command arguments to broad Pi and Gemini matchers', async () => {
207
+ const processes: ProcessInfo[] = [
208
+ { pid: 100, command: 'node /usr/local/lib/gemini.js', cwd: '/g', tty: 's001' },
209
+ { pid: 200, command: 'codex exec --cd /Users/x/repos/gemini', cwd: '/c', tty: 's002' },
210
+ { pid: 300, command: 'node /usr/local/lib/pi.js', cwd: '/p', tty: 's003' },
211
+ { pid: 400, command: 'claude --resume /Users/x/pi/session.jsonl', cwd: '/a', tty: 's004' },
212
+ ];
213
+ const captureSnapshot = vi.fn(async () => processes);
214
+ const createSnapshotAdapter = (type: AgentType, processNames: string[]) => ({
215
+ type,
216
+ processNames,
217
+ detectAgents: vi.fn(async () => []),
218
+ canHandle: () => true,
219
+ getConversation: () => [],
220
+ listSessions: async () => [],
221
+ });
222
+ const gemini = createSnapshotAdapter('gemini_cli', ['node']);
223
+ const codex = createSnapshotAdapter('codex', ['codex']);
224
+ const pi = createSnapshotAdapter('pi', ['pi', 'node']);
225
+ const claude = createSnapshotAdapter('claude', ['claude']);
226
+ const snapshotManager = new AgentManager(
227
+ new AgentRegistry(path.join(tmpDir, 'filtered-snapshot-agents.json')),
228
+ captureSnapshot,
229
+ );
230
+
231
+ snapshotManager.registerAdapter(gemini as AgentAdapter);
232
+ snapshotManager.registerAdapter(codex as AgentAdapter);
233
+ snapshotManager.registerAdapter(pi as AgentAdapter);
234
+ snapshotManager.registerAdapter(claude as AgentAdapter);
235
+
236
+ await snapshotManager.listAgents();
237
+
238
+ expect(captureSnapshot).toHaveBeenCalledTimes(1);
239
+ expect(captureSnapshot).toHaveBeenCalledWith(['node', 'codex', 'pi', 'claude']);
240
+ expect(gemini.detectAgents).toHaveBeenCalledWith({ processes: [processes[0], processes[2]] });
241
+ expect(codex.detectAgents).toHaveBeenCalledWith({ processes: [processes[1]] });
242
+ expect(pi.detectAgents).toHaveBeenCalledWith({ processes: [processes[0], processes[2]] });
243
+ expect(claude.detectAgents).toHaveBeenCalledWith({ processes: [processes[3]] });
244
+ });
245
+
246
+ it('does not pass a snapshot context to legacy adapters', async () => {
247
+ const captureSnapshot = vi.fn(async () => []);
248
+ const legacy = new MockAdapter('claude');
249
+ const detect = vi.spyOn(legacy, 'detectAgents');
250
+ const snapshotManager = new AgentManager(
251
+ new AgentRegistry(path.join(tmpDir, 'legacy-agents.json')),
252
+ captureSnapshot,
253
+ );
254
+ snapshotManager.registerAdapter(legacy);
255
+
256
+ await snapshotManager.listAgents();
257
+
258
+ expect(captureSnapshot).not.toHaveBeenCalled();
259
+ expect(detect).toHaveBeenCalledWith();
260
+ });
261
+
173
262
  it('should return empty array when no adapters registered', async () => {
174
263
  const agents = await manager.listAgents();
175
264
  expect(agents).toEqual([]);
@@ -255,15 +344,25 @@ describe('AgentManager', () => {
255
344
  let regPath: string;
256
345
  let registry: AgentRegistry;
257
346
  let scopedManager: AgentManager;
347
+ let nowMs: number;
348
+ let databaseOperations: string[];
258
349
 
259
350
  beforeEach(() => {
260
351
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-manager-'));
261
352
  regPath = path.join(tmpDir, 'agents.json');
262
- registry = new AgentRegistry(regPath);
353
+ nowMs = Date.parse('2026-08-14T10:00:00.000Z');
354
+ databaseOperations = [];
355
+ registry = new AgentRegistry(regPath, {
356
+ now: () => new Date(nowMs),
357
+ pruneIntervalMs: 30_000,
358
+ onDatabaseOperation: (sql) => databaseOperations.push(sql),
359
+ });
263
360
  scopedManager = new AgentManager(registry);
361
+ databaseOperations = [];
264
362
  });
265
363
 
266
364
  afterEach(() => {
365
+ vi.restoreAllMocks();
267
366
  fs.rmSync(tmpDir, { recursive: true, force: true });
268
367
  });
269
368
 
@@ -340,6 +439,35 @@ describe('AgentManager', () => {
340
439
  expect(registry.list()[0].startedAt).toBe('2026-05-30T00:00:00.000Z');
341
440
  });
342
441
 
442
+ it('preserves custom name and tmux session across two EPERM refresh cycles', async () => {
443
+ registry.register({
444
+ name: 'merry',
445
+ type: 'claude',
446
+ pid: process.pid,
447
+ tmuxSession: 'merry-tmux',
448
+ cwd: '/cwd/merry',
449
+ startedAt: '2026-05-30T00:00:00.000Z',
450
+ sessionId: 'sid-merry',
451
+ sessionFilePath: '/path/merry.jsonl',
452
+ });
453
+ scopedManager.registerAdapter(new MockAdapter('claude', [
454
+ createMockAgent({ name: `ai-devkit-${process.pid}`, pid: process.pid }),
455
+ ]));
456
+ vi.spyOn(process, 'kill').mockImplementation(() => {
457
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
458
+ });
459
+
460
+ const firstRefresh = await scopedManager.listAgents();
461
+ const secondRefresh = await scopedManager.listAgents();
462
+
463
+ expect(firstRefresh[0].name).toBe('merry');
464
+ expect(secondRefresh[0].name).toBe('merry');
465
+ expect(registry.lookup('merry')).toMatchObject({
466
+ name: 'merry',
467
+ tmuxSession: 'merry-tmux',
468
+ });
469
+ });
470
+
343
471
  it('preserves a user-managed name when a fallback row was written later for the same pid', async () => {
344
472
  registry.register({
345
473
  name: 'agent-list-debug',
@@ -406,15 +534,114 @@ describe('AgentManager', () => {
406
534
  .toEqual(['a', 'b']);
407
535
  });
408
536
 
409
- it('skips registerBatch when no agents detected (still calls prune)', async () => {
537
+ it('performs zero database writes on an unchanged refresh', async () => {
538
+ const adapter = new MockAdapter('claude', [
539
+ createMockAgent({
540
+ name: 'stable',
541
+ pid: process.pid,
542
+ projectPath: '/cwd/stable',
543
+ sessionId: 'stable-session',
544
+ sessionFilePath: '/sessions/stable.jsonl',
545
+ }),
546
+ ]);
547
+ scopedManager.registerAdapter(adapter);
548
+ await scopedManager.listAgents();
549
+ databaseOperations = [];
550
+
551
+ await scopedManager.listAgents();
552
+
553
+ const writes = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT|INSERT|UPDATE|DELETE)/i.test(sql));
554
+ expect(writes).toEqual([]);
555
+ });
556
+
557
+ it('persists changed fields once in one write transaction', async () => {
558
+ const adapter = new MockAdapter('claude', [
559
+ createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/before' }),
560
+ ]);
561
+ scopedManager.registerAdapter(adapter);
562
+ await scopedManager.listAgents();
563
+ databaseOperations = [];
564
+ nowMs += 1_000;
565
+ adapter.setAgents([
566
+ createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/after' }),
567
+ ]);
568
+
569
+ await scopedManager.listAgents();
570
+
571
+ const upserts = databaseOperations.filter((sql) => /^\s*INSERT INTO agents/i.test(sql));
572
+ const transactions = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT)/i.test(sql));
573
+ expect(upserts).toHaveLength(1);
574
+ expect(upserts[0]).toContain("'2026-08-14T10:00:01.000Z'");
575
+ expect(transactions).toHaveLength(2);
576
+ expect(registry.lookup('changing')?.cwd).toBe('/cwd/after');
577
+ });
578
+
579
+ it('prunes newly dead entries only when the passive cadence is due', async () => {
580
+ registry.register({
581
+ name: 'cadenced',
582
+ type: 'claude',
583
+ pid: process.pid,
584
+ tmuxSession: '',
585
+ cwd: '/cwd/cadenced',
586
+ startedAt: '2026-05-30T00:00:00.000Z',
587
+ sessionId: 'sid-cadenced',
588
+ sessionFilePath: '',
589
+ });
590
+ const alive = vi.spyOn(registry, 'isAlive').mockReturnValue(true);
591
+
592
+ await scopedManager.listAgents();
593
+ expect(alive).toHaveBeenCalledTimes(1);
594
+ alive.mockReturnValue(false);
595
+ nowMs += 29_999;
596
+
597
+ await scopedManager.listAgents();
598
+ expect(alive).toHaveBeenCalledTimes(1);
599
+ expect(registry.lookup('cadenced')).not.toBeNull();
600
+
601
+ nowMs += 1;
602
+ await scopedManager.listAgents();
603
+ expect(alive).toHaveBeenCalledTimes(2);
604
+ expect(registry.lookup('cadenced')).toBeNull();
605
+ });
606
+
607
+ it('does not inherit a name when the same pid is reused by another agent type', async () => {
608
+ registry.register({
609
+ name: 'old-claude',
610
+ type: 'claude',
611
+ pid: process.pid,
612
+ tmuxSession: 'old-claude',
613
+ cwd: '/cwd/old',
614
+ startedAt: '2026-05-30T00:00:00.000Z',
615
+ sessionId: 'old-session',
616
+ sessionFilePath: '',
617
+ });
618
+ scopedManager.registerAdapter(new MockAdapter('codex', [
619
+ createMockAgent({
620
+ name: 'new-codex',
621
+ type: 'codex',
622
+ pid: process.pid,
623
+ projectPath: '/cwd/new',
624
+ sessionId: 'new-session',
625
+ }),
626
+ ]));
627
+
628
+ const agents = await scopedManager.listAgents();
629
+
630
+ expect(agents[0].name).toBe('new-codex');
631
+ expect(registry.lookup('old-claude')).toBeNull();
632
+ expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid });
633
+ });
634
+
635
+ it('skips registerBatch when no agents are detected and prune is not due', async () => {
410
636
  const writeSpy = vi.spyOn(registry, 'registerBatch');
411
- const pruneSpy = vi.spyOn(registry, 'prune');
637
+ const pruneSpy = vi.spyOn(registry, 'pruneIfDue');
412
638
 
413
639
  scopedManager.registerAdapter(new MockAdapter('claude', []));
414
640
  await scopedManager.listAgents();
641
+ await scopedManager.listAgents();
415
642
 
416
643
  expect(writeSpy).not.toHaveBeenCalled();
417
- expect(pruneSpy).toHaveBeenCalledTimes(1);
644
+ expect(pruneSpy).toHaveBeenCalledTimes(2);
418
645
  });
419
646
  });
420
647