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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
phase: design
title: Shared Asynchronous Process Snapshot
description: One enriched process snapshot per AgentManager refresh
---

# System Design & Architecture

## Architecture Overview

```mermaid
flowchart LR
Console[Ink console polling] --> Manager[AgentManager.listAgents]
Manager --> Snapshot[captureProcessSnapshot]
Snapshot --> PS[async ps once]
Snapshot --> Enrich[async batched cwd/start enrichment]
Manager --> F1[filter by adapter processNames]
Manager --> F2[filter by adapter processNames]
F1 --> A1[adapter canHandle/session mapping]
F2 --> A2[adapter canHandle/session mapping]
Snapshot --> Manager
```

`AgentManager` gathers the optional executable-name hints advertised by snapshot-aware adapters and requests one enriched union snapshot. Before dispatch, it slices that snapshot by each adapter's declared argv[0] executable names. Each adapter then applies its existing `canHandle` logic before session discovery. This preserves the pre-snapshot candidate pools for broad Node-entrypoint matchers such as Pi and Gemini without repeating process scans.

## Data Models and API

- `AgentDetectionContext.processes`: a read-only, adapter-scoped array of enriched `ProcessInfo` records from one capture.
- `AgentAdapter.processNames?`: executable basenames needed by that adapter (`node` included for Gemini and Pi).
- `AgentAdapter.detectAgents(context?)`: optional context preserves source compatibility for existing implementations and direct calls.
- `captureProcessSnapshot(names)`: async utility that performs one `ps` listing, filters relevant basenames, then asynchronously enriches the union of candidate PIDs.
- `filterByProcessNames(processes, names)`: shared argv[0] filter used by capture, manager dispatch, and defensive adapter boundaries, including Windows separator and `.exe` normalization.

## Design Decisions

- Chosen: optional adapter hints plus an optional detection context. This avoids enriching every OS process and preserves legacy third-party adapters.
- Rejected: capture/enrich all OS processes. It is simpler at the interface but can create oversized `lsof`/`ps -p` arguments and unnecessary work.
- Rejected: async scans inside every adapter. It frees the event loop but retains repeated scans and does not satisfy one-snapshot-per-refresh semantics.
- Chosen: manager-owned executable-name slicing based only on the adapter's declared `processNames`. This restores historical input pools without coupling the manager to tool-specific token matching.
- Rejected: passing the full union to every adapter. Pi and Gemini intentionally inspect argv[1..], so foreign agents with `pi` or `gemini` path arguments become false positives.

## Failure and Compatibility Behavior

- Snapshot command failures resolve to an empty snapshot, matching prior discovery helpers.
- Enrichment is best-effort and preserves empty `cwd`/missing `startTime` per PID.
- `lsof` failure uses asynchronous per-PID `pwdx` fallback on platforms where available.
- Snapshot-aware built-ins use a local async snapshot when invoked without manager context.
- Built-ins defensively scope hand-built contexts to their declared executable names before applying `canHandle`.
- Adapters without `processNames` receive no context and keep their historical behavior.

## Non-Functional Requirements

- No `execFileSync` on the built-in multi-adapter refresh path.
- Exactly one base `ps -axo` capture per manager refresh with snapshot-aware adapters.
- No polling or Ink rendering configuration changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
phase: implementation
title: Shared Process Snapshot Implementation
description: Implementation record for asynchronous agent discovery
---

# Implementation Guide

## Status

Implemented after focused tests failed for the intended missing snapshot behavior.

## Intended Code Structure

- `utils/process.ts`: asynchronous capture, parsing, filtering, and enrichment.
- `adapters/AgentAdapter.ts`: optional discovery context and process-name hints.
- `AgentManager.ts`: one shared snapshot per refresh and legacy adapter compatibility.
- Built-in adapters: filter the provided snapshot or asynchronously capture one for direct calls.

## Compatibility and Error Handling

Keep current synchronous exports intact for external callers. Async discovery resolves command failures to empty/partial data. Adapter exceptions remain isolated by `AgentManager`.

## Design Deviations

None.

## Alignment Review

The implementation matches the requirements and reviewed design: one manager-owned async union snapshot, manager-owned executable slicing, adapter-owned command matching, async direct-call fallback, preserved legacy adapters, unchanged registry/sorting/error boundaries, and no console polling or rendering-option changes.

## Changed Files and Decisions

- `utils/process.ts` exposes callback-based async capture and enrichment plus shared executable normalization/filtering while retaining sync compatibility exports. Async commands use an explicit 10 MiB buffer and no unsupported `stdio` option.
- `AgentAdapter` accepts an optional read-only detection context and optional executable hints.
- `AgentManager` captures one union snapshot and passes each snapshot-aware adapter only the argv[0] slice declared by its `processNames`.
- All seven built-in adapters defensively scope provided contexts, preserve their `canHandle` narrowing, support Windows command paths, and use async standalone capture when called directly.
- Adapter fixtures retain their existing behavior assertions through a compatibility-shim mock of standalone capture; manager-path behavior is tested separately against the real union-and-slice contract.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
phase: planning
title: Shared Process Snapshot Plan
description: Test-first implementation plan for responsive agent refreshes
---

# Project Planning & Task Breakdown

## Task Queue

- [x] `done` Add deterministic failing manager/process tests for one shared snapshot and async discovery.
- [x] `done` Implement asynchronous process capture and enrichment with platform fallbacks.
- [x] `done` Add optional adapter discovery context and migrate all built-in adapters.
- [x] `done` Preserve and test failure, sorting, registry, direct-adapter, and export compatibility.
- [x] `done` Validate agent-manager and CLI focused/full tests, lint, and builds.
- [x] `done` Correct PR review findings: per-adapter snapshot slicing, defensive adapter filtering, async buffer options, and Windows path normalization.
- [x] `done` Commit and push the review fix to the existing PR without merging.

## Dependencies

Tests define the public boundary before production changes. Utility implementation precedes adapter migration; full validation precedes commit and publication.

## Risks & Mitigation

- Direct adapter callers could break: make context optional and capture asynchronously when absent.
- Third-party adapters could receive an incomplete snapshot: only pass context to adapters advertising process names.
- Concurrent mutation could leak across adapters: expose a read-only snapshot and filter into new arrays.
- Platform fallback could regress: retain command shapes and best-effort empty/partial results.

## Progress Summary

The review fix and fresh validation are complete with no blockers. The restricted sandbox could not inspect the current process for one print integration, so the full suite was rerun with process-inspection access and passed. The reviewed implementation is ready on the existing PR branch.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
phase: testing
title: Shared Process Snapshot Testing
description: Deterministic validation of shared asynchronous discovery
---

# Testing Strategy

## Unit and Integration Scenarios

- [x] Manager captures one union snapshot and dispatches only each adapter's declared executable slice.
- [x] Foreign Codex/Claude path arguments cannot reach Gemini/Pi broad token matchers.
- [x] Legacy adapters remain callable without a discovery context.
- [x] Process capture uses asynchronous child-process execution and one base scan.
- [x] Relevant executable filtering includes `.exe` and shared `node` candidates.
- [x] Windows separators are normalized consistently in capture, manager slicing, and adapter matching.
- [x] Async child-process calls set an explicit buffer and do not pass unsupported `stdio` options.
- [x] Async enrichment preserves partial data and Linux `pwdx` fallback.
- [x] Direct built-in adapter calls remain compatible.
- [x] Existing adapter failure, sorting, registry, and session tests remain green.

## Non-Flaky Proof

Mock callback-based child-process boundaries and assert invocation/order/data flow. Do not use elapsed-time thresholds.

## Validation Commands

- Focused/new agent-manager tests.
- Full agent-manager tests, lint, and build.
- Focused CLI console tests.
- Full CLI tests, lint, and build.
- Root full test, lint, and build commands.

## Current Evidence

- Review red: six deterministic failures proved full-union leakage, missing shared filtering, missing async `maxBuffer`, and Windows-path rejection.
- Review green: focused manager/process/Pi/Gemini tests pass (131 tests).
- Adapter regression: all seven adapter files pass (296 tests).
- Regression gate: the foreign-argument test fails when manager slicing is removed and passes after restoration.
- Agent-manager full: 24 files / 510 tests passed; lint, typecheck, and build exit 0.
- CLI agent/console focused: 23 files / 207 tests passed.
- CLI full: 81 files / 967 tests passed; lint and build exit 0.
- Repository full: all six projects passed serial tests, lint, and build with exit 0. Lint reports six existing warnings and zero errors.
- Feature/base lifecycle lint passed.
32 changes: 28 additions & 4 deletions packages/agent-manager/src/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import type {
AgentInfo,
SessionSummary,
ListSessionsOptions,
ProcessInfo,
} from './adapters/AgentAdapter.js';
import { sortAgents, type AgentSortKey } from './utils/sortAgents.js';
import { AgentRegistry, type RegistryEntry } from './utils/AgentRegistry.js';
import { captureProcessSnapshot, filterByProcessNames } from './utils/process.js';

type ProcessSnapshotCapture = (namePatterns: readonly string[]) => Promise<ProcessInfo[]>;

export interface ListAgentsOptions {
/**
Expand Down Expand Up @@ -41,7 +45,10 @@ export class AgentManager {
private adapters: Map<string, AgentAdapter> = new Map();
private registry: AgentRegistry;

constructor(registry: AgentRegistry = AgentRegistry.default()) {
constructor(
registry: AgentRegistry = AgentRegistry.default(),
private readonly captureSnapshot: ProcessSnapshotCapture = captureProcessSnapshot,
) {
this.registry = registry;
}

Expand Down Expand Up @@ -126,10 +133,27 @@ export class AgentManager {
const allAgents: AgentInfo[] = [];
const errors: Array<{ type: string; error: Error }> = [];

// Query all adapters in parallel
const adapterPromises = Array.from(this.adapters.values()).map(async (adapter) => {
const adapters = Array.from(this.adapters.values());
const processNames = Array.from(new Set(adapters.flatMap(
(adapter) => adapter.processNames ? [...adapter.processNames] : [],
)));
let processes: readonly ProcessInfo[] = [];
if (processNames.length > 0) {
try {
processes = await this.captureSnapshot(processNames);
} catch {
processes = [];
}
}

// Query all adapters in parallel using executable-scoped slices of the shared snapshot.
const adapterPromises = adapters.map(async (adapter) => {
try {
const agents = await adapter.detectAgents();
const agents = adapter.processNames
? await adapter.detectAgents({
processes: filterByProcessNames(processes, adapter.processNames),
})
: await adapter.detectAgents();
return { type: adapter.type, agents, error: null };
} catch (error) {
// Capture error but don't throw - allow other adapters to continue
Expand Down
89 changes: 89 additions & 0 deletions packages/agent-manager/src/__tests__/AgentManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -170,6 +171,94 @@ describe('AgentManager', () => {
});

describe('listAgents', () => {
it('shares one process capture while giving each adapter only its declared executables', async () => {
const processes: ProcessInfo[] = [
{ pid: 101, command: 'claude', cwd: '/claude', tty: 's001' },
{ pid: 202, command: 'node /bin/pi', cwd: '/pi', tty: 's002' },
];
const captureSnapshot = vi.fn(async () => processes);
const createSnapshotAdapter = (type: AgentType, processNames: string[]) => ({
type,
processNames,
detectAgents: vi.fn(async () => []),
canHandle: () => true,
getConversation: () => [],
listSessions: async () => [],
});
const claude = createSnapshotAdapter('claude', ['claude']);
const pi = createSnapshotAdapter('pi', ['pi', 'node']);
const snapshotManager = new AgentManager(
new AgentRegistry(path.join(tmpDir, 'snapshot-agents.json')),
captureSnapshot,
);

snapshotManager.registerAdapter(claude as AgentAdapter);
snapshotManager.registerAdapter(pi as AgentAdapter);

await snapshotManager.listAgents();

expect(captureSnapshot).toHaveBeenCalledTimes(1);
expect(captureSnapshot).toHaveBeenCalledWith(['claude', 'pi', 'node']);
expect(claude.detectAgents).toHaveBeenCalledWith({ processes: [processes[0]] });
expect(pi.detectAgents).toHaveBeenCalledWith({ processes: [processes[1]] });
});

it('does not expose foreign command arguments to broad Pi and Gemini matchers', async () => {
const processes: ProcessInfo[] = [
{ pid: 100, command: 'node /usr/local/lib/gemini.js', cwd: '/g', tty: 's001' },
{ pid: 200, command: 'codex exec --cd /Users/x/repos/gemini', cwd: '/c', tty: 's002' },
{ pid: 300, command: 'node /usr/local/lib/pi.js', cwd: '/p', tty: 's003' },
{ pid: 400, command: 'claude --resume /Users/x/pi/session.jsonl', cwd: '/a', tty: 's004' },
];
const captureSnapshot = vi.fn(async () => processes);
const createSnapshotAdapter = (type: AgentType, processNames: string[]) => ({
type,
processNames,
detectAgents: vi.fn(async () => []),
canHandle: () => true,
getConversation: () => [],
listSessions: async () => [],
});
const gemini = createSnapshotAdapter('gemini_cli', ['node']);
const codex = createSnapshotAdapter('codex', ['codex']);
const pi = createSnapshotAdapter('pi', ['pi', 'node']);
const claude = createSnapshotAdapter('claude', ['claude']);
const snapshotManager = new AgentManager(
new AgentRegistry(path.join(tmpDir, 'filtered-snapshot-agents.json')),
captureSnapshot,
);

snapshotManager.registerAdapter(gemini as AgentAdapter);
snapshotManager.registerAdapter(codex as AgentAdapter);
snapshotManager.registerAdapter(pi as AgentAdapter);
snapshotManager.registerAdapter(claude as AgentAdapter);

await snapshotManager.listAgents();

expect(captureSnapshot).toHaveBeenCalledTimes(1);
expect(captureSnapshot).toHaveBeenCalledWith(['node', 'codex', 'pi', 'claude']);
expect(gemini.detectAgents).toHaveBeenCalledWith({ processes: [processes[0], processes[2]] });
expect(codex.detectAgents).toHaveBeenCalledWith({ processes: [processes[1]] });
expect(pi.detectAgents).toHaveBeenCalledWith({ processes: [processes[0], processes[2]] });
expect(claude.detectAgents).toHaveBeenCalledWith({ processes: [processes[3]] });
});

it('does not pass a snapshot context to legacy adapters', async () => {
const captureSnapshot = vi.fn(async () => []);
const legacy = new MockAdapter('claude');
const detect = vi.spyOn(legacy, 'detectAgents');
const snapshotManager = new AgentManager(
new AgentRegistry(path.join(tmpDir, 'legacy-agents.json')),
captureSnapshot,
);
snapshotManager.registerAdapter(legacy);

await snapshotManager.listAgents();

expect(captureSnapshot).not.toHaveBeenCalled();
expect(detect).toHaveBeenCalledWith();
});

it('should return empty array when no adapters registered', async () => {
const agents = await manager.listAgents();
expect(agents).toEqual([]);
Expand Down
Loading
Loading