diff --git a/packages/agent-manager/src/__tests__/utils/process.test.ts b/packages/agent-manager/src/__tests__/utils/process.test.ts index 0966d83f..ca86d9e3 100644 --- a/packages/agent-manager/src/__tests__/utils/process.test.ts +++ b/packages/agent-manager/src/__tests__/utils/process.test.ts @@ -12,6 +12,7 @@ import { enrichProcesses, findWrapperProcess, findWrapperProcessPids, + getProcessTty, } from '../../utils/process.js'; vi.mock('child_process', () => ({ @@ -19,6 +20,22 @@ vi.mock('child_process', () => ({ })); const mockedExecFileSync = execFileSync as MockedFunction; +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +} + +beforeEach(() => { + setPlatform('darwin'); +}); + +afterEach(() => { + setPlatform(originalPlatform); +}); describe('listAgentProcesses', () => { beforeEach(() => { @@ -168,6 +185,128 @@ describe('batchGetProcessStartTimes', () => { }); }); +describe('process utilities on win32', () => { + beforeEach(() => { + mockedExecFileSync.mockReset(); + setPlatform('win32'); + }); + + afterEach(() => { + setPlatform(originalPlatform); + mockedExecFileSync.mockReset(); + }); + + it('listAgentProcesses parses CIM process rows and filters by executable name', () => { + const resumeId = '123e4567-e89b-12d3-a456-426614174000'; + mockedExecFileSync.mockReturnValue(JSON.stringify([ + { + ProcessId: 100, + ParentProcessId: 1, + ExecutablePath: 'C:\\Users\\user\\AppData\\Local\\Programs\\Claude\\claude.exe', + CommandLine: `C:\\Users\\user\\AppData\\Local\\Programs\\Claude\\claude.exe --resume ${resumeId}`, + }, + { + ProcessId: 200, + ParentProcessId: 100, + ExecutablePath: 'C:\\Program Files\\Claude\\claude.exe', + CommandLine: `"C:\\Program Files\\Claude\\claude.exe" --resume ${resumeId}`, + }, + { + ProcessId: 300, + ParentProcessId: 1, + ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe', + CommandLine: 'node.exe server.js', + }, + ])); + + const processes = listAgentProcesses('claude'); + + expect(processes).toHaveLength(2); + expect(processes[0]).toMatchObject({ + pid: 100, + ppid: 1, + command: `C:\\Users\\user\\AppData\\Local\\Programs\\Claude\\claude.exe --resume ${resumeId}`, + cwd: '', + tty: '?', + }); + expect(processes[1]).toMatchObject({ + pid: 200, + ppid: 100, + command: `"C:\\Program Files\\Claude\\claude.exe" --resume ${resumeId}`, + cwd: '', + tty: '?', + }); + expect(mockedExecFileSync).toHaveBeenCalledTimes(1); + expect(mockedExecFileSync.mock.calls[0][0]).toBe('powershell'); + }); + + it('listAgentProcesses handles single-object JSON output', () => { + mockedExecFileSync.mockReturnValue(JSON.stringify({ + ProcessId: 100, + ParentProcessId: 1, + ExecutablePath: 'C:\\Program Files\\Claude\\claude.exe', + CommandLine: '"C:\\Program Files\\Claude\\claude.exe" --resume abc', + })); + + const processes = listAgentProcesses('claude'); + + expect(processes).toHaveLength(1); + expect(processes[0]).toMatchObject({ + pid: 100, + ppid: 1, + command: '"C:\\Program Files\\Claude\\claude.exe" --resume abc', + tty: '?', + }); + }); + + it('listAgentProcesses returns an empty array for malformed or empty PowerShell output', () => { + mockedExecFileSync.mockReturnValue('{not json'); + expect(listAgentProcesses('claude')).toEqual([]); + + mockedExecFileSync.mockReset(); + mockedExecFileSync.mockReturnValue(''); + expect(listAgentProcesses('claude')).toEqual([]); + }); + + it('listAgentProcesses rejects invalid name patterns before invoking PowerShell', () => { + expect(listAgentProcesses('claude; rm -rf /')).toEqual([]); + expect(mockedExecFileSync).not.toHaveBeenCalled(); + }); + + it('batchGetProcessStartTimes parses CIM Unix millisecond output and skips bad rows', () => { + const firstTime = Date.UTC(2026, 2, 18, 23, 18, 1); + const secondTime = Date.UTC(2026, 2, 9, 21, 41, 42); + mockedExecFileSync.mockReturnValue(JSON.stringify([ + { ProcessId: 78070, StartTimeMs: firstTime }, + { ProcessId: 'bad', StartTimeMs: secondTime }, + { ProcessId: 55106, StartTimeMs: String(secondTime) }, + { ProcessId: 99999, StartTimeMs: 'not-a-date' }, + ])); + + const times = batchGetProcessStartTimes([78070, 55106, 99999]); + + expect(times.size).toBe(2); + expect(times.get(78070)?.getTime()).toBe(firstTime); + expect(times.get(55106)?.getTime()).toBe(secondTime); + expect(times.has(99999)).toBe(false); + expect(mockedExecFileSync).toHaveBeenCalledTimes(1); + expect(mockedExecFileSync.mock.calls[0][0]).toBe('powershell'); + expect((mockedExecFileSync.mock.calls[0][1] as string[])[3]).toContain('ToUnixTimeMilliseconds'); + }); + + it('batchGetProcessCwds returns an empty map without invoking PowerShell', () => { + const cwds = batchGetProcessCwds([78070, 55106]); + + expect(cwds).toEqual(new Map()); + expect(mockedExecFileSync).not.toHaveBeenCalled(); + }); + + it('getProcessTty returns unknown tty without invoking PowerShell', () => { + expect(getProcessTty(78070)).toBe('?'); + expect(mockedExecFileSync).not.toHaveBeenCalled(); + }); +}); + describe('enrichProcesses', () => { beforeEach(() => { mockedExecFileSync.mockReset(); diff --git a/packages/agent-manager/src/utils/process.ts b/packages/agent-manager/src/utils/process.ts index 8675829b..bc181f1a 100644 --- a/packages/agent-manager/src/utils/process.ts +++ b/packages/agent-manager/src/utils/process.ts @@ -9,6 +9,48 @@ import * as path from 'path'; import { execFileSync } from 'child_process'; import type { ProcessInfo } from '../adapters/AgentAdapter.js'; +type PowerShellJsonRecord = Record; + +function runPowerShell(script: string): string { + return execFileSync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }, + ); +} + +function parsePowerShellJson(output: string): PowerShellJsonRecord[] { + const trimmed = output.trim(); + if (!trimmed) return []; + + const parsed: unknown = JSON.parse(trimmed); + const rows = Array.isArray(parsed) ? parsed : [parsed]; + + return rows.filter((row): row is PowerShellJsonRecord => ( + row !== null && typeof row === 'object' && !Array.isArray(row) + )); +} + +function getNumber(value: unknown): number { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim()) return Number(value); + return NaN; +} + +function getString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function getWindowsCommandExecutable(command: string): string { + const trimmed = command.trim(); + if (!trimmed) return ''; + + const quoted = trimmed.match(/^"([^"]+)"/); + if (quoted) return quoted[1]; + + return trimmed.split(/\s+/)[0] || ''; +} + /** * List running processes matching an agent executable name. * @@ -24,6 +66,42 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] { return []; } + if (process.platform === 'win32') { + try { + const output = runPowerShell( + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + ); + const lowerPattern = namePattern.toLowerCase(); + const processes: ProcessInfo[] = []; + + for (const row of parsePowerShellJson(output)) { + const pid = getNumber(row.ProcessId); + const ppid = getNumber(row.ParentProcessId); + if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue; + + const executablePath = getString(row.ExecutablePath); + const commandLine = getString(row.CommandLine); + const executable = executablePath || getWindowsCommandExecutable(commandLine); + const base = path.win32.basename(executable).toLowerCase(); + if (base !== lowerPattern && base !== `${lowerPattern}.exe`) { + continue; + } + + processes.push({ + pid, + ppid, + command: commandLine || executablePath, + cwd: '', + tty: '?', + }); + } + + return processes; + } catch { + return []; + } + } + try { const output = execFileSync('ps', ['-axo', 'pid=,ppid=,tty=,command='], { encoding: 'utf-8' }); @@ -76,6 +154,7 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] { export function batchGetProcessCwds(pids: number[]): Map { const result = new Map(); if (pids.length === 0) return result; + if (process.platform === 'win32') return result; try { const output = execFileSync( @@ -125,6 +204,32 @@ export function batchGetProcessStartTimes(pids: number[]): Map { const result = new Map(); if (pids.length === 0) return result; + if (process.platform === 'win32') { + const safePids = pids.filter(pid => Number.isInteger(pid) && pid > 0); + if (safePids.length === 0) return result; + + const filter = safePids.map(pid => `ProcessId = ${pid}`).join(' OR '); + const script = `Get-CimInstance Win32_Process -Filter "${filter}" | Select-Object ProcessId,@{Name='StartTimeMs';Expression={([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()}} | ConvertTo-Json -Compress`; + + try { + const output = runPowerShell(script); + for (const row of parsePowerShellJson(output)) { + const pid = getNumber(row.ProcessId); + const startTimeMs = getNumber(row.StartTimeMs); + if (!Number.isFinite(pid) || !Number.isFinite(startTimeMs)) continue; + + const date = new Date(startTimeMs); + if (!Number.isNaN(date.getTime())) { + result.set(pid, date); + } + } + } catch { + // Return whatever we have + } + + return result; + } + try { const output = execFileSync( 'ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')], @@ -236,6 +341,8 @@ export function findWrapperProcessPids( * Get the TTY device for a specific process */ export function getProcessTty(pid: number): string { + if (process.platform === 'win32') return '?'; + try { const output = execFileSync( 'ps', ['-p', String(pid), '-o', 'tty='],