diff --git a/docs/ai/design/2026-08-23-feature-claude-provider-refactor.md b/docs/ai/design/2026-08-23-feature-claude-provider-refactor.md new file mode 100644 index 00000000..74331e63 --- /dev/null +++ b/docs/ai/design/2026-08-23-feature-claude-provider-refactor.md @@ -0,0 +1,157 @@ +--- +phase: design +title: Claude Provider Refactor Design +description: Provider-local Claude module boundary with public package exports +--- + +# Claude Provider Refactor Design + +## Architecture Overview + +The refactor introduces a Claude provider-local implementation area while preserving current package-root adapter and durable exports. + +```mermaid +graph TD + PublicIndex["src/index.ts"] --> ClaudeAdapter["src/providers/claude/ClaudeCodeAdapter.ts"] + AdapterIndex["src/adapters/index.ts"] --> ClaudeAdapter + + ClaudeAdapter --> Locator["ClaudeSessionLocator"] + ClaudeAdapter --> Parser["ClaudeSessionParser"] + ClaudeAdapter --> Mapper["ClaudeAgentMapper"] + ClaudeAdapter --> SharedProcess["utils/process"] + ClaudeAdapter --> SharedMatching["utils/matching"] + + PublicIndex --> ClaudeDurable["src/providers/claude/durable/*"] + ClaudeDurable --> Database["database + DurableAgentRepository contracts"] + ClaudeDurable --> ClaudeCli["Claude CLI"] +``` + +The public package shape remains stable. The internal shape changes from feature/top-level scattered Claude files to provider-local ownership: + +```text +packages/agent-manager/src/ + providers/ + claude/ + ClaudeCodeAdapter.ts + ClaudeSessionLocator.ts + ClaudeSessionParser.ts + ClaudeAgentMapper.ts + durable/ + ClaudeCliProbe.ts + ClaudePrintRunner.ts + ClaudePrintAgentService.ts +``` + +## Data Models + +No persisted schema changes are introduced. + +Provider-local transient types: + +- `ClaudePidFileEntry`: parsed shape of `~/.claude/sessions/.json`. +- `ClaudeDirectMatch`: process, session file, optional live status, optional waiting reason. +- `ClaudeLocatedSessionFile`: existing `SessionFile` plus Claude-resolved cwd semantics. +- `ClaudeAgentMappingInput`: parsed Claude session, process, located session file, optional live info. + +Existing public models remain unchanged: + +- `AgentInfo` +- `ProcessInfo` +- `ConversationMessage` +- `SessionSummary` +- `DurableAgent` +- `CapacityReport` + +## API Design + +### Public API + +No public package-root API change. + +Existing exports stay valid: + +```ts +export { ClaudeCodeAdapter } from './providers/claude/ClaudeCodeAdapter.js'; +export { ClaudePrintAgentService } from './providers/claude/durable/ClaudePrintAgentService.js'; +``` + +The implementation exports high-level public provider entry points directly from `src/providers/claude/...` through `src/index.ts` and `src/adapters/index.ts`. Lower-level Claude print probe/runner classes stay provider-local; thin path-level wrapper files were removed because they added no behavior or contract value. + +### Internal Interfaces + +The first implementation should prefer small concrete classes/functions over broad interfaces: + +```ts +class ClaudeSessionLocator { + matchRunningProcesses(processes: ProcessInfo[]): ClaudeMatchSet; + discoverHistoricalSessionFiles(): Array<{ filePath: string; defaultCwd: string }>; + getProjectDir(cwd: string): string; +} + +class ClaudeAgentMapper { + mapSessionToAgent(input: ClaudeAgentMappingInput): AgentInfo; + mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo; +} +``` + +Avoid introducing a cross-provider `Provider` or `CapabilityProvider` public abstraction in this feature. If needed later, it should be based on multiple real provider capability implementations. + +## Component Breakdown + +### `providers/claude/ClaudeCodeAdapter.ts` + +Keeps the `AgentAdapter` implementation and orchestrates the flow: + +1. Capture/filter Claude processes. +2. Ask `ClaudeSessionLocator` for direct and legacy matches. +3. Parse matched session files with `ClaudeSessionParser`. +4. Map sessions/processes to `AgentInfo` with `ClaudeAgentMapper`. +5. Delegate conversation and historical session listing. + +### `providers/claude/ClaudeSessionLocator.ts` + +Owns Claude filesystem/session location rules: + +- Claude project path encoding. +- `claude --resume ` extraction. +- PID-file read and stale guard. +- PID-file live status mapping. +- Direct match construction. +- Legacy CWD + birthtime discovery setup. +- Historical session candidate walking. + +It may still call shared utilities such as `batchGetSessionFileBirthtimes`, `safeStat`, `safeReaddir`, `listJsonl`, and `matchProcessesToSessions`. + +### `providers/claude/ClaudeSessionParser.ts` + +Moves from `utils/` without logic changes. It remains responsible for Claude JSONL parsing, conversation extraction, noise filtering, and JSONL-derived status. + +### `providers/claude/ClaudeAgentMapper.ts` + +Owns conversion from Claude provider data to `AgentInfo`: + +- live PID status precedence; +- waiting reason summary decoration; +- process-only fallback representation; +- generated agent names; +- project path and session file path assignment. + +### `providers/claude/durable/*` + +Provider-local home for Claude print-mode execution mechanics. Existing `src/durable/*` files should stay as compatibility exports unless planning decides the move is too broad for the first implementation pass. + +## Design Decisions + +- **Provider-local first, generic later.** The chosen structure improves locality without inventing abstractions before there are multiple implementations. +- **Compatibility exports stay.** Public and existing internal import paths are kept stable while implementation files move. +- **Move/extract before behavior change.** The initial implementation should avoid modifying matching/status/listing behavior. +- **Capacity and durable are capabilities.** Their current top-level facades can remain public, but provider-specific implementation should live with the provider over time. +- **Claude first.** Codex capacity, Pi sessions, Gemini sessions, and other providers are explicitly deferred. + +## Non-Functional Requirements + +- **Reliability:** All existing Claude interactive and durable behavior must remain covered by tests. +- **Performance:** Session discovery should preserve existing batching behavior and should not add broad directory scans to `detectAgents()`. +- **Security:** Prompt content, provider output, and credentials handling remain unchanged. Durable prompt stdin behavior and bounded result sanitization remain intact. +- **Maintainability:** New modules should have one clear responsibility and avoid one-file directories unless they are compatibility wrappers. +- **Deletion cost:** Compatibility wrappers should be easy to remove in a future breaking cleanup, but they must stay for this feature. diff --git a/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md b/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md new file mode 100644 index 00000000..e320cfa7 --- /dev/null +++ b/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md @@ -0,0 +1,119 @@ +--- +phase: implementation +title: Claude Provider Refactor Implementation Notes +description: Technical notes and evidence for behavior-preserving Claude provider extraction +--- + +# Claude Provider Refactor Implementation Notes + +## Development Setup + +- Active worktree: `.worktrees/feature-claude-provider-refactor` +- Branch: `feature-claude-provider-refactor` +- Dependency bootstrap: `npm ci` completed. +- Lifecycle lint: `npx ai-devkit@latest lint --feature claude-provider-refactor` passes. + +## Code Structure + +Target internal structure: + +```text +packages/agent-manager/src/providers/claude/ + ClaudeCodeAdapter.ts + ClaudeSessionLocator.ts + ClaudeSessionParser.ts + ClaudeAgentMapper.ts + types.ts + durable/ +``` + +Package-root exports remain the compatibility surface. Thin path-level wrappers under `src/adapters`, `src/utils`, and `src/durable` were removed after review because they only re-exported provider-local modules. + +## Implementation Notes + +### Core Features + +- Move files first, then extract logic. +- Keep `ClaudeCodeAdapter` as the public `AgentAdapter` implementation. +- Keep parser behavior unchanged. +- Keep live PID-file status precedence unchanged. +- Keep legacy CWD + birthtime matching fallback unchanged. +- Keep durable Claude print-mode behavior unchanged. + +### Patterns & Best Practices + +- Preserve public package exports while avoiding no-value path-level re-export files. +- Keep provider-local concrete classes small and focused. +- Avoid a generic provider/capability framework in this feature. +- Add comments only where extraction makes responsibility boundaries clearer. +- Prefer existing utilities from `utils/session`, `utils/matching`, and `utils/process`. + +## Integration Points + +- `src/index.ts` and `src/adapters/index.ts` continue exporting `ClaudeCodeAdapter` directly from the Claude provider. +- `AgentManager` continues working through `AgentAdapter`. +- `ClaudeCodeAdapter` continues using shared process snapshot filtering. +- Durable service continues using `DurableAgentRepository`, provider-local `ClaudeCliProbe`, and provider-local `ClaudePrintRunner` contracts. + +## Error Handling + +- PID-file read, malformed JSON, stale metadata, and missing JSONL errors remain swallowed and routed to fallback behavior. +- Session JSONL read errors continue returning `null` or empty conversations as before. +- Durable provider errors and sanitization remain unchanged. + +## Performance Considerations + +- Live detection must remain process-scoped and avoid scanning all Claude project directories. +- Historical `listSessions()` may continue walking every Claude project directory by design. +- `batchGetSessionFileBirthtimes()` remains the shared stat batching utility for legacy live matching. + +## Security Notes + +- No new provider command execution behavior is introduced. +- Prompt handling and durable stdin behavior remain unchanged. +- Provider-reported metadata is not promoted to persisted state by this refactor. +- Public exports must not duplicate or alter durable persistence behavior. + +## Implementation Log + +- Baseline validation before source changes: + - `npm run nx -- test agent-manager` passed with 28 test files and 568 tests. + - `npm run lint` in `packages/agent-manager` passed. + - `npm run typecheck` in `packages/agent-manager` passed. + - `npm run build` in `packages/agent-manager` passed. +- Moved `ClaudeSessionParser` to `packages/agent-manager/src/providers/claude/ClaudeSessionParser.ts`. + - Focused parser validation passed with 19 tests. +- Moved `ClaudeCodeAdapter` implementation to `packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts`. + - Focused adapter validation passed with 87 tests. +- Added `packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts`. + - Extracted session-backed and process-only `AgentInfo` mapping from the adapter. + - Added focused mapper tests covering live status precedence, waiting summaries, and process-only fallback. +- Added `packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts`. + - Extracted resume matching, PID-file matching, legacy live discovery, project-dir encoding, and historical session discovery from the adapter. + - Added focused locator test covering resume matching plus live PID status metadata. + - Moved the adapter's locator-facing tests onto `ClaudeSessionLocator` so no compatibility proxies remain on the adapter. +- Moved Claude durable execution implementations under `packages/agent-manager/src/providers/claude/durable/`. + - Claude print-mode focused validation passed with 4 test files and 8 tests. +- Removed no-value wrapper files after review: + - `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + - `packages/agent-manager/src/utils/ClaudeSessionParser.ts` + - `packages/agent-manager/src/durable/ClaudeCliProbe.ts` + - `packages/agent-manager/src/durable/ClaudePrintRunner.ts` + - `packages/agent-manager/src/durable/ClaudePrintAgentService.ts` + - Updated `src/index.ts`, `src/adapters/index.ts`, and focused tests to import provider-local modules directly. +- Cleaned package-root exports after review: + - Removed process-helper exports: `getProcessTty`, `captureProcessSnapshot`, `executableBasename`, and `filterByProcessNames`. + - Removed root export of `AgentSortKey`; `ListAgentsOptions` remains exported as the public options contract. + - Removed root export of `LocalProcessInspector`; `ProcessInspector` remains exported as the injectable contract. + - Kept `ClaudePrintAgentService` public and moved `ClaudeCliProbe`/`ClaudePrintRunner` tests to provider-local imports. +- Final validation after source changes: + - `npm run nx -- test agent-manager` passed with 30 test files and 571 tests. + - `npm run lint` in `packages/agent-manager` passed. + - `npm run typecheck` in `packages/agent-manager` passed. + - `npm run build` in `packages/agent-manager` passed. + - `npx ai-devkit@latest lint --feature claude-provider-refactor` passed. + +## Design Deviations + +- `providers/claude/types.ts` was not created. The extracted modules did not need a shared provider-local type barrel, and skipping it avoids a thin abstraction. +- Adapter private compatibility proxies for `discoverSessions`, `tryPidFileMatching`, and `getProjectDir` were removed after review. The 19 tests that drove them moved to `__tests__/providers/claude/ClaudeSessionLocator.test.ts` and now target `ClaudeSessionLocator` directly, so `tryPidFileMatching` and `getProjectDir` are private on the locator again. diff --git a/docs/ai/planning/2026-08-23-feature-claude-provider-refactor.md b/docs/ai/planning/2026-08-23-feature-claude-provider-refactor.md new file mode 100644 index 00000000..6704d26a --- /dev/null +++ b/docs/ai/planning/2026-08-23-feature-claude-provider-refactor.md @@ -0,0 +1,108 @@ +--- +phase: planning +title: Claude Provider Refactor Plan +description: Ordered behavior-preserving extraction plan for Claude provider code +--- + +# Claude Provider Refactor Plan + +## Milestones + +- [x] Milestone 1: Requirements, design, and testing strategy created. +- [x] Milestone 2: Baseline validation recorded before source changes. +- [x] Milestone 3: Claude parser and adapter implementation moved into provider-local modules. +- [x] Milestone 4: Claude session locator and agent mapper extracted with focused tests. +- [x] Milestone 5: Claude durable implementation moved into provider-local modules. +- [x] Milestone 6: Final validation, implementation check, testing update, and review complete. + +## Task Breakdown + +### Phase 1: Baseline and Compatibility Shell + +- [x] Task 1.1: Run baseline `agent-manager` test, typecheck, lint, and build commands. + - Outcome: pre-refactor pass/fail evidence is recorded. + - Validation: task evidence includes command, exit code, and summary. + - Testing scenarios: baseline reporting in testing doc. +- [x] Task 1.2: Create `src/providers/claude/` and move `ClaudeSessionParser` to the provider-local path. + - Outcome: parser tests import the provider-local module directly. + - Validation: `ClaudeSessionParser` tests pass. + - Testing scenarios: parser compatibility. +- [x] Task 1.3: Move `ClaudeCodeAdapter` implementation to the provider-local path. + - Outcome: public and adapter barrel exports remain valid without a path-level wrapper. + - Validation: `ClaudeCodeAdapter` tests compile and pass. + - Testing scenarios: adapter export compatibility. + +### Phase 2: Provider-Local Extraction + +- [x] Task 2.1: Extract Claude session locating/matching logic into `providers/claude/ClaudeSessionLocator.ts`. + - Outcome: resume matching, PID-file matching, legacy discovery, and historical discovery are isolated from adapter orchestration. + - Dependencies: Task 1.3. + - Validation: existing adapter tests pass; add focused locator tests if exposed behavior is easier to assert directly. + - Testing scenarios: resume direct match, stale PID fallback, missing JSONL fallback, historical discovery. +- [x] Task 2.2: Extract Claude `AgentInfo` mapping into `providers/claude/ClaudeAgentMapper.ts`. + - Outcome: live status precedence, waiting summary decoration, and process-only fallback are isolated. + - Dependencies: Task 2.1. + - Validation: adapter tests pass; focused mapper tests cover status/summary/path behavior. + - Testing scenarios: mapper unit scenarios. +- [x] Task 2.3: Add provider-local `types.ts` only for shared Claude internal types that are used by more than one provider-local module. + - Outcome: not needed. Shared internal types stayed local to `ClaudeAgentMapper` and `ClaudeSessionLocator`, avoiding a thin unused file. + - Dependencies: Task 2.1 or 2.2 if duplication appears. + - Validation: typecheck. + +### Phase 3: Durable Provider Locality + +- [x] Task 3.1: Move Claude durable execution files under `providers/claude/durable/`, if the move is low-risk after Phase 2. + - Outcome: `ClaudeCliProbe`, `ClaudePrintRunner`, and `ClaudePrintAgentService` are provider-local while the package root keeps the high-level durable service public. + - Dependencies: Phase 2 complete and green. + - Validation: durable print tests pass. + - Testing scenarios: existing durable tests through compatibility exports. +- [x] Task 3.2: If durable relocation creates disproportionate churn, defer it explicitly in implementation notes and keep the interactive Claude extraction complete. + - Outcome: durable relocation completed; no deferral was required. + - Outcome: scope remains safe and documented. + - Dependencies: Phase 2 outcome. + - Validation: docs record deferral rationale. + +### Phase 4: Final Checks + +- [x] Task 4.1: Update implementation and testing docs with changed files, decisions, deviations, and command evidence. +- [x] Task 4.2: Run final package validation: tests, typecheck, lint, build, and feature lint. +- [x] Task 4.3: Run implementation alignment check against requirements/design. +- [x] Task 4.4: Run final review and close task if no blocking findings remain. + +## Dependencies + +- Existing public exports in `src/index.ts` and `src/adapters/index.ts` must remain compatible throughout. +- Task 1.1 must complete before source edits. +- Parser and adapter moves should happen before extraction to minimize import churn. +- Durable relocation depends on interactive provider extraction being stable. + +## Timeline & Estimates + +- Baseline and compatibility shell: small. +- Locator and mapper extraction: medium, highest regression risk because tests currently exercise private adapter behavior indirectly. +- Durable relocation: small to medium, but can be deferred if it adds unrelated blast radius. +- Final validation/review: medium because full `agent-manager` tests and build must run. + +## Risks & Mitigation + +- **Risk:** Provider-local exports break declaration output or package barrels. + - Mitigation: run typecheck/build after moves and inspect public exports. +- **Risk:** Private-method tests become brittle after extraction. + - Mitigation: move assertions to provider-local module tests where behavior is now first-class. +- **Risk:** Locator extraction accidentally changes fallback ordering. + - Mitigation: preserve existing test coverage and move code mechanically before simplifying. +- **Risk:** Durable relocation pulls in broader repository import churn. + - Mitigation: keep old durable files as re-exports; defer relocation if it becomes disproportionate. +- **Risk:** Lint rules reject re-export-only compatibility files. + - Mitigation: use existing barrel export style and validate early. + +## Resources Needed + +- Existing `agent-manager` Vitest suites. +- Existing Claude adapter/parser/durable fixtures and fake Claude executable. +- `npx ai-devkit@latest lint --feature claude-provider-refactor` for lifecycle validation. +- Task tracing under `claude-provider-refactor`. + +## Progress Summary + +Implementation completed. Claude interactive detection, parsing, mapping, session locating, and durable print-mode execution now live under `src/providers/claude/`. Package-root and adapter-barrel exports are preserved for high-level public entry points, while no-value path-level wrappers and lower-level root exports were removed. The planned `types.ts` file was intentionally skipped because extracted modules did not need a shared provider-local type barrel. diff --git a/docs/ai/requirements/2026-08-23-feature-claude-provider-refactor.md b/docs/ai/requirements/2026-08-23-feature-claude-provider-refactor.md new file mode 100644 index 00000000..da96cf49 --- /dev/null +++ b/docs/ai/requirements/2026-08-23-feature-claude-provider-refactor.md @@ -0,0 +1,108 @@ +--- +phase: requirements +title: Claude Provider Refactor Requirements +description: Behavior-preserving Claude-first provider organization for agent-manager +--- + +# Claude Provider Refactor Requirements + +## Problem Statement + +`@ai-devkit/agent-manager` has grown from interactive process detection into a package that also owns session listing, conversation reading, capacity reporting, and durable Claude print-mode agents. The current layout keeps most interactive provider implementations under `src/adapters/`, while newer features live in top-level feature folders such as `capacity/` and `durable/`. + +Claude is now the clearest pressure point: + +- `ClaudeCodeAdapter` combines process filtering, Claude project path encoding, PID-file matching, resume matching, legacy birthtime matching, session-to-agent mapping, process-only fallback mapping, conversation delegation, and historical session discovery. +- Claude print-mode durable support lives separately under `src/durable/` as `ClaudeCliProbe`, `ClaudePrintRunner`, and `ClaudePrintAgentService`. +- Claude-specific parsing lives in `src/utils/ClaudeSessionParser.ts`, even though it is not a generic utility. +- The package has no explicit provider boundary that can scale as Claude, Codex, Pi, Gemini, and other runtimes gain different capability sets. + +The current behavior works, but the structure makes future changes harder to reason about. A developer adding or changing Claude behavior must scan `adapters/`, `utils/`, and `durable/` to understand one provider. A developer adding capacity or durable support for another runtime may be tempted to add more top-level `Claude*`, `Codex*`, or `Pi*` files instead of using a provider-local boundary. + +## Goals & Objectives + +### Goals + +- Refactor Claude-related `agent-manager` code into a provider-local module boundary. +- Preserve public package contracts and current runtime behavior. +- Keep `ClaudeCodeAdapter` available from the existing public and adapter exports. +- Make Claude's internal responsibilities easier to test independently: + - process/session matching; + - Claude project/session file location; + - PID-file live status metadata; + - session parsing and conversation reading; + - `AgentInfo` mapping; + - durable print-mode provider execution. +- Treat Claude, Codex, Pi, Gemini, and similar tools as providers or runtimes. +- Treat capacity and durable execution as optional capabilities that provider implementations may support. +- Establish a package shape that can later move Codex capacity and other provider-specific code without forcing a speculative generic framework now. +- Follow the safe refactor rule: move/extract behavior first, change behavior only in later explicit tasks. + +### Non-goals + +- Changing `agent list`, `agent detail`, `agent send`, `agent sessions`, capacity, durable agent, or conversation output behavior. +- Renaming public exported classes or types. +- Replacing `AgentAdapter` with a new public provider interface in this feature. +- Implementing Claude capacity reporting. +- Implementing Codex, Pi, Gemini, or other provider refactors. +- Generalizing durable agents beyond the current Claude print-mode implementation. +- Changing durable persistence, database schema, locking, or run semantics. +- Changing Claude JSONL parsing rules, PID-file matching semantics, resume matching semantics, or status mapping beyond mechanical extraction. +- Preserving path-level compatibility re-exports that add no value after the provider-local shape is proven. + +## User Stories & Use Cases + +- As a maintainer, I can find Claude interactive detection, Claude session parsing, and Claude durable print-mode code under one provider-local area. +- As a maintainer, I can modify Claude PID-file matching or resume matching without editing a monolithic adapter class. +- As a maintainer, I can add focused tests for Claude session location and agent mapping without reaching through private adapter methods. +- As a CLI user, I see identical `agent list`, `agent sessions`, `agent detail`, and durable Claude behavior after the refactor. +- As a package consumer, existing imports from `@ai-devkit/agent-manager` continue to work. +- As a future feature author, I can model provider-specific capacity or durable support as provider capabilities rather than adding more unrelated top-level files. + +### Edge cases + +- Existing tests that import Claude implementation files should target provider-local paths unless they are testing package barrels. +- Tests that currently spy on private methods should either continue through compatibility wrappers or move to newly extracted provider-local modules with equivalent assertions. +- Claude Code PID files may be missing, stale, malformed, or point to a missing JSONL; fallback behavior must remain unchanged. +- `claude --resume ` matching must remain authoritative for resumed sessions. +- Historical `listSessions({ cwd })` behavior must continue walking all Claude project directories and filtering by recorded cwd, including worktree/current-cwd divergence. +- Durable Claude print-mode services must keep current names and exports even if their implementation files move. +- Build output and declaration files must not drop public exports. + +## Success Criteria + +1. Claude provider code is organized under a provider-local boundary, with package-root exports preserving the public package contract. +2. `ClaudeCodeAdapter.detectAgents()` produces the same `AgentInfo` results for existing tested scenarios. +3. `ClaudeCodeAdapter.getConversation()` and `listSessions()` remain behaviorally compatible with existing tests. +4. Claude durable print-mode exports and behavior remain compatible with current durable tests. +5. The refactor introduces no public breaking change in `packages/agent-manager/src/index.ts` or `packages/agent-manager/src/adapters/index.ts`; no-value path-level wrappers are removed. +6. New or updated tests cover extracted Claude session locating/matching and agent mapping directly where practical. +7. Baseline validation is recorded before behavior-preserving moves, and each extraction stage is validated before the next one. +8. `npm run nx -- test agent-manager`, `npm run nx -- run agent-manager:typecheck` or equivalent TypeScript validation, package lint, and package build pass after the refactor. +9. No unrelated provider behavior changes for Codex, Pi, Gemini, Grok, Copilot, or OpenCode. +10. No unrelated top-level docs, generated files, or existing user changes are reverted. + +## Constraints & Assumptions + +- The work starts from branch `feature-claude-provider-refactor` in `.worktrees/feature-claude-provider-refactor`. +- The package remains ESM TypeScript and uses the existing Nx/npm workspace conventions. +- Current public exports in `packages/agent-manager/src/index.ts` are compatibility contracts. +- Existing `AgentAdapter` remains the public detection contract for this feature. +- Provider-specific implementation files may move, but the first refactor must preserve compatibility wrappers where old paths are imported. +- `capacity` remains Codex-only in this feature. Its current shape is used only as a design input for future provider capability organization. +- `durable` remains Claude-only in this feature. Moving or wrapping Claude durable files must not change persistence or execution behavior. +- The phrase "provider" is preferred over "vendor" for code and docs because it captures local CLI runtimes such as Claude Code, Codex CLI, Pi, Gemini CLI, and similar tools without implying a commercial contract. +- Broad shared abstractions require at least two current callers. This feature should avoid speculative base classes and optional-method interfaces. + +## Alternatives Considered + +1. **Leave the structure unchanged and only add comments.** Lowest risk now, but it does not reduce the current cost of changing Claude behavior or adding future provider capabilities. +2. **Create a generic provider framework immediately.** Rejected for this phase because Claude, Codex, and Pi have different session formats, matching sources, and capability surfaces. A generic framework would likely become optional-method scaffolding before there are enough real callers. +3. **Move only `ClaudeCodeAdapter` under `providers/claude`.** Useful but incomplete because Claude durable print-mode and Claude parsing would remain scattered. +4. **Provider-local Claude boundary with compatibility exports.** Chosen because it improves locality, preserves behavior, and creates a scalable path for future provider capability moves without requiring them now. + +## Questions & Open Items + +- No blocking product questions remain. +- Design must choose the exact internal folder name and compatibility wrapper pattern. +- Planning must decide whether durable Claude files move in the first implementation task or after interactive Claude extraction, based on test blast radius. diff --git a/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md b/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md new file mode 100644 index 00000000..b746cecb --- /dev/null +++ b/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md @@ -0,0 +1,126 @@ +--- +phase: testing +title: Claude Provider Refactor Testing Strategy +description: Characterization and validation plan for behavior-preserving Claude refactor +--- + +# Claude Provider Refactor Testing Strategy + +## Test Coverage Goals + +- Preserve all existing `agent-manager` Claude adapter, Claude parser, and durable Claude print-mode tests. +- Add or update focused tests for extracted modules where private adapter behavior becomes public-to-package behavior. +- Target 100% coverage for new extracted code branches where practical. +- Prefer characterization tests before logic moves; tests should assert current behavior, not redesigned behavior. +- No test should require a real Claude model invocation. + +## Unit Tests + +### `ClaudeSessionLocator` + +- [x] Matches `claude --resume ` to the expected project JSONL and skips legacy birthtime matching for that process. +- [x] Reads matching PID files and returns direct matches with live status and `waitingFor` metadata. +- [x] Treats stale PID files as fallback when `startedAt` differs from process start time beyond the existing tolerance. +- [x] Falls back when PID JSON is malformed, absent, or points to a missing JSONL. +- [x] Discovers legacy session candidates by unique encoded process cwd and preserves existing batched birthtime lookup. +- [x] Walks all Claude project directories for historical `listSessions()` candidates. +- [x] Preserves lossy Claude project-dir encoding behavior. + +### `ClaudeAgentMapper` + +- [x] PID-file live status overrides JSONL-derived status. +- [x] Waiting summaries append the existing waiting reason text only for waiting agents with `waitingFor`. +- [x] Session-backed agents preserve name, type, pid, project path, session id, last active, and session file path. +- [x] Process-only agents preserve existing idle status, unknown summary, `pid-` session id, and cwd behavior. + +### `ClaudeSessionParser` + +- [x] Existing parser tests continue to pass after moving imports. +- [x] Conversation extraction remains unchanged for verbose and non-verbose modes. +- [x] Noise filtering, interruption handling, and UI-state entry handling remain unchanged. + +### Claude Durable Provider Files + +- [x] Existing `ClaudePrintAgentService` tests continue to pass through package-root exports. +- [x] Lower-level `ClaudeCliProbe` and `ClaudePrintRunner` tests import provider-local modules directly. +- [x] Provider-local tests import provider-local modules directly. + +## Integration Tests + +- [x] `ClaudeCodeAdapter.detectAgents()` still returns empty results for no Claude processes. +- [x] Matched Claude sessions still produce waiting/running/idle status according to existing fixtures. +- [x] Mixed direct PID-file and legacy matching still returns one agent per process. +- [x] Bad direct matches still fall back to process-only or legacy behavior as before. +- [x] `ClaudeCodeAdapter.listSessions({ cwd })` still handles worktree/current-cwd divergence. +- [x] Adapter and package barrel exports compile after no-value path-level wrappers are removed. + +## End-to-End Tests + +- [x] Package-level agent-manager test suite passes. +- [x] CLI tests that import or exercise `agent-manager` continue to pass when selected by planning. +- [x] No real Claude CLI/model call is required; durable tests continue using fake provider processes or injected runners. + +## Test Data + +- Existing Claude JSONL test files and inline temporary fixtures remain valid. +- PID-file fixtures should include happy path, stale `startedAt`, malformed JSON, missing JSONL, `status`, and `waitingFor`. +- Durable tests continue to use fake Claude executables/runners and temporary repositories/databases. +- No credentials, real home-directory data, or live provider sessions should be used. + +## Test Reporting & Coverage + +Baseline before implementation: + +```bash +npm run nx -- test agent-manager +npm run nx -- run agent-manager:typecheck +npm run nx -- run agent-manager:lint +npm run nx -- run agent-manager:build +``` + +Validation after each implementation stage: + +```bash +npm run nx -- test agent-manager -- ClaudeCodeAdapter +npm run nx -- test agent-manager -- ClaudeSessionParser +npm run nx -- test agent-manager -- ClaudePrint +``` + +Final validation: + +```bash +npm run nx -- test agent-manager +npm run nx -- run agent-manager:typecheck +npm run nx -- run agent-manager:lint +npm run nx -- run agent-manager:build +npx ai-devkit@latest lint --feature claude-provider-refactor +``` + +Any pre-existing baseline failures must be recorded before implementation and not misreported as refactor regressions. + +### Results + +- `npm run nx -- test agent-manager`: passed with 30 test files and 571 tests. +- `npm test -- src/__tests__/print/ClaudeCliProbe.test.ts src/__tests__/print/ClaudePrintRunner.test.ts src/__tests__/print/ClaudePrintAgentService.test.ts src/__tests__/print/ClaudePrintAgent.integration.test.ts src/__tests__/print/DurableAgent.test.ts src/__tests__/print/DurableAgentRepository.test.ts`: passed with 6 test files and 16 tests. +- `npm test -- src/__tests__/adapters/ClaudeCodeAdapter.test.ts src/__tests__/utils/ClaudeSessionParser.test.ts src/__tests__/print/ClaudeCliProbe.test.ts src/__tests__/print/ClaudePrintRunner.test.ts src/__tests__/print/ClaudePrintAgentService.test.ts src/__tests__/print/ClaudePrintAgent.integration.test.ts src/__tests__/providers/claude/ClaudeAgentMapper.test.ts src/__tests__/providers/claude/ClaudeSessionLocator.test.ts`: passed with 8 test files and 117 tests. +- `npm run lint` in `packages/agent-manager`: passed. +- `npm run typecheck` in `packages/agent-manager`: passed. +- `npm run build` in `packages/agent-manager`: passed. +- `npx ai-devkit@latest lint --feature claude-provider-refactor`: passed. + +## Manual Testing + +- Run `ai-devkit agent list --type claude` or equivalent local command only if a real Claude process is already available and no model turn is triggered. +- Run `ai-devkit agent sessions --type claude --json` against local session files only if needed for smoke validation. +- Do not start a real Claude model turn solely for this refactor. + +## Performance Testing + +- Confirm `detectAgents()` still uses bounded process-scoped session discovery and batched session file stat calls. +- Confirm historical `listSessions()` behavior remains intentionally broader than live detection. +- No load test is required unless implementation adds new filesystem scans beyond the current behavior. + +## Bug Tracking + +- Regressions should be tied to the affected stage: compatibility move, locator extraction, mapper extraction, parser move, or durable move. +- If a behavior change is discovered, either preserve the old behavior or split the change into a separate explicit feature/bug task. diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 5b8b1785..074e2cee 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -6,7 +6,7 @@ import type { MockedFunction } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; -import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter.js'; +import { ClaudeCodeAdapter } from '../../providers/claude/ClaudeCodeAdapter.js'; import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; import { AgentStatus } from '../../adapters/AgentAdapter.js'; import { listAgentProcesses, enrichProcesses, captureProcessSnapshot } from '../../utils/process.js'; @@ -764,115 +764,6 @@ describe('ClaudeCodeAdapter', () => { }); }); - describe('discoverSessions', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-')); - (adapter as any).projectsDir = path.join(tmpDir, 'projects'); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should return empty when projects dir does not exist', () => { - (adapter as any).projectsDir = path.join(tmpDir, 'nonexistent'); - const discoverSessions = (adapter as any).discoverSessions.bind(adapter); - - const result = discoverSessions([ - { pid: 1, command: 'claude', cwd: '/test', tty: '' }, - ]); - expect(result).toEqual([]); - }); - - it('should scan only directories matching process CWDs', () => { - const projectsDir = path.join(tmpDir, 'projects'); - (adapter as any).projectsDir = projectsDir; - const discoverSessions = (adapter as any).discoverSessions.bind(adapter); - - // /my/project → -my-project (encoded dir) - const encodedDir = path.join(projectsDir, '-my-project'); - fs.mkdirSync(encodedDir, { recursive: true }); - - // Also create another dir that should NOT be scanned - const otherDir = path.join(projectsDir, '-other-project'); - fs.mkdirSync(otherDir, { recursive: true }); - - const mockFiles: SessionFile[] = [ - { - sessionId: 's1', - filePath: path.join(encodedDir, 's1.jsonl'), - projectDir: encodedDir, - birthtimeMs: 1710800324000, - resolvedCwd: '', - }, - ]; - mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles); - - const processes = [ - { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }, - ]; - - const result = discoverSessions(processes); - expect(result).toHaveLength(1); - expect(result[0].resolvedCwd).toBe('/my/project'); - // batchGetSessionFileBirthtimes called once with all dirs - expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1); - expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]); - }); - - it('should return empty when encoded dir does not exist', () => { - const projectsDir = path.join(tmpDir, 'projects'); - fs.mkdirSync(projectsDir, { recursive: true }); - (adapter as any).projectsDir = projectsDir; - const discoverSessions = (adapter as any).discoverSessions.bind(adapter); - - // Process CWD /test encodes to -test, but that dir doesn't exist - const result = discoverSessions([ - { pid: 1, command: 'claude', cwd: '/test', tty: '' }, - ]); - expect(result).toEqual([]); - expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled(); - }); - - it('should deduplicate when multiple processes share same CWD', () => { - const projectsDir = path.join(tmpDir, 'projects'); - (adapter as any).projectsDir = projectsDir; - const discoverSessions = (adapter as any).discoverSessions.bind(adapter); - - const encodedDir = path.join(projectsDir, '-my-project'); - fs.mkdirSync(encodedDir, { recursive: true }); - - mockedBatchGetSessionFileBirthtimes.mockReturnValue([ - { sessionId: 's1', filePath: path.join(encodedDir, 's1.jsonl'), projectDir: encodedDir, birthtimeMs: 1710800324000, resolvedCwd: '' }, - ]); - - const processes = [ - { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }, - { pid: 2, command: 'claude', cwd: '/my/project', tty: '' }, - ]; - - const result = discoverSessions(processes); - // Should only call batch once with deduplicated dir - expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1); - expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]); - expect(result).toHaveLength(1); - }); - - it('should skip processes with empty cwd', () => { - const projectsDir = path.join(tmpDir, 'projects'); - fs.mkdirSync(projectsDir, { recursive: true }); - (adapter as any).projectsDir = projectsDir; - const discoverSessions = (adapter as any).discoverSessions.bind(adapter); - - const result = discoverSessions([ - { pid: 1, command: 'claude', cwd: '', tty: '' }, - ]); - expect(result).toEqual([]); - }); - }); - describe('helper methods', () => { describe('determineStatus', () => { it('should return "unknown" for sessions with no last entry type', () => { @@ -1092,203 +983,6 @@ describe('ClaudeCodeAdapter', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - describe('tryPidFileMatching', () => { - let sessionsDir: string; - let projectsDir: string; - - beforeEach(() => { - sessionsDir = path.join(tmpDir, 'sessions'); - projectsDir = path.join(tmpDir, 'projects'); - fs.mkdirSync(sessionsDir, { recursive: true }); - (adapter as any).sessionsDir = sessionsDir; - (adapter as any).projectsDir = projectsDir; - }); - - const makeProc = (pid: number, cwd = '/project/test', startTime?: Date): ProcessInfo => ({ - pid, command: 'claude', cwd, tty: 'ttys001', startTime, - }); - - const writePidFile = (pid: number, sessionId: string, cwd: string, startedAt: number) => { - fs.writeFileSync( - path.join(sessionsDir, `${pid}.json`), - JSON.stringify({ pid, sessionId, cwd, startedAt, kind: 'interactive', entrypoint: 'cli' }), - ); - }; - - const writeJsonl = (cwd: string, sessionId: string) => { - const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); - const projDir = path.join(projectsDir, encoded); - fs.mkdirSync(projDir, { recursive: true }); - const filePath = path.join(projDir, `${sessionId}.jsonl`); - fs.writeFileSync(filePath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() })); - return filePath; - }; - - it('should return direct match when PID file and JSONL both exist within time tolerance', () => { - const startTime = new Date(); - const proc = makeProc(1001, '/project/test', startTime); - writePidFile(1001, 'session-abc', '/project/test', startTime.getTime()); - writeJsonl('/project/test', 'session-abc'); - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - const { direct, fallback } = tryMatch([proc]); - - expect(direct).toHaveLength(1); - expect(fallback).toHaveLength(0); - expect(direct[0].sessionFile.sessionId).toBe('session-abc'); - expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test'); - expect(direct[0].process.pid).toBe(1001); - }); - - it('should fall back when PID file exists but JSONL is missing', () => { - const startTime = new Date(); - const proc = makeProc(1002, '/project/test', startTime); - writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime()); - // No JSONL file written - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - const { direct, fallback } = tryMatch([proc]); - - expect(direct).toHaveLength(0); - expect(fallback).toHaveLength(1); - expect(fallback[0].pid).toBe(1002); - }); - - it('should fall back when startedAt is stale (>60s from proc.startTime)', () => { - const startTime = new Date(); - const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier - const proc = makeProc(1003, '/project/test', startTime); - writePidFile(1003, 'stale-session', '/project/test', staleTime); - writeJsonl('/project/test', 'stale-session'); - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - const { direct, fallback } = tryMatch([proc]); - - expect(direct).toHaveLength(0); - expect(fallback).toHaveLength(1); - }); - - it('should accept PID file when startedAt is within 60s tolerance', () => { - const startTime = new Date(); - const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance - const proc = makeProc(1004, '/project/test', startTime); - writePidFile(1004, 'close-session', '/project/test', closeTime); - writeJsonl('/project/test', 'close-session'); - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - const { direct, fallback } = tryMatch([proc]); - - expect(direct).toHaveLength(1); - expect(fallback).toHaveLength(0); - }); - - it('should fall back when PID file is absent', () => { - const proc = makeProc(1005, '/project/test', new Date()); - // No PID file written - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - const { direct, fallback } = tryMatch([proc]); - - expect(direct).toHaveLength(0); - expect(fallback).toHaveLength(1); - }); - - it('should fall back when PID file contains malformed JSON', () => { - const proc = makeProc(1006, '/project/test', new Date()); - fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{'); - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - expect(() => { - const { direct, fallback } = tryMatch([proc]); - expect(direct).toHaveLength(0); - expect(fallback).toHaveLength(1); - }).not.toThrow(); - }); - - it('should fall back for all processes when sessions dir does not exist', () => { - (adapter as any).sessionsDir = path.join(tmpDir, 'nonexistent-sessions'); - const processes = [makeProc(2001, '/a', new Date()), makeProc(2002, '/b', new Date())]; - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - const { direct, fallback } = tryMatch(processes); - - expect(direct).toHaveLength(0); - expect(fallback).toHaveLength(2); - }); - - it('should correctly split mixed processes (some with PID files, some without)', () => { - const startTime = new Date(); - const proc1 = makeProc(3001, '/project/one', startTime); - const proc2 = makeProc(3002, '/project/two', startTime); - const proc3 = makeProc(3003, '/project/three', startTime); - - writePidFile(3001, 'session-one', '/project/one', startTime.getTime()); - writeJsonl('/project/one', 'session-one'); - writePidFile(3003, 'session-three', '/project/three', startTime.getTime()); - writeJsonl('/project/three', 'session-three'); - // proc2 has no PID file - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - const { direct, fallback } = tryMatch([proc1, proc2, proc3]); - - expect(direct).toHaveLength(2); - expect(fallback).toHaveLength(1); - expect(direct.map((d: any) => d.process.pid).sort()).toEqual([3001, 3003]); - expect(fallback[0].pid).toBe(3002); - }); - - it('should skip stale-file check when proc.startTime is undefined', () => { - const proc = makeProc(4001, '/project/test', undefined); // no startTime - writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999); - writeJsonl('/project/test', 'no-time-session'); - - const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); - const { direct, fallback } = tryMatch([proc]); - - // startTime undefined → stale check skipped → direct match - expect(direct).toHaveLength(1); - expect(fallback).toHaveLength(0); - }); - }); - - describe('getProjectDir', () => { - const encode = (cwd: string) => (adapter as any).getProjectDir(cwd) as string; - - it('should replace path separators with hyphens', () => { - const expected = path.join((adapter as any).projectsDir, '-Users-foo-bar'); - expect(encode('/Users/foo/bar')).toBe(expected); - }); - - it('should encode underscores as hyphens (matches Claude Code CLI)', () => { - const expected = path.join((adapter as any).projectsDir, '-Users-foo-my-project'); - expect(encode('/Users/foo/my_project')).toBe(expected); - }); - - it('should encode dots as hyphens', () => { - const expected = path.join((adapter as any).projectsDir, '-Users-foo--worktrees-x'); - expect(encode('/Users/foo/.worktrees/x')).toBe(expected); - }); - - it('should collide paths that differ only in non-alphanumeric chars', () => { - // The encoding is intentionally lossy — callers must - // disambiguate via session JSONL contents, not dir name. - expect(encode('/a/b_c')).toBe(encode('/a/b-c')); - expect(encode('/a/b_c')).toBe(encode('/a/b.c')); - }); - - it('should resolve to a real session dir when cwd contains underscores', () => { - const cwd = '/Users/foo/my_project'; - const projectsDir = (adapter as any).projectsDir as string; - const expectedDir = path.join(projectsDir, '-Users-foo-my-project'); - fs.mkdirSync(expectedDir, { recursive: true }); - const sessionFile = path.join(expectedDir, 'session-underscore.jsonl'); - fs.writeFileSync(sessionFile, ''); - - expect(encode(cwd)).toBe(expectedDir); - expect(fs.existsSync(path.join(encode(cwd), 'session-underscore.jsonl'))).toBe(true); - }); - }); - describe('readSession', () => { it('should parse session file with timestamps, cwd, and entry type', () => { const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser); diff --git a/packages/agent-manager/src/__tests__/print/ClaudeCliProbe.test.ts b/packages/agent-manager/src/__tests__/print/ClaudeCliProbe.test.ts index f513c62c..0d0be3b9 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudeCliProbe.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudeCliProbe.test.ts @@ -1,17 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; +import { ClaudeCliProbe } from '../../providers/claude/durable/ClaudeCliProbe.js'; describe('ClaudeCliProbe', () => { it('validates only version/help and requires the print session flags', async () => { - const api = await import('../../index.js') as Record; - expect(api).toHaveProperty('ClaudeCliProbe'); const exec = vi.fn() .mockResolvedValueOnce({ stdout: '2.1.220\n', stderr: '' }) .mockResolvedValueOnce({ stdout: '--print --session-id --resume --output-format stream-json', stderr: '', }); - const Probe = api.ClaudeCliProbe as new (options: unknown) => { validate(): Promise }; - await expect(new Probe({ exec }).validate()).resolves.toEqual({ + await expect(new ClaudeCliProbe({ exec }).validate()).resolves.toEqual({ executable: 'claude', version: '2.1.220', }); expect(exec.mock.calls).toEqual([ @@ -21,12 +19,10 @@ describe('ClaudeCliProbe', () => { }); it('rejects a CLI missing a required capability', async () => { - const api = await import('../../index.js') as Record; const exec = vi.fn() .mockResolvedValueOnce({ stdout: 'old', stderr: '' }) .mockResolvedValueOnce({ stdout: '--print only', stderr: '' }); - const Probe = api.ClaudeCliProbe as new (options: unknown) => { validate(): Promise }; - await expect(new Probe({ exec }).validate()).rejects.toMatchObject({ code: 'CLAUDE_CLI_UNSUPPORTED' }); + await expect(new ClaudeCliProbe({ exec }).validate()).rejects.toMatchObject({ code: 'CLAUDE_CLI_UNSUPPORTED' }); }); }); diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts index fd1be31c..c87ac4ba 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts @@ -4,12 +4,12 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import { - ClaudeCliProbe, ClaudePrintAgentService, - ClaudePrintRunner, DurableAgentRepository, type ProcessInspector, } from '../../index.js'; +import { ClaudeCliProbe } from '../../providers/claude/durable/ClaudeCliProbe.js'; +import { ClaudePrintRunner } from '../../providers/claude/durable/ClaudePrintRunner.js'; const roots: string[] = []; const originalCapture = process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE; diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts index 5ad793de..200ab6f1 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'; import { PassThrough, Writable } from 'node:stream'; import { describe, expect, it, vi } from 'vitest'; import type { DurableAgent } from '../../index.js'; +import { ClaudePrintRunner } from '../../providers/claude/durable/ClaudePrintRunner.js'; function agent(): DurableAgent { return { @@ -34,15 +35,12 @@ function fakeSpawn(events: object[], exitCode = 0) { describe('ClaudePrintRunner', () => { it('starts a caller-assigned session and persists provider identity before stdin', async () => { - const api = await import('../../index.js') as Record; - expect(api).toHaveProperty('ClaudePrintRunner'); const fixture = fakeSpawn([ { type: 'system', subtype: 'init', session_id: agent().providerSessionId }, { type: 'result', session_id: agent().providerSessionId, result: 'done' }, ]); let persisted = false; - const Runner = api.ClaudePrintRunner as new (options: unknown) => any; - const runner = new Runner({ spawn: fixture.spawn, processInspector: { + const runner = new ClaudePrintRunner({ spawn: fixture.spawn, processInspector: { getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }), } }); @@ -63,10 +61,8 @@ describe('ClaudePrintRunner', () => { }); it('uses exact resume and rejects a mismatched result session', async () => { - const api = await import('../../index.js') as Record; const fixture = fakeSpawn([{ type: 'result', session_id: 'wrong', result: 'nope' }]); - const Runner = api.ClaudePrintRunner as new (options: unknown) => any; - const runner = new Runner({ spawn: fixture.spawn, processInspector: { + const runner = new ClaudePrintRunner({ spawn: fixture.spawn, processInspector: { getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }), } }); @@ -79,10 +75,8 @@ describe('ClaudePrintRunner', () => { }); it('does not disclose provider stderr in a failed-run error', async () => { - const api = await import('../../index.js') as Record; const fixture = fakeSpawn([], 1); - const Runner = api.ClaudePrintRunner as new (options: unknown) => any; - const runner = new Runner({ spawn: fixture.spawn, processInspector: { + const runner = new ClaudePrintRunner({ spawn: fixture.spawn, processInspector: { getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }), } }); fixture.spawn.mockImplementationOnce((...args: unknown[]) => { diff --git a/packages/agent-manager/src/__tests__/providers/claude/ClaudeAgentMapper.test.ts b/packages/agent-manager/src/__tests__/providers/claude/ClaudeAgentMapper.test.ts new file mode 100644 index 00000000..784e7feb --- /dev/null +++ b/packages/agent-manager/src/__tests__/providers/claude/ClaudeAgentMapper.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { AgentStatus, type ProcessInfo } from '../../../adapters/AgentAdapter.js'; +import { ClaudeAgentMapper } from '../../../providers/claude/ClaudeAgentMapper.js'; +import type { ClaudeSession } from '../../../providers/claude/ClaudeSessionParser.js'; +import type { SessionFile } from '../../../utils/session.js'; + +function makeProcess(overrides: Partial = {}): ProcessInfo { + return { + pid: 123, + command: 'claude', + cwd: '/repo/my-app', + tty: 'ttys001', + ...overrides, + }; +} + +function makeSession(overrides: Partial = {}): ClaudeSession { + return { + sessionId: 'session-1', + projectPath: '/repo/my-app', + sessionStart: new Date('2026-08-23T10:00:00.000Z'), + lastActive: new Date('2026-08-23T10:05:00.000Z'), + lastEntryType: 'assistant', + isInterrupted: false, + lastUserMessage: 'Review this change', + ...overrides, + }; +} + +function makeSessionFile(overrides: Partial = {}): SessionFile { + return { + sessionId: 'session-1', + filePath: '/home/.claude/projects/-repo-my-app/session-1.jsonl', + projectDir: '/home/.claude/projects/-repo-my-app', + birthtimeMs: new Date('2026-08-23T10:00:00.000Z').getTime(), + resolvedCwd: '/repo/my-app', + ...overrides, + }; +} + +describe('ClaudeAgentMapper', () => { + it('prefers live PID-file status and waiting reason over JSONL-derived status', () => { + const mapper = new ClaudeAgentMapper(); + + const agent = mapper.mapSessionToAgent({ + session: makeSession({ lastEntryType: 'assistant' }), + processInfo: makeProcess(), + sessionFile: makeSessionFile(), + liveInfo: { + pidStatus: AgentStatus.WAITING, + waitingFor: 'approve Read', + }, + }); + + expect(agent).toMatchObject({ + name: 'my-app-123', + type: 'claude', + status: AgentStatus.WAITING, + summary: 'Review this change — waiting for approve Read', + pid: 123, + projectPath: '/repo/my-app', + sessionId: 'session-1', + sessionFilePath: '/home/.claude/projects/-repo-my-app/session-1.jsonl', + }); + }); + + it('maps unmatched processes to the existing process-only fallback shape', () => { + const mapper = new ClaudeAgentMapper(); + + const agent = mapper.mapProcessOnlyAgent(makeProcess({ pid: 456, cwd: '/repo/tooling' })); + + expect(agent).toMatchObject({ + name: 'tooling-456', + type: 'claude', + status: AgentStatus.IDLE, + summary: 'Unknown', + pid: 456, + projectPath: '/repo/tooling', + sessionId: 'pid-456', + }); + expect(agent.lastActive).toBeInstanceOf(Date); + }); +}); diff --git a/packages/agent-manager/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts b/packages/agent-manager/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts new file mode 100644 index 00000000..eef05485 --- /dev/null +++ b/packages/agent-manager/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts @@ -0,0 +1,411 @@ +import type { MockedFunction } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AgentStatus, type ProcessInfo } from '../../../adapters/AgentAdapter.js'; +import { ClaudeSessionLocator } from '../../../providers/claude/ClaudeSessionLocator.js'; +import { batchGetSessionFileBirthtimes } from '../../../utils/session.js'; +import type { SessionFile } from '../../../utils/session.js'; + +vi.mock('../../../utils/session.js', async (importOriginal) => { + const actual = await importOriginal() as typeof import('../../../utils/session.js'); + return { + ...actual, + batchGetSessionFileBirthtimes: vi.fn(), + }; +}); + +const mockedBatchGetSessionFileBirthtimes = + batchGetSessionFileBirthtimes as MockedFunction; + +const tmpDirs: string[] = []; + +function makeTmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-locator-test-')); + tmpDirs.push(dir); + return dir; +} + +function makeProcess(overrides: Partial = {}): ProcessInfo { + return { + pid: 123, + command: 'claude', + cwd: '/repo/my-app', + tty: 'ttys001', + startTime: new Date('2026-08-23T10:00:00.000Z'), + ...overrides, + }; +} + +type PidFileMatching = (processes: ProcessInfo[]) => { + direct: Array<{ process: ProcessInfo; sessionFile: SessionFile }>; + fallback: ProcessInfo[]; +}; + +/** `tryPidFileMatching` is private; tests drive it directly like the sibling adapter suites do. */ +function bindPidFileMatching(locator: ClaudeSessionLocator): PidFileMatching { + return (locator as unknown as { tryPidFileMatching: PidFileMatching }) + .tryPidFileMatching.bind(locator) as PidFileMatching; +} + +/** `getProjectDir` is private; bound here so the encoding tests read cleanly. */ +function bindProjectDir(locator: ClaudeSessionLocator): (cwd: string) => string { + return (locator as unknown as { getProjectDir: (cwd: string) => string }) + .getProjectDir.bind(locator); +} + +describe('ClaudeSessionLocator', () => { + beforeEach(() => { + mockedBatchGetSessionFileBirthtimes.mockReset(); + }); + + afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('matches resumed sessions directly and carries live PID status metadata', () => { + const root = makeTmpDir(); + const projectsDir = path.join(root, 'projects'); + const sessionsDir = path.join(root, 'sessions'); + const cwd = '/repo/my-app'; + const sessionId = '12345678-1234-1234-1234-123456789abc'; + const projectDir = path.join(projectsDir, '-repo-my-app'); + const sessionFile = path.join(projectDir, `${sessionId}.jsonl`); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync(sessionFile, '{}\n'); + fs.writeFileSync(path.join(sessionsDir, '123.json'), JSON.stringify({ + pid: 123, + sessionId, + cwd, + startedAt: new Date('2026-08-23T10:00:00.000Z').getTime(), + kind: 'interactive', + entrypoint: 'cli', + status: 'waiting', + waitingFor: 'approve Read', + })); + + const locator = new ClaudeSessionLocator({ projectsDir, sessionsDir }); + const matches = locator.matchRunningProcesses([ + makeProcess({ command: `claude --resume ${sessionId}`, cwd }), + ]); + + expect(matches.direct).toHaveLength(1); + expect(matches.legacyMatches).toEqual([]); + expect(matches.direct[0]).toMatchObject({ + pidStatus: AgentStatus.WAITING, + waitingFor: 'approve Read', + sessionFile: { + sessionId, + filePath: sessionFile, + projectDir, + resolvedCwd: cwd, + }, + }); + }); + describe('discoverLiveSessions', () => { + let tmpDir: string; + let projectsDir: string; + let locator: ClaudeSessionLocator; + + beforeEach(() => { + tmpDir = makeTmpDir(); + projectsDir = path.join(tmpDir, 'projects'); + locator = new ClaudeSessionLocator({ + projectsDir, + sessionsDir: path.join(tmpDir, 'sessions'), + }); + }); + + it('should return empty when projects dir does not exist', () => { + const missing = new ClaudeSessionLocator({ + projectsDir: path.join(tmpDir, 'nonexistent'), + sessionsDir: path.join(tmpDir, 'sessions'), + }); + + const result = missing.discoverLiveSessions([ + { pid: 1, command: 'claude', cwd: '/test', tty: '' }, + ]); + expect(result).toEqual([]); + }); + + it('should scan only directories matching process CWDs', () => { + // /my/project → -my-project (encoded dir) + const encodedDir = path.join(projectsDir, '-my-project'); + fs.mkdirSync(encodedDir, { recursive: true }); + + // Also create another dir that should NOT be scanned + const otherDir = path.join(projectsDir, '-other-project'); + fs.mkdirSync(otherDir, { recursive: true }); + + const mockFiles: SessionFile[] = [ + { + sessionId: 's1', + filePath: path.join(encodedDir, 's1.jsonl'), + projectDir: encodedDir, + birthtimeMs: 1710800324000, + resolvedCwd: '', + }, + ]; + mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles); + + const processes = [ + { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }, + ]; + + const result = locator.discoverLiveSessions(processes); + expect(result).toHaveLength(1); + expect(result[0].resolvedCwd).toBe('/my/project'); + // batchGetSessionFileBirthtimes called once with all dirs + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1); + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]); + }); + + it('should return empty when encoded dir does not exist', () => { + fs.mkdirSync(projectsDir, { recursive: true }); + + // Process CWD /test encodes to -test, but that dir doesn't exist + const result = locator.discoverLiveSessions([ + { pid: 1, command: 'claude', cwd: '/test', tty: '' }, + ]); + expect(result).toEqual([]); + expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled(); + }); + + it('should deduplicate when multiple processes share same CWD', () => { + const encodedDir = path.join(projectsDir, '-my-project'); + fs.mkdirSync(encodedDir, { recursive: true }); + + mockedBatchGetSessionFileBirthtimes.mockReturnValue([ + { sessionId: 's1', filePath: path.join(encodedDir, 's1.jsonl'), projectDir: encodedDir, birthtimeMs: 1710800324000, resolvedCwd: '' }, + ]); + + const processes = [ + { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }, + { pid: 2, command: 'claude', cwd: '/my/project', tty: '' }, + ]; + + const result = locator.discoverLiveSessions(processes); + // Should only call batch once with deduplicated dir + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1); + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]); + expect(result).toHaveLength(1); + }); + + it('should skip processes with empty cwd', () => { + fs.mkdirSync(projectsDir, { recursive: true }); + + const result = locator.discoverLiveSessions([ + { pid: 1, command: 'claude', cwd: '', tty: '' }, + ]); + expect(result).toEqual([]); + }); + }); + + describe('tryPidFileMatching', () => { + let tmpDir: string; + let sessionsDir: string; + let projectsDir: string; + let tryMatch: (processes: ProcessInfo[]) => { + direct: Array<{ process: ProcessInfo; sessionFile: SessionFile }>; + fallback: ProcessInfo[]; + }; + + beforeEach(() => { + tmpDir = makeTmpDir(); + sessionsDir = path.join(tmpDir, 'sessions'); + projectsDir = path.join(tmpDir, 'projects'); + fs.mkdirSync(sessionsDir, { recursive: true }); + tryMatch = bindPidFileMatching(new ClaudeSessionLocator({ projectsDir, sessionsDir })); + }); + + const makeProc = (pid: number, cwd = '/project/test', startTime?: Date): ProcessInfo => ({ + pid, command: 'claude', cwd, tty: 'ttys001', startTime, + }); + + const writePidFile = (pid: number, sessionId: string, cwd: string, startedAt: number) => { + fs.writeFileSync( + path.join(sessionsDir, `${pid}.json`), + JSON.stringify({ pid, sessionId, cwd, startedAt, kind: 'interactive', entrypoint: 'cli' }), + ); + }; + + const writeJsonl = (cwd: string, sessionId: string) => { + const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); + const projDir = path.join(projectsDir, encoded); + fs.mkdirSync(projDir, { recursive: true }); + const filePath = path.join(projDir, `${sessionId}.jsonl`); + fs.writeFileSync(filePath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() })); + return filePath; + }; + + it('should return direct match when PID file and JSONL both exist within time tolerance', () => { + const startTime = new Date(); + const proc = makeProc(1001, '/project/test', startTime); + writePidFile(1001, 'session-abc', '/project/test', startTime.getTime()); + writeJsonl('/project/test', 'session-abc'); + + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(1); + expect(fallback).toHaveLength(0); + expect(direct[0].sessionFile.sessionId).toBe('session-abc'); + expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test'); + expect(direct[0].process.pid).toBe(1001); + }); + + it('should fall back when PID file exists but JSONL is missing', () => { + const startTime = new Date(); + const proc = makeProc(1002, '/project/test', startTime); + writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime()); + // No JSONL file written + + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(1); + expect(fallback[0].pid).toBe(1002); + }); + + it('should fall back when startedAt is stale (>60s from proc.startTime)', () => { + const startTime = new Date(); + const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier + const proc = makeProc(1003, '/project/test', startTime); + writePidFile(1003, 'stale-session', '/project/test', staleTime); + writeJsonl('/project/test', 'stale-session'); + + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(1); + }); + + it('should accept PID file when startedAt is within 60s tolerance', () => { + const startTime = new Date(); + const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance + const proc = makeProc(1004, '/project/test', startTime); + writePidFile(1004, 'close-session', '/project/test', closeTime); + writeJsonl('/project/test', 'close-session'); + + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(1); + expect(fallback).toHaveLength(0); + }); + + it('should fall back when PID file is absent', () => { + const proc = makeProc(1005, '/project/test', new Date()); + // No PID file written + + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(1); + }); + + it('should fall back when PID file contains malformed JSON', () => { + const proc = makeProc(1006, '/project/test', new Date()); + fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{'); + + expect(() => { + const { direct, fallback } = tryMatch([proc]); + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(1); + }).not.toThrow(); + }); + + it('should fall back for all processes when sessions dir does not exist', () => { + const missingSessions = bindPidFileMatching(new ClaudeSessionLocator({ + projectsDir, + sessionsDir: path.join(tmpDir, 'nonexistent-sessions'), + })); + const processes = [makeProc(2001, '/a', new Date()), makeProc(2002, '/b', new Date())]; + + const { direct, fallback } = missingSessions(processes); + + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(2); + }); + + it('should correctly split mixed processes (some with PID files, some without)', () => { + const startTime = new Date(); + const proc1 = makeProc(3001, '/project/one', startTime); + const proc2 = makeProc(3002, '/project/two', startTime); + const proc3 = makeProc(3003, '/project/three', startTime); + + writePidFile(3001, 'session-one', '/project/one', startTime.getTime()); + writeJsonl('/project/one', 'session-one'); + writePidFile(3003, 'session-three', '/project/three', startTime.getTime()); + writeJsonl('/project/three', 'session-three'); + // proc2 has no PID file + + const { direct, fallback } = tryMatch([proc1, proc2, proc3]); + + expect(direct).toHaveLength(2); + expect(fallback).toHaveLength(1); + expect(direct.map((d) => d.process.pid).sort()).toEqual([3001, 3003]); + expect(fallback[0].pid).toBe(3002); + }); + + it('should skip stale-file check when proc.startTime is undefined', () => { + const proc = makeProc(4001, '/project/test', undefined); // no startTime + writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999); + writeJsonl('/project/test', 'no-time-session'); + + const { direct, fallback } = tryMatch([proc]); + + // startTime undefined → stale check skipped → direct match + expect(direct).toHaveLength(1); + expect(fallback).toHaveLength(0); + }); + }); + + describe('getProjectDir', () => { + let tmpDir: string; + let projectsDir: string; + let encode: (cwd: string) => string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + projectsDir = path.join(tmpDir, 'projects'); + encode = bindProjectDir(new ClaudeSessionLocator({ + projectsDir, + sessionsDir: path.join(tmpDir, 'sessions'), + })); + }); + + it('should replace path separators with hyphens', () => { + expect(encode('/Users/foo/bar')).toBe(path.join(projectsDir, '-Users-foo-bar')); + }); + + it('should encode underscores as hyphens (matches Claude Code CLI)', () => { + expect(encode('/Users/foo/my_project')).toBe(path.join(projectsDir, '-Users-foo-my-project')); + }); + + it('should encode dots as hyphens', () => { + expect(encode('/Users/foo/.worktrees/x')).toBe(path.join(projectsDir, '-Users-foo--worktrees-x')); + }); + + it('should collide paths that differ only in non-alphanumeric chars', () => { + // The encoding is intentionally lossy — callers must + // disambiguate via session JSONL contents, not dir name. + expect(encode('/a/b_c')).toBe(encode('/a/b-c')); + expect(encode('/a/b_c')).toBe(encode('/a/b.c')); + }); + + it('should resolve to a real session dir when cwd contains underscores', () => { + const cwd = '/Users/foo/my_project'; + const expectedDir = path.join(projectsDir, '-Users-foo-my-project'); + fs.mkdirSync(expectedDir, { recursive: true }); + const sessionFile = path.join(expectedDir, 'session-underscore.jsonl'); + fs.writeFileSync(sessionFile, ''); + + expect(encode(cwd)).toBe(expectedDir); + expect(fs.existsSync(path.join(encode(cwd), 'session-underscore.jsonl'))).toBe(true); + }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/utils/ClaudeSessionParser.test.ts b/packages/agent-manager/src/__tests__/utils/ClaudeSessionParser.test.ts index 1ad3c89c..01294e35 100644 --- a/packages/agent-manager/src/__tests__/utils/ClaudeSessionParser.test.ts +++ b/packages/agent-manager/src/__tests__/utils/ClaudeSessionParser.test.ts @@ -1,5 +1,5 @@ /** - * Tests for utils/ClaudeSessionParser.ts — focused on stripping + * Tests for providers/claude/ClaudeSessionParser.ts — focused on stripping * harness-injected XML tags from conversation content. */ @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { ClaudeSessionParser } from '../../utils/ClaudeSessionParser.js'; +import { ClaudeSessionParser } from '../../providers/claude/ClaudeSessionParser.js'; interface JsonlEntry { type: 'user' | 'assistant' | 'system'; diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 766639d6..05612286 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -1,4 +1,4 @@ -export { ClaudeCodeAdapter } from './ClaudeCodeAdapter.js'; +export { ClaudeCodeAdapter } from '../providers/claude/ClaudeCodeAdapter.js'; export { CodexAdapter } from './CodexAdapter.js'; export { CopilotAdapter } from './CopilotAdapter.js'; export { GeminiCliAdapter } from './GeminiCliAdapter.js'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index eccf4441..ea9d5424 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -6,7 +6,7 @@ export type { CapacityWindow, } from './capacity/index.js'; -export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js'; +export { ClaudeCodeAdapter } from './providers/claude/ClaudeCodeAdapter.js'; export { CodexAdapter } from './adapters/CodexAdapter.js'; export { CopilotAdapter } from './adapters/CopilotAdapter.js'; export { GeminiCliAdapter } from './adapters/GeminiCliAdapter.js'; @@ -29,9 +29,6 @@ export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusMana export type { TerminalLocation } from './terminal/TerminalFocusManager.js'; export { TtyWriter } from './terminal/TtyWriter.js'; -export { getProcessTty } from './utils/process.js'; -export { captureProcessSnapshot, executableBasename, filterByProcessNames } from './utils/process.js'; -export type { AgentSortKey } from './utils/sortAgents.js'; export type { ListAgentsOptions } from './AgentManager.js'; export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js'; @@ -62,23 +59,14 @@ export type { ProcessIdentity, } from './durable/DurableAgent.js'; export { DurableAgentRepository } from './durable/DurableAgentRepository.js'; -export { LocalProcessInspector } from './durable/DurableAgentRepository.js'; export type { CreateDurableAgentInput, DurableAgentRepositoryOptions, ProcessInspector, DurableRunCompletion, } from './durable/DurableAgentRepository.js'; -export { ClaudeCliProbe } from './durable/ClaudeCliProbe.js'; -export type { ClaudeCliProbeOptions } from './durable/ClaudeCliProbe.js'; -export { ClaudePrintRunner } from './durable/ClaudePrintRunner.js'; -export type { - ClaudePrintRunnerOptions, - ClaudePrintRunRequest, - ClaudePrintRunResult, -} from './durable/ClaudePrintRunner.js'; -export { ClaudePrintAgentService } from './durable/ClaudePrintAgentService.js'; +export { ClaudePrintAgentService } from './providers/claude/durable/ClaudePrintAgentService.js'; export type { ClaudePrintAgentServiceOptions, ClaudePrintSendResult, -} from './durable/ClaudePrintAgentService.js'; +} from './providers/claude/durable/ClaudePrintAgentService.js'; diff --git a/packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts b/packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts new file mode 100644 index 00000000..af1b1055 --- /dev/null +++ b/packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts @@ -0,0 +1,62 @@ +import type { AgentInfo, ProcessInfo } from '../../adapters/AgentAdapter.js'; +import { AgentStatus } from '../../adapters/AgentAdapter.js'; +import { generateAgentName } from '../../utils/matching.js'; +import type { SessionFile } from '../../utils/session.js'; +import { ClaudeSessionParser, type ClaudeSession } from './ClaudeSessionParser.js'; + +export interface ClaudeAgentLiveInfo { + pidStatus?: AgentStatus; + waitingFor?: string; +} + +export interface ClaudeSessionAgentInput { + session: ClaudeSession; + processInfo: ProcessInfo; + sessionFile: SessionFile; + liveInfo?: ClaudeAgentLiveInfo; +} + +export class ClaudeAgentMapper { + constructor(private readonly parser: ClaudeSessionParser = new ClaudeSessionParser()) {} + + mapSessionToAgent({ + session, + processInfo, + sessionFile, + liveInfo, + }: ClaudeSessionAgentInput): AgentInfo { + // Live PID-file status is authoritative when present — JSONL-derived + // status mis-classifies sessions whose latest entry is a UI-state + // event like `permission-mode` or `ai-title`. + const status = liveInfo?.pidStatus ?? this.parser.determineStatus(session); + const baseSummary = session.lastUserMessage || 'Session started'; + const summary = status === AgentStatus.WAITING && liveInfo?.waitingFor + ? `${baseSummary} — waiting for ${liveInfo.waitingFor}` + : baseSummary; + + return { + name: generateAgentName(processInfo.cwd, processInfo.pid), + type: 'claude', + status, + summary, + pid: processInfo.pid, + projectPath: sessionFile.resolvedCwd || processInfo.cwd || '', + sessionId: sessionFile.sessionId, + lastActive: session.lastActive, + sessionFilePath: sessionFile.filePath, + }; + } + + mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { + return { + name: generateAgentName(processInfo.cwd || '', processInfo.pid), + type: 'claude', + status: AgentStatus.IDLE, + summary: 'Unknown', + pid: processInfo.pid, + projectPath: processInfo.cwd || '', + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), + }; + } +} diff --git a/packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts b/packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts new file mode 100644 index 00000000..e5e26eaf --- /dev/null +++ b/packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts @@ -0,0 +1,160 @@ +import * as path from 'path'; +import type { + AgentAdapter, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, + AgentDetectionContext, +} from '../../adapters/AgentAdapter.js'; +import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../../utils/process.js'; +import { safeStat } from '../../utils/session.js'; +import { ClaudeSessionParser } from './ClaudeSessionParser.js'; +import { ClaudeAgentMapper } from './ClaudeAgentMapper.js'; +import { ClaudeSessionLocator } from './ClaudeSessionLocator.js'; + +/** + * Claude Code Adapter + * + * Detects Claude Code agents by: + * 1. Filtering Claude processes from a shared asynchronous process snapshot + * 2. Using snapshot CWD and start-time enrichment + * 3. Attempting authoritative PID-file matching via ~/.claude/sessions/.json + * 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file + * 5. Extracting summary from last user message in session JSONL + */ +export class ClaudeCodeAdapter implements AgentAdapter { + readonly type = 'claude' as const; + readonly processNames = ['claude'] as const; + + private parser: ClaudeSessionParser; + private mapper: ClaudeAgentMapper; + private projectsDir: string; + private sessionsDir: string; + + constructor() { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.projectsDir = path.join(homeDir, '.claude', 'projects'); + this.sessionsDir = path.join(homeDir, '.claude', 'sessions'); + this.parser = new ClaudeSessionParser(); + this.mapper = new ClaudeAgentMapper(this.parser); + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isClaudeExecutable(processInfo.command); + } + + private isClaudeExecutable(command: string): boolean { + const base = executableBasename(command); + return base === 'claude' || base === 'claude.exe'; + } + + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); + if (processes.length === 0) { + return []; + } + + const { direct, legacyMatches } = this.createLocator().matchRunningProcesses(processes); + + const matchedPids = new Set([ + ...direct.map((d) => d.process.pid), + ...legacyMatches.map((m) => m.process.pid), + ]); + + const agents: AgentInfo[] = []; + + // Build agents from direct (resume + PID-file) matches + for (const match of direct) { + const { process: proc, sessionFile } = match; + const sessionData = this.parser.readSession(sessionFile.filePath, sessionFile.resolvedCwd); + if (sessionData) { + agents.push(this.mapper.mapSessionToAgent({ + session: sessionData, + processInfo: proc, + sessionFile, + liveInfo: { + pidStatus: match.pidStatus, + waitingFor: match.waitingFor, + }, + })); + } else { + matchedPids.delete(proc.pid); + } + } + + // Build agents from legacy matches + for (const match of legacyMatches) { + const sessionData = this.parser.readSession( + match.session.filePath, + match.session.resolvedCwd, + ); + if (sessionData) { + agents.push(this.mapper.mapSessionToAgent({ + session: sessionData, + processInfo: match.process, + sessionFile: match.session, + })); + } else { + matchedPids.delete(match.process.pid); + } + } + + // Any process with no match (direct or legacy) appears as IDLE + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapper.mapProcessOnlyAgent(proc)); + } + } + + return agents; + } + + private createLocator(): ClaudeSessionLocator { + return new ClaudeSessionLocator({ + projectsDir: this.projectsDir, + sessionsDir: this.sessionsDir, + }); + } + + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + return this.parser.getConversation(sessionFilePath, options); + } + + async listSessions(opts?: ListSessionsOptions): Promise { + const filterCwd = opts?.cwd; + const candidates = this.createLocator().discoverHistoricalSessionFiles(); + const summaries: SessionSummary[] = []; + + for (const { filePath, defaultCwd } of candidates) { + const session = this.parser.readSession(filePath, defaultCwd); + if (!session) continue; + + // Drop sessions whose JSONL had no parseable conversation entries. + // readSession is permissive (returns a shell record even when every + // line fails to parse); listSessions needs at least one real entry + // so we don't surface garbage files. + if (!session.lastEntryType) continue; + + const recordedCwd = session.lastCwd || defaultCwd; + if (filterCwd !== undefined && recordedCwd !== filterCwd) continue; + + const stat = safeStat(filePath); + + summaries.push({ + type: 'claude', + sessionId: session.sessionId, + cwd: recordedCwd, + firstUserMessage: session.firstUserMessage || '', + lastActive: session.lastActive ?? stat?.mtime ?? new Date(), + startedAt: session.sessionStart ?? stat?.birthtime ?? stat?.mtime ?? new Date(), + sessionFilePath: filePath, + }); + } + + return summaries; + } +} diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts similarity index 55% rename from packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts rename to packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts index a6dc7803..1290dd90 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts @@ -1,21 +1,16 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { - AgentAdapter, - AgentInfo, - ProcessInfo, - ConversationMessage, - SessionSummary, - ListSessionsOptions, - AgentDetectionContext, -} from './AgentAdapter.js'; -import { AgentStatus } from './AgentAdapter.js'; -import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js'; -import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session.js'; -import type { SessionFile } from '../utils/session.js'; -import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; -import { ClaudeSessionParser } from '../utils/ClaudeSessionParser.js'; -import type { ClaudeSession } from '../utils/ClaudeSessionParser.js'; +import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; +import { AgentStatus } from '../../adapters/AgentAdapter.js'; +import { matchProcessesToSessions, type MatchResult } from '../../utils/matching.js'; +import { + batchGetSessionFileBirthtimes, + isDirectory, + listJsonl, + safeReaddir, + safeStat, + type SessionFile, +} from '../../utils/session.js'; /** * Entry in ~/.claude/sessions/.json written by Claude Code. @@ -44,120 +39,113 @@ interface PidFileEntry { * A process directly matched to a session via PID file (authoritative path). * * When the matching PID file also exposes live status/waitingFor metadata, - * those values are carried here so `mapSessionToAgent` can prefer them - * over the JSONL-derived heuristic. + * those values are carried here so the agent mapper can prefer them over + * the JSONL-derived heuristic. */ -interface DirectMatch { +export interface ClaudeDirectMatch { process: ProcessInfo; sessionFile: SessionFile; pidStatus?: AgentStatus; waitingFor?: string; } +export interface ClaudeProcessSessionMatches { + direct: ClaudeDirectMatch[]; + legacyMatches: MatchResult[]; +} + +export interface ClaudeSessionLocatorOptions { + projectsDir?: string; + sessionsDir?: string; +} + /** Maximum allowed delta (ms) between process start time and PID file startedAt. */ const PID_FILE_STALENESS_MS = 60000; -/** - * Claude Code Adapter - * - * Detects Claude Code agents by: - * 1. Filtering Claude processes from a shared asynchronous process snapshot - * 2. Using snapshot CWD and start-time enrichment - * 3. Attempting authoritative PID-file matching via ~/.claude/sessions/.json - * 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file - * 5. Extracting summary from last user message in session JSONL - */ -export class ClaudeCodeAdapter implements AgentAdapter { - readonly type = 'claude' as const; - readonly processNames = ['claude'] as const; +export class ClaudeSessionLocator { + private readonly projectsDir: string; + private readonly sessionsDir: string; - private projectsDir: string; - private sessionsDir: string; - private parser: ClaudeSessionParser; - - constructor() { + constructor(options: ClaudeSessionLocatorOptions = {}) { const homeDir = process.env.HOME || process.env.USERPROFILE || ''; - this.projectsDir = path.join(homeDir, '.claude', 'projects'); - this.sessionsDir = path.join(homeDir, '.claude', 'sessions'); - this.parser = new ClaudeSessionParser(); + this.projectsDir = options.projectsDir ?? path.join(homeDir, '.claude', 'projects'); + this.sessionsDir = options.sessionsDir ?? path.join(homeDir, '.claude', 'sessions'); } - canHandle(processInfo: ProcessInfo): boolean { - return this.isClaudeExecutable(processInfo.command); - } - - private isClaudeExecutable(command: string): boolean { - const base = executableBasename(command); - return base === 'claude' || base === 'claude.exe'; - } - - async detectAgents(context?: AgentDetectionContext): Promise { - const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames); - const relevant = filterByProcessNames(snapshot, this.processNames); - const processes = relevant.filter((process) => this.canHandle(process)); - if (processes.length === 0) { - return []; - } - - // Step 1: extract `--resume ` from command line — authoritative for - // resumed sessions where the JSONL predates the process and PID-file/ - // birthtime heuristics can't match it. + /** + * Pair live Claude processes with their session files. + * + * Runs three staged strategies, each handing its unmatched processes + * to the next: + * 1. `--resume ` on the command line — authoritative for resumed + * sessions whose JSONL predates the process. + * 2. PID-file matching via ~/.claude/sessions/.json. + * 3. Legacy CWD+birthtime heuristic, for processes with no PID file. + */ + matchRunningProcesses(processes: ProcessInfo[]): ClaudeProcessSessionMatches { const { direct: resumeDirect, fallback: noResume } = this.tryResumeMatching(processes); - - // Step 2: try authoritative PID-file matching for the rest const { direct: pidDirect, fallback } = this.tryPidFileMatching(noResume); - - const direct = [...resumeDirect, ...pidDirect]; - - // Step 3: run legacy CWD+birthtime matching only for processes without a PID file - const legacySessions = this.discoverSessions(fallback); + const legacySessions = this.discoverLiveSessions(fallback); const legacyMatches = fallback.length > 0 && legacySessions.length > 0 ? matchProcessesToSessions(fallback, legacySessions) : []; - const matchedPids = new Set([ - ...direct.map((d) => d.process.pid), - ...legacyMatches.map((m) => m.process.pid), - ]); - - const agents: AgentInfo[] = []; - - // Build agents from direct (resume + PID-file) matches - for (const match of direct) { - const { process: proc, sessionFile } = match; - const sessionData = this.parser.readSession(sessionFile.filePath, sessionFile.resolvedCwd); - if (sessionData) { - agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile, { - pidStatus: match.pidStatus, - waitingFor: match.waitingFor, - })); - } else { - matchedPids.delete(proc.pid); - } - } + return { + direct: [...resumeDirect, ...pidDirect], + legacyMatches, + }; + } - // Build agents from legacy matches - for (const match of legacyMatches) { - const sessionData = this.parser.readSession( - match.session.filePath, - match.session.resolvedCwd, - ); - if (sessionData) { - agents.push(this.mapSessionToAgent(sessionData, match.process, match.session)); - } else { - matchedPids.delete(match.process.pid); - } - } + /** + * Discover candidate session files for listing historical sessions. + * + * Always walks every subdirectory of `projectsDir`. We can't use the + * encoded-dir shortcut for the cwd-scoped path because Claude Code + * indexes session files by where the *process was launched*, not by + * the recorded `cwd` field inside the session — these diverge in + * worktrees and similar setups. The cwd filter is applied later + * against `session.lastCwd` so callers see exactly the sessions whose + * recorded cwd matches. + */ + discoverHistoricalSessionFiles(): Array<{ filePath: string; defaultCwd: string }> { + const out: Array<{ filePath: string; defaultCwd: string }> = []; - // Any process with no match (direct or legacy) appears as IDLE - for (const proc of processes) { - if (!matchedPids.has(proc.pid)) { - agents.push(this.mapProcessOnlyAgent(proc)); + if (!isDirectory(this.projectsDir)) return out; + + for (const dirName of safeReaddir(this.projectsDir)) { + const projectDir = path.join(this.projectsDir, dirName); + if (!isDirectory(projectDir)) continue; + + // Best-effort decode for the rare case session content has no + // recorded cwd: '-Users-foo-bar' → '/Users/foo/bar'. Lossy for + // paths containing '-'; session content's lastCwd overrides + // this when available. + const decoded = dirName.replace(/-/g, '/'); + for (const name of listJsonl(projectDir)) { + out.push({ filePath: path.join(projectDir, name), defaultCwd: decoded }); } } - return agents; + return out; + } + + /** + * Derive the Claude Code project directory for a given CWD. + * + * Claude Code encodes paths by replacing every non-alphanumeric + * character with '-', so '/', '_', '.', spaces, etc. all collapse: + * /Users/foo/bar → -Users-foo-bar + * /Users/foo/my_project → -Users-foo-my-project + * /Users/foo/.worktrees/x → -Users-foo--worktrees-x + * + * The encoding is lossy — multiple real paths can collide on the + * same encoded dir. Callers that need to disambiguate must read the + * `cwd` field inside each session JSONL. + */ + private getProjectDir(cwd: string): string { + const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); + return path.join(this.projectsDir, encoded); } /** @@ -167,7 +155,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { * ~/.claude/projects// directory, then gets session file birthtimes * via a single batched stat call across all directories. */ - private discoverSessions(processes: ProcessInfo[]): SessionFile[] { + discoverLiveSessions(processes: ProcessInfo[]): SessionFile[] { const dirToCwd = new Map(); for (const proc of processes) { @@ -203,10 +191,10 @@ export class ClaudeCodeAdapter implements AgentAdapter { * matcher can't pair them) and the PID file may also be misaligned. */ private tryResumeMatching(processes: ProcessInfo[]): { - direct: DirectMatch[]; + direct: ClaudeDirectMatch[]; fallback: ProcessInfo[]; } { - const direct: DirectMatch[] = []; + const direct: ClaudeDirectMatch[] = []; const fallback: ProcessInfo[] = []; for (const proc of processes) { @@ -312,10 +300,10 @@ export class ClaudeCodeAdapter implements AgentAdapter { * stale startedAt (>60s from proc.startTime), or missing JSONL. */ private tryPidFileMatching(processes: ProcessInfo[]): { - direct: DirectMatch[]; + direct: ClaudeDirectMatch[]; fallback: ProcessInfo[]; } { - const direct: DirectMatch[] = []; + const direct: ClaudeDirectMatch[] = []; const fallback: ProcessInfo[] = []; for (const proc of processes) { @@ -349,134 +337,4 @@ export class ClaudeCodeAdapter implements AgentAdapter { return { direct, fallback }; } - - /** - * Derive the Claude Code project directory for a given CWD. - * - * Claude Code encodes paths by replacing every non-alphanumeric - * character with '-', so '/', '_', '.', spaces, etc. all collapse: - * /Users/foo/bar → -Users-foo-bar - * /Users/foo/my_project → -Users-foo-my-project - * /Users/foo/.worktrees/x → -Users-foo--worktrees-x - * - * The encoding is lossy — multiple real paths can collide on the - * same encoded dir. Callers that need to disambiguate must read the - * `cwd` field inside each session JSONL. - */ - private getProjectDir(cwd: string): string { - const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); - return path.join(this.projectsDir, encoded); - } - - private mapSessionToAgent( - session: ClaudeSession, - processInfo: ProcessInfo, - sessionFile: SessionFile, - liveInfo?: { pidStatus?: AgentStatus; waitingFor?: string }, - ): AgentInfo { - // Live PID-file status is authoritative when present — JSONL-derived - // status mis-classifies sessions whose latest entry is a UI-state - // event like `permission-mode` or `ai-title`. - const status = liveInfo?.pidStatus ?? this.parser.determineStatus(session); - const baseSummary = session.lastUserMessage || 'Session started'; - const summary = status === AgentStatus.WAITING && liveInfo?.waitingFor - ? `${baseSummary} — waiting for ${liveInfo.waitingFor}` - : baseSummary; - - return { - name: generateAgentName(processInfo.cwd, processInfo.pid), - type: this.type, - status, - summary, - pid: processInfo.pid, - projectPath: sessionFile.resolvedCwd || processInfo.cwd || '', - sessionId: sessionFile.sessionId, - lastActive: session.lastActive, - sessionFilePath: sessionFile.filePath, - }; - } - - private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { - return { - name: generateAgentName(processInfo.cwd || '', processInfo.pid), - type: this.type, - status: AgentStatus.IDLE, - summary: 'Unknown', - pid: processInfo.pid, - projectPath: processInfo.cwd || '', - sessionId: `pid-${processInfo.pid}`, - lastActive: new Date(), - }; - } - - getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { - return this.parser.getConversation(sessionFilePath, options); - } - - async listSessions(opts?: ListSessionsOptions): Promise { - const filterCwd = opts?.cwd; - const candidates = this.discoverSessionFiles(); - const summaries: SessionSummary[] = []; - - for (const { filePath, defaultCwd } of candidates) { - const session = this.parser.readSession(filePath, defaultCwd); - if (!session) continue; - - // Drop sessions whose JSONL had no parseable conversation entries. - // readSession is permissive (returns a shell record even when every - // line fails to parse); listSessions needs at least one real entry - // so we don't surface garbage files. - if (!session.lastEntryType) continue; - - const recordedCwd = session.lastCwd || defaultCwd; - if (filterCwd !== undefined && recordedCwd !== filterCwd) continue; - - const stat = safeStat(filePath); - - summaries.push({ - type: 'claude', - sessionId: session.sessionId, - cwd: recordedCwd, - firstUserMessage: session.firstUserMessage || '', - lastActive: session.lastActive ?? stat?.mtime ?? new Date(), - startedAt: session.sessionStart ?? stat?.birthtime ?? stat?.mtime ?? new Date(), - sessionFilePath: filePath, - }); - } - - return summaries; - } - - /** - * Discover candidate session files for {@link listSessions}. - * - * Always walks every subdirectory of `projectsDir`. We can't use the - * encoded-dir shortcut for the cwd-scoped path because Claude Code - * indexes session files by where the *process was launched*, not by - * the recorded `cwd` field inside the session — these diverge in - * worktrees and similar setups. The cwd filter is applied later - * against `session.lastCwd` so callers see exactly the sessions whose - * recorded cwd matches. - */ - private discoverSessionFiles(): Array<{ filePath: string; defaultCwd: string }> { - const out: Array<{ filePath: string; defaultCwd: string }> = []; - - if (!isDirectory(this.projectsDir)) return out; - - for (const dirName of safeReaddir(this.projectsDir)) { - const projectDir = path.join(this.projectsDir, dirName); - if (!isDirectory(projectDir)) continue; - - // Best-effort decode for the rare case session content has no - // recorded cwd: '-Users-foo-bar' → '/Users/foo/bar'. Lossy for - // paths containing '-'; session content's lastCwd overrides - // this when available. - const decoded = dirName.replace(/-/g, '/'); - for (const name of listJsonl(projectDir)) { - out.push({ filePath: path.join(projectDir, name), defaultCwd: decoded }); - } - } - - return out; - } } diff --git a/packages/agent-manager/src/utils/ClaudeSessionParser.ts b/packages/agent-manager/src/providers/claude/ClaudeSessionParser.ts similarity index 99% rename from packages/agent-manager/src/utils/ClaudeSessionParser.ts rename to packages/agent-manager/src/providers/claude/ClaudeSessionParser.ts index 775199ff..b82e26c4 100644 --- a/packages/agent-manager/src/utils/ClaudeSessionParser.ts +++ b/packages/agent-manager/src/providers/claude/ClaudeSessionParser.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { ConversationMessage } from '../adapters/AgentAdapter.js'; -import { AgentStatus } from '../adapters/AgentAdapter.js'; +import type { ConversationMessage } from '../../adapters/AgentAdapter.js'; +import { AgentStatus } from '../../adapters/AgentAdapter.js'; /** * Content block within a Claude Code JSONL message entry. diff --git a/packages/agent-manager/src/durable/ClaudeCliProbe.ts b/packages/agent-manager/src/providers/claude/durable/ClaudeCliProbe.ts similarity index 96% rename from packages/agent-manager/src/durable/ClaudeCliProbe.ts rename to packages/agent-manager/src/providers/claude/durable/ClaudeCliProbe.ts index faf2435a..2f5533be 100644 --- a/packages/agent-manager/src/durable/ClaudeCliProbe.ts +++ b/packages/agent-manager/src/providers/claude/durable/ClaudeCliProbe.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process'; import { promisify } from 'util'; -import { ClaudePrintError } from './DurableAgent.js'; +import { ClaudePrintError } from '../../../durable/DurableAgent.js'; type ExecResult = { stdout: string; stderr: string }; type Exec = (file: string, args: string[]) => Promise; diff --git a/packages/agent-manager/src/durable/ClaudePrintAgentService.ts b/packages/agent-manager/src/providers/claude/durable/ClaudePrintAgentService.ts similarity index 93% rename from packages/agent-manager/src/durable/ClaudePrintAgentService.ts rename to packages/agent-manager/src/providers/claude/durable/ClaudePrintAgentService.ts index fa875ac7..8e6c68e3 100644 --- a/packages/agent-manager/src/durable/ClaudePrintAgentService.ts +++ b/packages/agent-manager/src/providers/claude/durable/ClaudePrintAgentService.ts @@ -1,8 +1,8 @@ -import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; -import { ClaudePrintError, DurableAgentNotFoundError } from './DurableAgent.js'; +import type { DurableAgent, ProcessIdentity } from '../../../durable/DurableAgent.js'; +import { ClaudePrintError, DurableAgentNotFoundError } from '../../../durable/DurableAgent.js'; import { ClaudeCliProbe } from './ClaudeCliProbe.js'; import { ClaudePrintRunner, type ClaudePrintRunResult } from './ClaudePrintRunner.js'; -import { DurableAgentRepository, type CreateDurableAgentInput, type DurableRunCompletion } from './DurableAgentRepository.js'; +import { DurableAgentRepository, type CreateDurableAgentInput, type DurableRunCompletion } from '../../../durable/DurableAgentRepository.js'; interface RepositoryLike { create(input: CreateDurableAgentInput): Promise; diff --git a/packages/agent-manager/src/durable/ClaudePrintRunner.ts b/packages/agent-manager/src/providers/claude/durable/ClaudePrintRunner.ts similarity index 95% rename from packages/agent-manager/src/durable/ClaudePrintRunner.ts rename to packages/agent-manager/src/providers/claude/durable/ClaudePrintRunner.ts index 5e248372..5432d073 100644 --- a/packages/agent-manager/src/durable/ClaudePrintRunner.ts +++ b/packages/agent-manager/src/providers/claude/durable/ClaudePrintRunner.ts @@ -1,7 +1,7 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; -import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; -import { ClaudePrintError } from './DurableAgent.js'; -import { LocalProcessInspector, type ProcessInspector } from './DurableAgentRepository.js'; +import type { DurableAgent, ProcessIdentity } from '../../../durable/DurableAgent.js'; +import { ClaudePrintError } from '../../../durable/DurableAgent.js'; +import { LocalProcessInspector, type ProcessInspector } from '../../../durable/DurableAgentRepository.js'; type Spawn = ( command: string,