From 465ea58cc80d120f193e838730163c53a5847ffe Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 14:48:30 +0200 Subject: [PATCH 1/2] fix(agent-manager): share async process snapshots --- ...ture-console-main-thread-responsiveness.md | 50 ++++++ ...ture-console-main-thread-responsiveness.md | 38 +++++ ...ture-console-main-thread-responsiveness.md | 31 ++++ ...ture-console-main-thread-responsiveness.md | 43 ++++++ ...ture-console-main-thread-responsiveness.md | 41 +++++ packages/agent-manager/src/AgentManager.ts | 30 +++- .../src/__tests__/AgentManager.test.ts | 52 +++++++ .../adapters/ClaudeCodeAdapter.test.ts | 8 +- .../__tests__/adapters/CodexAdapter.test.ts | 8 +- .../__tests__/adapters/CopilotAdapter.test.ts | 8 +- .../adapters/GeminiCliAdapter.test.ts | 8 +- .../__tests__/adapters/GrokCliAdapter.test.ts | 8 +- .../adapters/OpenCodeAdapter.test.ts | 8 +- .../src/__tests__/adapters/PiAdapter.test.ts | 8 +- .../src/__tests__/utils/process.test.ts | 55 ++++++- .../src/adapters/AgentAdapter.ts | 14 +- .../src/adapters/ClaudeCodeAdapter.ts | 13 +- .../src/adapters/CodexAdapter.ts | 13 +- .../src/adapters/CopilotAdapter.ts | 13 +- .../src/adapters/GeminiCliAdapter.ts | 16 +- .../src/adapters/GrokCliAdapter.ts | 11 +- .../src/adapters/OpenCodeAdapter.ts | 13 +- .../agent-manager/src/adapters/PiAdapter.ts | 16 +- packages/agent-manager/src/adapters/index.ts | 2 +- packages/agent-manager/src/index.ts | 2 + packages/agent-manager/src/utils/index.ts | 2 +- packages/agent-manager/src/utils/process.ts | 145 +++++++++++++++++- 27 files changed, 598 insertions(+), 58 deletions(-) create mode 100644 docs/ai/design/2026-08-14-feature-console-main-thread-responsiveness.md create mode 100644 docs/ai/implementation/2026-08-14-feature-console-main-thread-responsiveness.md create mode 100644 docs/ai/planning/2026-08-14-feature-console-main-thread-responsiveness.md create mode 100644 docs/ai/requirements/2026-08-14-feature-console-main-thread-responsiveness.md create mode 100644 docs/ai/testing/2026-08-14-feature-console-main-thread-responsiveness.md 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..52c0b4d4 --- /dev/null +++ b/docs/ai/design/2026-08-14-feature-console-main-thread-responsiveness.md @@ -0,0 +1,50 @@ +--- +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 --> A1[adapter filter/session mapping] + Manager --> A2[adapter filter/session mapping] + Snapshot --> Manager +``` + +`AgentManager` gathers the optional executable-name hints advertised by snapshot-aware adapters, requests one enriched snapshot, and passes the same read-only process array to those adapters. Each adapter filters with its existing `canHandle` logic before session discovery. + +## Data Models and API + +- `AgentDetectionContext.processes`: a read-only 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. + +## 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. +- Rejected: manager-owned tool-specific filtering. It couples `AgentManager` to adapter command-line details and duplicates `canHandle` behavior. + +## 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. +- 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..5186bce8 --- /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 design: one manager-owned async snapshot, adapter-owned filtering, 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` now exposes callback-based async capture and enrichment while retaining sync compatibility exports. +- `AgentAdapter` accepts an optional read-only detection context and optional executable hints. +- `AgentManager` captures one union snapshot and passes the same context to snapshot-aware adapters. +- All seven built-in adapters filter the shared context and use async standalone capture when called directly. +- Adapter fixtures retain their existing behavior assertions through a mocked async snapshot boundary. 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..5be3b236 --- /dev/null +++ b/docs/ai/planning/2026-08-14-feature-console-main-thread-responsiveness.md @@ -0,0 +1,31 @@ +--- +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. +- [ ] `todo` Commit, rebase on `origin/main`, push, and open a 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 + +Implementation and validation are complete with no scope changes or blockers. One concurrent root run exposed the existing 5-second print integration timeout under cross-project contention; the full suite passed serially without changing thresholds. Remaining work is publication only: commit, rebase, push, and open the PR. 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..f70ff9a6 --- /dev/null +++ b/docs/ai/testing/2026-08-14-feature-console-main-thread-responsiveness.md @@ -0,0 +1,41 @@ +--- +phase: testing +title: Shared Process Snapshot Testing +description: Deterministic validation of shared asynchronous discovery +--- + +# Testing Strategy + +## Unit and Integration Scenarios + +- [x] Manager shares one snapshot across multiple snapshot-aware adapters. +- [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] 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 + +- Red: manager capture count was 0; async utility was missing. +- Green: focused manager/process tests pass (56 tests). +- Adapter regression: all seven adapter files pass (294 tests). +- Regression gate: test fails with manager capture removed and passes after restoration. +- Agent-manager full: 24 files / 506 tests passed; lint, typecheck, and build exit 0. +- CLI console focused: 20 files / 125 tests passed. +- CLI full: 79 files / 959 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..79a53939 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 } 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,25 @@ 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 the same immutable snapshot. + const adapterPromises = adapters.map(async (adapter) => { try { - const agents = await adapter.detectAgents(); + const agents = adapter.processNames + ? await adapter.detectAgents({ processes }) + : 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..824e7329 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,57 @@ describe('AgentManager', () => { }); describe('listAgents', () => { + it('shares one process snapshot across snapshot-aware adapters', 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 (context?: { processes: readonly ProcessInfo[] }) => { + expect(context?.processes).toBe(processes); + return []; + }), + 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).toHaveBeenCalledTimes(1); + expect(pi.detectAgents).toHaveBeenCalledTimes(1); + }); + + 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..efb29193 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -9,7 +9,7 @@ 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'; @@ -18,6 +18,7 @@ import * as os from 'os'; vi.mock('../../utils/process.js', () => ({ listAgentProcesses: vi.fn(), enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), })); vi.mock('../../utils/session.js', async () => { @@ -35,6 +36,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 +47,15 @@ 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); + 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..5f0c22a8 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -10,7 +10,7 @@ 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'; @@ -20,6 +20,7 @@ import * as os from 'os'; vi.mock('../../utils/process.js', () => ({ listAgentProcesses: vi.fn(), enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), })); vi.mock('../../utils/session.js', async () => { @@ -37,6 +38,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 +50,15 @@ 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); + 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..bee44ff3 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,12 @@ describe('CopilotAdapter', () => { mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedGenerateAgentName.mockReset(); mockedEnrichProcesses.mockImplementation((procs) => procs); + 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..9a10e9cc 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,14 @@ 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); + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); mockedMatchProcessesToSessions.mockReturnValue([]); mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => { const folder = path.basename(cwd) || 'unknown'; diff --git a/packages/agent-manager/src/__tests__/adapters/GrokCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GrokCliAdapter.test.ts index 38641db1..58742070 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,13 @@ describe('GrokCliAdapter', () => { mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedGenerateAgentName.mockReset(); mockedEnrichProcesses.mockImplementation((procs) => procs); + 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..33eaf9ef 100644 --- a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts @@ -9,13 +9,14 @@ 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(), + captureProcessSnapshot: vi.fn(), })); vi.mock('../../utils/matching.js', () => ({ @@ -25,6 +26,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 +106,13 @@ describe('OpenCodeAdapter', () => { mockedListAgentProcesses.mockReset(); mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); mockedGenerateAgentName.mockReset(); mockedEnrichProcesses.mockImplementation((procs) => procs); + 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..7e8dc214 100644 --- a/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts @@ -11,12 +11,13 @@ 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(), + captureProcessSnapshot: vi.fn(), })); vi.mock('../../utils/matching.js', () => ({ @@ -26,6 +27,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 +45,14 @@ 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); + mockedCaptureProcessSnapshot.mockImplementation(async (names) => ( + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))) + )); mockedMatchProcessesToSessions.mockReturnValue([]); mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => { const folder = path.basename(cwd) || 'unknown'; diff --git a/packages/agent-manager/src/__tests__/utils/process.test.ts b/packages/agent-manager/src/__tests__/utils/process.test.ts index 0966d83f..ba939c3d 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,66 @@ import { enrichProcesses, findWrapperProcess, findWrapperProcessPids, + captureProcessSnapshot, } 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); + }); +}); 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..4e0b66fb 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 } 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; @@ -90,8 +92,9 @@ export class ClaudeCodeAdapter implements AgentAdapter { 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 processes = snapshot.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..887c847f 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 } 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,9 @@ 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 processes = snapshot.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; const { cachedAgents, remaining } = this.tryRegistryCache(processes); diff --git a/packages/agent-manager/src/adapters/CopilotAdapter.ts b/packages/agent-manager/src/adapters/CopilotAdapter.ts index 9fffe60d..19ce456b 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,10 @@ 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, 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 +90,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 +122,9 @@ 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 processes = snapshot.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; const processByPid = new Map(processes.map((proc) => [proc.pid, proc])); diff --git a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts index 5e3e7481..b678939e 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,10 @@ 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, 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 +82,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 +111,12 @@ 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 processes = snapshot.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; const wrapperPids = findWrapperProcessPids(processes); diff --git a/packages/agent-manager/src/adapters/GrokCliAdapter.ts b/packages/agent-manager/src/adapters/GrokCliAdapter.ts index 165863c9..0dffecfb 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 } 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; @@ -92,8 +94,9 @@ export class GrokCliAdapter implements AgentAdapter { 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 processes = snapshot.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..a4f2ab07 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 } 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; @@ -89,8 +91,9 @@ export class OpenCodeAdapter implements AgentAdapter { 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 processes = snapshot.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..fc288838 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 } 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,9 @@ 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 processes = this.listPiProcesses(snapshot); if (processes.length === 0) return []; const { cachedAgents, remaining } = this.tryRegistryCache(processes); @@ -149,12 +152,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()); 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..95481c5b 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 } 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..cb83257f 100644 --- a/packages/agent-manager/src/utils/process.ts +++ b/packages/agent-manager/src/utils/process.ts @@ -2,11 +2,12 @@ * 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'; /** @@ -67,6 +68,146 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] { } } +function execFileText( + file: string, + args: readonly string[], + options: { stdio?: ['pipe', 'pipe', 'ignore'] } = {}, +): Promise { + return new Promise((resolve, reject) => { + execFile(file, args, { ...options, encoding: 'utf-8' }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }); + }); +} + +function executableBasename(command: string): string { + const executable = command.trim().split(/\s+/)[0] || ''; + return path.basename(executable.replace(/\\/g, '/')).toLowerCase(); +} + +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 = base.endsWith('.exe') ? base.slice(0, -4) : 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(',')], + { stdio: ['pipe', 'pipe', 'ignore'] }, + ); + 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)], + { stdio: ['pipe', 'pipe', 'ignore'] }, + ); + 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 = new Set( + namePatterns + .filter((name) => Boolean(name) && /^[a-zA-Z0-9_-]+$/.test(name)) + .map((name) => name.toLowerCase()), + ); + 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. * From 1d6894dfd8a2944c80239e1a7bac790ce359d4c7 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 16:18:13 +0200 Subject: [PATCH 2/2] fix(agent-manager): scope shared process snapshots --- ...ture-console-main-thread-responsiveness.md | 15 +++-- ...ture-console-main-thread-responsiveness.md | 10 +-- ...ture-console-main-thread-responsiveness.md | 5 +- ...ture-console-main-thread-responsiveness.md | 19 +++--- packages/agent-manager/src/AgentManager.ts | 8 ++- .../src/__tests__/AgentManager.test.ts | 51 +++++++++++++-- .../adapters/ClaudeCodeAdapter.test.ts | 15 +++-- .../__tests__/adapters/CodexAdapter.test.ts | 15 +++-- .../__tests__/adapters/CopilotAdapter.test.ts | 1 + .../adapters/GeminiCliAdapter.test.ts | 16 ++++- .../__tests__/adapters/GrokCliAdapter.test.ts | 1 + .../adapters/OpenCodeAdapter.test.ts | 15 +++-- .../src/__tests__/adapters/PiAdapter.test.ts | 27 ++++++-- .../src/__tests__/utils/process.test.ts | 18 +++++ .../src/adapters/ClaudeCodeAdapter.ts | 8 +-- .../src/adapters/CodexAdapter.ts | 8 +-- .../src/adapters/CopilotAdapter.ts | 14 ++-- .../src/adapters/GeminiCliAdapter.ts | 13 +++- .../src/adapters/GrokCliAdapter.ts | 8 +-- .../src/adapters/OpenCodeAdapter.ts | 8 +-- .../agent-manager/src/adapters/PiAdapter.ts | 7 +- packages/agent-manager/src/index.ts | 2 +- packages/agent-manager/src/utils/process.ts | 65 +++++++++++-------- 23 files changed, 241 insertions(+), 108 deletions(-) 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 index 52c0b4d4..d9eef737 100644 --- 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 @@ -14,26 +14,30 @@ flowchart LR Manager --> Snapshot[captureProcessSnapshot] Snapshot --> PS[async ps once] Snapshot --> Enrich[async batched cwd/start enrichment] - Manager --> A1[adapter filter/session mapping] - Manager --> A2[adapter filter/session mapping] + 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, requests one enriched snapshot, and passes the same read-only process array to those adapters. Each adapter filters with its existing `canHandle` logic before session discovery. +`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 array of enriched `ProcessInfo` records from one capture. +- `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. -- Rejected: manager-owned tool-specific filtering. It couples `AgentManager` to adapter command-line details and duplicates `canHandle` behavior. +- 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 @@ -41,6 +45,7 @@ flowchart LR - 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 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 index 5186bce8..3db6a939 100644 --- 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 @@ -27,12 +27,12 @@ None. ## Alignment Review -The implementation matches the requirements and design: one manager-owned async snapshot, adapter-owned filtering, async direct-call fallback, preserved legacy adapters, unchanged registry/sorting/error boundaries, and no console polling or rendering-option changes. +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` now exposes callback-based async capture and enrichment while retaining sync compatibility exports. +- `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 the same context to snapshot-aware adapters. -- All seven built-in adapters filter the shared context and use async standalone capture when called directly. -- Adapter fixtures retain their existing behavior assertions through a mocked async snapshot boundary. +- `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 index 5be3b236..96e47e81 100644 --- 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 @@ -13,7 +13,8 @@ description: Test-first implementation plan for responsive agent refreshes - [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. -- [ ] `todo` Commit, rebase on `origin/main`, push, and open a PR without merging. +- [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 @@ -28,4 +29,4 @@ Tests define the public boundary before production changes. Utility implementati ## Progress Summary -Implementation and validation are complete with no scope changes or blockers. One concurrent root run exposed the existing 5-second print integration timeout under cross-project contention; the full suite passed serially without changing thresholds. Remaining work is publication only: commit, rebase, push, and open the PR. +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/testing/2026-08-14-feature-console-main-thread-responsiveness.md b/docs/ai/testing/2026-08-14-feature-console-main-thread-responsiveness.md index f70ff9a6..a0421724 100644 --- 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 @@ -8,10 +8,13 @@ description: Deterministic validation of shared asynchronous discovery ## Unit and Integration Scenarios -- [x] Manager shares one snapshot across multiple snapshot-aware adapters. +- [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. @@ -30,12 +33,12 @@ Mock callback-based child-process boundaries and assert invocation/order/data fl ## Current Evidence -- Red: manager capture count was 0; async utility was missing. -- Green: focused manager/process tests pass (56 tests). -- Adapter regression: all seven adapter files pass (294 tests). -- Regression gate: test fails with manager capture removed and passes after restoration. -- Agent-manager full: 24 files / 506 tests passed; lint, typecheck, and build exit 0. -- CLI console focused: 20 files / 125 tests passed. -- CLI full: 79 files / 959 tests passed; lint and build exit 0. +- 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 79a53939..7141b24b 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -14,7 +14,7 @@ import type { } from './adapters/AgentAdapter.js'; import { sortAgents, type AgentSortKey } from './utils/sortAgents.js'; import { AgentRegistry, type RegistryEntry } from './utils/AgentRegistry.js'; -import { captureProcessSnapshot } from './utils/process.js'; +import { captureProcessSnapshot, filterByProcessNames } from './utils/process.js'; type ProcessSnapshotCapture = (namePatterns: readonly string[]) => Promise; @@ -146,11 +146,13 @@ export class AgentManager { } } - // Query all adapters in parallel using the same immutable snapshot. + // Query all adapters in parallel using executable-scoped slices of the shared snapshot. const adapterPromises = adapters.map(async (adapter) => { try { const agents = adapter.processNames - ? await adapter.detectAgents({ processes }) + ? await adapter.detectAgents({ + processes: filterByProcessNames(processes, adapter.processNames), + }) : await adapter.detectAgents(); return { type: adapter.type, agents, error: null }; } catch (error) { diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index 824e7329..b4783ae5 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -171,7 +171,7 @@ describe('AgentManager', () => { }); describe('listAgents', () => { - it('shares one process snapshot across snapshot-aware adapters', async () => { + 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' }, @@ -180,10 +180,7 @@ describe('AgentManager', () => { const createSnapshotAdapter = (type: AgentType, processNames: string[]) => ({ type, processNames, - detectAgents: vi.fn(async (context?: { processes: readonly ProcessInfo[] }) => { - expect(context?.processes).toBe(processes); - return []; - }), + detectAgents: vi.fn(async () => []), canHandle: () => true, getConversation: () => [], listSessions: async () => [], @@ -202,8 +199,48 @@ describe('AgentManager', () => { expect(captureSnapshot).toHaveBeenCalledTimes(1); expect(captureSnapshot).toHaveBeenCalledWith(['claude', 'pi', 'node']); - expect(claude.detectAgents).toHaveBeenCalledTimes(1); - expect(pi.detectAgents).toHaveBeenCalledTimes(1); + 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 () => { diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index efb29193..5b8b1785 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -15,11 +15,15 @@ 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(), - captureProcessSnapshot: 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'); @@ -53,6 +57,7 @@ describe('ClaudeCodeAdapter', () => { 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))) )); diff --git a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts index 5f0c22a8..5a13e08f 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -17,11 +17,15 @@ import { matchProcessesToSessions, generateAgentName } from '../../utils/matchin import type { MatchResult } from '../../utils/matching.js'; import * as os from 'os'; -vi.mock('../../utils/process.js', () => ({ - listAgentProcesses: vi.fn(), - enrichProcesses: vi.fn(), - captureProcessSnapshot: 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'); @@ -56,6 +60,7 @@ describe('CodexAdapter', () => { 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))) )); diff --git a/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts index bee44ff3..3b1b3b56 100644 --- a/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts @@ -50,6 +50,7 @@ describe('CopilotAdapter', () => { 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))) )); diff --git a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts index 9a10e9cc..0e7a2f1f 100644 --- a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts @@ -52,6 +52,7 @@ describe('GeminiCliAdapter', () => { 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))) )); @@ -116,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', () => { @@ -150,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'), @@ -263,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'), @@ -389,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 58742070..4c686ca9 100644 --- a/packages/agent-manager/src/__tests__/adapters/GrokCliAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/GrokCliAdapter.test.ts @@ -70,6 +70,7 @@ describe('GrokCliAdapter', () => { 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))) )); diff --git a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts index 33eaf9ef..2f472d6f 100644 --- a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts @@ -13,11 +13,15 @@ import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../ import { generateAgentName } from '../../utils/matching.js'; import * as os from 'os'; -vi.mock('../../utils/process.js', () => ({ - listAgentProcesses: vi.fn(), - enrichProcesses: vi.fn(), - captureProcessSnapshot: 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(), @@ -110,6 +114,7 @@ describe('OpenCodeAdapter', () => { 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))) )); diff --git a/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts index 7e8dc214..a885fb33 100644 --- a/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts @@ -14,11 +14,15 @@ import { AgentRegistry } from '../../utils/AgentRegistry.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(), - captureProcessSnapshot: 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(), @@ -50,6 +54,7 @@ describe('PiAdapter', () => { 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))) )); @@ -73,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 ba939c3d..ab1ba5b0 100644 --- a/packages/agent-manager/src/__tests__/utils/process.test.ts +++ b/packages/agent-manager/src/__tests__/utils/process.test.ts @@ -13,6 +13,7 @@ import { findWrapperProcess, findWrapperProcessPids, captureProcessSnapshot, + filterByProcessNames, } from '../../utils/process.js'; vi.mock('child_process', () => ({ @@ -70,6 +71,23 @@ describe('captureProcessSnapshot', () => { ]); 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]]); }); }); diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index 4e0b66fb..a6dc7803 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -10,7 +10,7 @@ import type { AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { captureProcessSnapshot } 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'; @@ -87,14 +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(context?: AgentDetectionContext): Promise { const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); - const processes = snapshot.filter((process) => this.canHandle(process)); + 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 887c847f..29874230 100644 --- a/packages/agent-manager/src/adapters/CodexAdapter.ts +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -23,7 +23,7 @@ import type { AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { captureProcessSnapshot } 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'; @@ -115,7 +115,8 @@ export class CodexAdapter implements AgentAdapter { */ async detectAgents(context?: AgentDetectionContext): Promise { const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); - const processes = snapshot.filter((process) => this.canHandle(process)); + 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); @@ -660,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 19ce456b..4bf86caf 100644 --- a/packages/agent-manager/src/adapters/CopilotAdapter.ts +++ b/packages/agent-manager/src/adapters/CopilotAdapter.ts @@ -20,7 +20,13 @@ import type { AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { captureProcessSnapshot, findWrapperProcess, findWrapperProcessPids } 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'; @@ -124,7 +130,8 @@ export class CopilotAdapter implements AgentAdapter { async detectAgents(context?: AgentDetectionContext): Promise { const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); - const processes = snapshot.filter((process) => this.canHandle(process)); + 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])); @@ -460,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 b678939e..adfa5ba1 100644 --- a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts +++ b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts @@ -23,7 +23,13 @@ import type { AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { captureProcessSnapshot, 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'; @@ -116,7 +122,8 @@ export class GeminiCliAdapter implements AgentAdapter { */ async detectAgents(context?: AgentDetectionContext): Promise { const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); - const processes = snapshot.filter((process) => this.canHandle(process)); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; const wrapperPids = findWrapperProcessPids(processes); @@ -543,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 0dffecfb..d2f3cb3a 100644 --- a/packages/agent-manager/src/adapters/GrokCliAdapter.ts +++ b/packages/agent-manager/src/adapters/GrokCliAdapter.ts @@ -9,7 +9,7 @@ import type { AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { captureProcessSnapshot } 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'; @@ -89,14 +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(context?: AgentDetectionContext): Promise { const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); - const processes = snapshot.filter((process) => this.canHandle(process)); + 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 a4f2ab07..507d9759 100644 --- a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts @@ -24,7 +24,7 @@ import type { AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { captureProcessSnapshot } from '../utils/process.js'; +import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js'; import { generateAgentName } from '../utils/matching.js'; const SESSION_REF_SEP = '::'; @@ -86,14 +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(context?: AgentDetectionContext): Promise { const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); - const processes = snapshot.filter((process) => this.canHandle(process)); + 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 fc288838..94d02eb1 100644 --- a/packages/agent-manager/src/adapters/PiAdapter.ts +++ b/packages/agent-manager/src/adapters/PiAdapter.ts @@ -20,7 +20,7 @@ import type { AgentDetectionContext, } from './AgentAdapter.js'; import { AgentStatus } from './AgentAdapter.js'; -import { captureProcessSnapshot } 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'; @@ -90,7 +90,8 @@ export class PiAdapter implements AgentAdapter { async detectAgents(context?: AgentDetectionContext): Promise { const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); - const processes = this.listPiProcesses(snapshot); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = this.listPiProcesses(relevant); if (processes.length === 0) return []; const { cachedAgents, remaining } = this.tryRegistryCache(processes); @@ -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/index.ts b/packages/agent-manager/src/index.ts index 95481c5b..8e274c75 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -24,7 +24,7 @@ export type { TerminalLocation } from './terminal/TerminalFocusManager.js'; export { TtyWriter } from './terminal/TtyWriter.js'; export { getProcessTty } from './utils/process.js'; -export { captureProcessSnapshot } 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/process.ts b/packages/agent-manager/src/utils/process.ts index cb83257f..12a5281b 100644 --- a/packages/agent-manager/src/utils/process.ts +++ b/packages/agent-manager/src/utils/process.ts @@ -10,6 +10,34 @@ import * as path from 'path'; 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. * @@ -21,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')) { @@ -44,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; @@ -71,10 +94,9 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] { function execFileText( file: string, args: readonly string[], - options: { stdio?: ['pipe', 'pipe', 'ignore'] } = {}, ): Promise { return new Promise((resolve, reject) => { - execFile(file, args, { ...options, encoding: 'utf-8' }, (error, stdout) => { + execFile(file, args, { encoding: 'utf-8', maxBuffer: PROCESS_EXEC_MAX_BUFFER }, (error, stdout) => { if (error) { reject(error); return; @@ -84,11 +106,6 @@ function execFileText( }); } -function executableBasename(command: string): string { - const executable = command.trim().split(/\s+/)[0] || ''; - return path.basename(executable.replace(/\\/g, '/')).toLowerCase(); -} - function parseProcessList(output: string, namePatterns: ReadonlySet): ProcessInfo[] { const processes: ProcessInfo[] = []; @@ -105,7 +122,7 @@ function parseProcessList(output: string, namePatterns: ReadonlySet): Pr const tty = match[3]; const command = match[4]; const base = executableBasename(command); - const normalizedBase = base.endsWith('.exe') ? base.slice(0, -4) : base; + const normalizedBase = normalizeExecutableName(base); if (!namePatterns.has(normalizedBase)) continue; processes.push({ @@ -125,10 +142,7 @@ async function batchGetProcessCwdsAsync(pids: number[]): Promise { try { - const output = await execFileText( - 'pwdx', [String(pid)], - { stdio: ['pipe', 'pipe', 'ignore'] }, - ); + const output = await execFileText('pwdx', [String(pid)]); const match = output.match(/^\d+:\s*(.+)$/); return match ? [pid, match[1].trim()] as const : null; } catch { @@ -183,10 +194,8 @@ async function batchGetProcessStartTimesAsync(pids: number[]): Promise { - const names = new Set( - namePatterns - .filter((name) => Boolean(name) && /^[a-zA-Z0-9_-]+$/.test(name)) - .map((name) => name.toLowerCase()), + const names = normalizedProcessNames( + namePatterns.filter((name) => Boolean(name) && VALID_EXECUTABLE_NAME.test(name)), ); if (names.size === 0) return [];