diff --git a/docs/ai/design/feature-agent-manager.md b/docs/ai/design/feature-agent-manager.md new file mode 100644 index 0000000..4662b77 --- /dev/null +++ b/docs/ai/design/feature-agent-manager.md @@ -0,0 +1,197 @@ +--- +phase: design +title: "Agent Manager Package - Design" +feature: agent-manager +description: Architecture and design for the @ai-devkit/agent-manager package +--- + +# Design: @ai-devkit/agent-manager Package + +## Architecture Overview + +```mermaid +graph TD + subgraph "@ai-devkit/agent-manager" + AM[AgentManager] -->|registers| AA[AgentAdapter Interface] + AA -->|implemented by| CCA[ClaudeCodeAdapter] + AA -->|implemented by| FutureAdapter["Future Adapters..."] + + CCA -->|uses| PU[Process Utils] + CCA -->|uses| FU[File Utils] + + TFM[TerminalFocusManager] -->|uses| PU + + Types[Types & Enums] -->|consumed by| AM + Types -->|consumed by| CCA + Types -->|consumed by| TFM + end + + subgraph "CLI Package (consumer)" + CMD[agent command] -->|imports| AM + CMD -->|imports| CCA + CMD -->|imports| TFM + CMD -->|imports| Types + end +``` + +### Package Directory Structure + +``` +packages/agent-manager/ +├── src/ +│ ├── index.ts # Public API barrel export +│ ├── AgentManager.ts # Core orchestrator +│ ├── adapters/ +│ │ ├── AgentAdapter.ts # Interface, types, enums +│ │ ├── ClaudeCodeAdapter.ts # Claude Code detection +│ │ └── index.ts # Adapter barrel export +│ ├── terminal/ +│ │ ├── TerminalFocusManager.ts # Terminal focus (macOS) +│ │ └── index.ts # Terminal barrel export +│ └── utils/ +│ ├── process.ts # Process detection utilities +│ ├── file.ts # File reading utilities +│ └── index.ts # Utils barrel export +├── src/__tests__/ +│ ├── AgentManager.test.ts +│ └── adapters/ +│ └── ClaudeCodeAdapter.test.ts +├── package.json +├── tsconfig.json +├── jest.config.js +├── project.json +└── .eslintrc.json +``` + +## Data Models + +Types are adapted for a data-first package contract: + +- **AgentType**: `'claude' | 'gemini_cli' | 'codex' | 'other'` +- **AgentStatus**: Enum (`RUNNING`, `WAITING`, `IDLE`, `UNKNOWN`) +- **AgentInfo**: Full agent information (name, type, status, pid, projectPath, sessionId, slug, lastActive, etc.) +- **ProcessInfo**: `{ pid, command, cwd, tty }` +- **AgentAdapter**: Interface with `type`, `detectAgents()`, `canHandle()` +- **TerminalType**: Enum (`TMUX`, `ITERM2`, `TERMINAL_APP`, `UNKNOWN`) +- **TerminalLocation**: `{ type: TerminalType, identifier, tty }` (from TerminalFocusManager) + +## API Design + +### Public Exports (`index.ts`) + +```typescript +// Core +export { AgentManager } from './AgentManager'; + +// Adapters +export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; +export type { AgentAdapter } from './adapters/AgentAdapter'; +export { AgentStatus } from './adapters/AgentAdapter'; +export type { AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter'; + +// Terminal +export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager'; +export type { TerminalLocation } from './terminal/TerminalFocusManager'; + +// Utilities +export { listProcesses, getProcessCwd, getProcessTty, isProcessRunning, getProcessInfo } from './utils/process'; +export type { ListProcessesOptions } from './utils/process'; +export { readLastLines, readJsonLines, fileExists, readJson } from './utils/file'; +``` + +### Usage Example + +```typescript +import { AgentManager, ClaudeCodeAdapter } from '@ai-devkit/agent-manager'; + +const manager = new AgentManager(); +manager.registerAdapter(new ClaudeCodeAdapter()); + +const agents = await manager.listAgents(); +agents.forEach(agent => { + console.log(`${agent.name}: ${agent.status}`); +}); +``` + +### Migration Notes + +- `AgentType` values are now normalized codes (`claude`, `gemini_cli`, `codex`, `other`) +- `AgentInfo` no longer includes UI/display fields (`statusDisplay`, `lastActiveDisplay`) +- `STATUS_CONFIG` / `StatusConfig` were removed; consumers should map presentation in their own layer + +## Component Breakdown + +### 1. AgentManager (core orchestrator) +- Adapter registration/unregistration +- Agent listing with parallel adapter queries +- Agent resolution (exact/partial name matching) +- Status-based sorting +- **Extracted from**: `packages/cli/src/lib/AgentManager.ts` +- **Changes**: None — direct copy + +### 2. AgentAdapter + Types (interface layer) +- Interface contract for adapters +- Type definitions and enums +- Normalized agent type codes for machine-friendly integrations +- **Extracted from**: `packages/cli/src/lib/adapters/AgentAdapter.ts` +- **Changes**: Agent type literals normalized; display-oriented fields removed from core model + +### 3. ClaudeCodeAdapter (concrete adapter) +- Claude Code process detection via `ps aux` +- Session file reading from `~/.claude/projects/` +- Status determination from JSONL entries +- History-based summary extraction +- **Extracted from**: `packages/cli/src/lib/adapters/ClaudeCodeAdapter.ts` +- **Changes**: Import paths updated to use local `utils/` instead of `../../util/` + +### 4. TerminalFocusManager (terminal control) +- Terminal emulator detection (tmux, iTerm2, Terminal.app) +- Terminal window/pane focusing +- macOS-specific AppleScript integration +- **Extracted from**: `packages/cli/src/lib/TerminalFocusManager.ts` +- **Changes**: Import paths updated to use local `utils/process` + +### 5. Process Utilities +- `listProcesses()` — system process listing with filtering +- `getProcessCwd()` — process working directory lookup +- `getProcessTty()` — process TTY device lookup +- `isProcessRunning()` — process existence check +- `getProcessInfo()` — detailed single-process info +- **Extracted from**: `packages/cli/src/util/process.ts` +- **Changes**: `ProcessInfo` type import updated (now from `../adapters/AgentAdapter`) + +### 6. File Utilities +- `readLastLines()` — efficient last-N-lines reading +- `readJsonLines()` — JSONL file parsing +- `fileExists()` — file existence check +- `readJson()` — safe JSON file parsing +- **Extracted from**: `packages/cli/src/util/file.ts` +- **Changes**: None — direct copy + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Package name | `@ai-devkit/agent-manager` | Consistent with `@ai-devkit/memory` naming | +| Build system | `tsc` (not SWC) | Simpler setup; no special transforms needed; consistent with CLI package | +| Runtime deps | Zero | Only Node.js built-ins used; keeps package lightweight | +| Include TerminalFocusManager | Yes, as separate module | Useful for consumers; closely related to agent management | +| Include utilities | Yes, within package | They're tightly coupled to adapter implementation; not general-purpose enough for a separate package | +| Test framework | Jest with ts-jest | Matches existing monorepo conventions | + +## Non-Functional Requirements + +### Performance +- Process listing uses `ps aux` (single exec, ~50ms typical) +- Session file reading limited to last 100 lines for large JSONL files +- Adapter queries run in parallel via `Promise.all` + +### Platform Support +- Process detection: macOS and Linux (uses `ps aux`, `lsof`, `pwdx`) +- Terminal focus: macOS only (AppleScript for iTerm2/Terminal.app, tmux universal) + +### Security +- No external network calls +- Reads only from `~/.claude/` directory (user-owned) +- Process inspection uses standard OS tools +- No secrets or credentials handled diff --git a/docs/ai/implementation/feature-agent-manager.md b/docs/ai/implementation/feature-agent-manager.md new file mode 100644 index 0000000..6666129 --- /dev/null +++ b/docs/ai/implementation/feature-agent-manager.md @@ -0,0 +1,175 @@ +--- +phase: implementation +title: "Agent Manager Package - Implementation Guide" +feature: agent-manager +description: Technical implementation notes for the @ai-devkit/agent-manager package +--- + +# Implementation Guide: @ai-devkit/agent-manager Package + +## Development Setup + +### Prerequisites +- Node.js >= 16.0.0 +- npm (workspaces enabled in root) +- TypeScript 5.3+ + +### Setup Steps +1. Package directory created at `packages/agent-manager/` +2. Run `npm install` from monorepo root to link workspace +3. Build with `npm run build` from `packages/agent-manager/` + +## Code Structure + +### Directory Organization +``` +src/ +├── index.ts # Main barrel export +├── AgentManager.ts # Core orchestrator class +├── adapters/ +│ ├── index.ts # Adapter barrel +│ ├── AgentAdapter.ts # Interface + types + enums +│ └── ClaudeCodeAdapter.ts # Claude Code detection +├── terminal/ +│ ├── index.ts # Terminal barrel +│ └── TerminalFocusManager.ts # macOS terminal focus +└── utils/ + ├── index.ts # Utils barrel + ├── process.ts # Process detection + └── file.ts # File reading helpers +``` + +### Import Path Mapping (CLI → agent-manager) +| CLI Path | Agent Manager Path | +|----------|-------------------| +| `src/lib/AgentManager.ts` | `src/AgentManager.ts` | +| `src/lib/adapters/AgentAdapter.ts` | `src/adapters/AgentAdapter.ts` | +| `src/lib/adapters/ClaudeCodeAdapter.ts` | `src/adapters/ClaudeCodeAdapter.ts` | +| `src/lib/TerminalFocusManager.ts` | `src/terminal/TerminalFocusManager.ts` | +| `src/util/process.ts` | `src/utils/process.ts` | +| `src/util/file.ts` | `src/utils/file.ts` | + +### Import Changes Required +- `ClaudeCodeAdapter.ts`: `../../util/process` → `../utils/process`, `../../util/file` → `../utils/file` +- `process.ts`: `../lib/adapters/AgentAdapter` → `../adapters/AgentAdapter` +- `TerminalFocusManager.ts`: `../util/process` → `../utils/process` + +## Patterns & Best Practices + +- **Adapter pattern**: All agent detection goes through `AgentAdapter` interface +- **Barrel exports**: Each directory has an `index.ts` for clean imports +- **Zero dependencies**: Only Node.js built-ins (fs, path, child_process, util) +- **Graceful degradation**: Adapter failures don't crash the system — partial results returned + +## Error Handling + +- AgentManager catches adapter errors individually, logs warnings, returns partial results +- File utilities return empty arrays/null on read failures +- Process utilities return empty results when `ps`/`lsof` commands fail +- TerminalFocusManager returns `false`/`null` when terminal can't be found or focused + +## Implementation Status + +Completed on February 25, 2026 in worktree `feature-agent-manager`. + +- Scaffolded `packages/agent-manager/` with `package.json`, `tsconfig.json`, `project.json`, `jest.config.js`, `.eslintrc.json` +- Extracted source files from CLI package into: + - `src/AgentManager.ts` + - `src/adapters/AgentAdapter.ts` + - `src/adapters/ClaudeCodeAdapter.ts` + - `src/terminal/TerminalFocusManager.ts` + - `src/utils/process.ts` + - `src/utils/file.ts` +- Applied import-path updates defined in design/planning docs +- Added barrel exports: + - `src/index.ts` + - `src/adapters/index.ts` + - `src/terminal/index.ts` + - `src/utils/index.ts` +- Extracted and fixed test imports for: + - `src/__tests__/AgentManager.test.ts` + - `src/__tests__/adapters/ClaudeCodeAdapter.test.ts` + +Validation: +- `npm run lint` passes +- `npm run typecheck` passes +- `npm run build` passes +- `npm run test` passes (38 tests) + +Data-model refinements (February 25, 2026): +- Normalized `AgentType` to code-style values: `claude`, `gemini_cli`, `codex`, `other` +- Removed display-oriented contract elements from package API: + - Removed `STATUS_CONFIG` and `StatusConfig` + - Removed `AgentInfo.statusDisplay` + - Removed `AgentInfo.lastActiveDisplay` +- Updated `ClaudeCodeAdapter` to return data-only fields (`status`, `lastActive`, `summary`) without UI formatting +- Replaced hardcoded string literals with enums where appropriate: + - Added `TerminalType` enum for terminal location/focus flow + - Added `SessionEntryType` enum in `ClaudeCodeAdapter` status logic + +## Phase 6 Check Implementation (February 25, 2026) + +### Alignment Summary + +- Overall status: **Mostly aligned** +- Requirements/design coverage: package scaffold, extracted components, API surface, and validations are implemented as specified +- Backward-compatibility non-goal respected: CLI behavior/source was not modified in this feature branch + +### File-by-File Verification + +- `packages/agent-manager/package.json` + - Matches package naming/version/scripts/engine constraints from requirements + - Uses zero runtime dependencies (only devDependencies) +- `packages/agent-manager/tsconfig.json`, `project.json`, `jest.config.js`, `.eslintrc.json` + - Conform to monorepo conventions and planned targets (build/test/lint/typecheck) +- `packages/agent-manager/src/AgentManager.ts` + - Adapter orchestration and status-based sorting match design +- `packages/agent-manager/src/adapters/AgentAdapter.ts` + - Types and interface extracted as designed +- `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + - Core detection/session/status logic extracted with planned import-path updates +- `packages/agent-manager/src/terminal/TerminalFocusManager.ts` + - Terminal focus logic extracted with planned import-path updates +- `packages/agent-manager/src/utils/process.ts`, `src/utils/file.ts` + - Utility extraction and API signatures match design intent +- `packages/agent-manager/src/index.ts` and barrel files + - Public API exports include core classes/types plus terminal and utils as designed +- `packages/agent-manager/src/__tests__/...` + - Test files extracted and passing in package context + +### Deviations / Risks + +1. **Resolved (February 25, 2026)**: Claude adapter tests now mock process/session/history dependencies and no longer rely on local `~/.claude` state or `ps` availability. +2. **Resolved (February 25, 2026)**: Explicit `any` warnings in extracted runtime code were removed by tightening adapter and utility generic typings. + +### Phase Decision + +- No major implementation/design mismatch detected. +- Proceed to **Phase 8 (Code Review)**. + +## Phase 8 Code Review (February 25, 2026) + +### Findings + +1. **Resolved**: tmux focus command previously used shell interpolation for the target identifier. + - Updated `TerminalFocusManager.focusTmuxPane()` to use `execFile('tmux', ['switch-client', '-t', identifier])` to avoid shell command injection paths. + +2. **Non-blocking follow-up**: coverage threshold enforcement currently depends on running Jest with coverage enabled. + - Suggested project policy: require `npm run test:coverage` (or equivalent) in CI for this package. + +### Review Verdict + +- No remaining blocking correctness or security issues in `packages/agent-manager`. +- Feature is ready for commit/PR from a code review perspective. + +## Code Review Continuation (February 25, 2026) + +### Findings + +1. **No new blocking issues** after `AgentType` normalization and display-field removal. +2. **Compatibility note**: this is an intentional contract change for package consumers (type literals and removed display fields/constants). + +### Documentation Updates Applied + +- Requirements/design docs updated to describe the data-first API boundary. +- Added explicit migration notes for callers formatting status/time displays externally. diff --git a/docs/ai/planning/feature-agent-manager.md b/docs/ai/planning/feature-agent-manager.md new file mode 100644 index 0000000..6779b3e --- /dev/null +++ b/docs/ai/planning/feature-agent-manager.md @@ -0,0 +1,95 @@ +--- +phase: planning +title: "Agent Manager Package - Planning" +feature: agent-manager +description: Task breakdown for creating the @ai-devkit/agent-manager package +--- + +# Planning: @ai-devkit/agent-manager Package + +## Milestones + +- [x] Milestone 1: Package scaffold and build infrastructure +- [x] Milestone 2: Core code extraction and adaptation +- [x] Milestone 3: Tests and validation + +## Task Breakdown + +### Phase 1: Package Scaffold + +- [x] Task 1.1: Create `packages/agent-manager/` directory structure + - Create `src/`, `src/adapters/`, `src/terminal/`, `src/utils/`, `src/__tests__/`, `src/__tests__/adapters/` +- [x] Task 1.2: Create `package.json` with proper metadata + - Name: `@ai-devkit/agent-manager`, version: `0.1.0` + - Zero runtime dependencies + - Scripts: build, test, lint, typecheck, clean + - Exports map for main and sub-paths +- [x] Task 1.3: Create `tsconfig.json` extending `../../tsconfig.base.json` + - rootDir: `./src`, outDir: `./dist` + - Exclude: node_modules, dist, `src/__tests__` +- [x] Task 1.4: Create `project.json` for Nx integration + - Targets: build, test, lint +- [x] Task 1.5: Create `jest.config.js` matching monorepo conventions + - Preset: ts-jest, testEnvironment: node + - Coverage thresholds: 80% across branches/functions/lines/statements +- [x] Task 1.6: Create `.eslintrc.json` matching monorepo conventions + +### Phase 2: Core Code Extraction + +- [x] Task 2.1: Extract `src/adapters/AgentAdapter.ts` + - Direct copy from `packages/cli/src/lib/adapters/AgentAdapter.ts` + - No modifications needed +- [x] Task 2.2: Extract `src/utils/file.ts` + - Direct copy from `packages/cli/src/util/file.ts` + - No modifications needed +- [x] Task 2.3: Extract `src/utils/process.ts` + - Copy from `packages/cli/src/util/process.ts` + - Update import: `ProcessInfo` now from `../adapters/AgentAdapter` +- [x] Task 2.4: Extract `src/AgentManager.ts` + - Copy from `packages/cli/src/lib/AgentManager.ts` + - Update import paths: `./adapters/AgentAdapter` +- [x] Task 2.5: Extract `src/adapters/ClaudeCodeAdapter.ts` + - Copy from `packages/cli/src/lib/adapters/ClaudeCodeAdapter.ts` + - Update imports: `../../util/process` → `../utils/process`, `../../util/file` → `../utils/file` +- [x] Task 2.6: Extract `src/terminal/TerminalFocusManager.ts` + - Copy from `packages/cli/src/lib/TerminalFocusManager.ts` + - Update import: `../util/process` → `../utils/process` +- [x] Task 2.7: Create barrel exports + - `src/adapters/index.ts` — re-export adapter types and ClaudeCodeAdapter + - `src/terminal/index.ts` — re-export TerminalFocusManager and types + - `src/utils/index.ts` — re-export process and file utilities + - `src/index.ts` — main barrel export for the entire package + +### Phase 3: Tests + +- [x] Task 3.1: Extract `src/__tests__/AgentManager.test.ts` + - Copy from `packages/cli/src/__tests__/lib/AgentManager.test.ts` + - Update import paths +- [x] Task 3.2: Extract `src/__tests__/adapters/ClaudeCodeAdapter.test.ts` + - Copy from `packages/cli/src/__tests__/lib/adapters/ClaudeCodeAdapter.test.ts` + - Update import paths +- [x] Task 3.3: Run tests and verify all pass +- [x] Task 3.4: Run build and verify clean compilation +- [x] Task 3.5: Run lint and fix any issues + +## Dependencies + +- Task 2.1 (AgentAdapter types) must complete before Tasks 2.3, 2.4, 2.5, 2.6 +- Task 2.2 (file utils) must complete before Task 2.5 (ClaudeCodeAdapter) +- Task 2.3 (process utils) must complete before Tasks 2.5, 2.6 +- Phase 1 must complete before Phase 2 +- Phase 2 must complete before Phase 3 + +## Risks & Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Import path mismatches after extraction | Build failures | Careful path mapping; verify with `tsc --noEmit` after each file | +| Test environment differences | Test failures | Run tests early in Phase 3; match jest config to CLI package | +| Missing utility dependencies | Runtime errors | Trace all imports from source files before extraction | + +## Resources Needed + +- Existing source files in `packages/cli/src/lib/` and `packages/cli/src/util/` +- Existing tests in `packages/cli/src/__tests__/` +- Monorepo configuration files as reference (`packages/memory/` structure) diff --git a/docs/ai/requirements/feature-agent-manager.md b/docs/ai/requirements/feature-agent-manager.md new file mode 100644 index 0000000..d3b0866 --- /dev/null +++ b/docs/ai/requirements/feature-agent-manager.md @@ -0,0 +1,76 @@ +--- +phase: requirements +title: "Agent Manager Package - Requirements" +feature: agent-manager +description: Extract agent detection and management into a standalone @ai-devkit/agent-manager package +--- + +# Requirements: @ai-devkit/agent-manager Package + +## Problem Statement + +Agent detection and management code (AgentManager, adapters, process utilities, file utilities) currently lives inside `packages/cli/src/lib/`. This creates several issues: + +- **Tight coupling**: The agent detection logic is buried in the CLI package, making it inaccessible to other consumers (MCP servers, web dashboards, other tools) +- **Reusability**: Other packages or external tools cannot import agent detection capabilities without depending on the entire CLI +- **Separation of concerns**: CLI-specific UI code (commands, terminal formatting) is mixed with core domain logic (process detection, session parsing) +- **Testing isolation**: Agent-related tests are interleaved with CLI tests, making it harder to test the core logic independently + +## Goals & Objectives + +### Primary Goals +- Create a new `@ai-devkit/agent-manager` package at `packages/agent-manager/` +- Extract and enhance core agent detection logic from CLI into the new package +- Export a clean public API for agent detection, adapter registration, and agent resolution +- Include all supporting utilities (process detection, file reading) within the package + +### Secondary Goals +- Improve code quality during extraction (better error handling, consistent patterns) +- Include TerminalFocusManager as an optional export (terminal focus capability) +- Maintain backward compatibility — CLI should still function identically after extraction +- Keep package contracts data-first (machine-friendly enums/codes, no UI display formatting) + +### Non-Goals +- Modifying the CLI `agent` command behavior or UI (stays in CLI, just re-imports) +- Adding new adapters (Gemini CLI, Codex) in this iteration +- Removing agent code from CLI package (future task — for now, the new package is standalone) +- Creating an MCP server for agent management + +## User Stories & Use Cases + +1. **As a library consumer**, I want to `import { AgentManager, ClaudeCodeAdapter } from '@ai-devkit/agent-manager'` so that I can detect running AI agents in my own tools. + +2. **As a CLI maintainer**, I want agent logic in a separate package so that the CLI can import it as a dependency, keeping the CLI focused on command presentation. + +3. **As an adapter author**, I want a clear `AgentAdapter` interface and documented patterns so that I can implement adapters for new agent types (Gemini CLI, Codex, etc.). + +4. **As a tool developer**, I want process detection utilities (`listProcesses`, `getProcessCwd`) available as standalone imports so that I can use them independently. + +## Success Criteria + +- [x] `packages/agent-manager/` exists with proper monorepo setup (package.json, tsconfig, project.json, jest config) +- [x] All core files extracted: `AgentManager`, `AgentAdapter` types, `ClaudeCodeAdapter`, `TerminalFocusManager`, `process` utils, `file` utils +- [x] Package exports a clean public API via `index.ts` +- [x] All existing tests pass in the new package context +- [x] Package builds successfully with `npm run build` +- [x] Package follows existing monorepo conventions (same as `@ai-devkit/memory`) + +## Constraints & Assumptions + +### Technical Constraints +- Must follow existing monorepo conventions (Nx, npm workspaces, TypeScript) +- Zero runtime dependencies — only Node.js built-ins (fs, path, child_process, util) +- Must support Node.js >= 16.0.0 (matching existing engine requirement) +- Build system: use `tsc` for now (simpler than SWC since no special transforms needed) + +### Assumptions +- The CLI package will NOT be modified to import from the new package in this iteration +- TerminalFocusManager is macOS-specific and should be documented as such +- Process detection utilities (`ps aux`, `lsof`) are Unix/macOS-specific +- Any consumer-facing formatting (emoji/labels/relative-time strings) is the responsibility of callers, not this package + +## Questions & Open Items + +- **Resolved**: Package name will be `@ai-devkit/agent-manager` (consistent with `@ai-devkit/memory`) +- **Resolved**: TerminalFocusManager will be included in the package as a separate export path +- **Open**: Should we add a `GeminiCLIAdapter` stub/skeleton for future use? (Recommend: no, keep scope minimal) diff --git a/docs/ai/testing/feature-agent-manager.md b/docs/ai/testing/feature-agent-manager.md new file mode 100644 index 0000000..36dee47 --- /dev/null +++ b/docs/ai/testing/feature-agent-manager.md @@ -0,0 +1,52 @@ +--- +phase: testing +title: "Agent Manager Package - Testing Strategy" +feature: agent-manager +description: Testing approach for the @ai-devkit/agent-manager package +--- + +# Testing Strategy: @ai-devkit/agent-manager Package + +## Test Coverage Goals + +- Unit test coverage target: 100% of extracted code +- All existing CLI tests for agent code must pass in new package context +- Coverage thresholds: 80% branches, 80% functions, 80% lines, 80% statements + +## Unit Tests + +### AgentManager (`src/__tests__/AgentManager.test.ts`) +- [x] Adapter registration (single, duplicate, multiple types) +- [x] Adapter unregistration (existing, non-existent) +- [x] Get adapters (empty, populated) +- [x] Has adapter (registered, unregistered) +- [x] List agents (no adapters, single adapter, multiple adapters) +- [x] Agent status sorting (waiting > running > idle > unknown) +- [x] Error handling (adapter failures, all adapters fail) +- [x] Agent resolution (exact match, partial match, ambiguous, no match) +- [x] Adapter count and clear + +### ClaudeCodeAdapter (`src/__tests__/adapters/ClaudeCodeAdapter.test.ts`) +- [x] Adapter type and canHandle() +- [x] Agent detection (mocked process/session data) +- [x] Helper methods: determineStatus(), generateAgentName() + +## Test Data + +- Mock adapters implementing `AgentAdapter` interface +- Mock `AgentInfo` objects with configurable overrides +- Tests use Jest mocking for process/session/history dependencies — no real process detection in unit tests + +## Test Reporting & Coverage + +- Run: `npm run test` from `packages/agent-manager/` +- Coverage: `npm run test -- --coverage` +- Threshold enforcement via jest.config.js `coverageThreshold` + +## Execution Results + +Executed on February 25, 2026: + +- `npm run test` passed +- Total: 38 tests passed, 2 suites passed +- Claude adapter unit tests are deterministic and run without relying on host process permissions diff --git a/package-lock.json b/package-lock.json index 816bec3..fa8a761 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,10 @@ "node": ">=16.0.0" } }, + "node_modules/@ai-devkit/agent-manager": { + "resolved": "packages/agent-manager", + "link": true + }, "node_modules/@ai-devkit/memory": { "resolved": "packages/memory", "link": true @@ -13015,6 +13019,24 @@ "zod": "^3.25 || ^4" } }, + "packages/agent-manager": { + "name": "@ai-devkit/agent-manager", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.4.5", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "packages/cli": { "name": "ai-devkit", "version": "0.15.0", diff --git a/packages/agent-manager/.eslintrc.json b/packages/agent-manager/.eslintrc.json new file mode 100644 index 0000000..ddc47aa --- /dev/null +++ b/packages/agent-manager/.eslintrc.json @@ -0,0 +1,31 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "plugins": ["@typescript-eslint"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "node": true, + "es6": true, + "jest": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-var-requires": "error" + }, + "overrides": [ + { + "files": ["**/__tests__/**/*.ts", "**/*.test.ts", "**/*.spec.ts"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-var-requires": "off" + } + } + ] +} diff --git a/packages/agent-manager/jest.config.js b/packages/agent-manager/jest.config.js new file mode 100644 index 0000000..6e5e050 --- /dev/null +++ b/packages/agent-manager/jest.config.js @@ -0,0 +1,21 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'], + collectCoverageFrom: [ + 'src/**/*.{ts,js}', + '!src/**/*.d.ts', + '!src/index.ts' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + } +}; diff --git a/packages/agent-manager/package.json b/packages/agent-manager/package.json new file mode 100644 index 0000000..a3b205e --- /dev/null +++ b/packages/agent-manager/package.json @@ -0,0 +1,42 @@ +{ + "name": "@ai-devkit/agent-manager", + "version": "0.1.0", + "description": "Standalone agent detection and management utilities for AI DevKit", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "test": "jest", + "test:coverage": "jest --coverage", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "keywords": [ + "ai", + "agent", + "manager", + "claude" + ], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.4.5", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=16.0.0" + } +} diff --git a/packages/agent-manager/project.json b/packages/agent-manager/project.json new file mode 100644 index 0000000..229932f --- /dev/null +++ b/packages/agent-manager/project.json @@ -0,0 +1,29 @@ +{ + "name": "agent-manager", + "root": "packages/agent-manager", + "sourceRoot": "packages/agent-manager/src", + "projectType": "library", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "command": "npm run build", + "cwd": "packages/agent-manager" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "npm run test", + "cwd": "packages/agent-manager" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "npm run lint", + "cwd": "packages/agent-manager" + } + } + } +} diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts new file mode 100644 index 0000000..017afa2 --- /dev/null +++ b/packages/agent-manager/src/AgentManager.ts @@ -0,0 +1,198 @@ +/** + * Agent Manager + * + * Orchestrates agent detection across multiple adapter types. + * Manages adapter registration and aggregates results from all adapters. + */ + +import type { AgentAdapter, AgentInfo } from './adapters/AgentAdapter'; +import { AgentStatus } from './adapters/AgentAdapter'; + +/** + * Agent Manager Class + * + * Central manager for detecting AI agents across different types. + * Supports multiple adapters (Claude Code, Gemini CLI, etc.) + * + * @example + * ```typescript + * const manager = new AgentManager(); + * manager.registerAdapter(new ClaudeCodeAdapter()); + * + * const agents = await manager.listAgents(); + * console.log(`Found ${agents.length} agents`); + * ``` + */ +export class AgentManager { + private adapters: Map = new Map(); + + /** + * Register an adapter for a specific agent type + * + * @param adapter Agent adapter to register + * @throws Error if an adapter for this type is already registered + * + * @example + * ```typescript + * manager.registerAdapter(new ClaudeCodeAdapter()); + * ``` + */ + registerAdapter(adapter: AgentAdapter): void { + const adapterKey = adapter.type; + + if (this.adapters.has(adapterKey)) { + throw new Error(`Adapter for type "${adapterKey}" is already registered`); + } + + this.adapters.set(adapterKey, adapter); + } + + /** + * Unregister an adapter by type + * + * @param type Agent type to unregister + * @returns True if adapter was removed, false if not found + */ + unregisterAdapter(type: string): boolean { + return this.adapters.delete(type); + } + + /** + * Get all registered adapters + * + * @returns Array of registered adapters + */ + getAdapters(): AgentAdapter[] { + return Array.from(this.adapters.values()); + } + + /** + * Check if an adapter is registered for a specific type + * + * @param type Agent type to check + * @returns True if adapter is registered + */ + hasAdapter(type: string): boolean { + return this.adapters.has(type); + } + + /** + * List all running AI agents detected by registered adapters + * + * Queries all registered adapters and aggregates results. + * Handles errors gracefully - if one adapter fails, others still run. + * + * @returns Array of detected agents from all adapters + * + * @example + * ```typescript + * const agents = await manager.listAgents(); + * + * agents.forEach(agent => { + * console.log(`${agent.name}: ${agent.status}`); + * }); + * ``` + */ + async listAgents(): Promise { + 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) => { + try { + const agents = await adapter.detectAgents(); + return { type: adapter.type, agents, error: null }; + } catch (error) { + // Capture error but don't throw - allow other adapters to continue + const err = error instanceof Error ? error : new Error(String(error)); + errors.push({ type: adapter.type, error: err }); + return { type: adapter.type, agents: [], error: err }; + } + }); + + const results = await Promise.all(adapterPromises); + + // Aggregate all successful results + for (const result of results) { + if (result.error === null) { + allAgents.push(...result.agents); + } + } + + // Log errors if any (but don't throw - partial results are useful) + if (errors.length > 0) { + console.error(`Warning: ${errors.length} adapter(s) failed:`); + errors.forEach(({ type, error }) => { + console.error(` - ${type}: ${error.message}`); + }); + } + + // Sort by status priority (waiting first, then running, then idle) + return this.sortAgentsByStatus(allAgents); + } + + /** + * Sort agents by status priority + * + * Priority order: waiting > running > idle > unknown + * This ensures agents that need attention appear first. + * + * @param agents Array of agents to sort + * @returns Sorted array of agents + */ + private sortAgentsByStatus(agents: AgentInfo[]): AgentInfo[] { + const statusPriority: Record = { + [AgentStatus.WAITING]: 0, + [AgentStatus.RUNNING]: 1, + [AgentStatus.IDLE]: 2, + [AgentStatus.UNKNOWN]: 3, + }; + + return agents.sort((a, b) => { + const priorityA = statusPriority[a.status] ?? 999; + const priorityB = statusPriority[b.status] ?? 999; + return priorityA - priorityB; + }); + } + + /** + * Get count of registered adapters + * + * @returns Number of registered adapters + */ + getAdapterCount(): number { + return this.adapters.size; + } + + /** + * Clear all registered adapters + */ + clear(): void { + this.adapters.clear(); + } + + /** + * Resolve an agent by name (exact or partial match) + * + * @param input Name to search for + * @param agents List of agents to search within + * @returns Matched agent (unique), array of agents (ambiguous), or null (none) + */ + resolveAgent(input: string, agents: AgentInfo[]): AgentInfo | AgentInfo[] | null { + if (!input || agents.length === 0) return null; + + const lowerInput = input.toLowerCase(); + + // 1. Exact match (case-insensitive) + const exactMatch = agents.find(a => a.name.toLowerCase() === lowerInput); + if (exactMatch) return exactMatch; + + // 2. Partial match (prefix or contains) + const matches = agents.filter(a => a.name.toLowerCase().includes(lowerInput)); + + if (matches.length === 1) return matches[0]; + if (matches.length > 1) return matches; + + return null; + } +} diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts new file mode 100644 index 0000000..04b30aa --- /dev/null +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -0,0 +1,308 @@ +/** + * Tests for AgentManager + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { AgentManager } from '../AgentManager'; +import type { AgentAdapter, AgentInfo, AgentType } from '../adapters/AgentAdapter'; +import { AgentStatus } from '../adapters/AgentAdapter'; + +// Mock adapter for testing +class MockAdapter implements AgentAdapter { + constructor( + public readonly type: AgentType, + private mockAgents: AgentInfo[] = [], + private shouldFail: boolean = false + ) { } + + async detectAgents(): Promise { + if (this.shouldFail) { + throw new Error(`Mock adapter ${this.type} failed`); + } + return this.mockAgents; + } + + canHandle(): boolean { + return true; + } + + setAgents(agents: AgentInfo[]): void { + this.mockAgents = agents; + } + + setFail(shouldFail: boolean): void { + this.shouldFail = shouldFail; + } +} + +// Helper to create mock agent +function createMockAgent(overrides: Partial = {}): AgentInfo { + return { + name: 'test-agent', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'Test summary', + pid: 12345, + projectPath: '/test/path', + sessionId: 'test-session-id', + slug: 'test-slug', + lastActive: new Date(), + ...overrides, + }; +} + +describe('AgentManager', () => { + let manager: AgentManager; + + beforeEach(() => { + manager = new AgentManager(); + }); + + describe('registerAdapter', () => { + it('should register a new adapter', () => { + const adapter = new MockAdapter('claude'); + + manager.registerAdapter(adapter); + + expect(manager.hasAdapter('claude')).toBe(true); + expect(manager.getAdapterCount()).toBe(1); + }); + + it('should throw error when registering duplicate adapter type', () => { + const adapter1 = new MockAdapter('claude'); + const adapter2 = new MockAdapter('claude'); + + manager.registerAdapter(adapter1); + + expect(() => manager.registerAdapter(adapter2)).toThrow( + 'Adapter for type "claude" is already registered' + ); + }); + + it('should allow registering multiple different adapter types', () => { + const adapter1 = new MockAdapter('claude'); + const adapter2 = new MockAdapter('gemini_cli'); + + manager.registerAdapter(adapter1); + manager.registerAdapter(adapter2); + + expect(manager.getAdapterCount()).toBe(2); + expect(manager.hasAdapter('claude')).toBe(true); + expect(manager.hasAdapter('gemini_cli')).toBe(true); + }); + }); + + describe('unregisterAdapter', () => { + it('should unregister an existing adapter', () => { + const adapter = new MockAdapter('claude'); + manager.registerAdapter(adapter); + + const removed = manager.unregisterAdapter('claude'); + + expect(removed).toBe(true); + expect(manager.hasAdapter('claude')).toBe(false); + expect(manager.getAdapterCount()).toBe(0); + }); + + it('should return false when unregistering non-existent adapter', () => { + const removed = manager.unregisterAdapter('NonExistent'); + expect(removed).toBe(false); + }); + }); + + describe('getAdapters', () => { + it('should return empty array when no adapters registered', () => { + const adapters = manager.getAdapters(); + expect(adapters).toEqual([]); + }); + + it('should return all registered adapters', () => { + const adapter1 = new MockAdapter('claude'); + const adapter2 = new MockAdapter('gemini_cli'); + + manager.registerAdapter(adapter1); + manager.registerAdapter(adapter2); + + const adapters = manager.getAdapters(); + expect(adapters).toHaveLength(2); + expect(adapters).toContain(adapter1); + expect(adapters).toContain(adapter2); + }); + }); + + describe('hasAdapter', () => { + it('should return true for registered adapter', () => { + manager.registerAdapter(new MockAdapter('claude')); + expect(manager.hasAdapter('claude')).toBe(true); + }); + + it('should return false for non-registered adapter', () => { + expect(manager.hasAdapter('claude')).toBe(false); + }); + }); + + describe('listAgents', () => { + it('should return empty array when no adapters registered', async () => { + const agents = await manager.listAgents(); + expect(agents).toEqual([]); + }); + + it('should return agents from single adapter', async () => { + const mockAgents = [ + createMockAgent({ name: 'agent1' }), + createMockAgent({ name: 'agent2' }), + ]; + const adapter = new MockAdapter('claude', mockAgents); + + manager.registerAdapter(adapter); + const agents = await manager.listAgents(); + + expect(agents).toHaveLength(2); + expect(agents[0].name).toBe('agent1'); + expect(agents[1].name).toBe('agent2'); + }); + + it('should aggregate agents from multiple adapters', async () => { + const claudeAgents = [createMockAgent({ name: 'claude-agent', type: 'claude' })]; + const geminiAgents = [createMockAgent({ name: 'gemini-agent', type: 'gemini_cli' })]; + + manager.registerAdapter(new MockAdapter('claude', claudeAgents)); + manager.registerAdapter(new MockAdapter('gemini_cli', geminiAgents)); + + const agents = await manager.listAgents(); + + expect(agents).toHaveLength(2); + expect(agents.find(a => a.name === 'claude-agent')).toBeDefined(); + expect(agents.find(a => a.name === 'gemini-agent')).toBeDefined(); + }); + + it('should sort agents by status priority (waiting first)', async () => { + const mockAgents = [ + createMockAgent({ name: 'idle-agent', status: AgentStatus.IDLE }), + createMockAgent({ name: 'waiting-agent', status: AgentStatus.WAITING }), + createMockAgent({ name: 'running-agent', status: AgentStatus.RUNNING }), + createMockAgent({ name: 'unknown-agent', status: AgentStatus.UNKNOWN }), + ]; + const adapter = new MockAdapter('claude', mockAgents); + + manager.registerAdapter(adapter); + const agents = await manager.listAgents(); + + expect(agents[0].name).toBe('waiting-agent'); + expect(agents[1].name).toBe('running-agent'); + expect(agents[2].name).toBe('idle-agent'); + expect(agents[3].name).toBe('unknown-agent'); + }); + + it('should handle adapter errors gracefully', async () => { + const goodAdapter = new MockAdapter('claude', [ + createMockAgent({ name: 'good-agent' }), + ]); + const badAdapter = new MockAdapter('gemini_cli', [], true); // Will fail + + manager.registerAdapter(goodAdapter); + manager.registerAdapter(badAdapter); + + // Should not throw, should return results from working adapter + const agents = await manager.listAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('good-agent'); + }); + + it('should return empty array when all adapters fail', async () => { + const adapter1 = new MockAdapter('claude', [], true); + const adapter2 = new MockAdapter('gemini_cli', [], true); + + manager.registerAdapter(adapter1); + manager.registerAdapter(adapter2); + + const agents = await manager.listAgents(); + expect(agents).toEqual([]); + }); + }); + + describe('getAdapterCount', () => { + it('should return 0 when no adapters registered', () => { + expect(manager.getAdapterCount()).toBe(0); + }); + + it('should return correct count', () => { + manager.registerAdapter(new MockAdapter('claude')); + expect(manager.getAdapterCount()).toBe(1); + + manager.registerAdapter(new MockAdapter('gemini_cli')); + expect(manager.getAdapterCount()).toBe(2); + }); + }); + + describe('clear', () => { + it('should remove all adapters', () => { + manager.registerAdapter(new MockAdapter('claude')); + manager.registerAdapter(new MockAdapter('gemini_cli')); + + manager.clear(); + + expect(manager.getAdapterCount()).toBe(0); + expect(manager.getAdapters()).toEqual([]); + }); + }); + + describe('resolveAgent', () => { + it('should return null for empty input or empty agents list', () => { + const agent = createMockAgent({ name: 'test-agent' }); + expect(manager.resolveAgent('', [agent])).toBeNull(); + expect(manager.resolveAgent('test', [])).toBeNull(); + }); + + it('should resolve exact match (case-insensitive)', () => { + const agent = createMockAgent({ name: 'My-Agent' }); + const agents = [agent, createMockAgent({ name: 'Other' })]; + + // Exact match + expect(manager.resolveAgent('My-Agent', agents)).toBe(agent); + // Case-insensitive + expect(manager.resolveAgent('my-agent', agents)).toBe(agent); + }); + + it('should resolve unique partial match', () => { + const agent = createMockAgent({ name: 'ai-devkit' }); + const agents = [ + agent, + createMockAgent({ name: 'other-project' }) + ]; + + const result = manager.resolveAgent('dev', agents); + expect(result).toBe(agent); + }); + + it('should return array for ambiguous partial match', () => { + const agent1 = createMockAgent({ name: 'my-website' }); + const agent2 = createMockAgent({ name: 'my-app' }); + const agents = [agent1, agent2, createMockAgent({ name: 'other' })]; + + const result = manager.resolveAgent('my', agents); + + expect(Array.isArray(result)).toBe(true); + const matches = result as AgentInfo[]; + expect(matches).toHaveLength(2); + expect(matches).toContain(agent1); + expect(matches).toContain(agent2); + }); + + it('should return null for no match', () => { + const agents = [createMockAgent({ name: 'ai-devkit' })]; + expect(manager.resolveAgent('xyz', agents)).toBeNull(); + }); + + it('should prefer exact match over partial matches', () => { + // Edge case: "test" matches "test" (exact) and "testing" (partial) + // Should return exact "test" + const exact = createMockAgent({ name: 'test' }); + const partial = createMockAgent({ name: 'testing' }); + const agents = [exact, partial]; + + expect(manager.resolveAgent('test', agents)).toBe(exact); + }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts new file mode 100644 index 0000000..1a83a8d --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -0,0 +1,286 @@ +/** + * Tests for ClaudeCodeAdapter + */ + +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter'; +import type { AgentInfo, ProcessInfo } from '../../adapters/AgentAdapter'; +import { AgentStatus } from '../../adapters/AgentAdapter'; +import { listProcesses } from '../../utils/process'; + +jest.mock('../../utils/process', () => ({ + listProcesses: jest.fn(), +})); + +const mockedListProcesses = listProcesses as jest.MockedFunction; + +type PrivateMethod unknown> = T; + +interface AdapterPrivates { + readSessions: PrivateMethod<() => unknown[]>; + readHistory: PrivateMethod<() => unknown[]>; +} + +describe('ClaudeCodeAdapter', () => { + let adapter: ClaudeCodeAdapter; + + beforeEach(() => { + adapter = new ClaudeCodeAdapter(); + mockedListProcesses.mockReset(); + }); + + describe('initialization', () => { + it('should create adapter with correct type', () => { + expect(adapter.type).toBe('claude'); + }); + }); + + describe('canHandle', () => { + it('should return true for claude processes', () => { + const processInfo = { + pid: 12345, + command: 'claude', + cwd: '/test', + tty: 'ttys001', + }; + + expect(adapter.canHandle(processInfo)).toBe(true); + }); + + it('should return true for processes with "claude" in command (case-insensitive)', () => { + const processInfo = { + pid: 12345, + command: '/usr/local/bin/CLAUDE --some-flag', + cwd: '/test', + tty: 'ttys001', + }; + + expect(adapter.canHandle(processInfo)).toBe(true); + }); + + it('should return false for non-claude processes', () => { + const processInfo = { + pid: 12345, + command: 'node', + cwd: '/test', + tty: 'ttys001', + }; + + expect(adapter.canHandle(processInfo)).toBe(false); + }); + }); + + describe('detectAgents', () => { + it('should return empty array if no claude processes running', async () => { + mockedListProcesses.mockReturnValue([]); + + const agents = await adapter.detectAgents(); + expect(agents).toEqual([]); + }); + + it('should detect agents using mocked process/session/history data', async () => { + const processData: ProcessInfo[] = [ + { + pid: 12345, + command: 'claude --continue', + cwd: '/Users/test/my-project', + tty: 'ttys001', + }, + ]; + + const sessionData = [ + { + sessionId: 'session-1', + projectPath: '/Users/test/my-project', + sessionLogPath: '/mock/path/session-1.jsonl', + slug: 'merry-dog', + lastEntry: { type: 'assistant' }, + lastActive: new Date(), + }, + ]; + + const historyData = [ + { + display: 'Investigate failing tests in package', + timestamp: Date.now(), + project: '/Users/test/my-project', + sessionId: 'session-1', + }, + ]; + + mockedListProcesses.mockReturnValue(processData); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue(sessionData); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue(historyData); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + name: 'my-project', + type: 'claude', + status: AgentStatus.WAITING, + pid: 12345, + projectPath: '/Users/test/my-project', + sessionId: 'session-1', + slug: 'merry-dog', + }); + expect(agents[0].summary).toContain('Investigate failing tests in package'); + }); + + it('should return empty list when process cwd has no matching session', async () => { + mockedListProcesses.mockReturnValue([ + { + pid: 777, + command: 'claude', + cwd: '/project/without-session', + tty: 'ttys008', + }, + ]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ + { + sessionId: 'session-2', + projectPath: '/other/project', + sessionLogPath: '/mock/path/session-2.jsonl', + lastEntry: { type: 'assistant' }, + lastActive: new Date(), + }, + ]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); + + const agents = await adapter.detectAgents(); + expect(agents).toEqual([]); + }); + }); + + describe('helper methods', () => { + describe('determineStatus', () => { + it('should return "unknown" for sessions with no last entry', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionLogPath: '/test/log', + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.UNKNOWN); + }); + + it('should return "waiting" for assistant entries', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionLogPath: '/test/log', + lastEntry: { type: 'assistant' }, + lastActive: new Date(), + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.WAITING); + }); + + it('should return "waiting" for user interruption', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionLogPath: '/test/log', + lastEntry: { + type: 'user', + message: { + content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }], + }, + }, + lastActive: new Date(), + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.WAITING); + }); + + it('should return "running" for user/progress entries', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionLogPath: '/test/log', + lastEntry: { type: 'user' }, + lastActive: new Date(), + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.RUNNING); + }); + + it('should return "idle" for old sessions', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const oldDate = new Date(Date.now() - 10 * 60 * 1000); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionLogPath: '/test/log', + lastEntry: { type: 'assistant' }, + lastActive: oldDate, + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.IDLE); + }); + }); + + describe('generateAgentName', () => { + it('should use project name for first session', () => { + const adapter = new ClaudeCodeAdapter(); + const generateAgentName = (adapter as any).generateAgentName.bind(adapter); + + const session = { + sessionId: 'test-123', + projectPath: '/Users/test/my-project', + sessionLogPath: '/test/log', + }; + + const name = generateAgentName(session, []); + expect(name).toBe('my-project'); + }); + + it('should append slug for duplicate projects', () => { + const adapter = new ClaudeCodeAdapter(); + const generateAgentName = (adapter as any).generateAgentName.bind(adapter); + + const existingAgent: AgentInfo = { + name: 'my-project', + projectPath: '/Users/test/my-project', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'Test', + pid: 123, + sessionId: 'existing-123', + slug: 'happy-cat', + lastActive: new Date(), + }; + + const session = { + sessionId: 'test-456', + projectPath: '/Users/test/my-project', + sessionLogPath: '/test/log', + slug: 'merry-dog', + }; + + const name = generateAgentName(session, [existingAgent]); + expect(name).toBe('my-project (merry)'); + }); + }); + }); +}); diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts new file mode 100644 index 0000000..3131018 --- /dev/null +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -0,0 +1,94 @@ +/** + * Agent Adapter Interface + * + * Defines the contract for detecting and managing different types of AI agents. + * Each adapter is responsible for detecting agents of a specific type (e.g., claude). + */ + +/** + * Type of AI agent + */ +export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'other'; + +/** + * Current status of an agent + */ +export enum AgentStatus { + RUNNING = 'running', + WAITING = 'waiting', + IDLE = 'idle', + UNKNOWN = 'unknown' +} + +/** + * Information about a detected agent + */ +export interface AgentInfo { + /** Project-based name (e.g., "ai-devkit" or "ai-devkit (merry)") */ + name: string; + + /** Type of agent */ + type: AgentType; + + /** Current status */ + status: AgentStatus; + + /** Last user prompt from history */ + summary: string; + + /** Process ID */ + pid: number; + + /** Working directory/project path */ + projectPath: string; + + /** Session UUID */ + sessionId: string; + + /** Human-readable session name (e.g., "merry-wobbling-starlight"), may be undefined for new sessions */ + slug?: string; + + /** Timestamp of last activity */ + lastActive: Date; + +} + +/** + * Information about a running process + */ +export interface ProcessInfo { + /** Process ID */ + pid: number; + + /** Process command */ + command: string; + + /** Working directory */ + cwd: string; + + /** Terminal TTY (e.g., "ttys030") */ + tty: string; +} + +/** + * Agent Adapter Interface + * + * Implementations must provide detection logic for a specific agent type. + */ +export interface AgentAdapter { + /** Type of agent this adapter handles */ + readonly type: AgentType; + + /** + * Detect running agents of this type + * @returns List of detected agents + */ + detectAgents(): Promise; + + /** + * Check if this adapter can handle the given process + * @param processInfo Process information + * @returns True if this adapter can handle the process + */ + canHandle(processInfo: ProcessInfo): boolean; +} diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts new file mode 100644 index 0000000..8679368 --- /dev/null +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -0,0 +1,354 @@ +/** + * Claude Code Adapter + * + * Detects running Claude Code agents by reading session files + * from ~/.claude/ directory and correlating with running processes. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter'; +import { AgentStatus } from './AgentAdapter'; +import { listProcesses } from '../utils/process'; +import { readLastLines, readJsonLines, readJson } from '../utils/file'; + +/** + * Structure of ~/.claude/projects/{path}/sessions-index.json + */ +interface SessionsIndex { + originalPath: string; +} + +enum SessionEntryType { + ASSISTANT = 'assistant', + USER = 'user', + PROGRESS = 'progress', + THINKING = 'thinking', + SYSTEM = 'system', + MESSAGE = 'message', + TEXT = 'text', +} + +/** + * Entry in session JSONL file + */ +interface SessionEntry { + type?: SessionEntryType; + timestamp?: string; + slug?: string; + cwd?: string; + sessionId?: string; + message?: { + content?: Array<{ + type?: string; + text?: string; + content?: string; + }>; + }; + [key: string]: unknown; +} + +/** + * Entry in ~/.claude/history.jsonl + */ +interface HistoryEntry { + display: string; + timestamp: number; + project: string; + sessionId: string; +} + +/** + * Claude Code session information + */ +interface ClaudeSession { + sessionId: string; + projectPath: string; + slug?: string; + sessionLogPath: string; + lastEntry?: SessionEntry; + lastActive?: Date; +} + +/** + * Claude Code Adapter + * + * Detects Claude Code agents by: + * 1. Finding running claude processes + * 2. Reading session files from ~/.claude/projects/ + * 3. Matching sessions to processes via CWD + * 4. Extracting status from session JSONL + * 5. Extracting summary from history.jsonl + */ +export class ClaudeCodeAdapter implements AgentAdapter { + readonly type = 'claude' as const; + + /** Threshold in minutes before considering a session idle */ + private static readonly IDLE_THRESHOLD_MINUTES = 5; + + private claudeDir: string; + private projectsDir: string; + private historyPath: string; + + constructor() { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.claudeDir = path.join(homeDir, '.claude'); + this.projectsDir = path.join(this.claudeDir, 'projects'); + this.historyPath = path.join(this.claudeDir, 'history.jsonl'); + } + + /** + * Check if this adapter can handle a given process + */ + canHandle(processInfo: ProcessInfo): boolean { + return processInfo.command.toLowerCase().includes('claude'); + } + + /** + * Detect running Claude Code agents + */ + async detectAgents(): Promise { + // 1. Find running claude processes + const claudeProcesses = listProcesses({ namePattern: 'claude' }); + + if (claudeProcesses.length === 0) { + return []; + } + + // 2. Read all sessions + const sessions = this.readSessions(); + + // 3. Read history for summaries + const history = this.readHistory(); + + // 4. Group processes by CWD + const processesByCwd = new Map(); + for (const p of claudeProcesses) { + const list = processesByCwd.get(p.cwd) || []; + list.push(p); + processesByCwd.set(p.cwd, list); + } + + // 5. Match sessions to processes + const agents: AgentInfo[] = []; + + for (const [cwd, processes] of processesByCwd) { + // Find sessions for this project path + const projectSessions = sessions.filter(s => s.projectPath === cwd); + + if (projectSessions.length === 0) { + continue; + } + + // Sort sessions by last active time (newest first) + projectSessions.sort((a, b) => { + const timeA = a.lastActive?.getTime() || 0; + const timeB = b.lastActive?.getTime() || 0; + return timeB - timeA; + }); + + // Map processes to the most recent sessions + // If there are 2 processes, we take the 2 most recent sessions + const activeSessions = projectSessions.slice(0, processes.length); + + for (let i = 0; i < activeSessions.length; i++) { + const session = activeSessions[i]; + const process = processes[i]; // Assign process to session (arbitrary 1-to-1 mapping) + + const historyEntry = [...history].reverse().find( + h => h.sessionId === session.sessionId + ); + const summary = historyEntry?.display || 'Session started'; + const status = this.determineStatus(session); + const agentName = this.generateAgentName(session, agents); // Pass currently built agents for collision checks + + agents.push({ + name: agentName, + type: this.type, + status, + summary, + pid: process.pid, + projectPath: session.projectPath, + sessionId: session.sessionId, + slug: session.slug, + lastActive: session.lastActive || new Date(), + }); + } + } + + return agents; + } + + /** + * Read all Claude Code sessions + */ + private readSessions(): ClaudeSession[] { + if (!fs.existsSync(this.projectsDir)) { + return []; + } + + const sessions: ClaudeSession[] = []; + const projectDirs = fs.readdirSync(this.projectsDir); + + for (const dirName of projectDirs) { + if (dirName.startsWith('.')) { + continue; + } + + const projectDir = path.join(this.projectsDir, dirName); + if (!fs.statSync(projectDir).isDirectory()) { + continue; + } + + // Read sessions-index.json to get original project path + const indexPath = path.join(projectDir, 'sessions-index.json'); + if (!fs.existsSync(indexPath)) { + continue; + } + + const sessionsIndex = readJson(indexPath); + if (!sessionsIndex) { + console.error(`Failed to parse ${indexPath}`); + continue; + } + + const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl')); + + for (const sessionFile of sessionFiles) { + const sessionId = sessionFile.replace('.jsonl', ''); + const sessionLogPath = path.join(projectDir, sessionFile); + + try { + const sessionData = this.readSessionLog(sessionLogPath); + + sessions.push({ + sessionId, + projectPath: sessionsIndex.originalPath, + slug: sessionData.slug, + sessionLogPath, + lastEntry: sessionData.lastEntry, + lastActive: sessionData.lastActive, + }); + } catch (error) { + console.error(`Failed to read session ${sessionId}:`, error); + continue; + } + } + } + + return sessions; + } + + /** + * Read a session JSONL file + * Only reads last 100 lines for performance with large files + */ + private readSessionLog(logPath: string): { + slug?: string; + lastEntry?: SessionEntry; + lastActive?: Date; + } { + const lines = readLastLines(logPath, 100); + + let slug: string | undefined; + let lastEntry: SessionEntry | undefined; + let lastActive: Date | undefined; + + for (const line of lines) { + try { + const entry: SessionEntry = JSON.parse(line); + + if (entry.slug && !slug) { + slug = entry.slug; + } + + lastEntry = entry; + + if (entry.timestamp) { + lastActive = new Date(entry.timestamp); + } + } catch (error) { + continue; + } + } + + return { slug, lastEntry, lastActive }; + } + + /** + * Read history.jsonl for user prompts + * Only reads last 100 lines for performance + */ + private readHistory(): HistoryEntry[] { + return readJsonLines(this.historyPath, 100); + } + + /** + * Determine agent status from session entry + */ + private determineStatus(session: ClaudeSession): AgentStatus { + if (!session.lastEntry) { + return AgentStatus.UNKNOWN; + } + + const entryType = session.lastEntry.type; + const lastActive = session.lastActive || new Date(0); + const ageMinutes = (Date.now() - lastActive.getTime()) / 1000 / 60; + + if (ageMinutes > ClaudeCodeAdapter.IDLE_THRESHOLD_MINUTES) { + return AgentStatus.IDLE; + } + + if (entryType === SessionEntryType.USER) { + // Check if user interrupted manually - this puts agent back in waiting state + const content = session.lastEntry.message?.content; + if (Array.isArray(content)) { + const isInterrupted = content.some(c => + (c.type === SessionEntryType.TEXT && c.text?.includes('[Request interrupted')) || + (c.type === 'tool_result' && c.content?.includes('[Request interrupted')) + ); + if (isInterrupted) return AgentStatus.WAITING; + } + return AgentStatus.RUNNING; + } + + if (entryType === SessionEntryType.PROGRESS || entryType === SessionEntryType.THINKING) { + return AgentStatus.RUNNING; + } else if (entryType === SessionEntryType.ASSISTANT) { + return AgentStatus.WAITING; + } else if (entryType === SessionEntryType.SYSTEM) { + return AgentStatus.IDLE; + } + + return AgentStatus.UNKNOWN; + } + + /** + * Generate unique agent name + * Uses project basename, appends slug if multiple sessions for same project + */ + private generateAgentName(session: ClaudeSession, existingAgents: AgentInfo[]): string { + const projectName = path.basename(session.projectPath); + + const sameProjectAgents = existingAgents.filter( + a => a.projectPath === session.projectPath + ); + + if (sameProjectAgents.length === 0) { + return projectName; + } + + // Multiple sessions for same project, append slug + if (session.slug) { + // Use first word of slug for brevity (with safety check for format) + const slugPart = session.slug.includes('-') + ? session.slug.split('-')[0] + : session.slug.slice(0, 8); + return `${projectName} (${slugPart})`; + } + + // No slug available, use session ID prefix + return `${projectName} (${session.sessionId.slice(0, 8)})`; + } + +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts new file mode 100644 index 0000000..87137eb --- /dev/null +++ b/packages/agent-manager/src/adapters/index.ts @@ -0,0 +1,3 @@ +export { ClaudeCodeAdapter } from './ClaudeCodeAdapter'; +export { AgentStatus } from './AgentAdapter'; +export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts new file mode 100644 index 0000000..99cbe15 --- /dev/null +++ b/packages/agent-manager/src/index.ts @@ -0,0 +1,12 @@ +export { AgentManager } from './AgentManager'; + +export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; +export { AgentStatus } from './adapters/AgentAdapter'; +export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter'; + +export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager'; +export type { TerminalLocation } from './terminal/TerminalFocusManager'; + +export { listProcesses, getProcessCwd, getProcessTty, isProcessRunning, getProcessInfo } from './utils/process'; +export type { ListProcessesOptions } from './utils/process'; +export { readLastLines, readJsonLines, fileExists, readJson } from './utils/file'; diff --git a/packages/agent-manager/src/terminal/TerminalFocusManager.ts b/packages/agent-manager/src/terminal/TerminalFocusManager.ts new file mode 100644 index 0000000..21bc636 --- /dev/null +++ b/packages/agent-manager/src/terminal/TerminalFocusManager.ts @@ -0,0 +1,213 @@ +import { exec, execFile } from 'child_process'; +import { promisify } from 'util'; +import { getProcessTty } from '../utils/process'; + +const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +export enum TerminalType { + TMUX = 'tmux', + ITERM2 = 'iterm2', + TERMINAL_APP = 'terminal-app', + UNKNOWN = 'unknown', +} + +export interface TerminalLocation { + type: TerminalType; + identifier: string; // e.g., "session:window.pane" for tmux, or TTY for others + tty: string; // e.g., "/dev/ttys030" +} + +export class TerminalFocusManager { + /** + * Find the terminal location (emulator info) for a given process ID + */ + async findTerminal(pid: number): Promise { + const ttyShort = getProcessTty(pid); + + // If no TTY or invalid, we can't find the terminal + if (!ttyShort || ttyShort === '?') { + return null; + } + + const fullTty = `/dev/${ttyShort}`; + + // 1. Check tmux (most specific if running inside it) + const tmuxLocation = await this.findTmuxPane(fullTty); + if (tmuxLocation) return tmuxLocation; + + // 2. Check iTerm2 + const itermLocation = await this.findITerm2Session(fullTty); + if (itermLocation) return itermLocation; + + // 3. Check Terminal.app + const terminalAppLocation = await this.findTerminalAppWindow(fullTty); + if (terminalAppLocation) return terminalAppLocation; + + // 4. Fallback: we know the TTY but not the emulator wrapper + return { + type: TerminalType.UNKNOWN, + identifier: '', + tty: fullTty + }; + } + + /** + * Focus the terminal identified by the location + */ + async focusTerminal(location: TerminalLocation): Promise { + try { + switch (location.type) { + case TerminalType.TMUX: + return await this.focusTmuxPane(location.identifier); + case TerminalType.ITERM2: + return await this.focusITerm2Session(location.tty); + case TerminalType.TERMINAL_APP: + return await this.focusTerminalAppWindow(location.tty); + default: + return false; + } + } catch (error) { + return false; + } + } + + private async findTmuxPane(tty: string): Promise { + try { + // List all panes with their TTYs and identifiers + // Format: /dev/ttys001|my-session:1.1 + // using | as separator to handle spaces in session names + const { stdout } = await execAsync("tmux list-panes -a -F '#{pane_tty}|#{session_name}:#{window_index}.#{pane_index}'"); + + const lines = stdout.trim().split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + const [paneTty, identifier] = line.split('|'); + if (paneTty === tty && identifier) { + return { + type: TerminalType.TMUX, + identifier, + tty + }; + } + } + } catch (error) { + // tmux might not be installed or running + } + return null; + } + + private async findITerm2Session(tty: string): Promise { + try { + // Check if iTerm2 is running first to avoid launching it + const { stdout: isRunning } = await execAsync('pgrep -x iTerm2 || echo "no"'); + if (isRunning.trim() === "no") return null; + + const script = ` + tell application "iTerm" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if tty of s is "${tty}" then + return "found" + end if + end repeat + end repeat + end repeat + end tell + `; + + const { stdout } = await execAsync(`osascript -e '${script}'`); + if (stdout.trim() === "found") { + return { + type: TerminalType.ITERM2, + identifier: tty, + tty + }; + } + } catch (error) { + // iTerm2 not found or script failed + } + return null; + } + + private async findTerminalAppWindow(tty: string): Promise { + try { + // Check if Terminal is running + const { stdout: isRunning } = await execAsync('pgrep -x Terminal || echo "no"'); + if (isRunning.trim() === "no") return null; + + const script = ` + tell application "Terminal" + repeat with w in windows + repeat with t in tabs of w + if tty of t is "${tty}" then + return "found" + end if + end repeat + end repeat + end tell + `; + + const { stdout } = await execAsync(`osascript -e '${script}'`); + if (stdout.trim() === "found") { + return { + type: TerminalType.TERMINAL_APP, + identifier: tty, + tty + }; + } + } catch (error) { + // Terminal not found or script failed + } + return null; + } + + private async focusTmuxPane(identifier: string): Promise { + try { + await execFileAsync('tmux', ['switch-client', '-t', identifier]); + return true; + } catch (error) { + return false; + } + } + + private async focusITerm2Session(tty: string): Promise { + const script = ` + tell application "iTerm" + activate + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if tty of s is "${tty}" then + select s + return "true" + end if + end repeat + end repeat + end repeat + end tell + `; + const { stdout } = await execAsync(`osascript -e '${script}'`); + return stdout.trim() === "true"; + } + + private async focusTerminalAppWindow(tty: string): Promise { + const script = ` + tell application "Terminal" + activate + repeat with w in windows + repeat with t in tabs of w + if tty of t is "${tty}" then + set index of w to 1 + set selected tab of w to t + return "true" + end if + end repeat + end repeat + end tell + `; + const { stdout } = await execAsync(`osascript -e '${script}'`); + return stdout.trim() === "true"; + } +} diff --git a/packages/agent-manager/src/terminal/index.ts b/packages/agent-manager/src/terminal/index.ts new file mode 100644 index 0000000..caea431 --- /dev/null +++ b/packages/agent-manager/src/terminal/index.ts @@ -0,0 +1,3 @@ +export { TerminalFocusManager } from './TerminalFocusManager'; +export { TerminalType } from './TerminalFocusManager'; +export type { TerminalLocation } from './TerminalFocusManager'; diff --git a/packages/agent-manager/src/utils/file.ts b/packages/agent-manager/src/utils/file.ts new file mode 100644 index 0000000..e9dff36 --- /dev/null +++ b/packages/agent-manager/src/utils/file.ts @@ -0,0 +1,100 @@ +/** + * File Utilities + * + * Helper functions for reading files efficiently + */ + +import * as fs from 'fs'; + +/** + * Read last N lines from a file efficiently + * + * @param filePath Path to the file + * @param lineCount Number of lines to read from the end (default: 100) + * @returns Array of lines + * + * @example + * ```typescript + * const lastLines = readLastLines('/path/to/log.txt', 50); + * ``` + */ +export function readLastLines(filePath: string, lineCount: number = 100): string[] { + if (!fs.existsSync(filePath)) { + return []; + } + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const allLines = content.trim().split('\n'); + + // Return last N lines (or all if file has fewer lines) + return allLines.slice(-lineCount); + } catch (error) { + console.error(`Failed to read ${filePath}:`, error); + return []; + } +} + +/** + * Read a JSONL (JSON Lines) file and parse each line + * + * @param filePath Path to the JSONL file + * @param maxLines Maximum number of lines to read from end (default: 1000) + * @returns Array of parsed objects + * + * @example + * ```typescript + * const entries = readJsonLines('/path/to/data.jsonl'); + * const recent = readJsonLines('/path/to/data.jsonl', 100); + * ``` + */ +export function readJsonLines(filePath: string, maxLines: number = 1000): T[] { + const lines = readLastLines(filePath, maxLines); + + return lines.map(line => { + try { + return JSON.parse(line) as T; + } catch { + return null; + } + }).filter((entry): entry is T => entry !== null); +} + +/** + * Check if a file exists + * + * @param filePath Path to check + * @returns True if file exists + */ +export function fileExists(filePath: string): boolean { + try { + return fs.existsSync(filePath); + } catch { + return false; + } +} + +/** + * Read a JSON file safely + * + * @param filePath Path to JSON file + * @returns Parsed JSON object or null if error + * + * @example + * ```typescript + * const config = readJson('/path/to/config.json'); + * ``` + */ +export function readJson(filePath: string): T | null { + if (!fs.existsSync(filePath)) { + return null; + } + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(content) as T; + } catch (error) { + console.error(`Failed to parse JSON from ${filePath}:`, error); + return null; + } +} diff --git a/packages/agent-manager/src/utils/index.ts b/packages/agent-manager/src/utils/index.ts new file mode 100644 index 0000000..bf6832c --- /dev/null +++ b/packages/agent-manager/src/utils/index.ts @@ -0,0 +1,3 @@ +export { listProcesses, getProcessCwd, getProcessTty, isProcessRunning, getProcessInfo } from './process'; +export type { ListProcessesOptions } from './process'; +export { readLastLines, readJsonLines, fileExists, readJson } from './file'; diff --git a/packages/agent-manager/src/utils/process.ts b/packages/agent-manager/src/utils/process.ts new file mode 100644 index 0000000..fe38b5d --- /dev/null +++ b/packages/agent-manager/src/utils/process.ts @@ -0,0 +1,184 @@ +/** + * Process Detection Utilities + * + * Utilities for detecting and inspecting running processes on the system. + * Primarily focused on macOS/Unix-like systems using the `ps` command. + */ + +import { execSync } from 'child_process'; +import type { ProcessInfo } from '../adapters/AgentAdapter'; + +/** + * Options for listing processes + */ +export interface ListProcessesOptions { + /** Filter processes by name pattern (case-insensitive) */ + namePattern?: string; + + /** Include only processes matching these PIDs */ + pids?: number[]; +} + +/** + * List running processes on the system + * + * @param options Filtering options + * @returns Array of process information + * + * @example + * ```typescript + * // List all Claude Code processes + * const processes = listProcesses({ namePattern: 'claude' }); + * + * // Get specific process info + * const process = listProcesses({ pids: [12345] }); + * ``` + */ +export function listProcesses(options: ListProcessesOptions = {}): ProcessInfo[] { + try { + // Get all processes with full details + // Format: user pid command + const psOutput = execSync('ps aux', { encoding: 'utf-8' }); + + const lines = psOutput.trim().split('\n'); + // Skip header line + const processLines = lines.slice(1); + + const processes: ProcessInfo[] = []; + + for (const line of processLines) { + // Parse ps aux output + // Format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND + const parts = line.trim().split(/\s+/); + + if (parts.length < 11) continue; + + const pid = parseInt(parts[1], 10); + if (isNaN(pid)) continue; + + const tty = parts[6]; + const command = parts.slice(10).join(' '); + + // Apply PID filter + if (options.pids && !options.pids.includes(pid)) { + continue; + } + + // Apply name pattern filter (case-insensitive) + if (options.namePattern) { + const pattern = options.namePattern.toLowerCase(); + const commandLower = command.toLowerCase(); + if (!commandLower.includes(pattern)) { + continue; + } + } + + // Get working directory for this process + const cwd = getProcessCwd(pid); + + // Get TTY in short format (remove /dev/ prefix if present) + const ttyShort = tty.startsWith('/dev/') ? tty.slice(5) : tty; + + processes.push({ + pid, + command, + cwd, + tty: ttyShort, + }); + } + + return processes; + } catch (error) { + // If ps command fails, return empty array + console.error('Failed to list processes:', error); + return []; + } +} + +/** + * Get the current working directory for a specific process + * + * @param pid Process ID + * @returns Working directory path, or empty string if unavailable + */ +export function getProcessCwd(pid: number): string { + try { + // Use lsof to get the current working directory + // -a: AND the selections, -d cwd: get cwd only, -Fn: output format (file names only) + const output = execSync(`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null`, { + encoding: 'utf-8', + }); + + // Parse lsof output + // Format: p{PID}\nn{path} + const lines = output.trim().split('\n'); + for (const line of lines) { + if (line.startsWith('n')) { + return line.slice(1); // Remove 'n' prefix + } + } + + return ''; + } catch (error) { + // If lsof fails, try alternative method using pwdx (Linux) + try { + const output = execSync(`pwdx ${pid} 2>/dev/null`, { + encoding: 'utf-8', + }); + // Format: {PID}: {path} + const match = output.match(/^\d+:\s*(.+)$/); + return match ? match[1].trim() : ''; + } catch { + // Both methods failed + return ''; + } + } +} + +/** + * Get the TTY device for a specific process + * + * @param pid Process ID + * @returns TTY device name (e.g., "ttys030"), or "?" if unavailable + */ +export function getProcessTty(pid: number): string { + try { + const output = execSync(`ps -p ${pid} -o tty=`, { + encoding: 'utf-8', + }); + + const tty = output.trim(); + // Remove /dev/ prefix if present + return tty.startsWith('/dev/') ? tty.slice(5) : tty; + } catch (error) { + return '?'; + } +} + +/** + * Check if a process with the given PID is running + * + * @param pid Process ID + * @returns True if process is running + */ +export function isProcessRunning(pid: number): boolean { + try { + // Send signal 0 to check if process exists + // This doesn't actually send a signal, just checks if we can + execSync(`kill -0 ${pid} 2>/dev/null`); + return true; + } catch { + return false; + } +} + +/** + * Get detailed information for a specific process + * + * @param pid Process ID + * @returns Process information, or null if process not found + */ +export function getProcessInfo(pid: number): ProcessInfo | null { + const processes = listProcesses({ pids: [pid] }); + return processes.length > 0 ? processes[0] : null; +} diff --git a/packages/agent-manager/tsconfig.json b/packages/agent-manager/tsconfig.json new file mode 100644 index 0000000..9f3762b --- /dev/null +++ b/packages/agent-manager/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "src/__tests__" + ] +}