@ai-devkit/agent-manager 0.19.0 → 0.19.1
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.
- package/dist/__tests__/adapters/CopilotAdapter.test.js +127 -2
- package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -1
- package/dist/__tests__/adapters/GeminiCliAdapter.test.js +144 -2
- package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -1
- package/dist/__tests__/utils/process.test.js +54 -5
- package/dist/__tests__/utils/process.test.js.map +1 -1
- package/dist/adapters/AgentAdapter.d.ts +2 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/adapters/CopilotAdapter.d.ts +4 -2
- package/dist/adapters/CopilotAdapter.d.ts.map +1 -1
- package/dist/adapters/CopilotAdapter.js +23 -12
- package/dist/adapters/CopilotAdapter.js.map +1 -1
- package/dist/adapters/GeminiCliAdapter.d.ts +1 -0
- package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
- package/dist/adapters/GeminiCliAdapter.js +36 -11
- package/dist/adapters/GeminiCliAdapter.js.map +1 -1
- package/dist/utils/process.d.ts +11 -2
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +44 -9
- package/dist/utils/process.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/adapters/CopilotAdapter.test.ts +97 -5
- package/src/__tests__/adapters/GeminiCliAdapter.test.ts +139 -4
- package/src/__tests__/utils/process.test.ts +31 -7
- package/src/adapters/AgentAdapter.ts +3 -0
- package/src/adapters/CopilotAdapter.ts +25 -14
- package/src/adapters/GeminiCliAdapter.ts +42 -11
- package/src/utils/process.ts +64 -9
package/dist/utils/process.js
CHANGED
|
@@ -8,10 +8,10 @@ import { execFileSync } from 'child_process';
|
|
|
8
8
|
/**
|
|
9
9
|
* List running processes matching an agent executable name.
|
|
10
10
|
*
|
|
11
|
-
* Uses `ps
|
|
11
|
+
* Uses `ps -axo` then filters in JS for exact executable basename match.
|
|
12
12
|
* This avoids shell pipelines and string interpolation.
|
|
13
13
|
*
|
|
14
|
-
* Returned ProcessInfo has pid, command, tty populated.
|
|
14
|
+
* Returned ProcessInfo has pid, ppid, command, tty populated.
|
|
15
15
|
* cwd and startTime are NOT populated — call enrichProcesses() to fill them.
|
|
16
16
|
*/ export function listAgentProcesses(namePattern) {
|
|
17
17
|
// Validate pattern contains only safe characters (alphanumeric, dash, underscore)
|
|
@@ -20,7 +20,8 @@ import { execFileSync } from 'child_process';
|
|
|
20
20
|
}
|
|
21
21
|
try {
|
|
22
22
|
const output = execFileSync('ps', [
|
|
23
|
-
'
|
|
23
|
+
'-axo',
|
|
24
|
+
'pid=,ppid=,tty=,command='
|
|
24
25
|
], {
|
|
25
26
|
encoding: 'utf-8'
|
|
26
27
|
});
|
|
@@ -28,12 +29,13 @@ import { execFileSync } from 'child_process';
|
|
|
28
29
|
const processes = [];
|
|
29
30
|
for (const line of output.trim().split('\n')){
|
|
30
31
|
if (!line.trim()) continue;
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
const pid = parseInt(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
32
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
33
|
+
if (!match) continue;
|
|
34
|
+
const pid = parseInt(match[1], 10);
|
|
35
|
+
const ppid = parseInt(match[2], 10);
|
|
36
|
+
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
|
|
37
|
+
const tty = match[3];
|
|
38
|
+
const command = match[4];
|
|
37
39
|
// Check that the executable basename matches exactly
|
|
38
40
|
const executable = command.trim().split(/\s+/)[0] || '';
|
|
39
41
|
const base = path.basename(executable).toLowerCase();
|
|
@@ -43,6 +45,7 @@ import { execFileSync } from 'child_process';
|
|
|
43
45
|
const ttyShort = tty.startsWith('/dev/') ? tty.slice(5) : tty;
|
|
44
46
|
processes.push({
|
|
45
47
|
pid,
|
|
48
|
+
ppid,
|
|
46
49
|
command,
|
|
47
50
|
cwd: '',
|
|
48
51
|
tty: ttyShort
|
|
@@ -167,6 +170,38 @@ import { execFileSync } from 'child_process';
|
|
|
167
170
|
}
|
|
168
171
|
return processes;
|
|
169
172
|
}
|
|
173
|
+
function isSameTerminalProcess(proc, matched) {
|
|
174
|
+
const sameTty = proc.tty !== '' && proc.tty !== '?' && proc.tty === matched.tty;
|
|
175
|
+
const sameCwd = proc.cwd !== '' && proc.cwd === matched.cwd;
|
|
176
|
+
return sameTty && (sameCwd || proc.cwd === '' || matched.cwd === '');
|
|
177
|
+
}
|
|
178
|
+
function matchesProcessIdentity(proc, matched) {
|
|
179
|
+
return proc.pid === matched.pid || isSameTerminalProcess(proc, matched);
|
|
180
|
+
}
|
|
181
|
+
export function findWrapperProcess(processes, child) {
|
|
182
|
+
return processes.find((proc)=>proc.pid !== child.pid && child.ppid === proc.pid && matchesProcessIdentity(proc, child));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Find parent wrapper processes that should not be reported as separate agents.
|
|
186
|
+
*
|
|
187
|
+
* A process is considered a wrapper when it is the parent of another candidate
|
|
188
|
+
* agent process in the same terminal/worktree, or when it points at the same
|
|
189
|
+
* terminal/worktree as an already session-matched process.
|
|
190
|
+
*/ export function findWrapperProcessPids(processes, matchedProcesses = []) {
|
|
191
|
+
const wrappers = new Set();
|
|
192
|
+
for (const child of processes){
|
|
193
|
+
const wrapper = findWrapperProcess(processes, child);
|
|
194
|
+
if (wrapper) {
|
|
195
|
+
wrappers.add(wrapper.pid);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const proc of processes){
|
|
199
|
+
if (matchedProcesses.some((matched)=>proc.pid !== matched.pid && isSameTerminalProcess(proc, matched))) {
|
|
200
|
+
wrappers.add(proc.pid);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return wrappers;
|
|
204
|
+
}
|
|
170
205
|
/**
|
|
171
206
|
* Get the TTY device for a specific process
|
|
172
207
|
*/ export function getProcessTty(pid) {
|
|
@@ -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 aux` then filters in JS for exact executable basename match.\n * This avoids shell pipelines and string interpolation.\n *\n * Returned ProcessInfo has pid, 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', ['aux'], { 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 parts = line.trim().split(/\\s+/);\n if (parts.length < 11) continue;\n\n const pid = parseInt(parts[1], 10);\n if (Number.isNaN(pid)) continue;\n\n const tty = parts[6];\n const command = parts.slice(10).join(' ');\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 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\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","parts","length","pid","parseInt","Number","isNaN","tty","command","slice","join","executable","base","basename","ttyShort","startsWith","push","cwd","batchGetProcessCwds","pids","result","Map","stdio","currentPid","set","String","match","batchGetProcessStartTimes","rawLine","dateStr","isFinite","date","Date","getTime","enrichProcesses","map","p","cwdMap","startTimeMap","proc","get","startTime","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 * 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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"}
|
package/package.json
CHANGED
|
@@ -12,11 +12,16 @@ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
|
|
|
12
12
|
import { AgentStatus } from '../../adapters/AgentAdapter.js';
|
|
13
13
|
import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
|
|
14
14
|
import { generateAgentName } from '../../utils/matching.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
import { AgentRegistry } from '../../utils/AgentRegistry.js';
|
|
16
|
+
|
|
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
|
+
};
|
|
24
|
+
});
|
|
20
25
|
|
|
21
26
|
vi.mock('../../utils/matching.js', () => ({
|
|
22
27
|
generateAgentName: vi.fn(),
|
|
@@ -220,6 +225,55 @@ describe('CopilotAdapter', () => {
|
|
|
220
225
|
});
|
|
221
226
|
});
|
|
222
227
|
|
|
228
|
+
it('suppresses wrapper process-only agents before the session lock exists', async () => {
|
|
229
|
+
const processes: ProcessInfo[] = [
|
|
230
|
+
{ pid: 86800, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
|
|
231
|
+
{ pid: 86810, command: '/custom/install/copilot', cwd: '/repo', tty: 'ttys001', ppid: 86800 },
|
|
232
|
+
];
|
|
233
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
234
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
235
|
+
|
|
236
|
+
const agents = await adapter.detectAgents();
|
|
237
|
+
|
|
238
|
+
expect(agents).toHaveLength(1);
|
|
239
|
+
expect(agents[0]).toMatchObject({
|
|
240
|
+
pid: 86810,
|
|
241
|
+
sessionId: 'pid-86810',
|
|
242
|
+
summary: 'Copilot process running',
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('carries the managed wrapper name to a process-only child before the session lock exists', async () => {
|
|
247
|
+
const registry = new AgentRegistry(path.join(tmpDir, 'agents.json'));
|
|
248
|
+
adapter = new CopilotAdapter(registry);
|
|
249
|
+
(adapter as any).sessionStateDir = sessionStateDir;
|
|
250
|
+
const processes: ProcessInfo[] = [
|
|
251
|
+
{ pid: 86800, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
|
|
252
|
+
{ pid: 86810, command: '/custom/install/copilot', cwd: '/repo', tty: 'ttys001', ppid: 86800 },
|
|
253
|
+
];
|
|
254
|
+
registry.register({
|
|
255
|
+
name: 'copilot-started',
|
|
256
|
+
type: 'copilot',
|
|
257
|
+
pid: 86800,
|
|
258
|
+
tmuxSession: 'copilot-started',
|
|
259
|
+
cwd: '/repo',
|
|
260
|
+
startedAt: '2026-06-13T19:15:16.211Z',
|
|
261
|
+
sessionId: 'pid-86800',
|
|
262
|
+
sessionFilePath: '',
|
|
263
|
+
});
|
|
264
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
265
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
266
|
+
|
|
267
|
+
const agents = await adapter.detectAgents();
|
|
268
|
+
|
|
269
|
+
expect(agents).toHaveLength(1);
|
|
270
|
+
expect(agents[0]).toMatchObject({
|
|
271
|
+
name: 'copilot-started',
|
|
272
|
+
pid: 86810,
|
|
273
|
+
sessionId: 'pid-86810',
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
223
277
|
it('does not add duplicate process-only agent for wrapper process in the same terminal', async () => {
|
|
224
278
|
const processes: ProcessInfo[] = [
|
|
225
279
|
{ pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001' },
|
|
@@ -244,6 +298,44 @@ describe('CopilotAdapter', () => {
|
|
|
244
298
|
});
|
|
245
299
|
});
|
|
246
300
|
|
|
301
|
+
it('carries the managed wrapper name to a lock-backed child process', async () => {
|
|
302
|
+
const registry = new AgentRegistry(path.join(tmpDir, 'agents.json'));
|
|
303
|
+
adapter = new CopilotAdapter(registry);
|
|
304
|
+
(adapter as any).sessionStateDir = sessionStateDir;
|
|
305
|
+
const processes: ProcessInfo[] = [
|
|
306
|
+
{ pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
|
|
307
|
+
{ pid: 14096, command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot', cwd: '/repo', tty: 'ttys001', ppid: 14095 },
|
|
308
|
+
];
|
|
309
|
+
registry.register({
|
|
310
|
+
name: 'copilot-started',
|
|
311
|
+
type: 'copilot',
|
|
312
|
+
pid: 14095,
|
|
313
|
+
tmuxSession: 'copilot-started',
|
|
314
|
+
cwd: '/repo',
|
|
315
|
+
startedAt: '2026-06-13T19:15:16.211Z',
|
|
316
|
+
sessionId: 'pid-14095',
|
|
317
|
+
sessionFilePath: '',
|
|
318
|
+
});
|
|
319
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
320
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
321
|
+
writeSession('sess-wrapper', {
|
|
322
|
+
lockPid: 14096,
|
|
323
|
+
events: [
|
|
324
|
+
sessionStart('sess-wrapper', '/repo', '2026-06-09T09:50:00.000Z'),
|
|
325
|
+
{ type: 'user.message', data: { content: 'hello' }, timestamp: new Date().toISOString() },
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const agents = await adapter.detectAgents();
|
|
330
|
+
|
|
331
|
+
expect(agents).toHaveLength(1);
|
|
332
|
+
expect(agents[0]).toMatchObject({
|
|
333
|
+
name: 'copilot-started',
|
|
334
|
+
pid: 14096,
|
|
335
|
+
sessionId: 'sess-wrapper',
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
247
339
|
it('uses workspace metadata when events are missing', async () => {
|
|
248
340
|
const processes: ProcessInfo[] = [
|
|
249
341
|
{ pid: 300, command: 'copilot', cwd: '/proc-cwd', tty: 'ttys003' },
|
|
@@ -15,10 +15,14 @@ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
|
|
|
15
15
|
import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
|
|
16
16
|
import * as crypto from 'crypto';
|
|
17
17
|
|
|
18
|
-
vi.mock('../../utils/process.js', () =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
vi.mock('../../utils/process.js', async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal() as typeof import('../../utils/process.js');
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
listAgentProcesses: vi.fn(),
|
|
23
|
+
enrichProcesses: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
22
26
|
|
|
23
27
|
vi.mock('../../utils/matching.js', () => ({
|
|
24
28
|
matchProcessesToSessions: vi.fn(),
|
|
@@ -158,6 +162,77 @@ describe('GeminiCliAdapter', () => {
|
|
|
158
162
|
});
|
|
159
163
|
});
|
|
160
164
|
|
|
165
|
+
it('should suppress Gemini wrapper process-only agents before a session file exists', async () => {
|
|
166
|
+
const wrapperProc: ProcessInfo = {
|
|
167
|
+
pid: 20452,
|
|
168
|
+
ppid: 17530,
|
|
169
|
+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
|
|
170
|
+
cwd: '/repo',
|
|
171
|
+
tty: 'ttys007',
|
|
172
|
+
startTime: new Date('2026-06-13T08:25:21Z'),
|
|
173
|
+
};
|
|
174
|
+
const childProc: ProcessInfo = {
|
|
175
|
+
pid: 21373,
|
|
176
|
+
ppid: 20452,
|
|
177
|
+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
|
|
178
|
+
cwd: '/repo',
|
|
179
|
+
tty: 'ttys007',
|
|
180
|
+
startTime: new Date('2026-06-13T08:25:26Z'),
|
|
181
|
+
};
|
|
182
|
+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
|
|
183
|
+
|
|
184
|
+
const agents = await adapter.detectAgents();
|
|
185
|
+
|
|
186
|
+
expect(agents).toHaveLength(1);
|
|
187
|
+
expect(agents[0]).toMatchObject({
|
|
188
|
+
pid: 21373,
|
|
189
|
+
sessionId: 'pid-21373',
|
|
190
|
+
summary: 'Gemini CLI process running',
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should carry the managed wrapper name to a process-only child before a session file exists', async () => {
|
|
195
|
+
const regPath = path.join(tmpHome, 'agents.json');
|
|
196
|
+
const registry = new AgentRegistry(regPath);
|
|
197
|
+
const namedAdapter = new GeminiCliAdapter(registry);
|
|
198
|
+
const wrapperProc: ProcessInfo = {
|
|
199
|
+
pid: 35792,
|
|
200
|
+
ppid: 33068,
|
|
201
|
+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
|
|
202
|
+
cwd: '/repo',
|
|
203
|
+
tty: 'ttys002',
|
|
204
|
+
startTime: new Date('2026-06-13T19:15:16Z'),
|
|
205
|
+
};
|
|
206
|
+
const childProc: ProcessInfo = {
|
|
207
|
+
pid: 36514,
|
|
208
|
+
ppid: 35792,
|
|
209
|
+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
|
|
210
|
+
cwd: '/repo',
|
|
211
|
+
tty: 'ttys002',
|
|
212
|
+
startTime: new Date('2026-06-13T19:15:18Z'),
|
|
213
|
+
};
|
|
214
|
+
registry.register({
|
|
215
|
+
name: 'cli-mqcqj469',
|
|
216
|
+
type: 'gemini_cli',
|
|
217
|
+
pid: wrapperProc.pid,
|
|
218
|
+
tmuxSession: 'cli-mqcqj469',
|
|
219
|
+
cwd: wrapperProc.cwd,
|
|
220
|
+
startedAt: '2026-06-13T19:15:16.211Z',
|
|
221
|
+
sessionId: `pid-${wrapperProc.pid}`,
|
|
222
|
+
sessionFilePath: '',
|
|
223
|
+
});
|
|
224
|
+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
|
|
225
|
+
|
|
226
|
+
const agents = await namedAdapter.detectAgents();
|
|
227
|
+
|
|
228
|
+
expect(agents).toHaveLength(1);
|
|
229
|
+
expect(agents[0]).toMatchObject({
|
|
230
|
+
name: 'cli-mqcqj469',
|
|
231
|
+
pid: childProc.pid,
|
|
232
|
+
sessionId: `pid-${childProc.pid}`,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
161
236
|
it('should map a process to its matching session file via projectHash', async () => {
|
|
162
237
|
const cwd = '/repo/project-a';
|
|
163
238
|
const projectHash = hashProjectRoot(cwd);
|
|
@@ -433,6 +508,66 @@ describe('GeminiCliAdapter', () => {
|
|
|
433
508
|
|
|
434
509
|
expect(agents[0].sessionId).toBe('pid-100');
|
|
435
510
|
});
|
|
511
|
+
|
|
512
|
+
it('carries the managed wrapper name to the detected child process', async () => {
|
|
513
|
+
const wrapperProc: ProcessInfo = {
|
|
514
|
+
pid: 20339,
|
|
515
|
+
ppid: 17570,
|
|
516
|
+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
|
|
517
|
+
cwd: '/repo-a',
|
|
518
|
+
tty: 'ttys002',
|
|
519
|
+
startTime: new Date('2026-06-13T19:00:53Z'),
|
|
520
|
+
};
|
|
521
|
+
const childProc: ProcessInfo = {
|
|
522
|
+
pid: 21038,
|
|
523
|
+
ppid: 20339,
|
|
524
|
+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
|
|
525
|
+
cwd: '/repo-a',
|
|
526
|
+
tty: 'ttys002',
|
|
527
|
+
startTime: new Date('2026-06-13T19:00:57Z'),
|
|
528
|
+
};
|
|
529
|
+
const now = new Date().toISOString();
|
|
530
|
+
sessionFilePath = writeSession(tmpHome, 'cli-2', 'session-2026-06-13T19-00-s-cached', {
|
|
531
|
+
sessionId: 's-cached',
|
|
532
|
+
projectHash: hashProjectRoot('/repo-a'),
|
|
533
|
+
startTime: now,
|
|
534
|
+
lastUpdated: now,
|
|
535
|
+
directories: ['/repo-a'],
|
|
536
|
+
messages: [
|
|
537
|
+
{ id: 'm1', timestamp: now, type: 'user', content: 'Hello from child process' },
|
|
538
|
+
],
|
|
539
|
+
});
|
|
540
|
+
registerEntry({
|
|
541
|
+
name: 'cli-mqcq0mg5',
|
|
542
|
+
pid: wrapperProc.pid,
|
|
543
|
+
tmuxSession: 'cli-mqcq0mg5',
|
|
544
|
+
sessionId: 's-cached',
|
|
545
|
+
sessionFilePath,
|
|
546
|
+
});
|
|
547
|
+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
|
|
548
|
+
mockedMatchProcessesToSessions.mockReturnValue([
|
|
549
|
+
{
|
|
550
|
+
process: childProc,
|
|
551
|
+
session: {
|
|
552
|
+
sessionId: 's-cached',
|
|
553
|
+
filePath: sessionFilePath,
|
|
554
|
+
projectDir: path.dirname(sessionFilePath),
|
|
555
|
+
birthtimeMs: Date.now(),
|
|
556
|
+
resolvedCwd: '/repo-a',
|
|
557
|
+
},
|
|
558
|
+
deltaMs: 0,
|
|
559
|
+
},
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
const agents = await cachedAdapter.detectAgents();
|
|
563
|
+
|
|
564
|
+
expect(agents).toHaveLength(1);
|
|
565
|
+
expect(agents[0]).toMatchObject({
|
|
566
|
+
name: 'cli-mqcq0mg5',
|
|
567
|
+
pid: childProc.pid,
|
|
568
|
+
sessionId: 's-cached',
|
|
569
|
+
});
|
|
570
|
+
});
|
|
436
571
|
});
|
|
437
572
|
|
|
438
573
|
describe('discoverSessions', () => {
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
batchGetProcessCwds,
|
|
11
11
|
batchGetProcessStartTimes,
|
|
12
12
|
enrichProcesses,
|
|
13
|
+
findWrapperProcess,
|
|
14
|
+
findWrapperProcessPids,
|
|
13
15
|
} from '../../utils/process.js';
|
|
14
16
|
|
|
15
17
|
vi.mock('child_process', () => ({
|
|
@@ -23,26 +25,28 @@ describe('listAgentProcesses', () => {
|
|
|
23
25
|
mockedExecFileSync.mockReset();
|
|
24
26
|
});
|
|
25
27
|
|
|
26
|
-
it('should parse ps
|
|
28
|
+
it('should parse ps output and post-filter by executable name', () => {
|
|
27
29
|
mockedExecFileSync.mockReturnValue(
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
+
'78070 1 s018 claude\n' +
|
|
31
|
+
'55106 55100 s015 claude\n',
|
|
30
32
|
);
|
|
31
33
|
|
|
32
34
|
const processes = listAgentProcesses('claude');
|
|
33
35
|
expect(processes).toHaveLength(2);
|
|
34
36
|
expect(processes[0].pid).toBe(78070);
|
|
37
|
+
expect(processes[0].ppid).toBe(1);
|
|
35
38
|
expect(processes[0].command).toBe('claude');
|
|
36
39
|
expect(processes[0].tty).toBe('s018');
|
|
37
40
|
expect(processes[0].cwd).toBe(''); // not populated yet
|
|
38
41
|
expect(processes[1].pid).toBe(55106);
|
|
42
|
+
expect(processes[1].ppid).toBe(55100);
|
|
39
43
|
});
|
|
40
44
|
|
|
41
45
|
it('should filter out non-matching executables', () => {
|
|
42
46
|
mockedExecFileSync.mockReturnValue(
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
47
|
+
'100 1 s001 claude\n' +
|
|
48
|
+
'200 1 s002 claude-helper --pid 100\n' +
|
|
49
|
+
'300 1 s003 /usr/bin/claude\n',
|
|
46
50
|
);
|
|
47
51
|
|
|
48
52
|
const processes = listAgentProcesses('claude');
|
|
@@ -75,7 +79,11 @@ describe('listAgentProcesses', () => {
|
|
|
75
79
|
it('should accept valid patterns with dashes and underscores', () => {
|
|
76
80
|
mockedExecFileSync.mockReturnValue('');
|
|
77
81
|
listAgentProcesses('claude-code');
|
|
78
|
-
expect(mockedExecFileSync).
|
|
82
|
+
expect(mockedExecFileSync).toHaveBeenCalledWith(
|
|
83
|
+
'ps',
|
|
84
|
+
['-axo', 'pid=,ppid=,tty=,command='],
|
|
85
|
+
{ encoding: 'utf-8' },
|
|
86
|
+
);
|
|
79
87
|
|
|
80
88
|
mockedExecFileSync.mockReset();
|
|
81
89
|
mockedExecFileSync.mockReturnValue('');
|
|
@@ -201,3 +209,19 @@ describe('enrichProcesses', () => {
|
|
|
201
209
|
expect(enriched[0].startTime).toBeUndefined();
|
|
202
210
|
});
|
|
203
211
|
});
|
|
212
|
+
|
|
213
|
+
describe('wrapper process detection', () => {
|
|
214
|
+
it('finds the parent wrapper process for a child in the same terminal and cwd', () => {
|
|
215
|
+
const wrapper = { pid: 100, ppid: 1, command: 'node /bin/gemini', cwd: '/repo', tty: 'ttys001' };
|
|
216
|
+
const child = { pid: 200, ppid: 100, command: 'node --max-old-space-size=8192 /bin/gemini', cwd: '/repo', tty: 'ttys001' };
|
|
217
|
+
|
|
218
|
+
expect(findWrapperProcess([wrapper, child], child)).toBe(wrapper);
|
|
219
|
+
expect(findWrapperProcessPids([wrapper, child])).toEqual(new Set([100]));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('does not mark a matched child process as its own wrapper', () => {
|
|
223
|
+
const child = { pid: 200, ppid: 100, command: 'node --max-old-space-size=8192 /bin/gemini', cwd: '/repo', tty: 'ttys001' };
|
|
224
|
+
|
|
225
|
+
expect(findWrapperProcessPids([child], [child])).toEqual(new Set());
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -19,9 +19,10 @@ import type {
|
|
|
19
19
|
SessionSummary,
|
|
20
20
|
} from './AgentAdapter.js';
|
|
21
21
|
import { AgentStatus } from './AgentAdapter.js';
|
|
22
|
-
import { enrichProcesses, listAgentProcesses } from '../utils/process.js';
|
|
22
|
+
import { enrichProcesses, findWrapperProcess, findWrapperProcessPids, listAgentProcesses } from '../utils/process.js';
|
|
23
23
|
import { generateAgentName } from '../utils/matching.js';
|
|
24
24
|
import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
|
|
25
|
+
import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js';
|
|
25
26
|
|
|
26
27
|
interface CopilotEventEntry {
|
|
27
28
|
type?: string;
|
|
@@ -107,10 +108,12 @@ export class CopilotAdapter implements AgentAdapter {
|
|
|
107
108
|
]);
|
|
108
109
|
|
|
109
110
|
private sessionStateDir: string;
|
|
111
|
+
private registry: AgentRegistry;
|
|
110
112
|
|
|
111
|
-
constructor() {
|
|
113
|
+
constructor(registry: AgentRegistry = AgentRegistry.default()) {
|
|
112
114
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
113
115
|
this.sessionStateDir = path.join(homeDir, '.copilot', 'session-state');
|
|
116
|
+
this.registry = registry;
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
canHandle(processInfo: ProcessInfo): boolean {
|
|
@@ -122,6 +125,7 @@ export class CopilotAdapter implements AgentAdapter {
|
|
|
122
125
|
if (processes.length === 0) return [];
|
|
123
126
|
|
|
124
127
|
const processByPid = new Map(processes.map((proc) => [proc.pid, proc]));
|
|
128
|
+
const registryEntriesByPid = new Map(this.registry.list().map((entry) => [entry.pid, entry]));
|
|
125
129
|
const matchedPids = new Set<number>();
|
|
126
130
|
const matchedProcesses: ProcessInfo[] = [];
|
|
127
131
|
const agents: AgentInfo[] = [];
|
|
@@ -133,29 +137,36 @@ export class CopilotAdapter implements AgentAdapter {
|
|
|
133
137
|
const session = this.readSessionDir(lock.sessionDir, lock.sessionId);
|
|
134
138
|
if (!session) continue;
|
|
135
139
|
|
|
136
|
-
|
|
140
|
+
const agent = this.mapSessionToAgent(session, proc);
|
|
141
|
+
this.applyWrapperRegistryName(agent, proc, processes, registryEntriesByPid);
|
|
142
|
+
agents.push(agent);
|
|
137
143
|
matchedPids.add(proc.pid);
|
|
138
144
|
matchedProcesses.push(proc);
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
const wrapperPids = findWrapperProcessPids(processes, matchedProcesses);
|
|
141
148
|
for (const proc of processes) {
|
|
142
|
-
if (!matchedPids.has(proc.pid) && !
|
|
143
|
-
|
|
149
|
+
if (!matchedPids.has(proc.pid) && !wrapperPids.has(proc.pid)) {
|
|
150
|
+
const agent = this.mapProcessOnlyAgent(proc);
|
|
151
|
+
this.applyWrapperRegistryName(agent, proc, processes, registryEntriesByPid);
|
|
152
|
+
agents.push(agent);
|
|
144
153
|
}
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
return agents;
|
|
148
157
|
}
|
|
149
158
|
|
|
150
|
-
private
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
private applyWrapperRegistryName(
|
|
160
|
+
agent: AgentInfo,
|
|
161
|
+
processInfo: ProcessInfo,
|
|
162
|
+
processes: ProcessInfo[],
|
|
163
|
+
registryEntriesByPid: Map<number, RegistryEntry>,
|
|
164
|
+
): void {
|
|
165
|
+
const wrapper = findWrapperProcess(processes, processInfo);
|
|
166
|
+
const wrapperEntry = wrapper ? registryEntriesByPid.get(wrapper.pid) : undefined;
|
|
167
|
+
if (wrapperEntry?.type === this.type) {
|
|
168
|
+
agent.name = wrapperEntry.name;
|
|
169
|
+
}
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|