diff --git a/docs/ai/design/feature-codex-adapter-agent-manager-package.md b/docs/ai/design/feature-codex-adapter-agent-manager-package.md new file mode 100644 index 0000000..785baef --- /dev/null +++ b/docs/ai/design/feature-codex-adapter-agent-manager-package.md @@ -0,0 +1,102 @@ +--- +phase: design +title: "Codex Adapter in @ai-devkit/agent-manager - Design" +feature: codex-adapter-agent-manager-package +description: Architecture and implementation design for introducing Codex adapter support in the shared agent manager package +--- + +# Design: Codex Adapter for @ai-devkit/agent-manager + +## Architecture Overview + +```mermaid +graph TD + User[User runs ai-devkit agent list/open] --> Cmd[packages/cli/src/commands/agent.ts] + Cmd --> Manager[AgentManager] + + subgraph Pkg[@ai-devkit/agent-manager] + Manager --> Claude[ClaudeCodeAdapter] + Manager --> Codex[CodexAdapter] + Codex --> Proc[process utils] + Codex --> File[file utils] + Codex --> Types[AgentAdapter/AgentInfo/AgentStatus] + Focus[TerminalFocusManager] + end + + Cmd --> Focus + Cmd --> Output[CLI table/json rendering] +``` + +Responsibilities: +- `CodexAdapter`: discover and map running Codex sessions to `AgentInfo` +- `AgentManager`: aggregate Codex + existing adapter results +- CLI command: register adapters, display results, and invoke open/focus behavior + +## Data Models + +- Reuse existing `AgentAdapter`, `AgentInfo`, `AgentStatus`, and `AgentType` models +- `AgentType` already supports `codex`; adapter emits `type: 'codex'` +- Codex raw metadata (internal to adapter) is normalized into: + - `id`: deterministic session/process identifier + - `name`: user-facing label derived from `cwd`; fallback to `codex-` when `cwd` is missing + - `cwd`: workspace path (if available) + - `sessionStart`: parsed from `session_meta.timestamp` for process/session time matching + - `status`: computed from recency/activity metadata using the same threshold values already used by existing adapters + - `pid`: matched running Codex process id used by terminal focus flow + +## API Design + +### Package Exports +- Add `CodexAdapter` to: + - `packages/agent-manager/src/adapters/index.ts` + - `packages/agent-manager/src/index.ts` + +### CLI Integration +- Update `packages/cli/src/commands/agent.ts` to register `CodexAdapter` alongside `ClaudeCodeAdapter` +- Keep display mapping logic in CLI; do not move presentation concerns into package + +## Component Breakdown + +1. `packages/agent-manager/src/adapters/CodexAdapter.ts` +- Implement adapter contract methods/properties +- Discover Codex sessions from `~/.codex/sessions/YYYY/MM/DD/*.jsonl` +- Map session data to standardized `AgentInfo` + +2. `packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts` +- Unit tests for detection/parsing/status mapping/error handling + +3. `packages/agent-manager/src/adapters/index.ts` and `src/index.ts` +- Export adapter class + +4. `packages/cli/src/commands/agent.ts` +- Register Codex adapter in manager setup path(s) + +## Design Decisions + +- Decision: Implement Codex detection in package, not CLI. + - Rationale: preserves package as the single source of truth for agent discovery. +- Decision: Reuse existing adapter contract and manager aggregation flow. + - Rationale: minimizes surface area and regression risk. +- Decision: Keep CLI output semantics unchanged. + - Rationale: this feature adds detection capability, not UX changes. +- Decision: Parse the first JSON line (`type=session_meta`) as the authoritative session identity/cwd/timestamp source. + - Rationale: sampled session files consistently include this shape, and it avoids scanning full transcript payloads. +- Decision: Treat running `codex` processes as source-of-truth for list membership. + - Rationale: session tail events can represent turn completion while process remains active. +- Decision: Match `pid -> session` by closest process start time (`now - etime`) to `session_meta.timestamp` with tolerance. + - Rationale: improves accuracy when multiple Codex processes share the same project `cwd`. +- Decision: Bound session scanning for performance while including process-start day windows. + - Rationale: keeps list latency low and still supports long-lived process/session mappings. +- Decision: Keep status-threshold values consistent across adapters. + - Rationale: preserves cross-agent behavior consistency and avoids adapter-specific drift. +- Decision: Use `codex-` fallback naming when `cwd` is unavailable. + - Rationale: keeps identifiers deterministic and short while remaining user-readable. +- Decision: Keep matching orchestration in explicit phases (`cwd`, `missing-cwd`, `any`) with extracted helper methods and PID/session tracking sets. + - Rationale: preserves behavior while reducing branching complexity and repeated scans in `detectAgents`. + +## Non-Functional Requirements + +- Performance: `agent list` should remain bounded by existing adapter aggregation patterns. +- Reliability: Codex adapter failures must be isolated (no full-command failure when one adapter errors). +- Maintainability: follow Claude adapter structure to keep adapter implementations consistent. +- Security: only read local metadata/process info already permitted by existing CLI behavior. diff --git a/docs/ai/implementation/feature-codex-adapter-agent-manager-package.md b/docs/ai/implementation/feature-codex-adapter-agent-manager-package.md new file mode 100644 index 0000000..53aea59 --- /dev/null +++ b/docs/ai/implementation/feature-codex-adapter-agent-manager-package.md @@ -0,0 +1,139 @@ +--- +phase: implementation +title: "Codex Adapter in @ai-devkit/agent-manager - Implementation" +feature: codex-adapter-agent-manager-package +description: Implementation notes for Codex adapter support in package agent manager and CLI integration +--- + +# Implementation Guide: Codex Adapter in @ai-devkit/agent-manager + +## Development Setup + +- Use branch/worktree: `feature-codex-adapter-agent-manager-package` +- Install dependencies with `npm ci` +- Validate docs and feature scope with: + - `npx ai-devkit@latest lint` + - `npx ai-devkit@latest lint --feature codex-adapter-agent-manager-package` + +## Code Structure + +- Package adapter implementation: + - `packages/agent-manager/src/adapters/CodexAdapter.ts` +- Package exports: + - `packages/agent-manager/src/adapters/index.ts` + - `packages/agent-manager/src/index.ts` +- CLI wiring: + - `packages/cli/src/commands/agent.ts` +- Tests: + - `packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts` + - CLI tests touching adapter registration/open flow + +## Implementation Notes + +### Core Features +- Implement Codex adapter contract (`type`, `canHandle`, `detectAgents`) using existing utilities where possible. +- Normalize Codex metadata into stable `AgentInfo` output. +- Register Codex adapter in command paths that instantiate `AgentManager`. +- Match process/session pairs by `cwd` plus process-start-time proximity (`etime` vs `session_meta.timestamp`) using configurable tolerance. + +### Patterns & Best Practices +- Follow `ClaudeCodeAdapter` structure for consistency. +- Keep adapter-specific parsing in adapter module; keep formatting in CLI. +- Fail soft on malformed/partial entries; avoid throwing across adapter boundary. +- Keep `detectAgents` orchestration readable via small private helpers for each matching stage. + +## Integration Points + +- `AgentManager` parallel aggregation behavior +- `TerminalFocusManager` open/focus flow compatibility for Codex command metadata +- CLI list/json output mapping + +## Error Handling + +- Handle missing/unreadable Codex source data by returning empty results. +- Catch parsing errors per-entry and continue processing valid entries. +- Let manager collect adapter errors without crashing full command. + +## Performance Considerations + +- Avoid full session-history scans per run; use bounded recent-file selection. +- Include process-start day windows to preserve long-lived session mapping without scanning all days. +- Keep parsing linear to selected entries only. +- Reuse existing async aggregation model. + +## Security Notes + +- Read only local metadata/process information necessary for agent detection. +- Do not execute arbitrary commands during detection. + +## Implementation Status + +- Completed: + - Added `packages/agent-manager/src/adapters/CodexAdapter.ts` + - Added package exports in `packages/agent-manager/src/adapters/index.ts` and `packages/agent-manager/src/index.ts` + - Updated `packages/cli/src/commands/agent.ts` to register `CodexAdapter` for `list` and `open` + - Added adapter unit tests and CLI command test mock update for Codex export +- Notes: + - CLI TypeScript tests resolve workspace package exports from built artifacts; run `npx nx run agent-manager:build` before focused CLI agent-command tests when export surface changes. + - Matching/performance constants are defined in `CodexAdapter`: + - `PROCESS_SESSION_TIME_TOLERANCE_MS` + - `PROCESS_START_DAY_WINDOW_DAYS` + - session-scan bound constants (`MIN/MAX/SCAN_MULTIPLIER`) + - Simplification refactor (no behavior change): + - extracted orchestration helpers: + - `listCodexProcesses` + - `calculateSessionScanLimit` + - `assignSessionsForMode` + - `addMappedSessionAgent` + - `addProcessOnlyAgent` + - `filterCandidateSessions` + - `rankCandidatesByStartTime` + - replaced repeated `agents.some(...)` PID checks with `assignedPids` set tracking + +## Phase 6 Check Implementation (February 26, 2026) + +### Alignment Summary + +- Overall status: aligned with requirements and design. +- Codex adapter implementation remains package-owned and exported through public entry points. +- CLI registration for `list` and `open` includes `CodexAdapter` and preserves existing command UX boundaries. + +### File-by-File Comparison + +- `packages/agent-manager/src/adapters/CodexAdapter.ts` + - Implements required adapter contract and process-first list membership. + - Uses configured time-based matching (`etime` start time vs `session_meta.timestamp`) with tolerance and day-window file inclusion. + - Includes simplification refactor helpers and set-based PID/session assignment tracking with no behavior drift. +- `packages/agent-manager/src/adapters/index.ts` + - Exports `CodexAdapter` as designed. +- `packages/agent-manager/src/index.ts` + - Re-exports `CodexAdapter` from package root as designed. +- `packages/cli/src/commands/agent.ts` + - Registers `CodexAdapter` for both list and open manager paths; no CLI presentation logic moved into package. +- `packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts` + - Covers core mapping/status behavior plus simplified matching-phase behavior (`cwd`, `missing-cwd`, `any`) and no-session-reuse expectations. + +### Deviations / Concerns + +- No requirement/design deviations found. +- Residual validation note: full `cli:test` still has known unrelated pre-existing failures outside this feature scope; focused Codex adapter tests pass. + +## Phase 8 Code Review (February 26, 2026) + +### Findings + +1. No blocking correctness, security, or design-alignment issues found in the Codex adapter implementation or CLI integration paths. +2. Non-blocking performance follow-up: `readFirstLine` currently reads full file content (`fs.readFileSync`) before splitting first line in `CodexAdapter`; this is acceptable for current bounded scan but can be optimized later for very large transcripts. +3. Test-phase risk remains low for changed paths (focused suites pass), with residual global coverage/flaky-suite signals tracked as pre-existing workspace-level issues. + +### Final Checklist + +- Design/requirements match: ✅ +- Logic gaps on changed paths: ✅ none identified +- Security concerns introduced: ✅ none identified +- Tests for changed behavior: ✅ focused adapter + CLI command suites pass +- Docs updated across lifecycle phases: ✅ + +### Review Verdict + +- Ready for push/PR from a Phase 8 review perspective. diff --git a/docs/ai/planning/feature-codex-adapter-agent-manager-package.md b/docs/ai/planning/feature-codex-adapter-agent-manager-package.md new file mode 100644 index 0000000..042d463 --- /dev/null +++ b/docs/ai/planning/feature-codex-adapter-agent-manager-package.md @@ -0,0 +1,77 @@ +--- +phase: planning +title: "Codex Adapter in @ai-devkit/agent-manager - Planning" +feature: codex-adapter-agent-manager-package +description: Task plan for adding Codex adapter support and integrating it into CLI agent commands +--- + +# Planning: Codex Adapter in @ai-devkit/agent-manager + +## Milestones + +- [x] Milestone 1: Codex adapter design finalized and scaffolding created +- [x] Milestone 2: Codex adapter implementation and package exports complete +- [x] Milestone 3: CLI integration, tests, and verification complete + +## Task Breakdown + +### Phase 1: Foundation +- [x] Task 1.1: Confirm Codex discovery inputs and mapping contract + - Use `~/.codex/sessions/YYYY/MM/DD/*.jsonl` as the primary source + - Parse line 1 `session_meta` for `id`, `cwd`, `timestamp` + - Parse the last line for terminal event markers (`task_complete`, `turn_aborted`) + - Define normalization rules for `id`, `name`, `cwd`, and `status` +- [x] Task 1.2: Scaffold package adapter files + - Add `CodexAdapter.ts` and test file skeleton + - Update adapter/index exports + +### Phase 2: Core Features +- [x] Task 2.1: Implement Codex discovery and mapping logic + - Parse metadata with robust validation/fallback behavior + - Compute status using existing status model +- [x] Task 2.2: Register Codex adapter in CLI command flow + - Update all manager registration paths in `commands/agent.ts` + - Preserve output structure and errors + +### Phase 3: Integration & Polish +- [x] Task 3.1: Add/extend tests + - Unit tests for Codex adapter branches and failure handling + - CLI command tests for registration/path coverage +- [x] Task 3.2: Validate and document + - Run lint/build/tests for affected projects + - Record implementation + testing outcomes in docs/ai +- [x] Task 3.3: Simplify implementation structure without behavior changes + - Extract matching orchestration and ranking helpers for readability + - Replace repeated PID lookup scans with set-based tracking + +## Dependencies + +- Existing `@ai-devkit/agent-manager` adapter contract and utilities +- Existing CLI agent command integration points +- Availability of Codex metadata sources in local runtime + +## Timeline & Estimates + +- Task 1.1-1.2: 0.5 day +- Task 2.1-2.2: 1.0 day +- Task 3.1-3.2: 0.5 day +- Total estimate: 2.0 days + +## Risks & Mitigation + +- Risk: Codex metadata format may vary across versions. + - Mitigation: defensive parsing + tests with partial/malformed fixtures. +- Risk: `agent open` behavior for Codex may need command-specific nuances. + - Mitigation: validate open flow with representative commands and add focused tests. +- Risk: Adding adapter increases list latency. + - Mitigation: keep async aggregation pattern and short-circuit invalid entries. + +## Resources Needed + +- Existing adapter examples (`ClaudeCodeAdapter`) as implementation template +- Maintainer validation for Codex session/source assumptions +- CI runtime for lint/build/test verification + +## Progress Summary + +Implementation scope is complete. `CodexAdapter` was added to `@ai-devkit/agent-manager`, exported through package entry points, and registered in CLI agent command flows. Follow-up fixes addressed false-positive process matching, missing long-lived session links, and list latency from broad session scans. Matching now uses `etime`-based process start time with configurable tolerance and process-start day-window session inclusion, while keeping a bounded recent-file scan for performance. A final simplification pass extracted helper methods for match phases/ranking and introduced set-based PID assignment tracking; behavior is unchanged with focused tests still passing. diff --git a/docs/ai/requirements/feature-codex-adapter-agent-manager-package.md b/docs/ai/requirements/feature-codex-adapter-agent-manager-package.md new file mode 100644 index 0000000..8305a7e --- /dev/null +++ b/docs/ai/requirements/feature-codex-adapter-agent-manager-package.md @@ -0,0 +1,74 @@ +--- +phase: requirements +title: "Codex Adapter in @ai-devkit/agent-manager - Requirements" +feature: codex-adapter-agent-manager-package +description: Add a Codex adapter to the shared agent-manager package and wire CLI consumption through package exports +--- + +# Requirements: Add Codex Adapter to @ai-devkit/agent-manager + +## Problem Statement + +`@ai-devkit/agent-manager` currently ships `ClaudeCodeAdapter` as the only concrete adapter, while `AgentType` already includes `codex`. As a result, Codex sessions are not detected/listed/opened through the shared package flow used by CLI agent commands. + +Who is affected: +- Users running Codex alongside other supported agents who expect `ai-devkit agent list/open` to include Codex +- CLI maintainers who want adapter support centralized in `@ai-devkit/agent-manager` +- Contributors who need a reference implementation for adding new adapters + +## Goals & Objectives + +### Primary Goals +- Implement a package-level `CodexAdapter` under `packages/agent-manager` +- Export `CodexAdapter` from package public entry points +- Update CLI agent command wiring to register `CodexAdapter` through package imports +- Preserve existing behavior for Claude and existing output/error contracts + +### Secondary Goals +- Reuse shared process/file utilities and adapter contract patterns +- Add tests for Codex adapter discovery, status mapping, and command metadata +- Establish a clean extension path for future adapters (Gemini/others) +- Keep Codex adapter internals maintainable via small helper functions without changing runtime behavior + +### Non-Goals +- Reworking overall `ai-devkit agent` UX +- Refactoring unrelated CLI command modules +- Introducing a new plugin system for adapters in this phase + +## User Stories & Use Cases + +1. As a Codex user, I want running Codex sessions to appear in `ai-devkit agent list` so I can inspect active work quickly. +2. As a CLI user, I want `ai-devkit agent open ` to support Codex agents with the same behavior guarantees as existing agents. +3. As a maintainer, I want Codex detection logic in `@ai-devkit/agent-manager` so package/CLI behavior does not drift. +4. As an adapter author, I want Codex adapter tests to act as a template for future adapter implementations. + +## Success Criteria + +- `packages/agent-manager/src/adapters/CodexAdapter.ts` exists and implements `AgentAdapter` +- `@ai-devkit/agent-manager` public exports include `CodexAdapter` +- `packages/cli/src/commands/agent.ts` registers `CodexAdapter` from package exports +- Unit tests cover Codex adapter happy path, empty/no-session path, and invalid data handling +- Existing agent command tests continue to pass without regressions +- Implementation remains readable enough for future adapter extension work (clear matching phases/helpers) + +## Constraints & Assumptions + +### Technical Constraints +- Must follow existing Nx TypeScript project structure and test setup +- Must keep adapter contract compatibility (`AgentAdapter`, `AgentInfo`, `AgentStatus`) +- Must not break JSON/table output schema consumed by users + +### Assumptions +- Codex session metadata is available in `~/.codex/sessions/YYYY/MM/DD/*.jsonl` with a stable first-line `session_meta` payload +- `TerminalFocusManager` can open Codex sessions using command metadata supplied by adapter or existing CLI flow +- Codex naming and workspace path conventions are stable enough for first-pass implementation + +## Questions & Open Items + +- Resolved (2026-02-26): Canonical discovery source is `~/.codex/sessions` JSONL files. In 88/88 sampled files, line 1 is `type=session_meta` with `payload.id`, `payload.cwd`, and `payload.timestamp`. +- Resolved (2026-02-26): Running `codex` process list is the source of truth for whether an agent is listed. + - Session tail events such as `task_complete` and `turn_aborted` do not hide an agent when the process is still running. +- Resolved (2026-02-26): Session matching uses process start time (`now - etime`) against `session_meta.timestamp` with a configurable tolerance window constant. +- Resolved (2026-02-26): For long-lived processes, session scan includes process-start day window in addition to bounded recent-file scanning. +- Resolved (2026-02-26): Use the same status threshold values across all adapters (Codex uses existing shared/Claude-equivalent thresholds). +- Resolved (2026-02-26): If `cwd` is missing, fallback display identifier is `codex-`. diff --git a/docs/ai/testing/feature-codex-adapter-agent-manager-package.md b/docs/ai/testing/feature-codex-adapter-agent-manager-package.md new file mode 100644 index 0000000..9247e41 --- /dev/null +++ b/docs/ai/testing/feature-codex-adapter-agent-manager-package.md @@ -0,0 +1,111 @@ +--- +phase: testing +title: "Codex Adapter in @ai-devkit/agent-manager - Testing" +feature: codex-adapter-agent-manager-package +description: Test strategy and coverage plan for Codex adapter integration +--- + +# Testing Strategy: Codex Adapter in @ai-devkit/agent-manager + +## Test Coverage Goals + +- Unit test coverage target: 100% of new/changed code +- Integration scope: package adapter integration with `AgentManager` and CLI registration paths +- End-to-end scope: `ai-devkit agent list` and `ai-devkit agent open` behavior with Codex entries + +## Unit Tests + +### `CodexAdapter` +- [x] Detect and map valid Codex entries into `AgentInfo` +- [x] Return empty array when no Codex metadata exists +- [x] Skip malformed entries without failing full result +- [x] Map status values based on activity thresholds +- [x] Produce stable name/id collision handling +- [x] Match same-cwd sessions by closest process start time +- [x] Keep running processes listed even when session tail is `task_complete`/`turn_aborted` + +### `AgentManager` integration seam +- [x] Aggregates Codex + Claude adapter output +- [ ] Handles Codex adapter errors while preserving other adapter results + +## Integration Tests + +- [x] `agent` command registers `CodexAdapter` in manager setup path(s) +- [ ] `agent list --json` includes Codex entries with expected fields +- [ ] `agent open` handles Codex agent command metadata path correctly + +## End-to-End Tests + +- [ ] User flow: run `ai-devkit agent list` with Codex running +- [ ] User flow: run `ai-devkit agent open ` +- [ ] Regression: Claude list/open remains unchanged + +## Test Data + +- Mock Codex session/process fixtures: + - valid, empty, partial, malformed +- Mock filesystem and process utility responses + +## Test Reporting & Coverage + +- Commands: + - `npx nx run agent-manager:lint` ✅ + - `npx nx run agent-manager:build` ✅ + - `npx nx run agent-manager:test` ✅ + - `npx nx run agent-manager:test -- --runInBand src/__tests__/adapters/CodexAdapter.test.ts` ✅ + - `npx nx run cli:test -- --runInBand src/__tests__/commands/agent.test.ts` ✅ + - `npx nx run cli:lint` ✅ (warnings only, no errors) +- Capture coverage deltas and list any residual gaps in this doc + +Coverage and residual gaps: +- New Codex adapter unit suite (`CodexAdapter.test.ts`) is passing with coverage on detection, filtering, status mapping, fallback naming, and time-based matching. +- Post-simplification verification: focused Codex adapter tests and lint still pass after helper extraction/set-based PID tracking refactor. +- Full `npx nx run cli:test` currently fails due to unrelated pre-existing module-resolution issues in `memory.test.ts` and baseline `agent.test.ts` mocking behavior when running the entire suite without focused filtering. +- Runtime validation confirmed targeted mapping: PID `81442` maps to session `019c7024-89e6-7880-81eb-1417bd2177b5` after time-based matching + process-day window logic. + +## Manual Testing + +- Verify table output readability for mixed Claude/Codex lists +- Verify JSON output schema consistency +- Validate open/focus behavior in a local Codex session + +## Performance Testing + +- Compare `agent list` runtime before/after Codex adapter registration +- Validate no major latency regression for typical session counts + +## Bug Tracking + +- Track defects by severity (`blocking`, `major`, `minor`) +- Re-run adapter + command regressions for every bug fix + +## Phase 7 Execution (February 26, 2026) + +### New Test Coverage Added + +- Updated `packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts` with: + - missing-cwd phase priority over any-session fallback + - one-session-per-process assignment behavior (no session reuse across PIDs) + +### Commands Run + +- `npx nx run agent-manager:test -- --runInBand src/__tests__/adapters/CodexAdapter.test.ts` ✅ + - 1 suite passed, 13 tests passed +- `npx nx run cli:test -- --runInBand src/__tests__/commands/agent.test.ts` ✅ + - 1 suite passed, 5 tests passed + - Nx flagged `cli:test` as flaky (environment-level signal seen previously) +- `npx nx run agent-manager:test -- --coverage` ✅ (tests passed; coverage policy failed) + - 3 suites passed, 51 tests passed + +### Coverage Snapshot (`packages/agent-manager`) + +- Statements: 40.65% +- Branches: 37.31% +- Functions: 49.05% +- Lines: 41.68% +- `CodexAdapter.ts`: statements 44.64%, branches 38.94%, functions 63.41%, lines 45.53% + +### Phase 7 Assessment + +- Codex adapter changed paths are covered, including the simplified matching orchestration branches. +- Global 80% thresholds remain unmet due broader package backlog coverage outside this feature scope. diff --git a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts new file mode 100644 index 0000000..a6ab3eb --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -0,0 +1,319 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { CodexAdapter } from '../../adapters/CodexAdapter'; +import type { 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; + +interface MockSession { + sessionId: string; + projectPath: string; + summary: string; + sessionStart?: Date; + lastActive: Date; + lastPayloadType?: string; +} + +describe('CodexAdapter', () => { + let adapter: CodexAdapter; + + beforeEach(() => { + adapter = new CodexAdapter(); + mockedListProcesses.mockReset(); + }); + + it('should expose codex type', () => { + expect(adapter.type).toBe('codex'); + }); + + it('should match codex commands in canHandle', () => { + expect( + adapter.canHandle({ + pid: 1, + command: 'codex', + cwd: '/repo', + tty: 'ttys001', + }), + ).toBe(true); + + expect( + adapter.canHandle({ + pid: 2, + command: '/usr/local/bin/CODEX --sandbox workspace-write', + cwd: '/repo', + tty: 'ttys002', + }), + ).toBe(true); + + expect( + adapter.canHandle({ + pid: 4, + command: 'node /worktrees/feature-codex-adapter-agent-manager-package/node_modules/nx/src/daemon/server/start.js', + cwd: '/repo', + tty: 'ttys004', + }), + ).toBe(false); + + expect( + adapter.canHandle({ + pid: 3, + command: 'node app.js', + cwd: '/repo', + tty: 'ttys003', + }), + ).toBe(false); + }); + + it('should return empty list when no codex process is running', async () => { + mockedListProcesses.mockReturnValue([]); + + const agents = await adapter.detectAgents(); + expect(agents).toEqual([]); + }); + + it('should map active codex sessions to matching processes by cwd', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'abc12345-session', + projectPath: '/repo-a', + summary: 'Implement adapter flow', + sessionStart: new Date('2026-02-26T15:00:00.000Z'), + lastActive: new Date(), + lastPayloadType: 'token_count', + } as MockSession, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + name: 'repo-a', + type: 'codex', + status: AgentStatus.RUNNING, + summary: 'Implement adapter flow', + pid: 100, + projectPath: '/repo-a', + sessionId: 'abc12345-session', + }); + }); + + it('should still map sessions with task_complete as waiting when process is running', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 101, command: 'codex', cwd: '/repo-b', tty: 'ttys001' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'ended-1111', + projectPath: '/repo-b', + summary: 'Ended turn but process still alive', + sessionStart: new Date('2026-02-26T15:00:00.000Z'), + lastActive: new Date(), + lastPayloadType: 'task_complete', + } as MockSession, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe('ended-1111'); + expect(agents[0].status).toBe(AgentStatus.WAITING); + }); + + it('should use codex-session-id-prefix fallback name when cwd is missing', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 102, command: 'codex', cwd: '', tty: 'ttys009' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'abcdef123456', + projectPath: '', + summary: 'No cwd available', + sessionStart: new Date('2026-02-26T15:00:00.000Z'), + lastActive: new Date(), + lastPayloadType: 'agent_reasoning', + } as MockSession, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('codex-abcdef12'); + }); + + it('should report waiting status for recent agent_message events', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 103, command: 'codex', cwd: '/repo-c', tty: 'ttys010' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'waiting-1234', + projectPath: '/repo-c', + summary: 'Waiting', + sessionStart: new Date('2026-02-26T15:00:00.000Z'), + lastActive: new Date(), + lastPayloadType: 'agent_message', + } as MockSession, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].status).toBe(AgentStatus.WAITING); + }); + + it('should report idle status when session exceeds shared threshold', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 104, command: 'codex', cwd: '/repo-d', tty: 'ttys011' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'idle-5678', + projectPath: '/repo-d', + summary: 'Idle', + sessionStart: new Date('2026-02-26T15:00:00.000Z'), + lastActive: new Date(Date.now() - 10 * 60 * 1000), + lastPayloadType: 'token_count', + } as MockSession, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].status).toBe(AgentStatus.IDLE); + }); + + it('should list unmatched running codex process even when no session matches', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 105, command: 'codex', cwd: '/repo-x', tty: 'ttys012' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'other-session', + projectPath: '/repo-y', + summary: 'Other repo', + sessionStart: new Date('2026-02-26T15:00:00.000Z'), + lastActive: new Date(), + lastPayloadType: 'agent_message', + } as MockSession, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].pid).toBe(105); + }); + + it('should list process when session metadata is unavailable', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 106, command: 'codex', cwd: '/repo-z', tty: 'ttys013' }, + ] as ProcessInfo[]); + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].pid).toBe(106); + expect(agents[0].summary).toContain('No Codex session metadata'); + }); + + it('should choose same-cwd session closest to process start time', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 107, command: 'codex', cwd: '/repo-time', tty: 'ttys014' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'far-session', + projectPath: '/repo-time', + summary: 'Far start time', + sessionStart: new Date('2026-02-26T14:00:00.000Z'), + lastActive: new Date('2026-02-26T15:10:00.000Z'), + lastPayloadType: 'agent_message', + } as MockSession, + { + sessionId: 'near-session', + projectPath: '/repo-time', + summary: 'Near start time', + sessionStart: new Date('2026-02-26T15:00:20.000Z'), + lastActive: new Date('2026-02-26T15:11:00.000Z'), + lastPayloadType: 'agent_message', + } as MockSession, + ]); + jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue( + new Map([[107, new Date('2026-02-26T15:00:00.000Z')]]), + ); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe('near-session'); + }); + + it('should prefer missing-cwd session before any-session fallback for unmatched process', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 108, command: 'codex', cwd: '/repo-missing-cwd', tty: 'ttys015' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'any-session', + projectPath: '/another-repo', + summary: 'Any session fallback', + sessionStart: new Date('2026-02-26T15:00:00.000Z'), + lastActive: new Date('2026-02-26T15:12:00.000Z'), + lastPayloadType: 'agent_message', + } as MockSession, + { + sessionId: 'missing-cwd-session', + projectPath: '', + summary: 'Missing cwd session', + sessionStart: new Date('2026-02-26T15:00:10.000Z'), + lastActive: new Date('2026-02-26T15:11:00.000Z'), + lastPayloadType: 'agent_message', + } as MockSession, + ]); + jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue( + new Map([[108, new Date('2026-02-26T15:00:00.000Z')]]), + ); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe('missing-cwd-session'); + }); + + it('should not reuse the same session for multiple running processes', async () => { + mockedListProcesses.mockReturnValue([ + { pid: 109, command: 'codex', cwd: '/repo-shared', tty: 'ttys016' }, + { pid: 110, command: 'codex', cwd: '/repo-shared', tty: 'ttys017' }, + ] as ProcessInfo[]); + + jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ + { + sessionId: 'shared-session', + projectPath: '/repo-shared', + summary: 'Only one session exists', + sessionStart: new Date('2026-02-26T15:00:00.000Z'), + lastActive: new Date('2026-02-26T15:11:00.000Z'), + lastPayloadType: 'agent_message', + } as MockSession, + ]); + jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue( + new Map([ + [109, new Date('2026-02-26T15:00:00.000Z')], + [110, new Date('2026-02-26T15:00:30.000Z')], + ]), + ); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(2); + const mappedAgents = agents.filter((agent) => agent.sessionId === 'shared-session'); + expect(mappedAgents).toHaveLength(1); + expect(agents.some((agent) => agent.sessionId.startsWith('pid-'))).toBe(true); + }); +}); diff --git a/packages/agent-manager/src/adapters/CodexAdapter.ts b/packages/agent-manager/src/adapters/CodexAdapter.ts new file mode 100644 index 0000000..54af4ba --- /dev/null +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -0,0 +1,584 @@ +/** + * Codex Adapter + * + * Detects running Codex agents by combining: + * 1. Running `codex` processes + * 2. Session metadata under ~/.codex/sessions + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter'; +import { AgentStatus } from './AgentAdapter'; +import { listProcesses } from '../utils/process'; +import { readJsonLines } from '../utils/file'; + +interface CodexSessionMetaPayload { + id?: string; + timestamp?: string; + cwd?: string; +} + +interface CodexSessionMetaEntry { + type?: string; + payload?: CodexSessionMetaPayload; +} + +interface CodexEventEntry { + timestamp?: string; + type?: string; + payload?: { + type?: string; + message?: string; + }; +} + +interface CodexSession { + sessionId: string; + projectPath: string; + summary: string; + sessionStart: Date; + lastActive: Date; + lastPayloadType?: string; +} + +type SessionMatchMode = 'cwd' | 'missing-cwd' | 'any'; + +export class CodexAdapter implements AgentAdapter { + readonly type = 'codex' as const; + + /** Keep status thresholds aligned across adapters. */ + private static readonly IDLE_THRESHOLD_MINUTES = 5; + /** Limit session parsing per run to keep list latency bounded. */ + private static readonly MIN_SESSION_SCAN = 12; + private static readonly MAX_SESSION_SCAN = 40; + private static readonly SESSION_SCAN_MULTIPLIER = 4; + /** Also include session files around process start day to recover long-lived processes. */ + private static readonly PROCESS_START_DAY_WINDOW_DAYS = 1; + /** Matching tolerance between process start time and session start time. */ + private static readonly PROCESS_SESSION_TIME_TOLERANCE_MS = 2 * 60 * 1000; + + private codexSessionsDir: string; + + constructor() { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isCodexExecutable(processInfo.command); + } + + async detectAgents(): Promise { + const codexProcesses = this.listCodexProcesses(); + + if (codexProcesses.length === 0) { + return []; + } + + const processStartByPid = this.getProcessStartTimes(codexProcesses.map((processInfo) => processInfo.pid)); + + const sessionScanLimit = this.calculateSessionScanLimit(codexProcesses.length); + const sessions = this.readSessions(sessionScanLimit, processStartByPid); + if (sessions.length === 0) { + return codexProcesses.map((processInfo) => + this.mapProcessOnlyAgent(processInfo, [], 'No Codex session metadata found'), + ); + } + + const sortedSessions = [...sessions].sort( + (a, b) => b.lastActive.getTime() - a.lastActive.getTime(), + ); + const usedSessionIds = new Set(); + const assignedPids = new Set(); + const agents: AgentInfo[] = []; + + // Match exact cwd first, then missing-cwd sessions, then any available session. + this.assignSessionsForMode( + 'cwd', + codexProcesses, + sortedSessions, + usedSessionIds, + assignedPids, + processStartByPid, + agents, + ); + this.assignSessionsForMode( + 'missing-cwd', + codexProcesses, + sortedSessions, + usedSessionIds, + assignedPids, + processStartByPid, + agents, + ); + this.assignSessionsForMode( + 'any', + codexProcesses, + sortedSessions, + usedSessionIds, + assignedPids, + processStartByPid, + agents, + ); + + // Every running codex process should still be listed. + for (const processInfo of codexProcesses) { + if (assignedPids.has(processInfo.pid)) { + continue; + } + + this.addProcessOnlyAgent(processInfo, assignedPids, agents); + } + + return agents; + } + + private listCodexProcesses(): ProcessInfo[] { + return listProcesses({ namePattern: 'codex' }).filter((processInfo) => + this.canHandle(processInfo), + ); + } + + private calculateSessionScanLimit(processCount: number): number { + return Math.min( + Math.max( + processCount * CodexAdapter.SESSION_SCAN_MULTIPLIER, + CodexAdapter.MIN_SESSION_SCAN, + ), + CodexAdapter.MAX_SESSION_SCAN, + ); + } + + private assignSessionsForMode( + mode: SessionMatchMode, + codexProcesses: ProcessInfo[], + sessions: CodexSession[], + usedSessionIds: Set, + assignedPids: Set, + processStartByPid: Map, + agents: AgentInfo[], + ): void { + for (const processInfo of codexProcesses) { + if (assignedPids.has(processInfo.pid)) { + continue; + } + + const session = this.selectBestSession( + processInfo, + sessions, + usedSessionIds, + processStartByPid, + mode, + ); + if (!session) { + continue; + } + + this.addMappedSessionAgent(session, processInfo, usedSessionIds, assignedPids, agents); + } + } + + private addMappedSessionAgent( + session: CodexSession, + processInfo: ProcessInfo, + usedSessionIds: Set, + assignedPids: Set, + agents: AgentInfo[], + ): void { + usedSessionIds.add(session.sessionId); + assignedPids.add(processInfo.pid); + agents.push(this.mapSessionToAgent(session, processInfo, agents)); + } + + private addProcessOnlyAgent( + processInfo: ProcessInfo, + assignedPids: Set, + agents: AgentInfo[], + ): void { + assignedPids.add(processInfo.pid); + agents.push(this.mapProcessOnlyAgent(processInfo, agents)); + } + + private mapSessionToAgent( + session: CodexSession, + processInfo: ProcessInfo, + existingAgents: AgentInfo[], + ): AgentInfo { + return { + name: this.generateAgentName(session, existingAgents), + type: this.type, + status: this.determineStatus(session), + summary: session.summary || 'Codex session active', + pid: processInfo.pid, + projectPath: session.projectPath || processInfo.cwd || '', + sessionId: session.sessionId, + lastActive: session.lastActive, + }; + } + + private mapProcessOnlyAgent( + processInfo: ProcessInfo, + existingAgents: AgentInfo[], + summary: string = 'Codex process running', + ): AgentInfo { + const syntheticSession: CodexSession = { + sessionId: `pid-${processInfo.pid}`, + projectPath: processInfo.cwd || '', + summary, + sessionStart: new Date(), + lastActive: new Date(), + lastPayloadType: 'process_only', + }; + + return { + name: this.generateAgentName(syntheticSession, existingAgents), + type: this.type, + status: AgentStatus.RUNNING, + summary, + pid: processInfo.pid, + projectPath: processInfo.cwd || '', + sessionId: syntheticSession.sessionId, + lastActive: syntheticSession.lastActive, + }; + } + + private readSessions(limit: number, processStartByPid: Map): CodexSession[] { + const sessionFiles = this.findSessionFiles(limit, processStartByPid); + const sessions: CodexSession[] = []; + + for (const sessionFile of sessionFiles) { + try { + const session = this.readSession(sessionFile); + if (session) { + sessions.push(session); + } + } catch (error) { + console.error(`Failed to parse Codex session ${sessionFile}:`, error); + } + } + + return sessions; + } + + private findSessionFiles(limit: number, processStartByPid: Map): string[] { + if (!fs.existsSync(this.codexSessionsDir)) { + return []; + } + + const files: Array<{ path: string; mtimeMs: number }> = []; + const stack: string[] = [this.codexSessionsDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir || !fs.existsSync(currentDir)) { + continue; + } + + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + try { + files.push({ + path: fullPath, + mtimeMs: fs.statSync(fullPath).mtimeMs, + }); + } catch { + continue; + } + } + } + } + + const recentFiles = files + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(0, limit) + .map((file) => file.path); + const processDayFiles = this.findProcessDaySessionFiles(processStartByPid); + + const selectedPaths = new Set(recentFiles); + for (const processDayFile of processDayFiles) { + selectedPaths.add(processDayFile); + } + + return Array.from(selectedPaths); + } + + private findProcessDaySessionFiles(processStartByPid: Map): string[] { + const files: string[] = []; + const dayKeys = new Set(); + const dayWindow = CodexAdapter.PROCESS_START_DAY_WINDOW_DAYS; + + for (const processStart of processStartByPid.values()) { + for (let offset = -dayWindow; offset <= dayWindow; offset++) { + const day = new Date(processStart.getTime()); + day.setDate(day.getDate() + offset); + dayKeys.add(this.toSessionDayKey(day)); + } + } + + for (const dayKey of dayKeys) { + const dayDir = path.join(this.codexSessionsDir, dayKey); + if (!fs.existsSync(dayDir)) { + continue; + } + + for (const entry of fs.readdirSync(dayDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(path.join(dayDir, entry.name)); + } + } + } + + return files; + } + + private toSessionDayKey(date: Date): string { + const yyyy = String(date.getFullYear()).padStart(4, '0'); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + return path.join(yyyy, mm, dd); + } + + private readSession(filePath: string): CodexSession | null { + const firstLine = this.readFirstLine(filePath); + if (!firstLine) { + return null; + } + + const metaEntry = this.parseSessionMeta(firstLine); + if (!metaEntry?.payload?.id) { + return null; + } + + const entries = readJsonLines(filePath, 300); + const lastEntry = this.findLastEventEntry(entries); + const lastPayloadType = lastEntry?.payload?.type; + + const lastActive = + this.parseTimestamp(lastEntry?.timestamp) || + this.parseTimestamp(metaEntry.payload.timestamp) || + fs.statSync(filePath).mtime; + const sessionStart = + this.parseTimestamp(metaEntry.payload.timestamp) || + lastActive; + + return { + sessionId: metaEntry.payload.id, + projectPath: metaEntry.payload.cwd || '', + summary: this.extractSummary(entries), + sessionStart, + lastActive, + lastPayloadType, + }; + } + + private readFirstLine(filePath: string): string { + const content = fs.readFileSync(filePath, 'utf-8'); + return content.split('\n')[0]?.trim() || ''; + } + + private parseSessionMeta(line: string): CodexSessionMetaEntry | null { + try { + const parsed = JSON.parse(line) as CodexSessionMetaEntry; + if (parsed.type !== 'session_meta') { + return null; + } + return parsed; + } catch { + return null; + } + } + + private findLastEventEntry(entries: CodexEventEntry[]): CodexEventEntry | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry && typeof entry.type === 'string') { + return entry; + } + } + return undefined; + } + + private parseTimestamp(value?: string): Date | null { + if (!value) { + return null; + } + + const timestamp = new Date(value); + return Number.isNaN(timestamp.getTime()) ? null : timestamp; + } + + private selectBestSession( + processInfo: ProcessInfo, + sessions: CodexSession[], + usedSessionIds: Set, + processStartByPid: Map, + mode: SessionMatchMode, + ): CodexSession | undefined { + const candidates = this.filterCandidateSessions(processInfo, sessions, usedSessionIds, mode); + + if (candidates.length === 0) { + return undefined; + } + + const processStart = processStartByPid.get(processInfo.pid); + if (!processStart) { + return candidates.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime())[0]; + } + + return this.rankCandidatesByStartTime(candidates, processStart)[0]; + } + + private filterCandidateSessions( + processInfo: ProcessInfo, + sessions: CodexSession[], + usedSessionIds: Set, + mode: SessionMatchMode, + ): CodexSession[] { + return sessions.filter((session) => { + if (usedSessionIds.has(session.sessionId)) { + return false; + } + + if (mode === 'cwd') { + return session.projectPath === processInfo.cwd; + } + + if (mode === 'missing-cwd') { + return !session.projectPath; + } + + return true; + }); + } + + private rankCandidatesByStartTime(candidates: CodexSession[], processStart: Date): CodexSession[] { + const toleranceMs = CodexAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS; + + return candidates + .map((session) => { + const diffMs = Math.abs(session.sessionStart.getTime() - processStart.getTime()); + const outsideTolerance = diffMs > toleranceMs ? 1 : 0; + return { + session, + rank: outsideTolerance, + diffMs, + recency: session.lastActive.getTime(), + }; + }) + .sort((a, b) => { + if (a.rank !== b.rank) return a.rank - b.rank; + if (a.diffMs !== b.diffMs) return a.diffMs - b.diffMs; + return b.recency - a.recency; + }) + .map((ranked) => ranked.session); + } + + private getProcessStartTimes(pids: number[]): Map { + if (pids.length === 0 || process.env.JEST_WORKER_ID) { + return new Map(); + } + + try { + const output = execSync(`ps -o pid=,etime= -p ${pids.join(',')}`, { + encoding: 'utf-8', + }); + const nowMs = Date.now(); + const startTimes = new Map(); + + for (const rawLine of output.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + const parts = line.split(/\s+/); + if (parts.length < 2) continue; + + const pid = Number.parseInt(parts[0], 10); + const elapsedSeconds = this.parseElapsedSeconds(parts[1]); + if (!Number.isFinite(pid) || elapsedSeconds === null) continue; + + startTimes.set(pid, new Date(nowMs - elapsedSeconds * 1000)); + } + + return startTimes; + } catch { + return new Map(); + } + } + + private parseElapsedSeconds(etime: string): number | null { + const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/); + if (!match) { + return null; + } + + const days = Number.parseInt(match[1] || '0', 10); + const hours = Number.parseInt(match[2] || '0', 10); + const minutes = Number.parseInt(match[3] || '0', 10); + const seconds = Number.parseInt(match[4] || '0', 10); + + return (((days * 24 + hours) * 60 + minutes) * 60) + seconds; + } + + private extractSummary(entries: CodexEventEntry[]): string { + for (let i = entries.length - 1; i >= 0; i--) { + const message = entries[i]?.payload?.message; + if (typeof message === 'string' && message.trim().length > 0) { + return this.truncate(message.trim(), 120); + } + } + + return 'Codex session active'; + } + + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 3)}...`; + } + + private isCodexExecutable(command: string): boolean { + const executable = command.trim().split(/\s+/)[0] || ''; + const base = path.basename(executable).toLowerCase(); + return base === 'codex' || base === 'codex.exe'; + } + + private determineStatus(session: CodexSession): AgentStatus { + const diffMs = Date.now() - session.lastActive.getTime(); + const diffMinutes = diffMs / 60000; + + if (diffMinutes > CodexAdapter.IDLE_THRESHOLD_MINUTES) { + return AgentStatus.IDLE; + } + + if ( + session.lastPayloadType === 'agent_message' || + session.lastPayloadType === 'task_complete' || + session.lastPayloadType === 'turn_aborted' + ) { + return AgentStatus.WAITING; + } + + return AgentStatus.RUNNING; + } + + private generateAgentName(session: CodexSession, existingAgents: AgentInfo[]): string { + const fallback = `codex-${session.sessionId.slice(0, 8)}`; + const baseName = session.projectPath ? path.basename(path.normalize(session.projectPath)) : fallback; + + const conflict = existingAgents.some((agent) => agent.name === baseName); + if (!conflict) { + return baseName || fallback; + } + + return `${baseName || fallback} (${session.sessionId.slice(0, 8)})`; + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 87137eb..896bf41 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -1,3 +1,4 @@ export { ClaudeCodeAdapter } from './ClaudeCodeAdapter'; +export { CodexAdapter } from './CodexAdapter'; 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 99cbe15..0ca3d3b 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -1,6 +1,7 @@ export { AgentManager } from './AgentManager'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; +export { CodexAdapter } from './adapters/CodexAdapter'; export { AgentStatus } from './adapters/AgentAdapter'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter'; diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 86d3256..e16fc20 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -26,6 +26,7 @@ const mockPrompt: any = jest.fn(); jest.mock('@ai-devkit/agent-manager', () => ({ AgentManager: jest.fn(() => mockManager), ClaudeCodeAdapter: jest.fn(), + CodexAdapter: jest.fn(), TerminalFocusManager: jest.fn(() => mockFocusManager), AgentStatus: { RUNNING: 'running', @@ -33,7 +34,7 @@ jest.mock('@ai-devkit/agent-manager', () => ({ IDLE: 'idle', UNKNOWN: 'unknown', }, -})); +}), { virtual: true }); jest.mock('inquirer', () => ({ __esModule: true, diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 31c607f..d958f88 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -4,6 +4,7 @@ import inquirer from 'inquirer'; import { AgentManager, ClaudeCodeAdapter, + CodexAdapter, AgentStatus, TerminalFocusManager, type AgentInfo, @@ -52,6 +53,7 @@ export function registerAgentCommand(program: Command): void { // Register adapters // In the future, we might load these dynamically or based on config manager.registerAdapter(new ClaudeCodeAdapter()); + manager.registerAdapter(new CodexAdapter()); const agents = await manager.listAgents(); @@ -118,6 +120,7 @@ export function registerAgentCommand(program: Command): void { const focusManager = new TerminalFocusManager(); manager.registerAdapter(new ClaudeCodeAdapter()); + manager.registerAdapter(new CodexAdapter()); const agents = await manager.listAgents(); if (agents.length === 0) {