diff --git a/docs/ai/design/2026-08-14-feature-console-main-thread-responsiveness.md b/docs/ai/design/2026-08-14-feature-console-main-thread-responsiveness.md new file mode 100644 index 00000000..d9eef737 --- /dev/null +++ b/docs/ai/design/2026-08-14-feature-console-main-thread-responsiveness.md @@ -0,0 +1,55 @@ +--- +phase: design +title: Shared Asynchronous Process Snapshot +description: One enriched process snapshot per AgentManager refresh +--- + +# System Design & Architecture + +## Architecture Overview + +```mermaid +flowchart LR + Console[Ink console polling] --> Manager[AgentManager.listAgents] + Manager --> Snapshot[captureProcessSnapshot] + Snapshot --> PS[async ps once] + Snapshot --> Enrich[async batched cwd/start enrichment] + Manager --> F1[filter by adapter processNames] + Manager --> F2[filter by adapter processNames] + F1 --> A1[adapter canHandle/session mapping] + F2 --> A2[adapter canHandle/session mapping] + Snapshot --> Manager +``` + +`AgentManager` gathers the optional executable-name hints advertised by snapshot-aware adapters and requests one enriched union snapshot. Before dispatch, it slices that snapshot by each adapter's declared argv[0] executable names. Each adapter then applies its existing `canHandle` logic before session discovery. This preserves the pre-snapshot candidate pools for broad Node-entrypoint matchers such as Pi and Gemini without repeating process scans. + +## Data Models and API + +- `AgentDetectionContext.processes`: a read-only, adapter-scoped array of enriched `ProcessInfo` records from one capture. +- `AgentAdapter.processNames?`: executable basenames needed by that adapter (`node` included for Gemini and Pi). +- `AgentAdapter.detectAgents(context?)`: optional context preserves source compatibility for existing implementations and direct calls. +- `captureProcessSnapshot(names)`: async utility that performs one `ps` listing, filters relevant basenames, then asynchronously enriches the union of candidate PIDs. +- `filterByProcessNames(processes, names)`: shared argv[0] filter used by capture, manager dispatch, and defensive adapter boundaries, including Windows separator and `.exe` normalization. + +## Design Decisions + +- Chosen: optional adapter hints plus an optional detection context. This avoids enriching every OS process and preserves legacy third-party adapters. +- Rejected: capture/enrich all OS processes. It is simpler at the interface but can create oversized `lsof`/`ps -p` arguments and unnecessary work. +- Rejected: async scans inside every adapter. It frees the event loop but retains repeated scans and does not satisfy one-snapshot-per-refresh semantics. +- Chosen: manager-owned executable-name slicing based only on the adapter's declared `processNames`. This restores historical input pools without coupling the manager to tool-specific token matching. +- Rejected: passing the full union to every adapter. Pi and Gemini intentionally inspect argv[1..], so foreign agents with `pi` or `gemini` path arguments become false positives. + +## Failure and Compatibility Behavior + +- Snapshot command failures resolve to an empty snapshot, matching prior discovery helpers. +- Enrichment is best-effort and preserves empty `cwd`/missing `startTime` per PID. +- `lsof` failure uses asynchronous per-PID `pwdx` fallback on platforms where available. +- Snapshot-aware built-ins use a local async snapshot when invoked without manager context. +- Built-ins defensively scope hand-built contexts to their declared executable names before applying `canHandle`. +- Adapters without `processNames` receive no context and keep their historical behavior. + +## Non-Functional Requirements + +- No `execFileSync` on the built-in multi-adapter refresh path. +- Exactly one base `ps -axo` capture per manager refresh with snapshot-aware adapters. +- No polling or Ink rendering configuration changes. diff --git a/docs/ai/implementation/2026-08-14-feature-console-main-thread-responsiveness.md b/docs/ai/implementation/2026-08-14-feature-console-main-thread-responsiveness.md new file mode 100644 index 00000000..3db6a939 --- /dev/null +++ b/docs/ai/implementation/2026-08-14-feature-console-main-thread-responsiveness.md @@ -0,0 +1,38 @@ +--- +phase: implementation +title: Shared Process Snapshot Implementation +description: Implementation record for asynchronous agent discovery +--- + +# Implementation Guide + +## Status + +Implemented after focused tests failed for the intended missing snapshot behavior. + +## Intended Code Structure + +- `utils/process.ts`: asynchronous capture, parsing, filtering, and enrichment. +- `adapters/AgentAdapter.ts`: optional discovery context and process-name hints. +- `AgentManager.ts`: one shared snapshot per refresh and legacy adapter compatibility. +- Built-in adapters: filter the provided snapshot or asynchronously capture one for direct calls. + +## Compatibility and Error Handling + +Keep current synchronous exports intact for external callers. Async discovery resolves command failures to empty/partial data. Adapter exceptions remain isolated by `AgentManager`. + +## Design Deviations + +None. + +## Alignment Review + +The implementation matches the requirements and reviewed design: one manager-owned async union snapshot, manager-owned executable slicing, adapter-owned command matching, async direct-call fallback, preserved legacy adapters, unchanged registry/sorting/error boundaries, and no console polling or rendering-option changes. + +## Changed Files and Decisions + +- `utils/process.ts` exposes callback-based async capture and enrichment plus shared executable normalization/filtering while retaining sync compatibility exports. Async commands use an explicit 10 MiB buffer and no unsupported `stdio` option. +- `AgentAdapter` accepts an optional read-only detection context and optional executable hints. +- `AgentManager` captures one union snapshot and passes each snapshot-aware adapter only the argv[0] slice declared by its `processNames`. +- All seven built-in adapters defensively scope provided contexts, preserve their `canHandle` narrowing, support Windows command paths, and use async standalone capture when called directly. +- Adapter fixtures retain their existing behavior assertions through a compatibility-shim mock of standalone capture; manager-path behavior is tested separately against the real union-and-slice contract. diff --git a/docs/ai/planning/2026-08-14-feature-console-main-thread-responsiveness.md b/docs/ai/planning/2026-08-14-feature-console-main-thread-responsiveness.md new file mode 100644 index 00000000..96e47e81 --- /dev/null +++ b/docs/ai/planning/2026-08-14-feature-console-main-thread-responsiveness.md @@ -0,0 +1,32 @@ +--- +phase: planning +title: Shared Process Snapshot Plan +description: Test-first implementation plan for responsive agent refreshes +--- + +# Project Planning & Task Breakdown + +## Task Queue + +- [x] `done` Add deterministic failing manager/process tests for one shared snapshot and async discovery. +- [x] `done` Implement asynchronous process capture and enrichment with platform fallbacks. +- [x] `done` Add optional adapter discovery context and migrate all built-in adapters. +- [x] `done` Preserve and test failure, sorting, registry, direct-adapter, and export compatibility. +- [x] `done` Validate agent-manager and CLI focused/full tests, lint, and builds. +- [x] `done` Correct PR review findings: per-adapter snapshot slicing, defensive adapter filtering, async buffer options, and Windows path normalization. +- [x] `done` Commit and push the review fix to the existing PR without merging. + +## Dependencies + +Tests define the public boundary before production changes. Utility implementation precedes adapter migration; full validation precedes commit and publication. + +## Risks & Mitigation + +- Direct adapter callers could break: make context optional and capture asynchronously when absent. +- Third-party adapters could receive an incomplete snapshot: only pass context to adapters advertising process names. +- Concurrent mutation could leak across adapters: expose a read-only snapshot and filter into new arrays. +- Platform fallback could regress: retain command shapes and best-effort empty/partial results. + +## Progress Summary + +The review fix and fresh validation are complete with no blockers. The restricted sandbox could not inspect the current process for one print integration, so the full suite was rerun with process-inspection access and passed. The reviewed implementation is ready on the existing PR branch. diff --git a/docs/ai/requirements/2026-08-14-feature-console-main-thread-responsiveness.md b/docs/ai/requirements/2026-08-14-feature-console-main-thread-responsiveness.md new file mode 100644 index 00000000..0778d5b5 --- /dev/null +++ b/docs/ai/requirements/2026-08-14-feature-console-main-thread-responsiveness.md @@ -0,0 +1,43 @@ +--- +phase: requirements +title: Agent Console Main-Thread Responsiveness +description: Remove repeated blocking process scans from agent-console refreshes +--- + +# Requirements & Problem Understanding + +## Problem Statement + +`AgentManager.listAgents()` invokes seven adapters in `Promise.all`, but every built-in adapter synchronously calls `listAgentProcesses()`. Gemini and Pi also inspect `node`, producing at least eight blocking `ps` scans per console refresh. The synchronous child-process calls block Ink input and rendering even though adapter promises are concurrent. + +## Goals & Objectives + +- Capture the relevant process data once per multi-adapter refresh. +- Run process discovery and enrichment asynchronously so the event loop remains available. +- Let adapters retain their existing process matching and session mapping behavior. +- Preserve standalone adapter calls and public compatibility where practical. +- Preserve process enrichment, partial adapter failure handling, sorting, registry behavior, and platform fallbacks. + +## Non-Goals + +- Changing the 3000 ms console polling interval. +- Disabling Ink `incrementalRendering`. +- Applying broad `React.memo` changes. +- Reworking session discovery or registry semantics. + +## Success Criteria + +- A multi-adapter `listAgents()` call performs one shared asynchronous process snapshot. +- Built-in adapter discovery no longer calls repeated synchronous process scans. +- Tests prove sharing and async boundaries using deterministic mocks rather than wall-clock thresholds. +- Agent-manager and CLI focused/full tests, lint, and builds pass. + +## Constraints & Assumptions + +- Existing synchronous process helpers remain exported for compatibility, but the refresh path does not use them. +- A process snapshot failure behaves like an empty process list; individual adapter failures still yield partial results. +- Linux `pwdx` fallback and Windows `.exe` matching remain supported. + +## Questions & Open Items + +No material open questions. The user explicitly approved the objective, constraints, validation, commit, and PR workflow. diff --git a/docs/ai/testing/2026-08-14-feature-console-main-thread-responsiveness.md b/docs/ai/testing/2026-08-14-feature-console-main-thread-responsiveness.md new file mode 100644 index 00000000..a0421724 --- /dev/null +++ b/docs/ai/testing/2026-08-14-feature-console-main-thread-responsiveness.md @@ -0,0 +1,44 @@ +--- +phase: testing +title: Shared Process Snapshot Testing +description: Deterministic validation of shared asynchronous discovery +--- + +# Testing Strategy + +## Unit and Integration Scenarios + +- [x] Manager captures one union snapshot and dispatches only each adapter's declared executable slice. +- [x] Foreign Codex/Claude path arguments cannot reach Gemini/Pi broad token matchers. +- [x] Legacy adapters remain callable without a discovery context. +- [x] Process capture uses asynchronous child-process execution and one base scan. +- [x] Relevant executable filtering includes `.exe` and shared `node` candidates. +- [x] Windows separators are normalized consistently in capture, manager slicing, and adapter matching. +- [x] Async child-process calls set an explicit buffer and do not pass unsupported `stdio` options. +- [x] Async enrichment preserves partial data and Linux `pwdx` fallback. +- [x] Direct built-in adapter calls remain compatible. +- [x] Existing adapter failure, sorting, registry, and session tests remain green. + +## Non-Flaky Proof + +Mock callback-based child-process boundaries and assert invocation/order/data flow. Do not use elapsed-time thresholds. + +## Validation Commands + +- Focused/new agent-manager tests. +- Full agent-manager tests, lint, and build. +- Focused CLI console tests. +- Full CLI tests, lint, and build. +- Root full test, lint, and build commands. + +## Current Evidence + +- Review red: six deterministic failures proved full-union leakage, missing shared filtering, missing async `maxBuffer`, and Windows-path rejection. +- Review green: focused manager/process/Pi/Gemini tests pass (131 tests). +- Adapter regression: all seven adapter files pass (296 tests). +- Regression gate: the foreign-argument test fails when manager slicing is removed and passes after restoration. +- Agent-manager full: 24 files / 510 tests passed; lint, typecheck, and build exit 0. +- CLI agent/console focused: 23 files / 207 tests passed. +- CLI full: 81 files / 967 tests passed; lint and build exit 0. +- Repository full: all six projects passed serial tests, lint, and build with exit 0. Lint reports six existing warnings and zero errors. +- Feature/base lifecycle lint passed. diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index 3220cf90..7141b24b 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -10,9 +10,13 @@ import type { AgentInfo, SessionSummary, ListSessionsOptions, + ProcessInfo, } from './adapters/AgentAdapter.js'; import { sortAgents, type AgentSortKey } from './utils/sortAgents.js'; import { AgentRegistry, type RegistryEntry } from './utils/AgentRegistry.js'; +import { captureProcessSnapshot, filterByProcessNames } from './utils/process.js'; + +type ProcessSnapshotCapture = (namePatterns: readonly string[]) => Promise; export interface ListAgentsOptions { /** @@ -41,7 +45,10 @@ export class AgentManager { private adapters: Map = new Map(); private registry: AgentRegistry; - constructor(registry: AgentRegistry = AgentRegistry.default()) { + constructor( + registry: AgentRegistry = AgentRegistry.default(), + private readonly captureSnapshot: ProcessSnapshotCapture = captureProcessSnapshot, + ) { this.registry = registry; } @@ -126,10 +133,27 @@ export class AgentManager { const allAgents: AgentInfo[] = []; const errors: Array<{ type: string; error: Error }> = []; - // Query all adapters in parallel - const adapterPromises = Array.from(this.adapters.values()).map(async (adapter) => { + const adapters = Array.from(this.adapters.values()); + const processNames = Array.from(new Set(adapters.flatMap( + (adapter) => adapter.processNames ? [...adapter.processNames] : [], + ))); + let processes: readonly ProcessInfo[] = []; + if (processNames.length > 0) { + try { + processes = await this.captureSnapshot(processNames); + } catch { + processes = []; + } + } + + // Query all adapters in parallel using executable-scoped slices of the shared snapshot. + const adapterPromises = adapters.map(async (adapter) => { try { - const agents = await adapter.detectAgents(); + const agents = adapter.processNames + ? await adapter.detectAgents({ + processes: filterByProcessNames(processes, adapter.processNames), + }) + : await adapter.detectAgents(); return { type: adapter.type, agents, error: null }; } catch (error) { // Capture error but don't throw - allow other adapters to continue diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index cf6273ed..b4783ae5 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -13,6 +13,7 @@ import type { AgentType, ConversationMessage, SessionSummary, + ProcessInfo, } from '../adapters/AgentAdapter.js'; import { AgentStatus } from '../adapters/AgentAdapter.js'; import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js'; @@ -170,6 +171,94 @@ describe('AgentManager', () => { }); describe('listAgents', () => { + it('shares one process capture while giving each adapter only its declared executables', async () => { + const processes: ProcessInfo[] = [ + { pid: 101, command: 'claude', cwd: '/claude', tty: 's001' }, + { pid: 202, command: 'node /bin/pi', cwd: '/pi', tty: 's002' }, + ]; + const captureSnapshot = vi.fn(async () => processes); + const createSnapshotAdapter = (type: AgentType, processNames: string[]) => ({ + type, + processNames, + detectAgents: vi.fn(async () => []), + canHandle: () => true, + getConversation: () => [], + listSessions: async () => [], + }); + const claude = createSnapshotAdapter('claude', ['claude']); + const pi = createSnapshotAdapter('pi', ['pi', 'node']); + const snapshotManager = new AgentManager( + new AgentRegistry(path.join(tmpDir, 'snapshot-agents.json')), + captureSnapshot, + ); + + snapshotManager.registerAdapter(claude as AgentAdapter); + snapshotManager.registerAdapter(pi as AgentAdapter); + + await snapshotManager.listAgents(); + + expect(captureSnapshot).toHaveBeenCalledTimes(1); + expect(captureSnapshot).toHaveBeenCalledWith(['claude', 'pi', 'node']); + expect(claude.detectAgents).toHaveBeenCalledWith({ processes: [processes[0]] }); + expect(pi.detectAgents).toHaveBeenCalledWith({ processes: [processes[1]] }); + }); + + it('does not expose foreign command arguments to broad Pi and Gemini matchers', async () => { + const processes: ProcessInfo[] = [ + { pid: 100, command: 'node /usr/local/lib/gemini.js', cwd: '/g', tty: 's001' }, + { pid: 200, command: 'codex exec --cd /Users/x/repos/gemini', cwd: '/c', tty: 's002' }, + { pid: 300, command: 'node /usr/local/lib/pi.js', cwd: '/p', tty: 's003' }, + { pid: 400, command: 'claude --resume /Users/x/pi/session.jsonl', cwd: '/a', tty: 's004' }, + ]; + const captureSnapshot = vi.fn(async () => processes); + const createSnapshotAdapter = (type: AgentType, processNames: string[]) => ({ + type, + processNames, + detectAgents: vi.fn(async () => []), + canHandle: () => true, + getConversation: () => [], + listSessions: async () => [], + }); + const gemini = createSnapshotAdapter('gemini_cli', ['node']); + const codex = createSnapshotAdapter('codex', ['codex']); + const pi = createSnapshotAdapter('pi', ['pi', 'node']); + const claude = createSnapshotAdapter('claude', ['claude']); + const snapshotManager = new AgentManager( + new AgentRegistry(path.join(tmpDir, 'filtered-snapshot-agents.json')), + captureSnapshot, + ); + + snapshotManager.registerAdapter(gemini as AgentAdapter); + snapshotManager.registerAdapter(codex as AgentAdapter); + snapshotManager.registerAdapter(pi as AgentAdapter); + snapshotManager.registerAdapter(claude as AgentAdapter); + + await snapshotManager.listAgents(); + + expect(captureSnapshot).toHaveBeenCalledTimes(1); + expect(captureSnapshot).toHaveBeenCalledWith(['node', 'codex', 'pi', 'claude']); + expect(gemini.detectAgents).toHaveBeenCalledWith({ processes: [processes[0], processes[2]] }); + expect(codex.detectAgents).toHaveBeenCalledWith({ processes: [processes[1]] }); + expect(pi.detectAgents).toHaveBeenCalledWith({ processes: [processes[0], processes[2]] }); + expect(claude.detectAgents).toHaveBeenCalledWith({ processes: [processes[3]] }); + }); + + it('does not pass a snapshot context to legacy adapters', async () => { + const captureSnapshot = vi.fn(async () => []); + const legacy = new MockAdapter('claude'); + const detect = vi.spyOn(legacy, 'detectAgents'); + const snapshotManager = new AgentManager( + new AgentRegistry(path.join(tmpDir, 'legacy-agents.json')), + captureSnapshot, + ); + snapshotManager.registerAdapter(legacy); + + await snapshotManager.listAgents(); + + expect(captureSnapshot).not.toHaveBeenCalled(); + expect(detect).toHaveBeenCalledWith(); + }); + it('should return empty array when no adapters registered', async () => { const agents = await manager.listAgents(); expect(agents).toEqual([]); diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index a965213f..5b8b1785 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -9,16 +9,21 @@ import * as path from 'path'; import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter.js'; import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; import { AgentStatus } from '../../adapters/AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js'; import { batchGetSessionFileBirthtimes } from '../../utils/session.js'; import type { SessionFile } from '../../utils/session.js'; import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js'; import type { MatchResult } from '../../utils/matching.js'; import * as os from 'os'; -vi.mock('../../utils/process.js', () => ({ - listAgentProcesses: vi.fn(), - enrichProcesses: vi.fn(), -})); +vi.mock('../../utils/process.js', async (importOriginal) => { + const actual = await importOriginal() as typeof import('../../utils/process.js'); + return { + ...actual, + listAgentProcesses: vi.fn(), + enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), + }; +}); vi.mock('../../utils/session.js', async () => { const actual = await vi.importActual('../../utils/session') as typeof import('../../utils/session'); @@ -35,6 +40,7 @@ vi.mock('../../utils/matching.js', () => ({ const mockedListAgentProcesses = listAgentProcesses as MockedFunction; const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction; const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as MockedFunction; const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction; const mockedGenerateAgentName = generateAgentName as MockedFunction; @@ -45,11 +51,16 @@ describe('ClaudeCodeAdapter', () => { adapter = new ClaudeCodeAdapter(); mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedBatchGetSessionFileBirthtimes.mockReset(); mockedMatchProcessesToSessions.mockReset(); mockedGenerateAgentName.mockReset(); // Default: enrichProcesses returns what it receives mockedEnrichProcesses.mockImplementation((procs) => procs); + // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name. + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); // Default: generateAgentName returns "folder (pid)" mockedGenerateAgentName.mockImplementation((cwd, pid) => { const folder = path.basename(cwd) || 'unknown'; diff --git a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts index 7bc591c9..5a13e08f 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -10,17 +10,22 @@ import { CodexAdapter } from '../../adapters/CodexAdapter.js'; import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; import { AgentStatus } from '../../adapters/AgentAdapter.js'; import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js'; -import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js'; import { batchGetSessionFileBirthtimes } from '../../utils/session.js'; import type { SessionFile } from '../../utils/session.js'; import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js'; import type { MatchResult } from '../../utils/matching.js'; import * as os from 'os'; -vi.mock('../../utils/process.js', () => ({ - listAgentProcesses: vi.fn(), - enrichProcesses: vi.fn(), -})); +vi.mock('../../utils/process.js', async (importOriginal) => { + const actual = await importOriginal() as typeof import('../../utils/process.js'); + return { + ...actual, + listAgentProcesses: vi.fn(), + enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), + }; +}); vi.mock('../../utils/session.js', async () => { const actual = await vi.importActual('../../utils/session') as typeof import('../../utils/session'); @@ -37,6 +42,7 @@ vi.mock('../../utils/matching.js', () => ({ const mockedListAgentProcesses = listAgentProcesses as MockedFunction; const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction; const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as MockedFunction; const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction; const mockedGenerateAgentName = generateAgentName as MockedFunction; @@ -48,11 +54,16 @@ describe('CodexAdapter', () => { adapter = new CodexAdapter(); mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedBatchGetSessionFileBirthtimes.mockReset(); mockedMatchProcessesToSessions.mockReset(); mockedGenerateAgentName.mockReset(); // Default: enrichProcesses returns what it receives mockedEnrichProcesses.mockImplementation((procs) => procs); + // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name. + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); // Default: generateAgentName returns "folder (pid)" mockedGenerateAgentName.mockImplementation((cwd, pid) => { const folder = path.basename(cwd) || 'unknown'; diff --git a/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts index 54db3b80..3b1b3b56 100644 --- a/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { CopilotAdapter } from '../../adapters/CopilotAdapter.js'; import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; import { AgentStatus } from '../../adapters/AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js'; import { generateAgentName } from '../../utils/matching.js'; import { AgentRegistry } from '../../utils/AgentRegistry.js'; @@ -20,6 +20,7 @@ vi.mock('../../utils/process.js', async (importOriginal) => { ...actual, listAgentProcesses: vi.fn(), enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), }; }); @@ -29,6 +30,7 @@ vi.mock('../../utils/matching.js', () => ({ const mockedListAgentProcesses = listAgentProcesses as MockedFunction; const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction; const mockedGenerateAgentName = generateAgentName as MockedFunction; describe('CopilotAdapter', () => { @@ -45,8 +47,13 @@ describe('CopilotAdapter', () => { mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedGenerateAgentName.mockReset(); mockedEnrichProcesses.mockImplementation((procs) => procs); + // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name. + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); mockedGenerateAgentName.mockImplementation((cwd, pid) => `${path.basename(cwd) || 'unknown'} (${pid})`); }); diff --git a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts index 07ec453f..0e7a2f1f 100644 --- a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts @@ -11,7 +11,7 @@ import { GeminiCliAdapter } from '../../adapters/GeminiCliAdapter.js'; import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; import { AgentStatus } from '../../adapters/AgentAdapter.js'; import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js'; -import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js'; import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js'; import * as crypto from 'crypto'; @@ -21,6 +21,7 @@ vi.mock('../../utils/process.js', async (importOriginal) => { ...actual, listAgentProcesses: vi.fn(), enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), }; }); @@ -31,6 +32,7 @@ vi.mock('../../utils/matching.js', () => ({ const mockedListAgentProcesses = listAgentProcesses as MockedFunction; const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction; const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction; const mockedGenerateAgentName = generateAgentName as MockedFunction; @@ -45,10 +47,15 @@ describe('GeminiCliAdapter', () => { adapter = new GeminiCliAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json'))); mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedMatchProcessesToSessions.mockReset(); mockedGenerateAgentName.mockReset(); mockedEnrichProcesses.mockImplementation((procs) => procs); + // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name. + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); mockedMatchProcessesToSessions.mockReturnValue([]); mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => { const folder = path.basename(cwd) || 'unknown'; @@ -110,6 +117,15 @@ describe('GeminiCliAdapter', () => { tty: 'ttys006', })).toBe(true); }); + + it('should recognize Windows executable and entrypoint paths', () => { + expect(adapter.canHandle({ + pid: 7, + command: 'C:\\tools\\node.exe C:\\lib\\gemini.js', + cwd: 'C:\\repo', + tty: '', + })).toBe(true); + }); }); describe('detectAgents', () => { @@ -144,7 +160,7 @@ describe('GeminiCliAdapter', () => { it('should return process-only agents when no session files exist for the process', async () => { const proc: ProcessInfo = { pid: 1234, - command: 'gemini', + command: 'node /usr/local/bin/gemini', cwd: '/repo', tty: 'ttys001', startTime: new Date('2026-04-18T00:00:00Z'), @@ -257,7 +273,7 @@ describe('GeminiCliAdapter', () => { const proc: ProcessInfo = { pid: 42, - command: 'gemini', + command: 'node /usr/local/bin/gemini', cwd, tty: 'ttys001', startTime: new Date('2026-04-18T00:00:00Z'), @@ -383,7 +399,7 @@ describe('GeminiCliAdapter', () => { const proc: ProcessInfo = { pid: 7, - command: 'gemini', + command: 'node /usr/local/bin/gemini', cwd: procCwd, tty: 'ttys001', startTime: new Date(), diff --git a/packages/agent-manager/src/__tests__/adapters/GrokCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GrokCliAdapter.test.ts index 38641db1..4c686ca9 100644 --- a/packages/agent-manager/src/__tests__/adapters/GrokCliAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/GrokCliAdapter.test.ts @@ -13,7 +13,7 @@ import * as path from 'path'; import { GrokCliAdapter } from '../../adapters/GrokCliAdapter.js'; import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; import { AgentStatus } from '../../adapters/AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js'; import { generateAgentName } from '../../utils/matching.js'; vi.mock('../../utils/process.js', async (importOriginal) => { @@ -22,6 +22,7 @@ vi.mock('../../utils/process.js', async (importOriginal) => { ...actual, listAgentProcesses: vi.fn(), enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), }; }); @@ -35,6 +36,7 @@ vi.mock('../../utils/matching.js', async (importOriginal) => { const mockedListAgentProcesses = listAgentProcesses as MockedFunction; const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction; const mockedGenerateAgentName = generateAgentName as MockedFunction; const SESSION_ID = '019f16c3-5d5d-7dc3-85d1-bc629416ca2d'; @@ -64,9 +66,14 @@ describe('GrokCliAdapter', () => { mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedGenerateAgentName.mockReset(); mockedEnrichProcesses.mockImplementation((procs) => procs); + // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name. + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); mockedGenerateAgentName.mockImplementation((c: string, pid: number) => `${path.basename(c) || 'unknown'}-${pid}`); }); diff --git a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts index e4664f16..2f472d6f 100644 --- a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts @@ -9,14 +9,19 @@ import * as path from 'path'; import { OpenCodeAdapter } from '../../adapters/OpenCodeAdapter.js'; import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; import { AgentStatus } from '../../adapters/AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js'; import { generateAgentName } from '../../utils/matching.js'; import * as os from 'os'; -vi.mock('../../utils/process.js', () => ({ - listAgentProcesses: vi.fn(), - enrichProcesses: vi.fn(), -})); +vi.mock('../../utils/process.js', async (importOriginal) => { + const actual = await importOriginal() as typeof import('../../utils/process.js'); + return { + ...actual, + listAgentProcesses: vi.fn(), + enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), + }; +}); vi.mock('../../utils/matching.js', () => ({ generateAgentName: vi.fn(), @@ -25,6 +30,7 @@ vi.mock('../../utils/matching.js', () => ({ const mockedListAgentProcesses = listAgentProcesses as MockedFunction; const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction; const mockedGenerateAgentName = generateAgentName as MockedFunction; function makeDb(queries: { @@ -104,9 +110,14 @@ describe('OpenCodeAdapter', () => { mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedGenerateAgentName.mockReset(); mockedEnrichProcesses.mockImplementation((procs) => procs); + // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name. + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); mockedGenerateAgentName.mockImplementation((cwd, pid) => { const folder = path.basename(cwd) || 'unknown'; return `${folder}-${pid}`; diff --git a/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts index dbcaa436..a885fb33 100644 --- a/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts @@ -11,13 +11,18 @@ import { PiAdapter } from '../../adapters/PiAdapter.js'; import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; import { AgentStatus } from '../../adapters/AgentAdapter.js'; import { AgentRegistry } from '../../utils/AgentRegistry.js'; -import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js'; import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js'; -vi.mock('../../utils/process.js', () => ({ - listAgentProcesses: vi.fn(), - enrichProcesses: vi.fn(), -})); +vi.mock('../../utils/process.js', async (importOriginal) => { + const actual = await importOriginal() as typeof import('../../utils/process.js'); + return { + ...actual, + listAgentProcesses: vi.fn(), + enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), + }; +}); vi.mock('../../utils/matching.js', () => ({ matchProcessesToSessions: vi.fn(), @@ -26,6 +31,7 @@ vi.mock('../../utils/matching.js', () => ({ const mockedListAgentProcesses = listAgentProcesses as MockedFunction; const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction; const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction; const mockedGenerateAgentName = generateAgentName as MockedFunction; @@ -43,10 +49,15 @@ describe('PiAdapter', () => { adapter = new PiAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json'))); mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedMatchProcessesToSessions.mockReset(); mockedGenerateAgentName.mockReset(); mockedEnrichProcesses.mockImplementation((procs) => procs); + // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name. + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); mockedMatchProcessesToSessions.mockReturnValue([]); mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => { const folder = path.basename(cwd) || 'unknown'; @@ -67,6 +78,18 @@ describe('PiAdapter', () => { expect(adapter.canHandle({ pid: 2, command: '/usr/local/bin/PI --model x', cwd: '/repo', tty: 'ttys002' })).toBe(true); expect(adapter.canHandle({ pid: 3, command: 'node /opt/pi/bin/pi.js', cwd: '/repo', tty: 'ttys003' })).toBe(true); expect(adapter.canHandle({ pid: 4, command: 'node /repo/feature-pi-adapter/script.js', cwd: '/repo', tty: 'ttys004' })).toBe(false); + expect(adapter.canHandle({ pid: 5, command: 'C:\\tools\\node.exe C:\\lib\\pi.js', cwd: '/repo', tty: 'ttys005' })).toBe(true); + }); + + it('rejects foreign executables from a hand-built snapshot context', async () => { + const foreign: ProcessInfo = { + pid: 6, + command: 'claude --resume /Users/x/repos/pi/session.jsonl', + cwd: '/repo', + tty: 'ttys006', + }; + + expect(await adapter.detectAgents({ processes: [foreign] })).toEqual([]); }); it('maps a running Pi process to the tracker session for its PID', async () => { diff --git a/packages/agent-manager/src/__tests__/utils/process.test.ts b/packages/agent-manager/src/__tests__/utils/process.test.ts index 0966d83f..ab1ba5b0 100644 --- a/packages/agent-manager/src/__tests__/utils/process.test.ts +++ b/packages/agent-manager/src/__tests__/utils/process.test.ts @@ -4,7 +4,7 @@ import type { MockedFunction } from 'vitest'; -import { execFileSync } from 'child_process'; +import { execFile, execFileSync } from 'child_process'; import { listAgentProcesses, batchGetProcessCwds, @@ -12,13 +12,84 @@ import { enrichProcesses, findWrapperProcess, findWrapperProcessPids, + captureProcessSnapshot, + filterByProcessNames, } from '../../utils/process.js'; vi.mock('child_process', () => ({ + execFile: vi.fn(), execFileSync: vi.fn(), })); const mockedExecFileSync = execFileSync as MockedFunction; +const mockedExecFile = execFile as unknown as MockedFunction<( + file: string, + args: readonly string[], + options: object, + callback: (error: Error | null, stdout: string, stderr: string) => void, +) => void>; + +describe('captureProcessSnapshot', () => { + beforeEach(() => { + mockedExecFile.mockReset(); + mockedExecFileSync.mockReset(); + }); + + it('captures and enriches relevant processes without synchronous scans', async () => { + mockedExecFile.mockImplementation((file, args, _options, callback) => { + queueMicrotask(() => { + if (file === 'ps' && args.includes('-axo')) { + callback(null, + '100 1 s001 /usr/bin/claude --resume abc\n' + + '200 1 s002 C:\\\\tools\\\\node.exe C:\\\\bin\\\\pi.js\n' + + '300 1 s003 /usr/bin/unrelated\n', + ''); + return; + } + if (file === 'lsof') { + callback(null, 'p100\nn/projects/claude\np200\nn/projects/pi\n', ''); + return; + } + if (file === 'ps' && args.some((arg) => arg.includes('lstart='))) { + callback(null, + '100 Wed Mar 18 23:18:01 2026\n' + + '200 Thu Mar 19 10:00:00 2026\n', + ''); + return; + } + callback(new Error(`unexpected command: ${file} ${args.join(' ')}`), '', ''); + }); + }); + + const snapshot = await captureProcessSnapshot(['claude', 'node']); + + expect(snapshot.map((process) => process.pid)).toEqual([100, 200]); + expect(snapshot.map((process) => process.cwd)).toEqual(['/projects/claude', '/projects/pi']); + expect(snapshot.map((process) => process.startTime)).toEqual([ + expect.any(Date), + expect.any(Date), + ]); + expect(mockedExecFileSync).not.toHaveBeenCalled(); + expect(mockedExecFile.mock.calls.filter(([, args]) => args.includes('-axo'))).toHaveLength(1); + for (const [, , options] of mockedExecFile.mock.calls) { + expect(options).toMatchObject({ encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }); + expect(options).not.toHaveProperty('stdio'); + } + }); +}); + +describe('filterByProcessNames', () => { + it('matches only argv[0] and normalizes Windows paths and executable suffixes', () => { + const processes: ProcessInfo[] = [ + { pid: 1, command: 'C:\\tools\\node.exe C:\\bin\\pi.js', cwd: '', tty: '' }, + { pid: 2, command: '/usr/local/bin/node /opt/gemini.js', cwd: '', tty: '' }, + { pid: 3, command: 'codex exec --cd C:\\repos\\node', cwd: '', tty: '' }, + ]; + + expect(filterByProcessNames(processes, ['node'])).toEqual([processes[0], processes[1]]); + expect(filterByProcessNames(processes, ['node.exe'])).toEqual([processes[0], processes[1]]); + }); +}); describe('listAgentProcesses', () => { beforeEach(() => { diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index 2444d644..351bfbb3 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -59,7 +59,7 @@ export interface ProcessInfo { /** Process ID */ pid: number; - /** Parent process ID, populated by listAgentProcesses when available */ + /** Parent process ID, populated by process discovery when available */ ppid?: number; /** Process command */ @@ -71,7 +71,7 @@ export interface ProcessInfo { /** Terminal TTY (e.g., "ttys030") */ tty: string; - /** Process start time, populated by enrichProcesses */ + /** Process start time, populated by process enrichment */ startTime?: Date; } @@ -148,6 +148,11 @@ export interface ListSessionsOptions { type?: AgentType; } +export interface AgentDetectionContext { + /** One enriched process snapshot shared across this manager refresh. */ + readonly processes: readonly ProcessInfo[]; +} + /** * Agent Adapter Interface * @@ -157,11 +162,14 @@ export interface AgentAdapter { /** Type of agent this adapter handles */ readonly type: AgentType; + /** Executable basenames required for shared process discovery. */ + readonly processNames?: readonly string[]; + /** * Detect running agents of this type * @returns List of detected agents */ - detectAgents(): Promise; + detectAgents(context?: AgentDetectionContext): Promise; /** * Check if this adapter can handle the given process diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index cc7e4fde..a6dc7803 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -7,9 +7,10 @@ import type { ConversationMessage, SessionSummary, ListSessionsOptions, + AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../utils/process.js'; +import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js'; import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session.js'; import type { SessionFile } from '../utils/session.js'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; @@ -60,14 +61,15 @@ const PID_FILE_STALENESS_MS = 60000; * Claude Code Adapter * * Detects Claude Code agents by: - * 1. Finding running claude processes via shared listAgentProcesses() - * 2. Enriching with CWD and start times via shared enrichProcesses() + * 1. Filtering Claude processes from a shared asynchronous process snapshot + * 2. Using snapshot CWD and start-time enrichment * 3. Attempting authoritative PID-file matching via ~/.claude/sessions/.json * 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file * 5. Extracting summary from last user message in session JSONL */ export class ClaudeCodeAdapter implements AgentAdapter { readonly type = 'claude' as const; + readonly processNames = ['claude'] as const; private projectsDir: string; private sessionsDir: string; @@ -85,13 +87,14 @@ export class ClaudeCodeAdapter implements AgentAdapter { } private isClaudeExecutable(command: string): boolean { - const executable = command.trim().split(/\s+/)[0] || ''; - const base = path.basename(executable).toLowerCase(); + const base = executableBasename(command); return base === 'claude' || base === 'claude.exe'; } - async detectAgents(): Promise { - const processes = enrichProcesses(listAgentProcesses('claude')); + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); if (processes.length === 0) { return []; } diff --git a/packages/agent-manager/src/adapters/CodexAdapter.ts b/packages/agent-manager/src/adapters/CodexAdapter.ts index 28c1629f..29874230 100644 --- a/packages/agent-manager/src/adapters/CodexAdapter.ts +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -2,8 +2,8 @@ * Codex Adapter * * Detects running Codex agents by: - * 1. Finding running codex processes via shared listAgentProcesses() - * 2. Enriching with CWD and start times via shared enrichProcesses() + * 1. Filtering Codex processes from a shared asynchronous process snapshot + * 2. Using snapshot CWD and start-time enrichment * 3. Matching exact PID-to-session metadata from ~/.codex/ai-devkit/sessions.json * 4. Discovering session files from ~/.codex/sessions/YYYY/MM/DD/ via shared batchGetSessionFileBirthtimes() * 5. Setting resolvedCwd from session_meta first line @@ -20,9 +20,10 @@ import type { ConversationMessage, SessionSummary, ListSessionsOptions, + AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../utils/process.js'; +import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js'; import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js'; import type { SessionFile } from '../utils/session.js'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; @@ -88,6 +89,7 @@ interface MappingMatchResult { export class CodexAdapter implements AgentAdapter { readonly type = 'codex' as const; + readonly processNames = ['codex'] as const; private static readonly IDLE_THRESHOLD_MINUTES = 5; /** Include session files around process start day to recover long-lived processes. */ @@ -111,8 +113,10 @@ export class CodexAdapter implements AgentAdapter { /** * Detect running Codex agents */ - async detectAgents(): Promise { - const processes = enrichProcesses(listAgentProcesses('codex')); + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; const { cachedAgents, remaining } = this.tryRegistryCache(processes); @@ -657,8 +661,7 @@ export class CodexAdapter implements AgentAdapter { } private isCodexExecutable(command: string): boolean { - const executable = command.trim().split(/\s+/)[0] || ''; - const base = path.basename(executable).toLowerCase(); + const base = executableBasename(command); return base === 'codex' || base === 'codex.exe'; } diff --git a/packages/agent-manager/src/adapters/CopilotAdapter.ts b/packages/agent-manager/src/adapters/CopilotAdapter.ts index 9fffe60d..4bf86caf 100644 --- a/packages/agent-manager/src/adapters/CopilotAdapter.ts +++ b/packages/agent-manager/src/adapters/CopilotAdapter.ts @@ -2,8 +2,8 @@ * Copilot Adapter * * Detects running GitHub Copilot CLI agents by: - * 1. Finding running copilot processes via shared listAgentProcesses() - * 2. Enriching with CWD and start times via shared enrichProcesses() + * 1. Filtering Copilot processes from a shared asynchronous process snapshot + * 2. Using snapshot CWD and start-time enrichment * 3. Mapping active ~/.copilot/session-state/{sessionId}/inuse.{pid}.lock files to processes * 4. Reading events.jsonl as the primary session/conversation source * 5. Reading workspace.yaml as a flat fallback metadata source @@ -17,9 +17,16 @@ import type { ListSessionsOptions, ProcessInfo, SessionSummary, + AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { enrichProcesses, findWrapperProcess, findWrapperProcessPids, listAgentProcesses } from '../utils/process.js'; +import { + captureProcessSnapshot, + executableBasename, + filterByProcessNames, + findWrapperProcess, + findWrapperProcessPids, +} from '../utils/process.js'; import { generateAgentName } from '../utils/matching.js'; import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js'; import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js'; @@ -89,6 +96,7 @@ interface CopilotEventSummary { export class CopilotAdapter implements AgentAdapter { readonly type = 'copilot' as const; + readonly processNames = ['copilot'] as const; private static readonly IDLE_THRESHOLD_MINUTES = 5; private static readonly VERBOSE_SYSTEM_EVENTS = new Set([ @@ -120,8 +128,10 @@ export class CopilotAdapter implements AgentAdapter { return this.isCopilotExecutable(processInfo.command); } - async detectAgents(): Promise { - const processes = enrichProcesses(listAgentProcesses('copilot')); + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; const processByPid = new Map(processes.map((proc) => [proc.pid, proc])); @@ -457,8 +467,7 @@ export class CopilotAdapter implements AgentAdapter { } private isCopilotExecutable(command: string): boolean { - const executable = command.trim().split(/\s+/)[0] || ''; - const base = path.basename(executable).toLowerCase(); + const base = executableBasename(command); return base === 'copilot' || base === 'copilot.exe'; } } diff --git a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts index 5e3e7481..adfa5ba1 100644 --- a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts +++ b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts @@ -2,8 +2,8 @@ * Gemini CLI Adapter * * Detects running Gemini CLI agents by: - * 1. Finding running gemini processes via shared listAgentProcesses() - * 2. Enriching with CWD and start times via shared enrichProcesses() + * 1. Filtering Gemini Node processes from a shared asynchronous process snapshot + * 2. Using snapshot CWD and start-time enrichment * 3. Discovering session files from ~/.gemini/tmp//chats/session-*.json * 4. Matching sessions to processes via shared matchProcessesToSessions() * using sha256(cwd) === session.projectHash as the resolvedCwd source @@ -20,9 +20,16 @@ import type { ConversationMessage, SessionSummary, ListSessionsOptions, + AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses, findWrapperProcess, findWrapperProcessPids } from '../utils/process.js'; +import { + captureProcessSnapshot, + executableBasename, + filterByProcessNames, + findWrapperProcess, + findWrapperProcessPids, +} from '../utils/process.js'; import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js'; import type { SessionFile } from '../utils/session.js'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; @@ -81,6 +88,7 @@ interface GeminiSession { export class GeminiCliAdapter implements AgentAdapter { readonly type = 'gemini_cli' as const; + readonly processNames = ['node'] as const; private static readonly IDLE_THRESHOLD_MINUTES = 5; private static readonly SESSION_FILE_PREFIX = 'session-'; @@ -109,12 +117,13 @@ export class GeminiCliAdapter implements AgentAdapter { * binary). The primary running process is therefore the Node runtime * itself, and `ps aux` lists it as `node /path/to/gemini ...` with * argv[0] = `node`. We scan the Node process pool via the shared - * helper and keep only those whose command line references the gemini + * snapshot and keep only those whose command line references the gemini * executable or script via isGeminiExecutable(). */ - async detectAgents(): Promise { - const nodeProcesses = enrichProcesses(listAgentProcesses('node')); - const processes = nodeProcesses.filter((proc) => this.isGeminiExecutable(proc.command)); + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; const wrapperPids = findWrapperProcessPids(processes); @@ -541,7 +550,7 @@ export class GeminiCliAdapter implements AgentAdapter { // other adapters' argv[0]-only check because the Node-script // distribution puts the real gemini path in argv[1..], not argv[0]. for (const token of command.trim().split(/\s+/)) { - const base = path.basename(token).toLowerCase(); + const base = executableBasename(token); if (base === 'gemini' || base === 'gemini.exe' || base === 'gemini.js') { return true; } diff --git a/packages/agent-manager/src/adapters/GrokCliAdapter.ts b/packages/agent-manager/src/adapters/GrokCliAdapter.ts index 165863c9..d2f3cb3a 100644 --- a/packages/agent-manager/src/adapters/GrokCliAdapter.ts +++ b/packages/agent-manager/src/adapters/GrokCliAdapter.ts @@ -6,9 +6,10 @@ import type { ConversationMessage, SessionSummary, ListSessionsOptions, + AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../utils/process.js'; +import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js'; import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js'; import { generateAgentName } from '../utils/matching.js'; @@ -16,7 +17,7 @@ import { generateAgentName } from '../utils/matching.js'; * Grok Build CLI Adapter * * Detects running Grok Build CLI agents by: - * 1. Finding running `grok` processes via shared listAgentProcesses() — Grok is + * 1. Filtering `grok` processes from a shared asynchronous snapshot — Grok is * a native binary at ~/.grok/bin/grok, so argv[0] basename is `grok`. * 2. Resolving each live process to its working directory via * ~/.grok/active_sessions.json, which Grok maintains as a list of @@ -69,6 +70,7 @@ interface GrokSession { export class GrokCliAdapter implements AgentAdapter { readonly type = 'grok_cli' as const; + readonly processNames = ['grok'] as const; private base: string; private sessionsDir: string; @@ -87,13 +89,14 @@ export class GrokCliAdapter implements AgentAdapter { } private isGrokExecutable(command: string): boolean { - const executable = command.trim().split(/\s+/)[0] || ''; - const base = path.basename(executable).toLowerCase(); + const base = executableBasename(command); return base === 'grok' || base === 'grok.exe'; } - async detectAgents(): Promise { - const processes = enrichProcesses(listAgentProcesses('grok')); + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); if (processes.length === 0) { return []; } diff --git a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts index f7b2771d..507d9759 100644 --- a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts @@ -2,8 +2,8 @@ * OpenCode Adapter * * Detects running OpenCode agents by: - * 1. Finding running opencode processes via shared listAgentProcesses() - * 2. Enriching with CWD and start times via shared enrichProcesses() + * 1. Filtering OpenCode processes from a shared asynchronous process snapshot + * 2. Using snapshot CWD and start-time enrichment * 3. Querying OpenCode's SQLite DB (~/.local/share/opencode/opencode.db) to * find the session matching each process's CWD and read status from message.time.completed * @@ -21,9 +21,10 @@ import type { ConversationMessage, SessionSummary, ListSessionsOptions, + AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../utils/process.js'; +import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js'; import { generateAgentName } from '../utils/matching.js'; const SESSION_REF_SEP = '::'; @@ -55,6 +56,7 @@ interface OpenCodeSessionStats { export class OpenCodeAdapter implements AgentAdapter { readonly type = 'opencode' as const; + readonly processNames = ['opencode'] as const; private static readonly IDLE_THRESHOLD_MINUTES = 5; @@ -84,13 +86,14 @@ export class OpenCodeAdapter implements AgentAdapter { } canHandle(processInfo: ProcessInfo): boolean { - const exe = (processInfo.command.trim().split(/\s+/)[0] || '').toLowerCase(); - const base = path.basename(exe); + const base = executableBasename(processInfo.command); return base === 'opencode' || base === 'opencode.exe'; } - async detectAgents(): Promise { - const processes = enrichProcesses(listAgentProcesses('opencode')); + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; const db = this.openDb(); diff --git a/packages/agent-manager/src/adapters/PiAdapter.ts b/packages/agent-manager/src/adapters/PiAdapter.ts index 8c31b4be..94d02eb1 100644 --- a/packages/agent-manager/src/adapters/PiAdapter.ts +++ b/packages/agent-manager/src/adapters/PiAdapter.ts @@ -17,9 +17,10 @@ import type { ConversationMessage, SessionSummary, ListSessionsOptions, + AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { listAgentProcesses, enrichProcesses } from '../utils/process.js'; +import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js'; import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js'; import type { SessionFile } from '../utils/session.js'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; @@ -66,6 +67,7 @@ interface TrackerAgentResult { export class PiAdapter implements AgentAdapter { readonly type = 'pi' as const; + readonly processNames = ['pi', 'node'] as const; private static readonly IDLE_THRESHOLD_MINUTES = 5; @@ -86,8 +88,10 @@ export class PiAdapter implements AgentAdapter { return this.isPiExecutable(processInfo.command); } - async detectAgents(): Promise { - const processes = enrichProcesses(this.listPiProcesses()); + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = this.listPiProcesses(relevant); if (processes.length === 0) return []; const { cachedAgents, remaining } = this.tryRegistryCache(processes); @@ -149,12 +153,9 @@ export class PiAdapter implements AgentAdapter { return agents; } - private listPiProcesses(): ProcessInfo[] { + private listPiProcesses(snapshot: readonly ProcessInfo[]): ProcessInfo[] { const byPid = new Map(); - for (const proc of listAgentProcesses('pi')) { - if (this.canHandle(proc)) byPid.set(proc.pid, proc); - } - for (const proc of listAgentProcesses('node')) { + for (const proc of snapshot) { if (this.canHandle(proc)) byPid.set(proc.pid, proc); } return Array.from(byPid.values()); @@ -547,7 +548,7 @@ export class PiAdapter implements AgentAdapter { private isPiExecutable(command: string): boolean { for (const token of command.trim().split(/\s+/)) { - const base = path.basename(token).toLowerCase(); + const base = executableBasename(token); if (base === 'pi' || base === 'pi.exe' || base === 'pi.js') return true; } return false; diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index c152c32c..766639d6 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -6,4 +6,4 @@ export { GrokCliAdapter } from './GrokCliAdapter.js'; export { OpenCodeAdapter } from './OpenCodeAdapter.js'; export { PiAdapter } from './PiAdapter.js'; export { AgentStatus } from './AgentAdapter.js'; -export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter.js'; +export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, AgentDetectionContext } from './AgentAdapter.js'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index ced400ec..8e274c75 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -16,6 +16,7 @@ export type { ConversationMessage, SessionSummary, ListSessionsOptions, + AgentDetectionContext, } from './adapters/AgentAdapter.js'; export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager.js'; @@ -23,6 +24,7 @@ export type { TerminalLocation } from './terminal/TerminalFocusManager.js'; export { TtyWriter } from './terminal/TtyWriter.js'; export { getProcessTty } from './utils/process.js'; +export { captureProcessSnapshot, executableBasename, filterByProcessNames } from './utils/process.js'; export type { AgentSortKey } from './utils/sortAgents.js'; export type { ListAgentsOptions } from './AgentManager.js'; diff --git a/packages/agent-manager/src/utils/index.ts b/packages/agent-manager/src/utils/index.ts index a7b0e973..2315eea5 100644 --- a/packages/agent-manager/src/utils/index.ts +++ b/packages/agent-manager/src/utils/index.ts @@ -1,4 +1,4 @@ -export { listAgentProcesses, batchGetProcessCwds, batchGetProcessStartTimes, enrichProcesses } from './process.js'; +export { listAgentProcesses, batchGetProcessCwds, batchGetProcessStartTimes, enrichProcesses, captureProcessSnapshot } from './process.js'; export { getProcessTty } from './process.js'; export { batchGetSessionFileBirthtimes } from './session.js'; export type { SessionFile } from './session.js'; diff --git a/packages/agent-manager/src/utils/process.ts b/packages/agent-manager/src/utils/process.ts index 8675829b..12a5281b 100644 --- a/packages/agent-manager/src/utils/process.ts +++ b/packages/agent-manager/src/utils/process.ts @@ -2,13 +2,42 @@ * Process Detection Utilities * * Shared shell command wrappers for detecting and inspecting running processes. - * All execFileSync calls for process data live here — adapters must not call execFileSync directly. + * Built-in refresh discovery uses captureProcessSnapshot(); synchronous helpers + * remain exported for compatibility with existing consumers. */ import * as path from 'path'; -import { execFileSync } from 'child_process'; +import { execFile, execFileSync } from 'child_process'; import type { ProcessInfo } from '../adapters/AgentAdapter.js'; +const PROCESS_EXEC_MAX_BUFFER = 10 * 1024 * 1024; +const VALID_EXECUTABLE_NAME = /^[a-zA-Z0-9_-]+$/; + +export function executableBasename(command: string): string { + const executable = command.trim().split(/\s+/)[0] || ''; + return path.basename(executable.replace(/\\/g, '/')).toLowerCase(); +} + +function normalizeExecutableName(name: string): string { + const lower = name.toLowerCase(); + return lower.endsWith('.exe') ? lower.slice(0, -4) : lower; +} + +function normalizedProcessNames(namePatterns: readonly string[]): Set { + return new Set(namePatterns.filter(Boolean).map(normalizeExecutableName)); +} + +export function filterByProcessNames( + processes: readonly ProcessInfo[], + namePatterns: readonly string[], +): ProcessInfo[] { + const names = normalizedProcessNames(namePatterns); + if (names.size === 0) return []; + return processes.filter((process) => ( + names.has(normalizeExecutableName(executableBasename(process.command))) + )); +} + /** * List running processes matching an agent executable name. * @@ -20,14 +49,14 @@ import type { ProcessInfo } from '../adapters/AgentAdapter.js'; */ export function listAgentProcesses(namePattern: string): ProcessInfo[] { // Validate pattern contains only safe characters (alphanumeric, dash, underscore) - if (!namePattern || !/^[a-zA-Z0-9_-]+$/.test(namePattern)) { + if (!namePattern || !VALID_EXECUTABLE_NAME.test(namePattern)) { return []; } try { const output = execFileSync('ps', ['-axo', 'pid=,ppid=,tty=,command='], { encoding: 'utf-8' }); - const lowerPattern = namePattern.toLowerCase(); + const names = normalizedProcessNames([namePattern]); const processes: ProcessInfo[] = []; for (const line of output.trim().split('\n')) { @@ -43,12 +72,7 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] { const tty = match[3]; const command = match[4]; - // Check that the executable basename matches exactly - const executable = command.trim().split(/\s+/)[0] || ''; - const base = path.basename(executable).toLowerCase(); - if (base !== lowerPattern && base !== `${lowerPattern}.exe`) { - continue; - } + if (!names.has(normalizeExecutableName(executableBasename(command)))) continue; const ttyShort = tty.startsWith('/dev/') ? tty.slice(5) : tty; @@ -67,6 +91,132 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] { } } +function execFileText( + file: string, + args: readonly string[], +): Promise { + return new Promise((resolve, reject) => { + execFile(file, args, { encoding: 'utf-8', maxBuffer: PROCESS_EXEC_MAX_BUFFER }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }); + }); +} + +function parseProcessList(output: string, namePatterns: ReadonlySet): ProcessInfo[] { + const processes: ProcessInfo[] = []; + + for (const line of output.trim().split('\n')) { + if (!line.trim()) continue; + + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/); + if (!match) continue; + + const pid = parseInt(match[1], 10); + const ppid = parseInt(match[2], 10); + if (Number.isNaN(pid) || Number.isNaN(ppid)) continue; + + const tty = match[3]; + const command = match[4]; + const base = executableBasename(command); + const normalizedBase = normalizeExecutableName(base); + if (!namePatterns.has(normalizedBase)) continue; + + processes.push({ + pid, + ppid, + command, + cwd: '', + tty: tty.startsWith('/dev/') ? tty.slice(5) : tty, + }); + } + + return processes; +} + +async function batchGetProcessCwdsAsync(pids: number[]): Promise> { + const result = new Map(); + if (pids.length === 0) return result; + + try { + const output = await execFileText('lsof', ['-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')]); + let currentPid: number | null = null; + for (const line of output.trim().split('\n')) { + if (line.startsWith('p')) { + currentPid = parseInt(line.slice(1), 10); + } else if (line.startsWith('n') && currentPid !== null) { + result.set(currentPid, line.slice(1)); + currentPid = null; + } + } + return result; + } catch { + const entries = await Promise.all(pids.map(async (pid) => { + try { + const output = await execFileText('pwdx', [String(pid)]); + const match = output.match(/^\d+:\s*(.+)$/); + return match ? [pid, match[1].trim()] as const : null; + } catch { + return null; + } + })); + for (const entry of entries) { + if (entry) result.set(entry[0], entry[1]); + } + return result; + } +} + +async function batchGetProcessStartTimesAsync(pids: number[]): Promise> { + const result = new Map(); + if (pids.length === 0) return result; + + try { + const output = await execFileText('ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')]); + for (const rawLine of output.split('\n')) { + const match = rawLine.trim().match(/^(\d+)\s+(.+)$/); + if (!match) continue; + const pid = parseInt(match[1], 10); + const date = new Date(match[2].trim()); + if (Number.isFinite(pid) && !Number.isNaN(date.getTime())) result.set(pid, date); + } + } catch { + // Return partial/empty data, matching the synchronous helper. + } + return result; +} + +/** + * Capture and enrich relevant processes without blocking the event loop. + * One base process listing is shared by every requested executable name. + */ +export async function captureProcessSnapshot(namePatterns: readonly string[]): Promise { + const names = normalizedProcessNames( + namePatterns.filter((name) => Boolean(name) && VALID_EXECUTABLE_NAME.test(name)), + ); + if (names.size === 0) return []; + + try { + const output = await execFileText('ps', ['-axo', 'pid=,ppid=,tty=,command=']); + const processes = parseProcessList(output, names); + const pids = processes.map((process) => process.pid); + const [cwdMap, startTimeMap] = await Promise.all([ + batchGetProcessCwdsAsync(pids), + batchGetProcessStartTimesAsync(pids), + ]); + return processes.map((process) => ({ + ...process, + cwd: cwdMap.get(process.pid) || '', + startTime: startTimeMap.get(process.pid), + })); + } catch { + return []; + } +} + /** * Batch-get current working directories for multiple PIDs. *