Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions packages/agent-manager/src/__tests__/utils/process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,30 @@ import {
enrichProcesses,
findWrapperProcess,
findWrapperProcessPids,
getProcessTty,
} from '../../utils/process.js';

vi.mock('child_process', () => ({
execFileSync: vi.fn(),
}));

const mockedExecFileSync = execFileSync as MockedFunction<typeof execFileSync>;
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(() => {
Expand Down Expand Up @@ -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();
Expand Down
107 changes: 107 additions & 0 deletions packages/agent-manager/src/utils/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,48 @@ import * as path from 'path';
import { execFileSync } from 'child_process';
import type { ProcessInfo } from '../adapters/AgentAdapter.js';

type PowerShellJsonRecord = Record<string, unknown>;

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.
*
Expand All @@ -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' });

Expand Down Expand Up @@ -76,6 +154,7 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] {
export function batchGetProcessCwds(pids: number[]): Map<number, string> {
const result = new Map<number, string>();
if (pids.length === 0) return result;
if (process.platform === 'win32') return result;

try {
const output = execFileSync(
Expand Down Expand Up @@ -125,6 +204,32 @@ export function batchGetProcessStartTimes(pids: number[]): Map<number, Date> {
const result = new Map<number, Date>();
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(',')],
Expand Down Expand Up @@ -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='],
Expand Down
Loading