From 19d606b13b535de3a60381dff37d71dfd4a600fc Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 25 Feb 2026 16:10:46 +0100 Subject: [PATCH 1/4] feat(agent-manager): add standalone package and lifecycle docs --- docs/ai/design/feature-agent-manager.md | 191 +++++++++ .../implementation/feature-agent-manager.md | 152 +++++++ docs/ai/planning/feature-agent-manager.md | 95 +++++ docs/ai/requirements/feature-agent-manager.md | 74 ++++ docs/ai/testing/feature-agent-manager.md | 52 +++ package-lock.json | 10 + packages/agent-manager/.eslintrc.json | 31 ++ packages/agent-manager/jest.config.js | 21 + packages/agent-manager/package.json | 42 ++ packages/agent-manager/project.json | 29 ++ packages/agent-manager/src/AgentManager.ts | 198 +++++++++ .../src/__tests__/AgentManager.test.ts | 310 ++++++++++++++ .../adapters/ClaudeCodeAdapter.test.ts | 354 ++++++++++++++++ .../src/adapters/AgentAdapter.ts | 118 ++++++ .../src/adapters/ClaudeCodeAdapter.ts | 378 ++++++++++++++++++ packages/agent-manager/src/adapters/index.ts | 3 + packages/agent-manager/src/index.ts | 12 + .../src/terminal/TerminalFocusManager.ts | 206 ++++++++++ packages/agent-manager/src/terminal/index.ts | 2 + packages/agent-manager/src/utils/file.ts | 100 +++++ packages/agent-manager/src/utils/index.ts | 3 + packages/agent-manager/src/utils/process.ts | 184 +++++++++ packages/agent-manager/tsconfig.json | 17 + 23 files changed, 2582 insertions(+) create mode 100644 docs/ai/design/feature-agent-manager.md create mode 100644 docs/ai/implementation/feature-agent-manager.md create mode 100644 docs/ai/planning/feature-agent-manager.md create mode 100644 docs/ai/requirements/feature-agent-manager.md create mode 100644 docs/ai/testing/feature-agent-manager.md create mode 100644 packages/agent-manager/.eslintrc.json create mode 100644 packages/agent-manager/jest.config.js create mode 100644 packages/agent-manager/package.json create mode 100644 packages/agent-manager/project.json create mode 100644 packages/agent-manager/src/AgentManager.ts create mode 100644 packages/agent-manager/src/__tests__/AgentManager.test.ts create mode 100644 packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts create mode 100644 packages/agent-manager/src/adapters/AgentAdapter.ts create mode 100644 packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts create mode 100644 packages/agent-manager/src/adapters/index.ts create mode 100644 packages/agent-manager/src/index.ts create mode 100644 packages/agent-manager/src/terminal/TerminalFocusManager.ts create mode 100644 packages/agent-manager/src/terminal/index.ts create mode 100644 packages/agent-manager/src/utils/file.ts create mode 100644 packages/agent-manager/src/utils/index.ts create mode 100644 packages/agent-manager/src/utils/process.ts create mode 100644 packages/agent-manager/tsconfig.json diff --git a/docs/ai/design/feature-agent-manager.md b/docs/ai/design/feature-agent-manager.md new file mode 100644 index 0000000..dea2b3d --- /dev/null +++ b/docs/ai/design/feature-agent-manager.md @@ -0,0 +1,191 @@ +--- +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 + +All types are extracted from the existing `AgentAdapter.ts` without changes: + +- **AgentType**: `'Claude Code' | 'Gemini CLI' | 'Codex' | 'Other'` +- **AgentStatus**: Enum (`RUNNING`, `WAITING`, `IDLE`, `UNKNOWN`) +- **StatusConfig**: `{ emoji, label, color }` +- **AgentInfo**: Full agent information (name, type, status, pid, projectPath, sessionId, slug, lastActive, etc.) +- **ProcessInfo**: `{ pid, command, cwd, tty }` +- **AgentAdapter**: Interface with `type`, `detectAgents()`, `canHandle()` +- **TerminalLocation**: `{ type, 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, STATUS_CONFIG } from './adapters/AgentAdapter'; +export type { AgentType, AgentInfo, ProcessInfo, StatusConfig } from './adapters/AgentAdapter'; + +// Terminal +export { TerminalFocusManager } 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.statusDisplay}`); +}); +``` + +## 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 +- Status display configuration +- **Extracted from**: `packages/cli/src/lib/adapters/AgentAdapter.ts` +- **Changes**: None — direct copy + +### 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..156700c --- /dev/null +++ b/docs/ai/implementation/feature-agent-manager.md @@ -0,0 +1,152 @@ +--- +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 (warnings only from inherited `any` usage in extracted code) +- `npm run typecheck` passes +- `npm run build` passes +- `npm run test` passes (43 tests) + +## 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. 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..8b583dc --- /dev/null +++ b/docs/ai/requirements/feature-agent-manager.md @@ -0,0 +1,74 @@ +--- +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 + +### 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 + +## 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..44f2294 --- /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: truncateSummary(), getRelativeTime(), 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: 44 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..0bbc844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4164,6 +4164,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -4181,6 +4182,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -4198,6 +4200,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4215,6 +4218,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4232,6 +4236,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4249,6 +4254,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4266,6 +4272,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4283,6 +4290,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4300,6 +4308,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4317,6 +4326,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } 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..644cf9b --- /dev/null +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -0,0 +1,310 @@ +/** + * 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 Code', + status: AgentStatus.RUNNING, + statusDisplay: '🟢 run', + summary: 'Test summary', + pid: 12345, + projectPath: '/test/path', + sessionId: 'test-session-id', + slug: 'test-slug', + lastActive: new Date(), + lastActiveDisplay: 'just now', + ...overrides, + }; +} + +describe('AgentManager', () => { + let manager: AgentManager; + + beforeEach(() => { + manager = new AgentManager(); + }); + + describe('registerAdapter', () => { + it('should register a new adapter', () => { + const adapter = new MockAdapter('Claude Code'); + + manager.registerAdapter(adapter); + + expect(manager.hasAdapter('Claude Code')).toBe(true); + expect(manager.getAdapterCount()).toBe(1); + }); + + it('should throw error when registering duplicate adapter type', () => { + const adapter1 = new MockAdapter('Claude Code'); + const adapter2 = new MockAdapter('Claude Code'); + + manager.registerAdapter(adapter1); + + expect(() => manager.registerAdapter(adapter2)).toThrow( + 'Adapter for type "Claude Code" is already registered' + ); + }); + + it('should allow registering multiple different adapter types', () => { + const adapter1 = new MockAdapter('Claude Code'); + const adapter2 = new MockAdapter('Gemini CLI'); + + manager.registerAdapter(adapter1); + manager.registerAdapter(adapter2); + + expect(manager.getAdapterCount()).toBe(2); + expect(manager.hasAdapter('Claude Code')).toBe(true); + expect(manager.hasAdapter('Gemini CLI')).toBe(true); + }); + }); + + describe('unregisterAdapter', () => { + it('should unregister an existing adapter', () => { + const adapter = new MockAdapter('Claude Code'); + manager.registerAdapter(adapter); + + const removed = manager.unregisterAdapter('Claude Code'); + + expect(removed).toBe(true); + expect(manager.hasAdapter('Claude Code')).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 Code'); + 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 Code')); + expect(manager.hasAdapter('Claude Code')).toBe(true); + }); + + it('should return false for non-registered adapter', () => { + expect(manager.hasAdapter('Claude Code')).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 Code', 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 Code' })]; + const geminiAgents = [createMockAgent({ name: 'gemini-agent', type: 'Gemini CLI' })]; + + manager.registerAdapter(new MockAdapter('Claude Code', 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 Code', 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 Code', [ + 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 Code', [], 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 Code')); + 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 Code')); + 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..f2b829f --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -0,0 +1,354 @@ +/** + * 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 Code'); + }); + }); + + 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 Code', + 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('truncateSummary', () => { + it('should truncate long summaries', () => { + const adapter = new ClaudeCodeAdapter(); + + const truncate = (adapter as any).truncateSummary.bind(adapter); + + const longSummary = 'This is a very long summary that should be truncated'; + const result = truncate(longSummary, 20); + + expect(result.length).toBeLessThanOrEqual(20); + expect(result).toContain('...'); + }); + + it('should not truncate short summaries', () => { + const adapter = new ClaudeCodeAdapter(); + const truncate = (adapter as any).truncateSummary.bind(adapter); + + const shortSummary = 'Short'; + const result = truncate(shortSummary, 20); + + expect(result).toBe(shortSummary); + }); + }); + + describe('getRelativeTime', () => { + it('should return "just now" for very recent dates', () => { + const adapter = new ClaudeCodeAdapter(); + const getRelativeTime = (adapter as any).getRelativeTime.bind(adapter); + + const now = new Date(); + const result = getRelativeTime(now); + + expect(result).toBe('just now'); + }); + + it('should return minutes for recent dates', () => { + const adapter = new ClaudeCodeAdapter(); + const getRelativeTime = (adapter as any).getRelativeTime.bind(adapter); + + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const result = getRelativeTime(fiveMinutesAgo); + + expect(result).toMatch(/^\d+m ago$/); + }); + + it('should return hours for older dates', () => { + const adapter = new ClaudeCodeAdapter(); + const getRelativeTime = (adapter as any).getRelativeTime.bind(adapter); + + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + const result = getRelativeTime(twoHoursAgo); + + expect(result).toMatch(/^\d+h ago$/); + }); + + it('should return days for very old dates', () => { + const adapter = new ClaudeCodeAdapter(); + const getRelativeTime = (adapter as any).getRelativeTime.bind(adapter); + + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + const result = getRelativeTime(twoDaysAgo); + + expect(result).toMatch(/^\d+d ago$/); + }); + }); + + 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 Code', + status: AgentStatus.RUNNING, + statusDisplay: '🟢 run', + summary: 'Test', + pid: 123, + sessionId: 'existing-123', + slug: 'happy-cat', + lastActive: new Date(), + lastActiveDisplay: 'just now', + }; + + 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..b8469ba --- /dev/null +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -0,0 +1,118 @@ +/** + * 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 Code). + */ + +/** + * Type of AI agent + */ +export type AgentType = 'Claude Code' | 'Gemini CLI' | 'Codex' | "Other"; + +/** + * Current status of an agent + */ +export enum AgentStatus { + RUNNING = 'running', + WAITING = 'waiting', + IDLE = 'idle', + UNKNOWN = 'unknown' +} + +/** + * Status display configuration + */ +export interface StatusConfig { + emoji: string; + label: string; + color: string; +} + +/** + * Status configuration map + */ +export const STATUS_CONFIG: Record = { + [AgentStatus.RUNNING]: { emoji: '🟢', label: 'running', color: 'green' }, + [AgentStatus.WAITING]: { emoji: '🟡', label: 'waiting', color: 'yellow' }, + [AgentStatus.IDLE]: { emoji: '⚪', label: 'idle', color: 'dim' }, + [AgentStatus.UNKNOWN]: { emoji: '❓', label: 'unknown', color: 'gray' }, +}; + +/** + * 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; + + /** Display format for status (e.g., "🟡 wait", "🟢 run") */ + statusDisplay: string; + + /** Last user prompt from history (truncated ~40 chars) */ + 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; + + /** Relative time display (e.g., "2m ago", "just now") */ + lastActiveDisplay: string; +} + +/** + * 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..7fa26f2 --- /dev/null +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -0,0 +1,378 @@ +/** + * 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, STATUS_CONFIG } 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; +} + +/** + * Entry in session JSONL file + */ +interface SessionEntry { + type?: 'assistant' | 'user' | 'progress' | 'thinking' | 'system' | 'message' | 'text'; + 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 Code' 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 + + // Get status display config + const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG[AgentStatus.UNKNOWN]; + const statusDisplay = `${statusConfig.emoji} ${statusConfig.label}`; + const lastActiveDisplay = this.getRelativeTime(session.lastActive || new Date()); + + agents.push({ + name: agentName, + type: this.type, + status, + statusDisplay, + summary: this.truncateSummary(summary), + pid: process.pid, + projectPath: session.projectPath, + sessionId: session.sessionId, + slug: session.slug, + lastActive: session.lastActive || new Date(), + lastActiveDisplay, + }); + } + } + + 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 === '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 === '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 === 'progress' || entryType === 'thinking') { + return AgentStatus.RUNNING; + } else if (entryType === 'assistant') { + return AgentStatus.WAITING; + } else if (entryType === '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)})`; + } + + /** + * Truncate summary to ~40 characters + */ + private truncateSummary(summary: string, maxLength: number = 40): string { + if (summary.length <= maxLength) { + return summary; + } + return summary.slice(0, maxLength - 3) + '...'; + } + + /** + * Get relative time display (e.g., "2m ago", "just now") + */ + private getRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts new file mode 100644 index 0000000..1c12feb --- /dev/null +++ b/packages/agent-manager/src/adapters/index.ts @@ -0,0 +1,3 @@ +export { ClaudeCodeAdapter } from './ClaudeCodeAdapter'; +export { AgentStatus, STATUS_CONFIG } from './AgentAdapter'; +export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, StatusConfig } from './AgentAdapter'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts new file mode 100644 index 0000000..b750769 --- /dev/null +++ b/packages/agent-manager/src/index.ts @@ -0,0 +1,12 @@ +export { AgentManager } from './AgentManager'; + +export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; +export { AgentStatus, STATUS_CONFIG } from './adapters/AgentAdapter'; +export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, StatusConfig } from './adapters/AgentAdapter'; + +export { TerminalFocusManager } 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..349626c --- /dev/null +++ b/packages/agent-manager/src/terminal/TerminalFocusManager.ts @@ -0,0 +1,206 @@ +import { exec, execFile } from 'child_process'; +import { promisify } from 'util'; +import { getProcessTty } from '../utils/process'; + +const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +export interface TerminalLocation { + type: 'tmux' | 'iterm2' | 'terminal-app' | 'unknown'; + 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: 'unknown', + identifier: '', + tty: fullTty + }; + } + + /** + * Focus the terminal identified by the location + */ + async focusTerminal(location: TerminalLocation): Promise { + try { + switch (location.type) { + case 'tmux': + return await this.focusTmuxPane(location.identifier); + case 'iterm2': + return await this.focusITerm2Session(location.tty); + case '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: '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: '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: '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..5c07f28 --- /dev/null +++ b/packages/agent-manager/src/terminal/index.ts @@ -0,0 +1,2 @@ +export { TerminalFocusManager } 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__" + ] +} From 6256e1153bab7cc698c3d18621f149c1acc317af Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 25 Feb 2026 16:42:59 +0100 Subject: [PATCH 2/4] refactor(agent-manager): normalize agent types and remove display fields --- docs/ai/design/feature-agent-manager.md | 21 ++++-- .../implementation/feature-agent-manager.md | 24 +++++- docs/ai/requirements/feature-agent-manager.md | 2 + docs/ai/testing/feature-agent-manager.md | 4 +- .../src/__tests__/AgentManager.test.ts | 66 ++++++++--------- .../adapters/ClaudeCodeAdapter.test.ts | 74 +------------------ .../src/adapters/AgentAdapter.ts | 30 +------- .../src/adapters/ClaudeCodeAdapter.ts | 40 +--------- packages/agent-manager/src/adapters/index.ts | 4 +- packages/agent-manager/src/index.ts | 4 +- 10 files changed, 84 insertions(+), 185 deletions(-) diff --git a/docs/ai/design/feature-agent-manager.md b/docs/ai/design/feature-agent-manager.md index dea2b3d..66a12c8 100644 --- a/docs/ai/design/feature-agent-manager.md +++ b/docs/ai/design/feature-agent-manager.md @@ -65,11 +65,10 @@ packages/agent-manager/ ## Data Models -All types are extracted from the existing `AgentAdapter.ts` without changes: +Types are adapted for a data-first package contract: -- **AgentType**: `'Claude Code' | 'Gemini CLI' | 'Codex' | 'Other'` +- **AgentType**: `'claude' | 'gemini_cli' | 'codex' | 'other'` - **AgentStatus**: Enum (`RUNNING`, `WAITING`, `IDLE`, `UNKNOWN`) -- **StatusConfig**: `{ emoji, label, color }` - **AgentInfo**: Full agent information (name, type, status, pid, projectPath, sessionId, slug, lastActive, etc.) - **ProcessInfo**: `{ pid, command, cwd, tty }` - **AgentAdapter**: Interface with `type`, `detectAgents()`, `canHandle()` @@ -86,8 +85,8 @@ export { AgentManager } from './AgentManager'; // Adapters export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; export type { AgentAdapter } from './adapters/AgentAdapter'; -export { AgentStatus, STATUS_CONFIG } from './adapters/AgentAdapter'; -export type { AgentType, AgentInfo, ProcessInfo, StatusConfig } from './adapters/AgentAdapter'; +export { AgentStatus } from './adapters/AgentAdapter'; +export type { AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter'; // Terminal export { TerminalFocusManager } from './terminal/TerminalFocusManager'; @@ -109,10 +108,16 @@ manager.registerAdapter(new ClaudeCodeAdapter()); const agents = await manager.listAgents(); agents.forEach(agent => { - console.log(`${agent.name}: ${agent.statusDisplay}`); + 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) @@ -126,9 +131,9 @@ agents.forEach(agent => { ### 2. AgentAdapter + Types (interface layer) - Interface contract for adapters - Type definitions and enums -- Status display configuration +- Normalized agent type codes for machine-friendly integrations - **Extracted from**: `packages/cli/src/lib/adapters/AgentAdapter.ts` -- **Changes**: None — direct copy +- **Changes**: Agent type literals normalized; display-oriented fields removed from core model ### 3. ClaudeCodeAdapter (concrete adapter) - Claude Code process detection via `ps aux` diff --git a/docs/ai/implementation/feature-agent-manager.md b/docs/ai/implementation/feature-agent-manager.md index 156700c..73a5456 100644 --- a/docs/ai/implementation/feature-agent-manager.md +++ b/docs/ai/implementation/feature-agent-manager.md @@ -91,10 +91,18 @@ Completed on February 25, 2026 in worktree `feature-agent-manager`. - `src/__tests__/adapters/ClaudeCodeAdapter.test.ts` Validation: -- `npm run lint` passes (warnings only from inherited `any` usage in extracted code) +- `npm run lint` passes - `npm run typecheck` passes - `npm run build` passes -- `npm run test` passes (43 tests) +- `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 ## Phase 6 Check Implementation (February 25, 2026) @@ -150,3 +158,15 @@ Validation: - 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/requirements/feature-agent-manager.md b/docs/ai/requirements/feature-agent-manager.md index 8b583dc..d3b0866 100644 --- a/docs/ai/requirements/feature-agent-manager.md +++ b/docs/ai/requirements/feature-agent-manager.md @@ -28,6 +28,7 @@ Agent detection and management code (AgentManager, adapters, process utilities, - 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) @@ -66,6 +67,7 @@ Agent detection and management code (AgentManager, adapters, process utilities, - 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 diff --git a/docs/ai/testing/feature-agent-manager.md b/docs/ai/testing/feature-agent-manager.md index 44f2294..36dee47 100644 --- a/docs/ai/testing/feature-agent-manager.md +++ b/docs/ai/testing/feature-agent-manager.md @@ -29,7 +29,7 @@ description: Testing approach for the @ai-devkit/agent-manager package ### ClaudeCodeAdapter (`src/__tests__/adapters/ClaudeCodeAdapter.test.ts`) - [x] Adapter type and canHandle() - [x] Agent detection (mocked process/session data) -- [x] Helper methods: truncateSummary(), getRelativeTime(), determineStatus(), generateAgentName() +- [x] Helper methods: determineStatus(), generateAgentName() ## Test Data @@ -48,5 +48,5 @@ description: Testing approach for the @ai-devkit/agent-manager package Executed on February 25, 2026: - `npm run test` passed -- Total: 44 tests passed, 2 suites 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/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index 644cf9b..04b30aa 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -39,16 +39,14 @@ class MockAdapter implements AgentAdapter { function createMockAgent(overrides: Partial = {}): AgentInfo { return { name: 'test-agent', - type: 'Claude Code', + type: 'claude', status: AgentStatus.RUNNING, - statusDisplay: '🟢 run', summary: 'Test summary', pid: 12345, projectPath: '/test/path', sessionId: 'test-session-id', slug: 'test-slug', lastActive: new Date(), - lastActiveDisplay: 'just now', ...overrides, }; } @@ -62,47 +60,47 @@ describe('AgentManager', () => { describe('registerAdapter', () => { it('should register a new adapter', () => { - const adapter = new MockAdapter('Claude Code'); + const adapter = new MockAdapter('claude'); manager.registerAdapter(adapter); - expect(manager.hasAdapter('Claude Code')).toBe(true); + 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 Code'); - const adapter2 = new MockAdapter('Claude Code'); + const adapter1 = new MockAdapter('claude'); + const adapter2 = new MockAdapter('claude'); manager.registerAdapter(adapter1); expect(() => manager.registerAdapter(adapter2)).toThrow( - 'Adapter for type "Claude Code" is already registered' + 'Adapter for type "claude" is already registered' ); }); it('should allow registering multiple different adapter types', () => { - const adapter1 = new MockAdapter('Claude Code'); - const adapter2 = new MockAdapter('Gemini CLI'); + 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 Code')).toBe(true); - expect(manager.hasAdapter('Gemini CLI')).toBe(true); + 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 Code'); + const adapter = new MockAdapter('claude'); manager.registerAdapter(adapter); - const removed = manager.unregisterAdapter('Claude Code'); + const removed = manager.unregisterAdapter('claude'); expect(removed).toBe(true); - expect(manager.hasAdapter('Claude Code')).toBe(false); + expect(manager.hasAdapter('claude')).toBe(false); expect(manager.getAdapterCount()).toBe(0); }); @@ -119,8 +117,8 @@ describe('AgentManager', () => { }); it('should return all registered adapters', () => { - const adapter1 = new MockAdapter('Claude Code'); - const adapter2 = new MockAdapter('Gemini CLI'); + const adapter1 = new MockAdapter('claude'); + const adapter2 = new MockAdapter('gemini_cli'); manager.registerAdapter(adapter1); manager.registerAdapter(adapter2); @@ -134,12 +132,12 @@ describe('AgentManager', () => { describe('hasAdapter', () => { it('should return true for registered adapter', () => { - manager.registerAdapter(new MockAdapter('Claude Code')); - expect(manager.hasAdapter('Claude Code')).toBe(true); + manager.registerAdapter(new MockAdapter('claude')); + expect(manager.hasAdapter('claude')).toBe(true); }); it('should return false for non-registered adapter', () => { - expect(manager.hasAdapter('Claude Code')).toBe(false); + expect(manager.hasAdapter('claude')).toBe(false); }); }); @@ -154,7 +152,7 @@ describe('AgentManager', () => { createMockAgent({ name: 'agent1' }), createMockAgent({ name: 'agent2' }), ]; - const adapter = new MockAdapter('Claude Code', mockAgents); + const adapter = new MockAdapter('claude', mockAgents); manager.registerAdapter(adapter); const agents = await manager.listAgents(); @@ -165,11 +163,11 @@ describe('AgentManager', () => { }); it('should aggregate agents from multiple adapters', async () => { - const claudeAgents = [createMockAgent({ name: 'claude-agent', type: 'Claude Code' })]; - const geminiAgents = [createMockAgent({ name: 'gemini-agent', type: 'Gemini CLI' })]; + const claudeAgents = [createMockAgent({ name: 'claude-agent', type: 'claude' })]; + const geminiAgents = [createMockAgent({ name: 'gemini-agent', type: 'gemini_cli' })]; - manager.registerAdapter(new MockAdapter('Claude Code', claudeAgents)); - manager.registerAdapter(new MockAdapter('Gemini CLI', geminiAgents)); + manager.registerAdapter(new MockAdapter('claude', claudeAgents)); + manager.registerAdapter(new MockAdapter('gemini_cli', geminiAgents)); const agents = await manager.listAgents(); @@ -185,7 +183,7 @@ describe('AgentManager', () => { createMockAgent({ name: 'running-agent', status: AgentStatus.RUNNING }), createMockAgent({ name: 'unknown-agent', status: AgentStatus.UNKNOWN }), ]; - const adapter = new MockAdapter('Claude Code', mockAgents); + const adapter = new MockAdapter('claude', mockAgents); manager.registerAdapter(adapter); const agents = await manager.listAgents(); @@ -197,10 +195,10 @@ describe('AgentManager', () => { }); it('should handle adapter errors gracefully', async () => { - const goodAdapter = new MockAdapter('Claude Code', [ + const goodAdapter = new MockAdapter('claude', [ createMockAgent({ name: 'good-agent' }), ]); - const badAdapter = new MockAdapter('Gemini CLI', [], true); // Will fail + const badAdapter = new MockAdapter('gemini_cli', [], true); // Will fail manager.registerAdapter(goodAdapter); manager.registerAdapter(badAdapter); @@ -213,8 +211,8 @@ describe('AgentManager', () => { }); it('should return empty array when all adapters fail', async () => { - const adapter1 = new MockAdapter('Claude Code', [], true); - const adapter2 = new MockAdapter('Gemini CLI', [], true); + const adapter1 = new MockAdapter('claude', [], true); + const adapter2 = new MockAdapter('gemini_cli', [], true); manager.registerAdapter(adapter1); manager.registerAdapter(adapter2); @@ -230,18 +228,18 @@ describe('AgentManager', () => { }); it('should return correct count', () => { - manager.registerAdapter(new MockAdapter('Claude Code')); + manager.registerAdapter(new MockAdapter('claude')); expect(manager.getAdapterCount()).toBe(1); - manager.registerAdapter(new MockAdapter('Gemini CLI')); + manager.registerAdapter(new MockAdapter('gemini_cli')); expect(manager.getAdapterCount()).toBe(2); }); }); describe('clear', () => { it('should remove all adapters', () => { - manager.registerAdapter(new MockAdapter('Claude Code')); - manager.registerAdapter(new MockAdapter('Gemini CLI')); + manager.registerAdapter(new MockAdapter('claude')); + manager.registerAdapter(new MockAdapter('gemini_cli')); manager.clear(); diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index f2b829f..1a83a8d 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -31,7 +31,7 @@ describe('ClaudeCodeAdapter', () => { describe('initialization', () => { it('should create adapter with correct type', () => { - expect(adapter.type).toBe('Claude Code'); + expect(adapter.type).toBe('claude'); }); }); @@ -117,7 +117,7 @@ describe('ClaudeCodeAdapter', () => { expect(agents).toHaveLength(1); expect(agents[0]).toMatchObject({ name: 'my-project', - type: 'Claude Code', + type: 'claude', status: AgentStatus.WAITING, pid: 12345, projectPath: '/Users/test/my-project', @@ -153,72 +153,6 @@ describe('ClaudeCodeAdapter', () => { }); describe('helper methods', () => { - describe('truncateSummary', () => { - it('should truncate long summaries', () => { - const adapter = new ClaudeCodeAdapter(); - - const truncate = (adapter as any).truncateSummary.bind(adapter); - - const longSummary = 'This is a very long summary that should be truncated'; - const result = truncate(longSummary, 20); - - expect(result.length).toBeLessThanOrEqual(20); - expect(result).toContain('...'); - }); - - it('should not truncate short summaries', () => { - const adapter = new ClaudeCodeAdapter(); - const truncate = (adapter as any).truncateSummary.bind(adapter); - - const shortSummary = 'Short'; - const result = truncate(shortSummary, 20); - - expect(result).toBe(shortSummary); - }); - }); - - describe('getRelativeTime', () => { - it('should return "just now" for very recent dates', () => { - const adapter = new ClaudeCodeAdapter(); - const getRelativeTime = (adapter as any).getRelativeTime.bind(adapter); - - const now = new Date(); - const result = getRelativeTime(now); - - expect(result).toBe('just now'); - }); - - it('should return minutes for recent dates', () => { - const adapter = new ClaudeCodeAdapter(); - const getRelativeTime = (adapter as any).getRelativeTime.bind(adapter); - - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - const result = getRelativeTime(fiveMinutesAgo); - - expect(result).toMatch(/^\d+m ago$/); - }); - - it('should return hours for older dates', () => { - const adapter = new ClaudeCodeAdapter(); - const getRelativeTime = (adapter as any).getRelativeTime.bind(adapter); - - const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); - const result = getRelativeTime(twoHoursAgo); - - expect(result).toMatch(/^\d+h ago$/); - }); - - it('should return days for very old dates', () => { - const adapter = new ClaudeCodeAdapter(); - const getRelativeTime = (adapter as any).getRelativeTime.bind(adapter); - - const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); - const result = getRelativeTime(twoDaysAgo); - - expect(result).toMatch(/^\d+d ago$/); - }); - }); - describe('determineStatus', () => { it('should return "unknown" for sessions with no last entry', () => { const adapter = new ClaudeCodeAdapter(); @@ -328,15 +262,13 @@ describe('ClaudeCodeAdapter', () => { const existingAgent: AgentInfo = { name: 'my-project', projectPath: '/Users/test/my-project', - type: 'Claude Code', + type: 'claude', status: AgentStatus.RUNNING, - statusDisplay: '🟢 run', summary: 'Test', pid: 123, sessionId: 'existing-123', slug: 'happy-cat', lastActive: new Date(), - lastActiveDisplay: 'just now', }; const session = { diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index b8469ba..3131018 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -2,13 +2,13 @@ * 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 Code). + * Each adapter is responsible for detecting agents of a specific type (e.g., claude). */ /** * Type of AI agent */ -export type AgentType = 'Claude Code' | 'Gemini CLI' | 'Codex' | "Other"; +export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'other'; /** * Current status of an agent @@ -20,25 +20,6 @@ export enum AgentStatus { UNKNOWN = 'unknown' } -/** - * Status display configuration - */ -export interface StatusConfig { - emoji: string; - label: string; - color: string; -} - -/** - * Status configuration map - */ -export const STATUS_CONFIG: Record = { - [AgentStatus.RUNNING]: { emoji: '🟢', label: 'running', color: 'green' }, - [AgentStatus.WAITING]: { emoji: '🟡', label: 'waiting', color: 'yellow' }, - [AgentStatus.IDLE]: { emoji: '⚪', label: 'idle', color: 'dim' }, - [AgentStatus.UNKNOWN]: { emoji: '❓', label: 'unknown', color: 'gray' }, -}; - /** * Information about a detected agent */ @@ -52,10 +33,7 @@ export interface AgentInfo { /** Current status */ status: AgentStatus; - /** Display format for status (e.g., "🟡 wait", "🟢 run") */ - statusDisplay: string; - - /** Last user prompt from history (truncated ~40 chars) */ + /** Last user prompt from history */ summary: string; /** Process ID */ @@ -73,8 +51,6 @@ export interface AgentInfo { /** Timestamp of last activity */ lastActive: Date; - /** Relative time display (e.g., "2m ago", "just now") */ - lastActiveDisplay: string; } /** diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index 7fa26f2..2177826 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter'; -import { AgentStatus, STATUS_CONFIG } from './AgentAdapter'; +import { AgentStatus } from './AgentAdapter'; import { listProcesses } from '../utils/process'; import { readLastLines, readJsonLines, readJson } from '../utils/file'; @@ -71,7 +71,7 @@ interface ClaudeSession { * 5. Extracting summary from history.jsonl */ export class ClaudeCodeAdapter implements AgentAdapter { - readonly type = 'Claude Code' as const; + readonly type = 'claude' as const; /** Threshold in minutes before considering a session idle */ private static readonly IDLE_THRESHOLD_MINUTES = 5; @@ -152,23 +152,16 @@ export class ClaudeCodeAdapter implements AgentAdapter { const status = this.determineStatus(session); const agentName = this.generateAgentName(session, agents); // Pass currently built agents for collision checks - // Get status display config - const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG[AgentStatus.UNKNOWN]; - const statusDisplay = `${statusConfig.emoji} ${statusConfig.label}`; - const lastActiveDisplay = this.getRelativeTime(session.lastActive || new Date()); - agents.push({ name: agentName, type: this.type, status, - statusDisplay, - summary: this.truncateSummary(summary), + summary, pid: process.pid, projectPath: session.projectPath, sessionId: session.sessionId, slug: session.slug, lastActive: session.lastActive || new Date(), - lastActiveDisplay, }); } } @@ -348,31 +341,4 @@ export class ClaudeCodeAdapter implements AgentAdapter { return `${projectName} (${session.sessionId.slice(0, 8)})`; } - /** - * Truncate summary to ~40 characters - */ - private truncateSummary(summary: string, maxLength: number = 40): string { - if (summary.length <= maxLength) { - return summary; - } - return summary.slice(0, maxLength - 3) + '...'; - } - - /** - * Get relative time display (e.g., "2m ago", "just now") - */ - private getRelativeTime(date: Date): string { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; - } } diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 1c12feb..87137eb 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -1,3 +1,3 @@ export { ClaudeCodeAdapter } from './ClaudeCodeAdapter'; -export { AgentStatus, STATUS_CONFIG } from './AgentAdapter'; -export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, StatusConfig } from './AgentAdapter'; +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 index b750769..7938bc5 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -1,8 +1,8 @@ export { AgentManager } from './AgentManager'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; -export { AgentStatus, STATUS_CONFIG } from './adapters/AgentAdapter'; -export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, StatusConfig } from './adapters/AgentAdapter'; +export { AgentStatus } from './adapters/AgentAdapter'; +export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter'; export { TerminalFocusManager } from './terminal/TerminalFocusManager'; export type { TerminalLocation } from './terminal/TerminalFocusManager'; From 6367ba8b018ef4994fd9868f9d75dab023a5ac7a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 25 Feb 2026 16:49:52 +0100 Subject: [PATCH 3/4] Update package lock --- package-lock.json | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0bbc844..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 @@ -4164,7 +4168,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -4182,7 +4185,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -4200,7 +4202,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -4218,7 +4219,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -4236,7 +4236,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -4254,7 +4253,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -4272,7 +4270,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -4290,7 +4287,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -4308,7 +4304,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -4326,7 +4321,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -13025,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", From 3ceb7d3164f90cf72677164ebb4b344e7c71a2ad Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 25 Feb 2026 17:15:40 +0100 Subject: [PATCH 4/4] refactor(agent-manager): replace hardcoded types with enums --- docs/ai/design/feature-agent-manager.md | 5 ++-- .../implementation/feature-agent-manager.md | 3 +++ .../src/adapters/ClaudeCodeAdapter.ts | 22 +++++++++++++----- packages/agent-manager/src/index.ts | 2 +- .../src/terminal/TerminalFocusManager.ts | 23 ++++++++++++------- packages/agent-manager/src/terminal/index.ts | 1 + 6 files changed, 39 insertions(+), 17 deletions(-) diff --git a/docs/ai/design/feature-agent-manager.md b/docs/ai/design/feature-agent-manager.md index 66a12c8..4662b77 100644 --- a/docs/ai/design/feature-agent-manager.md +++ b/docs/ai/design/feature-agent-manager.md @@ -72,7 +72,8 @@ Types are adapted for a data-first package contract: - **AgentInfo**: Full agent information (name, type, status, pid, projectPath, sessionId, slug, lastActive, etc.) - **ProcessInfo**: `{ pid, command, cwd, tty }` - **AgentAdapter**: Interface with `type`, `detectAgents()`, `canHandle()` -- **TerminalLocation**: `{ type, identifier, tty }` (from TerminalFocusManager) +- **TerminalType**: Enum (`TMUX`, `ITERM2`, `TERMINAL_APP`, `UNKNOWN`) +- **TerminalLocation**: `{ type: TerminalType, identifier, tty }` (from TerminalFocusManager) ## API Design @@ -89,7 +90,7 @@ export { AgentStatus } from './adapters/AgentAdapter'; export type { AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter'; // Terminal -export { TerminalFocusManager } from './terminal/TerminalFocusManager'; +export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager'; export type { TerminalLocation } from './terminal/TerminalFocusManager'; // Utilities diff --git a/docs/ai/implementation/feature-agent-manager.md b/docs/ai/implementation/feature-agent-manager.md index 73a5456..6666129 100644 --- a/docs/ai/implementation/feature-agent-manager.md +++ b/docs/ai/implementation/feature-agent-manager.md @@ -103,6 +103,9 @@ Data-model refinements (February 25, 2026): - 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) diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index 2177826..8679368 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -19,11 +19,21 @@ 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?: 'assistant' | 'user' | 'progress' | 'thinking' | 'system' | 'message' | 'text'; + type?: SessionEntryType; timestamp?: string; slug?: string; cwd?: string; @@ -289,12 +299,12 @@ export class ClaudeCodeAdapter implements AgentAdapter { return AgentStatus.IDLE; } - if (entryType === 'user') { + 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 === 'text' && c.text?.includes('[Request interrupted')) || + (c.type === SessionEntryType.TEXT && c.text?.includes('[Request interrupted')) || (c.type === 'tool_result' && c.content?.includes('[Request interrupted')) ); if (isInterrupted) return AgentStatus.WAITING; @@ -302,11 +312,11 @@ export class ClaudeCodeAdapter implements AgentAdapter { return AgentStatus.RUNNING; } - if (entryType === 'progress' || entryType === 'thinking') { + if (entryType === SessionEntryType.PROGRESS || entryType === SessionEntryType.THINKING) { return AgentStatus.RUNNING; - } else if (entryType === 'assistant') { + } else if (entryType === SessionEntryType.ASSISTANT) { return AgentStatus.WAITING; - } else if (entryType === 'system') { + } else if (entryType === SessionEntryType.SYSTEM) { return AgentStatus.IDLE; } diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 7938bc5..99cbe15 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -4,7 +4,7 @@ export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; export { AgentStatus } from './adapters/AgentAdapter'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter'; -export { TerminalFocusManager } from './terminal/TerminalFocusManager'; +export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager'; export type { TerminalLocation } from './terminal/TerminalFocusManager'; export { listProcesses, getProcessCwd, getProcessTty, isProcessRunning, getProcessInfo } from './utils/process'; diff --git a/packages/agent-manager/src/terminal/TerminalFocusManager.ts b/packages/agent-manager/src/terminal/TerminalFocusManager.ts index 349626c..21bc636 100644 --- a/packages/agent-manager/src/terminal/TerminalFocusManager.ts +++ b/packages/agent-manager/src/terminal/TerminalFocusManager.ts @@ -5,8 +5,15 @@ 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: 'tmux' | 'iterm2' | 'terminal-app' | 'unknown'; + type: TerminalType; identifier: string; // e.g., "session:window.pane" for tmux, or TTY for others tty: string; // e.g., "/dev/ttys030" } @@ -39,7 +46,7 @@ export class TerminalFocusManager { // 4. Fallback: we know the TTY but not the emulator wrapper return { - type: 'unknown', + type: TerminalType.UNKNOWN, identifier: '', tty: fullTty }; @@ -51,11 +58,11 @@ export class TerminalFocusManager { async focusTerminal(location: TerminalLocation): Promise { try { switch (location.type) { - case 'tmux': + case TerminalType.TMUX: return await this.focusTmuxPane(location.identifier); - case 'iterm2': + case TerminalType.ITERM2: return await this.focusITerm2Session(location.tty); - case 'terminal-app': + case TerminalType.TERMINAL_APP: return await this.focusTerminalAppWindow(location.tty); default: return false; @@ -78,7 +85,7 @@ export class TerminalFocusManager { const [paneTty, identifier] = line.split('|'); if (paneTty === tty && identifier) { return { - type: 'tmux', + type: TerminalType.TMUX, identifier, tty }; @@ -113,7 +120,7 @@ export class TerminalFocusManager { const { stdout } = await execAsync(`osascript -e '${script}'`); if (stdout.trim() === "found") { return { - type: 'iterm2', + type: TerminalType.ITERM2, identifier: tty, tty }; @@ -145,7 +152,7 @@ export class TerminalFocusManager { const { stdout } = await execAsync(`osascript -e '${script}'`); if (stdout.trim() === "found") { return { - type: 'terminal-app', + type: TerminalType.TERMINAL_APP, identifier: tty, tty }; diff --git a/packages/agent-manager/src/terminal/index.ts b/packages/agent-manager/src/terminal/index.ts index 5c07f28..caea431 100644 --- a/packages/agent-manager/src/terminal/index.ts +++ b/packages/agent-manager/src/terminal/index.ts @@ -1,2 +1,3 @@ export { TerminalFocusManager } from './TerminalFocusManager'; +export { TerminalType } from './TerminalFocusManager'; export type { TerminalLocation } from './TerminalFocusManager';