From 771b9f1d8bca2ade6d20ff9d58ae083913c043f7 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 23 Aug 2026 17:23:54 +0200 Subject: [PATCH 1/4] refactor(agent-manager): organize Claude provider code --- ...-08-23-feature-claude-provider-refactor.md | 165 ++++++ ...-08-23-feature-claude-provider-refactor.md | 118 +++++ ...-08-23-feature-claude-provider-refactor.md | 108 ++++ ...-08-23-feature-claude-provider-refactor.md | 108 ++++ ...-08-23-feature-claude-provider-refactor.md | 123 +++++ .../claude/ClaudeAgentMapper.test.ts | 84 +++ .../claude/ClaudeSessionLocator.test.ts | 75 +++ .../src/adapters/ClaudeCodeAdapter.ts | 483 +----------------- .../src/durable/ClaudeCliProbe.ts | 60 +-- .../src/durable/ClaudePrintAgentService.ts | 99 +--- .../src/durable/ClaudePrintRunner.ts | 145 +----- .../src/providers/claude/ClaudeAgentMapper.ts | 59 +++ .../src/providers/claude/ClaudeCodeAdapter.ts | 177 +++++++ .../providers/claude/ClaudeSessionLocator.ts | 240 +++++++++ .../providers/claude/ClaudeSessionParser.ts | 448 ++++++++++++++++ .../claude/durable/ClaudeCliProbe.ts | 58 +++ .../claude/durable/ClaudePrintAgentService.ts | 94 ++++ .../claude/durable/ClaudePrintRunner.ts | 139 +++++ .../src/utils/ClaudeSessionParser.ts | 456 +---------------- 19 files changed, 2018 insertions(+), 1221 deletions(-) create mode 100644 docs/ai/design/2026-08-23-feature-claude-provider-refactor.md create mode 100644 docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md create mode 100644 docs/ai/planning/2026-08-23-feature-claude-provider-refactor.md create mode 100644 docs/ai/requirements/2026-08-23-feature-claude-provider-refactor.md create mode 100644 docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md create mode 100644 packages/agent-manager/src/__tests__/providers/claude/ClaudeAgentMapper.test.ts create mode 100644 packages/agent-manager/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts create mode 100644 packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts create mode 100644 packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts create mode 100644 packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts create mode 100644 packages/agent-manager/src/providers/claude/ClaudeSessionParser.ts create mode 100644 packages/agent-manager/src/providers/claude/durable/ClaudeCliProbe.ts create mode 100644 packages/agent-manager/src/providers/claude/durable/ClaudePrintAgentService.ts create mode 100644 packages/agent-manager/src/providers/claude/durable/ClaudePrintRunner.ts 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..867c9530 --- /dev/null +++ b/docs/ai/design/2026-08-23-feature-claude-provider-refactor.md @@ -0,0 +1,165 @@ +--- +phase: design +title: Claude Provider Refactor Design +description: Provider-local Claude module boundary with compatibility exports +--- + +# Claude Provider Refactor Design + +## Architecture Overview + +The refactor introduces a Claude provider-local implementation area while preserving current public adapter and durable exports. + +```mermaid +graph TD + PublicIndex["src/index.ts"] --> AdapterCompat["src/adapters/ClaudeCodeAdapter.ts"] + AdapterIndex["src/adapters/index.ts"] --> AdapterCompat + AdapterCompat --> ClaudeAdapter["src/providers/claude/ClaudeCodeAdapter.ts"] + + ClaudeAdapter --> Locator["ClaudeSessionLocator"] + ClaudeAdapter --> Parser["ClaudeSessionParser"] + ClaudeAdapter --> Mapper["ClaudeAgentMapper"] + ClaudeAdapter --> SharedProcess["utils/process"] + ClaudeAdapter --> SharedMatching["utils/matching"] + + DurableExports["src/durable/*.ts compatibility exports"] --> 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 + types.ts + durable/ + ClaudeCliProbe.ts + ClaudePrintRunner.ts + ClaudePrintAgentService.ts + adapters/ + ClaudeCodeAdapter.ts compatibility export + durable/ + ClaudeCliProbe.ts compatibility export or wrapper + ClaudePrintRunner.ts compatibility export or wrapper + ClaudePrintAgentService.ts compatibility export or wrapper +``` + +## 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 API change. + +Existing exports stay valid: + +```ts +export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js'; +export { ClaudeCliProbe } from './durable/ClaudeCliProbe.js'; +export { ClaudePrintRunner } from './durable/ClaudePrintRunner.js'; +export { ClaudePrintAgentService } from './durable/ClaudePrintAgentService.js'; +``` + +### 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..d47dd13a --- /dev/null +++ b/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md @@ -0,0 +1,118 @@ +--- +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/ +``` + +Compatibility paths must remain: + +```text +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 +``` + +## 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 + +- Use compatibility re-exports instead of deleting old paths. +- 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`. +- `AgentManager` continues working through `AgentAdapter`. +- `ClaudeCodeAdapter` continues using shared process snapshot filtering. +- Durable service continues using `DurableAgentRepository`, `ClaudeCliProbe`, and `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. +- Compatibility wrappers 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`. + - Kept `packages/agent-manager/src/utils/ClaudeSessionParser.ts` as a compatibility export. + - Focused parser validation passed with 19 tests. +- Moved `ClaudeCodeAdapter` implementation to `packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts`. + - Kept `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` as a compatibility export. + - 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. + - Kept adapter private compatibility proxies for existing tests that mutate fixture directories. +- Moved Claude durable execution implementations under `packages/agent-manager/src/providers/claude/durable/`. + - Kept old `packages/agent-manager/src/durable/Claude*.ts` paths as compatibility exports. + - Claude print-mode focused validation passed with 4 test files and 8 tests. +- 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 remain for `discoverSessions`, `tryPidFileMatching`, and `getProjectDir` because the existing test suite exercises those hooks. They delegate to `ClaudeSessionLocator` and are not public package contracts. 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..5d8da974 --- /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 behind compatibility exports. +- [x] Milestone 4: Claude session locator and agent mapper extracted with focused tests. +- [x] Milestone 5: Claude durable implementation moved behind compatibility exports. +- [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` behind the old `utils/ClaudeSessionParser.ts` export. + - Outcome: existing parser tests and imports remain valid. + - Validation: `ClaudeSessionParser` tests pass. + - Testing scenarios: parser compatibility. +- [x] Task 1.3: Move `ClaudeCodeAdapter` implementation behind `src/adapters/ClaudeCodeAdapter.ts` compatibility export. + - Outcome: public and adapter barrel exports remain valid. + - 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/` with old `src/durable/*` paths as compatibility exports, if the move is low-risk after Phase 2. + - Outcome: `ClaudeCliProbe`, `ClaudePrintRunner`, and `ClaudePrintAgentService` are provider-local while public exports remain unchanged. + - 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`, `src/adapters/index.ts`, and `src/durable/*` 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:** Compatibility 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/` with compatibility re-exports preserving old adapter, utility, and durable paths. 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..cceb9f5e --- /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. +- Deleting compatibility re-exports during the first refactor. + +## 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` and `src/adapters/ClaudeCodeAdapter.js` 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 `ClaudeCodeAdapter` from adapter paths must continue to compile. +- 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 compatibility exports preserving existing import paths. +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`. +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..c6688646 --- /dev/null +++ b/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md @@ -0,0 +1,123 @@ +--- +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 `ClaudeCliProbe`, `ClaudePrintRunner`, and `ClaudePrintAgentService` tests continue to pass through compatibility exports. +- [x] If files move, tests import from the same public paths unless a new provider-local test is more direct. + +## 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 compatibility wrappers are introduced. + +## 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 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__/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..ff8542c6 --- /dev/null +++ b/packages/agent-manager/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts @@ -0,0 +1,75 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AgentStatus, type ProcessInfo } from '../../../adapters/AgentAdapter.js'; +import { ClaudeSessionLocator } from '../../../providers/claude/ClaudeSessionLocator.js'; + +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, + }; +} + +describe('ClaudeSessionLocator', () => { + 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, + }, + }); + }); +}); diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index a6dc7803..ef657984 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -1,482 +1 @@ -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'; - -/** - * Entry in ~/.claude/sessions/.json written by Claude Code. - * Maps a running process to its session file via PID. - */ -interface PidFileEntry { - pid: number; - sessionId: string; - cwd: string; - /** Epoch milliseconds when the Claude Code process started */ - startedAt: number; - kind: string; - entrypoint: string; - /** - * Authoritative live status published by the Claude Code process - * (e.g., 'running', 'waiting', 'idle'). Preferred over JSONL-derived - * status because trailing entries like 'permission-mode' / 'ai-title' - * can mask the real conversational state. - */ - status?: string; - /** Short description of what the agent is waiting on (e.g., "approve Read"). */ - waitingFor?: string; -} - -/** - * 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. - */ -interface DirectMatch { - process: ProcessInfo; - sessionFile: SessionFile; - pidStatus?: AgentStatus; - waitingFor?: 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; - - private projectsDir: string; - private sessionsDir: string; - private parser: ClaudeSessionParser; - - 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(); - } - - 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. - 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 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); - } - } - - // 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); - } - } - - // 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)); - } - } - - return agents; - } - - /** - * Discover session files for the given processes. - * - * For each unique process CWD, encodes it to derive the expected - * ~/.claude/projects// directory, then gets session file birthtimes - * via a single batched stat call across all directories. - */ - private discoverSessions(processes: ProcessInfo[]): SessionFile[] { - const dirToCwd = new Map(); - - for (const proc of processes) { - if (!proc.cwd) continue; - - const projectDir = this.getProjectDir(proc.cwd); - if (dirToCwd.has(projectDir)) continue; - - try { - if (!fs.statSync(projectDir).isDirectory()) continue; - } catch { - continue; - } - - dirToCwd.set(projectDir, proc.cwd); - } - - if (dirToCwd.size === 0) return []; - - const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]); - - for (const file of files) { - file.resolvedCwd = dirToCwd.get(file.projectDir) || ''; - } - - return files; - } - - /** - * Match processes via `claude --resume ` in their command line. - * This works for resumed sessions, where the JSONL was created earlier - * (so its birthtime is far from the process startTime and the legacy - * matcher can't pair them) and the PID file may also be misaligned. - */ - private tryResumeMatching(processes: ProcessInfo[]): { - direct: DirectMatch[]; - fallback: ProcessInfo[]; - } { - const direct: DirectMatch[] = []; - const fallback: ProcessInfo[] = []; - - for (const proc of processes) { - const sessionId = this.extractResumeSessionId(proc.command); - if (!sessionId || !proc.cwd) { - fallback.push(proc); - continue; - } - - const projectDir = this.getProjectDir(proc.cwd); - const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); - - const stat = safeStat(jsonlPath); - if (!stat) { - fallback.push(proc); - continue; - } - - // Best-effort: the PID file (if present for this proc) is the - // authoritative source of live status. We still match the session - // via --resume, but we read the PID file alongside to capture - // status/waitingFor. - const pidEntry = this.readMatchingPidFile(proc.pid, proc.startTime); - - direct.push({ - process: proc, - sessionFile: { - sessionId, - filePath: jsonlPath, - projectDir, - birthtimeMs: stat.birthtimeMs, - resolvedCwd: proc.cwd, - }, - pidStatus: this.mapPidStatus(pidEntry?.status), - waitingFor: pidEntry?.waitingFor, - }); - } - - return { direct, fallback }; - } - - private extractResumeSessionId(command: string): string | null { - const match = command.match(/--resume\s+([0-9a-f-]{36})/i); - return match?.[1] ?? null; - } - - /** - * Read and parse ~/.claude/sessions/.json, returning null on any - * I/O / parse failure or when the file is stale relative to the live - * process. - * - * "Stale" means the PID file's startedAt diverges from the process's - * start time by more than {@link PID_FILE_STALENESS_MS} — typically - * a previous Claude Code process recycled the same PID without cleanup. - */ - private readMatchingPidFile(pid: number, procStartTime?: Date): PidFileEntry | null { - const pidFilePath = path.join(this.sessionsDir, `${pid}.json`); - try { - const entry = JSON.parse( - fs.readFileSync(pidFilePath, 'utf-8'), - ) as PidFileEntry; - - if (procStartTime) { - const deltaMs = Math.abs(procStartTime.getTime() - entry.startedAt); - if (deltaMs > PID_FILE_STALENESS_MS) { - return null; - } - } - - return entry; - } catch { - return null; - } - } - - /** - * Map the PID file's live status string to {@link AgentStatus}. - * - * Returns undefined for missing / unrecognized values so the caller - * can fall back to JSONL-derived heuristics. - */ - private mapPidStatus(status: string | undefined): AgentStatus | undefined { - switch (status) { - case 'running': - return AgentStatus.RUNNING; - case 'waiting': - return AgentStatus.WAITING; - case 'idle': - return AgentStatus.IDLE; - default: - return undefined; - } - } - - /** - * Attempt to match each process to its session via ~/.claude/sessions/.json. - * - * Returns: - * direct — processes matched authoritatively via PID file - * fallback — processes with no valid PID file (sent to legacy matching) - * - * Per-process fallback triggers on: file absent, malformed JSON, - * stale startedAt (>60s from proc.startTime), or missing JSONL. - */ - private tryPidFileMatching(processes: ProcessInfo[]): { - direct: DirectMatch[]; - fallback: ProcessInfo[]; - } { - const direct: DirectMatch[] = []; - const fallback: ProcessInfo[] = []; - - for (const proc of processes) { - const entry = this.readMatchingPidFile(proc.pid, proc.startTime); - if (!entry) { - fallback.push(proc); - continue; - } - - const projectDir = this.getProjectDir(entry.cwd); - const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`); - - if (!fs.existsSync(jsonlPath)) { - fallback.push(proc); - continue; - } - - direct.push({ - process: proc, - sessionFile: { - sessionId: entry.sessionId, - filePath: jsonlPath, - projectDir, - birthtimeMs: entry.startedAt, - resolvedCwd: entry.cwd, - }, - pidStatus: this.mapPidStatus(entry.status), - waitingFor: entry.waitingFor, - }); - } - - 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; - } -} +export { ClaudeCodeAdapter } from '../providers/claude/ClaudeCodeAdapter.js'; diff --git a/packages/agent-manager/src/durable/ClaudeCliProbe.ts b/packages/agent-manager/src/durable/ClaudeCliProbe.ts index faf2435a..6784c72f 100644 --- a/packages/agent-manager/src/durable/ClaudeCliProbe.ts +++ b/packages/agent-manager/src/durable/ClaudeCliProbe.ts @@ -1,58 +1,2 @@ -import { execFile } from 'child_process'; -import { promisify } from 'util'; -import { ClaudePrintError } from './DurableAgent.js'; - -type ExecResult = { stdout: string; stderr: string }; -type Exec = (file: string, args: string[]) => Promise; - -const execFileAsync = promisify(execFile); -const REQUIRED = ['--print', '--session-id', '--resume', '--output-format', 'stream-json']; - -export interface ClaudeCliProbeOptions { - executable?: string; - exec?: Exec; -} - -export class ClaudeCliProbe { - private readonly executable: string; - private readonly exec: Exec; - - constructor(options: ClaudeCliProbeOptions = {}) { - this.executable = options.executable ?? 'claude'; - this.exec = options.exec ?? (async (file, args) => { - const result = await execFileAsync(file, args, { encoding: 'utf8', maxBuffer: 1024 * 1024 }); - return { stdout: result.stdout, stderr: result.stderr }; - }); - } - - async validate(): Promise<{ executable: string; version: string }> { - try { - const versionResult = await this.exec(this.executable, ['--version']); - const helpResult = await this.exec(this.executable, ['--help']); - const missing = REQUIRED.filter((capability) => !helpResult.stdout.includes(capability)); - if (missing.length > 0) { - throw new ClaudePrintError( - `Claude CLI does not support required print-mode capabilities: ${missing.join(', ')}.`, - 'CLAUDE_CLI_UNSUPPORTED', - ); - } - return { - executable: this.executable, - version: sanitize(versionResult.stdout, 256) || 'unknown', - }; - } catch (error) { - if (error instanceof ClaudePrintError) throw error; - throw new ClaudePrintError( - `Claude CLI validation failed: ${sanitize((error as Error).message, 512)}`, - 'CLAUDE_CLI_UNAVAILABLE', - ); - } - } -} - -function sanitize(value: string, max: number): string { - return Array.from(value, (character) => { - const code = character.charCodeAt(0); - return code <= 31 || code === 127 ? ' ' : character; - }).join('').trim().slice(0, max); -} +export { ClaudeCliProbe } from '../providers/claude/durable/ClaudeCliProbe.js'; +export type { ClaudeCliProbeOptions } from '../providers/claude/durable/ClaudeCliProbe.js'; diff --git a/packages/agent-manager/src/durable/ClaudePrintAgentService.ts b/packages/agent-manager/src/durable/ClaudePrintAgentService.ts index fa875ac7..4b026373 100644 --- a/packages/agent-manager/src/durable/ClaudePrintAgentService.ts +++ b/packages/agent-manager/src/durable/ClaudePrintAgentService.ts @@ -1,94 +1,5 @@ -import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; -import { ClaudePrintError, DurableAgentNotFoundError } from './DurableAgent.js'; -import { ClaudeCliProbe } from './ClaudeCliProbe.js'; -import { ClaudePrintRunner, type ClaudePrintRunResult } from './ClaudePrintRunner.js'; -import { DurableAgentRepository, type CreateDurableAgentInput, type DurableRunCompletion } from './DurableAgentRepository.js'; - -interface RepositoryLike { - create(input: CreateDurableAgentInput): Promise; - list(): Promise; - resolve(reference: string): Promise; - acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }>; - recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; - completeRun(id: string, token: string, result: DurableRunCompletion): Promise; -} - -interface ProbeLike { validate(): Promise<{ executable: string; version: string }> } -interface RunnerLike { run(request: Parameters[0]): Promise } - -export interface ClaudePrintAgentServiceOptions { - repository?: RepositoryLike; - probe?: ProbeLike; - runner?: RunnerLike; - executable?: string; -} - -export interface ClaudePrintSendResult extends ClaudePrintRunResult { - agentId: string; - agentName: string; -} - -export class ClaudePrintAgentService { - readonly repository: RepositoryLike; - private readonly probe: ProbeLike; - private readonly runner: RunnerLike; - private readonly executable?: string; - - constructor(options: ClaudePrintAgentServiceOptions = {}) { - this.repository = options.repository ?? new DurableAgentRepository(); - this.probe = options.probe ?? new ClaudeCliProbe(); - this.runner = options.runner ?? new ClaudePrintRunner(); - this.executable = options.executable; - } - - async create(input: CreateDurableAgentInput): Promise { - await this.probe.validate(); - return this.repository.create(input); - } - - async send(reference: string, prompt: string): Promise { - const resolved = await this.repository.resolve(reference); - if (!resolved) throw new DurableAgentNotFoundError(reference); - if (Array.isArray(resolved)) { - throw new ClaudePrintError(`Multiple durable agents match "${reference}".`, 'DURABLE_AGENT_AMBIGUOUS'); - } - const acquired = await this.repository.acquireRun(resolved.id); - try { - const result = await this.runner.run({ - agent: acquired.agent, - prompt, - executable: this.executable, - firstRun: acquired.agent.sessionHealth === 'uninitialized', - onSpawn: (identity) => this.repository.recordProviderProcess(resolved.id, acquired.token, identity), - }); - await this.repository.completeRun(resolved.id, acquired.token, { - status: 'succeeded', - exitCode: result.exitCode, - summary: sanitize(result.result, 4096), - sessionHealth: 'healthy', - }); - return { ...result, agentId: resolved.id, agentName: resolved.name }; - } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)); - const sessionHealth = error instanceof ClaudePrintError && error.code === 'CLAUDE_SESSION_MISMATCH' - ? 'mismatch' as const - : 'unknown' as const; - await this.repository.completeRun(resolved.id, acquired.token, { - status: 'failed', - exitCode: null, - summary: sanitize(failure.message, 4096), - sessionHealth, - }); - throw error; - } - } -} - -function sanitize(value: string, max: number): string { - return Array.from(value, (character) => { - const code = character.charCodeAt(0); - return (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) - ? ' ' - : character; - }).join('').trim().slice(0, max); -} +export { ClaudePrintAgentService } from '../providers/claude/durable/ClaudePrintAgentService.js'; +export type { + ClaudePrintAgentServiceOptions, + ClaudePrintSendResult, +} from '../providers/claude/durable/ClaudePrintAgentService.js'; diff --git a/packages/agent-manager/src/durable/ClaudePrintRunner.ts b/packages/agent-manager/src/durable/ClaudePrintRunner.ts index 5e248372..ec5c3b92 100644 --- a/packages/agent-manager/src/durable/ClaudePrintRunner.ts +++ b/packages/agent-manager/src/durable/ClaudePrintRunner.ts @@ -1,139 +1,6 @@ -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'; - -type Spawn = ( - command: string, - args: readonly string[], - options: SpawnOptionsWithoutStdio & { stdio: ['pipe', 'pipe', 'pipe'] }, -) => ChildProcessWithoutNullStreams; - -export interface ClaudePrintRunRequest { - agent: DurableAgent; - prompt: string; - executable?: string; - firstRun: boolean; - onSpawn(identity: ProcessIdentity): Promise; -} - -export interface ClaudePrintRunResult { - sessionId: string; - result: string; - exitCode: number; -} - -export interface ClaudePrintRunnerOptions { - spawn?: Spawn; - processInspector?: ProcessInspector; - maxLineBytes?: number; -} - -export class ClaudePrintRunner { - private readonly spawn: Spawn; - private readonly processInspector: ProcessInspector; - private readonly maxLineBytes: number; - - constructor(options: ClaudePrintRunnerOptions = {}) { - this.spawn = options.spawn ?? (nodeSpawn as Spawn); - this.processInspector = options.processInspector ?? new LocalProcessInspector(); - this.maxLineBytes = options.maxLineBytes ?? 1024 * 1024; - } - - async run(request: ClaudePrintRunRequest): Promise { - const sessionArgs = request.firstRun - ? ['--session-id', request.agent.providerSessionId] - : ['--resume', request.agent.providerSessionId]; - const args = ['-p', ...sessionArgs, '--output-format', 'stream-json', '--verbose']; - const child = this.spawn(request.executable ?? 'claude', args, { - cwd: request.agent.cwd, - shell: false, - stdio: ['pipe', 'pipe', 'pipe'], - }); - if (!child.pid) { - child.kill(); - throw new ClaudePrintError('Claude process did not provide a PID.', 'CLAUDE_PROCESS_IDENTITY'); - } - const identity = this.processInspector.getIdentity(child.pid); - if (!identity) { - child.kill(); - throw new ClaudePrintError('Cannot verify Claude process identity.', 'CLAUDE_PROCESS_IDENTITY'); - } - - let buffer = Buffer.alloc(0); - let terminal: ClaudePrintRunResult | null = null; - let protocolError: ClaudePrintError | null = null; - - child.stdout.on('data', (chunk: Buffer | string) => { - if (protocolError) return; - buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); - if (buffer.length > this.maxLineBytes && buffer.indexOf(0x0a) < 0) { - protocolError = new ClaudePrintError('Claude stream line exceeded the safety limit.', 'CLAUDE_STREAM_OVERSIZED'); - return; - } - let newline: number; - while ((newline = buffer.indexOf(0x0a)) >= 0) { - const line = buffer.subarray(0, newline); - buffer = buffer.subarray(newline + 1); - if (line.length === 0) continue; - if (line.length > this.maxLineBytes) { - protocolError = new ClaudePrintError('Claude stream line exceeded the safety limit.', 'CLAUDE_STREAM_OVERSIZED'); - return; - } - try { - const value = JSON.parse(line.toString('utf8')) as unknown; - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new ClaudePrintError('Claude emitted a non-object stream message.', 'CLAUDE_STREAM_INVALID'); - } - const event = value as Record; - if (typeof event.session_id === 'string' && event.session_id !== request.agent.providerSessionId) { - throw new ClaudePrintError('Claude returned a different session identity.', 'CLAUDE_SESSION_MISMATCH'); - } - if (event.type === 'result') { - if (terminal) throw new ClaudePrintError('Claude emitted more than one terminal result.', 'CLAUDE_STREAM_INVALID'); - if (typeof event.session_id !== 'string' || typeof event.result !== 'string') { - throw new ClaudePrintError('Claude emitted an invalid terminal result.', 'CLAUDE_STREAM_INVALID'); - } - terminal = { sessionId: event.session_id, result: event.result, exitCode: 0 }; - } - } catch (error) { - protocolError = error instanceof ClaudePrintError - ? error - : new ClaudePrintError('Claude emitted malformed stream JSON.', 'CLAUDE_STREAM_INVALID'); - return; - } - } - }); - // Drain provider diagnostics without reflecting potentially sensitive prompt/tool data. - child.stderr.resume(); - - const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - child.once('error', reject); - child.once('close', (code, signal) => resolve({ code, signal })); - }); - - try { - await request.onSpawn(identity); - } catch (error) { - child.kill(); - throw error; - } - - child.stdin.end(request.prompt); - const { code, signal } = await closed; - - if (protocolError) throw protocolError; - if (buffer.length > 0) { - throw new ClaudePrintError('Claude stream ended with incomplete JSON.', 'CLAUDE_STREAM_INVALID'); - } - if (code !== 0) { - throw new ClaudePrintError( - `Claude print run failed${signal ? ` (${signal})` : '.'}`, - 'CLAUDE_PROCESS_FAILED', - ); - } - if (!terminal) throw new ClaudePrintError('Claude stream ended without a terminal result.', 'CLAUDE_RESULT_MISSING'); - const finalResult = terminal as ClaudePrintRunResult; - return { sessionId: finalResult.sessionId, result: finalResult.result, exitCode: code }; - } -} +export { ClaudePrintRunner } from '../providers/claude/durable/ClaudePrintRunner.js'; +export type { + ClaudePrintRunnerOptions, + ClaudePrintRunRequest, + ClaudePrintRunResult, +} from '../providers/claude/durable/ClaudePrintRunner.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..ad9d7a20 --- /dev/null +++ b/packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts @@ -0,0 +1,59 @@ +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 { + 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..d1f4dfcc --- /dev/null +++ b/packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts @@ -0,0 +1,177 @@ +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 type { SessionFile } from '../../utils/session.js'; +import { ClaudeSessionParser } from './ClaudeSessionParser.js'; +import { ClaudeAgentMapper } from './ClaudeAgentMapper.js'; +import { ClaudeSessionLocator, type ClaudeDirectMatch } 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, + }); + } + + private discoverSessions(processes: ProcessInfo[]): SessionFile[] { + return this.createLocator().discoverLiveSessions(processes); + } + + private tryPidFileMatching(processes: ProcessInfo[]): { + direct: ClaudeDirectMatch[]; + fallback: ProcessInfo[]; + } { + return this.createLocator().tryPidFileMatching(processes); + } + + private getProjectDir(cwd: string): string { + return this.createLocator().getProjectDir(cwd); + } + + 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/providers/claude/ClaudeSessionLocator.ts b/packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts new file mode 100644 index 00000000..aecbfd54 --- /dev/null +++ b/packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts @@ -0,0 +1,240 @@ +import * as fs from 'fs'; +import * as path from 'path'; +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'; + +interface PidFileEntry { + pid: number; + sessionId: string; + cwd: string; + startedAt: number; + kind: string; + entrypoint: string; + status?: string; + waitingFor?: string; +} + +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; +} + +const PID_FILE_STALENESS_MS = 60000; + +export class ClaudeSessionLocator { + private readonly projectsDir: string; + private readonly sessionsDir: string; + + constructor(options: ClaudeSessionLocatorOptions = {}) { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.projectsDir = options.projectsDir ?? path.join(homeDir, '.claude', 'projects'); + this.sessionsDir = options.sessionsDir ?? path.join(homeDir, '.claude', 'sessions'); + } + + matchRunningProcesses(processes: ProcessInfo[]): ClaudeProcessSessionMatches { + const { direct: resumeDirect, fallback: noResume } = this.tryResumeMatching(processes); + const { direct: pidDirect, fallback } = this.tryPidFileMatching(noResume); + const legacySessions = this.discoverLiveSessions(fallback); + const legacyMatches = + fallback.length > 0 && legacySessions.length > 0 + ? matchProcessesToSessions(fallback, legacySessions) + : []; + + return { + direct: [...resumeDirect, ...pidDirect], + legacyMatches, + }; + } + + discoverHistoricalSessionFiles(): 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; + + const decoded = dirName.replace(/-/g, '/'); + for (const name of listJsonl(projectDir)) { + out.push({ filePath: path.join(projectDir, name), defaultCwd: decoded }); + } + } + + return out; + } + + getProjectDir(cwd: string): string { + const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); + return path.join(this.projectsDir, encoded); + } + + discoverLiveSessions(processes: ProcessInfo[]): SessionFile[] { + const dirToCwd = new Map(); + + for (const proc of processes) { + if (!proc.cwd) continue; + + const projectDir = this.getProjectDir(proc.cwd); + if (dirToCwd.has(projectDir)) continue; + + try { + if (!fs.statSync(projectDir).isDirectory()) continue; + } catch { + continue; + } + + dirToCwd.set(projectDir, proc.cwd); + } + + if (dirToCwd.size === 0) return []; + + const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]); + + for (const file of files) { + file.resolvedCwd = dirToCwd.get(file.projectDir) || ''; + } + + return files; + } + + private tryResumeMatching(processes: ProcessInfo[]): { + direct: ClaudeDirectMatch[]; + fallback: ProcessInfo[]; + } { + const direct: ClaudeDirectMatch[] = []; + const fallback: ProcessInfo[] = []; + + for (const proc of processes) { + const sessionId = this.extractResumeSessionId(proc.command); + if (!sessionId || !proc.cwd) { + fallback.push(proc); + continue; + } + + const projectDir = this.getProjectDir(proc.cwd); + const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); + + const stat = safeStat(jsonlPath); + if (!stat) { + fallback.push(proc); + continue; + } + + const pidEntry = this.readMatchingPidFile(proc.pid, proc.startTime); + + direct.push({ + process: proc, + sessionFile: { + sessionId, + filePath: jsonlPath, + projectDir, + birthtimeMs: stat.birthtimeMs, + resolvedCwd: proc.cwd, + }, + pidStatus: this.mapPidStatus(pidEntry?.status), + waitingFor: pidEntry?.waitingFor, + }); + } + + return { direct, fallback }; + } + + private extractResumeSessionId(command: string): string | null { + const match = command.match(/--resume\s+([0-9a-f-]{36})/i); + return match?.[1] ?? null; + } + + private readMatchingPidFile(pid: number, procStartTime?: Date): PidFileEntry | null { + const pidFilePath = path.join(this.sessionsDir, `${pid}.json`); + try { + const entry = JSON.parse( + fs.readFileSync(pidFilePath, 'utf-8'), + ) as PidFileEntry; + + if (procStartTime) { + const deltaMs = Math.abs(procStartTime.getTime() - entry.startedAt); + if (deltaMs > PID_FILE_STALENESS_MS) { + return null; + } + } + + return entry; + } catch { + return null; + } + } + + private mapPidStatus(status: string | undefined): AgentStatus | undefined { + switch (status) { + case 'running': + return AgentStatus.RUNNING; + case 'waiting': + return AgentStatus.WAITING; + case 'idle': + return AgentStatus.IDLE; + default: + return undefined; + } + } + + tryPidFileMatching(processes: ProcessInfo[]): { + direct: ClaudeDirectMatch[]; + fallback: ProcessInfo[]; + } { + const direct: ClaudeDirectMatch[] = []; + const fallback: ProcessInfo[] = []; + + for (const proc of processes) { + const entry = this.readMatchingPidFile(proc.pid, proc.startTime); + if (!entry) { + fallback.push(proc); + continue; + } + + const projectDir = this.getProjectDir(entry.cwd); + const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`); + + if (!fs.existsSync(jsonlPath)) { + fallback.push(proc); + continue; + } + + direct.push({ + process: proc, + sessionFile: { + sessionId: entry.sessionId, + filePath: jsonlPath, + projectDir, + birthtimeMs: entry.startedAt, + resolvedCwd: entry.cwd, + }, + pidStatus: this.mapPidStatus(entry.status), + waitingFor: entry.waitingFor, + }); + } + + return { direct, fallback }; + } +} diff --git a/packages/agent-manager/src/providers/claude/ClaudeSessionParser.ts b/packages/agent-manager/src/providers/claude/ClaudeSessionParser.ts new file mode 100644 index 00000000..b82e26c4 --- /dev/null +++ b/packages/agent-manager/src/providers/claude/ClaudeSessionParser.ts @@ -0,0 +1,448 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { ConversationMessage } from '../../adapters/AgentAdapter.js'; +import { AgentStatus } from '../../adapters/AgentAdapter.js'; + +/** + * Content block within a Claude Code JSONL message entry. + * Handles text, tool_use, and tool_result block types. + */ +export interface ContentBlock { + type?: string; + text?: string; + content?: string; + name?: string; + input?: Record; + tool_use_id?: string; + is_error?: boolean; +} + +/** + * A single line entry in a Claude Code session JSONL file. + * + * Each line is an independent JSON object with a type discriminator: + * - "user" / "assistant" / "system" — conversation turns + * - "progress" / "thinking" — intermediate agent state + * - "last-prompt" / "file-history-snapshot" — metadata (not conversation state) + */ +export interface SessionEntry { + type?: string; + timestamp?: string; + cwd?: string; + message?: { + content?: string | ContentBlock[]; + }; +} + +/** + * Parsed session state extracted from a JSONL file. + * Aggregates data from all entries into a single summary. + */ +export interface ClaudeSession { + sessionId: string; + projectPath: string; + lastCwd?: string; + sessionStart: Date; + lastActive: Date; + lastEntryType?: string; + isInterrupted: boolean; + lastUserMessage?: string; + /** First meaningful user prompt in the session (post noise filter) */ + firstUserMessage?: string; +} + +/** + * Top-level JSONL entry types that represent conversation/agent state. + * + * Only these types update `lastEntryType` for status determination. All + * other types Claude Code emits (`attachment`, `permission-mode`, + * `ai-title`, `queued_command`, `tools_changed`, `model_changed`, + * `hook_progress`, …) are UI-state events that must not overwrite the + * last conversation turn — otherwise polling between writes lands on a + * UI-state entry and `determineStatus` falls through to UNKNOWN. + */ +const CONVERSATION_ENTRY_TYPES = new Set([ + 'user', + 'assistant', + 'system', + 'progress', + 'thinking', +]); + +/** + * Parses Claude Code session JSONL files into structured data. + * + * Session files live at ~/.claude/projects//.jsonl + * and contain one JSON object per line, each representing a conversation + * event (user turn, assistant response, tool call, etc.). + */ +export class ClaudeSessionParser { + /** + * Parse a session JSONL file into a ClaudeSession summary. + * + * Iterates all lines to extract: session start time (from first entry), + * last activity timestamp, last entry type (for status), whether the + * session was interrupted, and the last meaningful user message. + * + * Returns null if the file is unreadable or empty. + */ + readSession(filePath: string, projectPath: string): ClaudeSession | null { + const sessionId = path.basename(filePath, '.jsonl'); + + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + + const allLines = content.trim().split('\n'); + if (allLines.length === 0) { + return null; + } + + const sessionStart = this.parseSessionStart(allLines[0]); + + let lastEntryType: string | undefined; + let lastActive: Date | undefined; + let lastCwd: string | undefined; + let isInterrupted = false; + let lastUserMessage: string | undefined; + let firstUserMessage: string | undefined; + + for (const line of allLines) { + try { + const entry: SessionEntry = JSON.parse(line); + + if (entry.timestamp) { + const ts = new Date(entry.timestamp); + if (!Number.isNaN(ts.getTime())) { + lastActive = ts; + } + } + + if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) { + lastCwd = entry.cwd; + } + + if (entry.type && CONVERSATION_ENTRY_TYPES.has(entry.type)) { + lastEntryType = entry.type; + + if (entry.type === 'user') { + const msgContent = entry.message?.content; + isInterrupted = + Array.isArray(msgContent) && + msgContent.some( + (c) => + (c.type === 'text' && + c.text?.includes('[Request interrupted')) || + (c.type === 'tool_result' && + c.content?.includes('[Request interrupted')), + ); + + const text = this.extractUserMessageText(msgContent); + if (text) { + lastUserMessage = text; + if (!firstUserMessage) { + firstUserMessage = text; + } + } + } else { + isInterrupted = false; + } + } + } catch { + continue; + } + } + + return { + sessionId, + projectPath: projectPath || lastCwd || '', + lastCwd, + sessionStart: sessionStart || lastActive || new Date(), + lastActive: lastActive || new Date(), + lastEntryType, + isInterrupted, + lastUserMessage, + firstUserMessage, + }; + } + + /** + * Determine agent status from parsed session state. + * + * Status mapping: + * - "user" + interrupted → WAITING (agent finished, awaiting new input) + * - "user" + not interrupted → RUNNING (agent is processing) + * - "progress" / "thinking" → RUNNING + * - "assistant" → WAITING (agent responded, awaiting user) + * - "system" → IDLE + */ + determineStatus(session: ClaudeSession): AgentStatus { + if (!session.lastEntryType) { + return AgentStatus.UNKNOWN; + } + + if (session.lastEntryType === 'user') { + return session.isInterrupted + ? AgentStatus.WAITING + : AgentStatus.RUNNING; + } + + if ( + session.lastEntryType === 'progress' || + session.lastEntryType === 'thinking' + ) { + return AgentStatus.RUNNING; + } + + if (session.lastEntryType === 'assistant') { + return AgentStatus.WAITING; + } + + if (session.lastEntryType === 'system') { + return AgentStatus.IDLE; + } + + return AgentStatus.UNKNOWN; + } + + /** + * Read the full conversation from a session JSONL file. + * + * Default mode returns only text content from user/assistant/system messages. + * Verbose mode also includes tool_use and tool_result blocks. + */ + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + const verbose = options?.verbose ?? false; + + let content: string; + try { + content = fs.readFileSync(sessionFilePath, 'utf-8'); + } catch { + return []; + } + + const lines = content.trim().split('\n'); + const messages: ConversationMessage[] = []; + + for (const line of lines) { + let entry: SessionEntry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + let role: ConversationMessage['role']; + if (entry.type === 'user') { + role = 'user'; + } else if (entry.type === 'assistant') { + role = 'assistant'; + } else if (entry.type === 'system') { + role = 'system'; + } else { + continue; + } + + const text = this.extractConversationContent(entry.message?.content, role, verbose); + if (!text) continue; + + messages.push({ + role, + content: text, + timestamp: entry.timestamp, + }); + } + + return messages; + } + + /** + * Parse session start time from the first JSONL line. + * + * Claude Code may emit a "file-history-snapshot" as the first entry, + * which stores its timestamp inside "snapshot.timestamp" rather than + * at the root level. + */ + private parseSessionStart(firstLine: string): Date | null { + try { + const firstEntry = JSON.parse(firstLine); + const rawTs: string | undefined = + firstEntry.timestamp || firstEntry.snapshot?.timestamp; + if (rawTs) { + const ts = new Date(rawTs); + if (!Number.isNaN(ts.getTime())) { + return ts; + } + } + } catch { + /* malformed first line */ + } + return null; + } + + /** + * Extract meaningful text from a user message content field. + * + * Handles multiple formats: + * - Plain string content + * - Array of content blocks (extracts first text block) + * - Skill slash-commands ( tags) + * - Expanded skill content (extracts ARGUMENTS line) + * - Filters noise messages (interruptions, tool loaded, session continued) + */ + private extractUserMessageText( + content: string | Array<{ type?: string; text?: string }> | undefined, + ): string | undefined { + if (!content) { + return undefined; + } + + let raw: string | undefined; + + if (typeof content === 'string') { + raw = content.trim(); + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && block.text?.trim()) { + raw = block.text.trim(); + break; + } + } + } + + if (!raw) { + return undefined; + } + + if (raw.startsWith('')) { + return this.parseCommandMessage(raw); + } + + if (raw.startsWith('Base directory for this skill:')) { + const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/); + return argsMatch?.[1]?.trim() || undefined; + } + + if (isNoiseMessage(raw)) { + return undefined; + } + + return raw; + } + + /** + * Parse a string into "/command args" format. + */ + private parseCommandMessage(raw: string): string | undefined { + const nameMatch = raw.match(/([^<]+)<\/command-name>/); + const argsMatch = raw.match(/([^<]+)<\/command-args>/); + const name = nameMatch?.[1]?.trim(); + if (!name) { + return undefined; + } + const args = argsMatch?.[1]?.trim(); + return args ? `${name} ${args}` : name; + } + + /** + * Extract displayable content from a message content field for conversation output. + */ + private extractConversationContent( + content: string | ContentBlock[] | undefined, + role: ConversationMessage['role'], + verbose: boolean, + ): string | undefined { + if (!content) return undefined; + + if (typeof content === 'string') { + const cleaned = stripHarnessTags(content); + if (role === 'user' && isNoiseMessage(cleaned)) return undefined; + return cleaned || undefined; + } + + if (!Array.isArray(content)) return undefined; + + const parts: string[] = []; + + for (const block of content) { + if (block.type === 'text' && block.text?.trim()) { + const cleaned = stripHarnessTags(block.text); + if (!cleaned) continue; + if (role === 'user' && isNoiseMessage(cleaned)) continue; + parts.push(cleaned); + } else if (block.type === 'tool_use' && verbose) { + const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || ''; + parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`); + } else if (block.type === 'tool_result' && verbose) { + const truncated = truncateToolResult(block.content || ''); + const prefix = block.is_error ? '[Tool Error]' : '[Tool Result]'; + parts.push(`${prefix} ${truncated}`); + } + } + + return parts.length > 0 ? parts.join('\n') : undefined; + } +} + +/** + * Tags whose entire block (including content) should be dropped — they are + * harness-injected prompt context (system reminders, hook output, command + * stdout), not meaningful conversation content. + */ +const HARNESS_DROP_TAGS = [ + 'system-reminder', + 'local-command-stdout', + 'local-command-stderr', + 'user-prompt-submit-hook', + 'command-stdout', + 'command-stderr', + 'bash-input', + 'bash-stdout', + 'bash-stderr', + 'command-message', +] as const; + +const HARNESS_DROP_RE = new RegExp( + `<(${HARNESS_DROP_TAGS.join('|')})>[\\s\\S]*?`, + 'g', +); + +const COMMAND_INVOCATION_RE = + /([^<]+)<\/command-name>(?:\s*([\s\S]*?)<\/command-args>)?/g; + +/** + * Remove harness-injected XML blocks from message text and collapse + * / pairs into a "/name args" shorthand. + * + * Returns the cleaned, trimmed text. Returns an empty string if nothing + * survives stripping. + */ +function stripHarnessTags(text: string): string { + let out = text.replace(HARNESS_DROP_RE, ''); + + out = out.replace(COMMAND_INVOCATION_RE, (_match, rawName: string, rawArgs?: string) => { + const name = rawName.trim(); + const args = rawArgs?.trim(); + return args ? `${name} ${args}` : name; + }); + + return out.replace(/\n{3,}/g, '\n\n').trim(); +} + +/** Check if a message is noise (not a meaningful user intent). */ +function isNoiseMessage(text: string): boolean { + return ( + text.startsWith('[Request interrupted') || + text === 'Tool loaded.' || + text.startsWith('This session is being continued') + ); +} + +function truncateToolResult(content: string, maxLength = 200): string { + const firstLine = content.split('\n')[0] || ''; + if (firstLine.length <= maxLength) return firstLine; + return firstLine.slice(0, maxLength - 3) + '...'; +} diff --git a/packages/agent-manager/src/providers/claude/durable/ClaudeCliProbe.ts b/packages/agent-manager/src/providers/claude/durable/ClaudeCliProbe.ts new file mode 100644 index 00000000..2f5533be --- /dev/null +++ b/packages/agent-manager/src/providers/claude/durable/ClaudeCliProbe.ts @@ -0,0 +1,58 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { ClaudePrintError } from '../../../durable/DurableAgent.js'; + +type ExecResult = { stdout: string; stderr: string }; +type Exec = (file: string, args: string[]) => Promise; + +const execFileAsync = promisify(execFile); +const REQUIRED = ['--print', '--session-id', '--resume', '--output-format', 'stream-json']; + +export interface ClaudeCliProbeOptions { + executable?: string; + exec?: Exec; +} + +export class ClaudeCliProbe { + private readonly executable: string; + private readonly exec: Exec; + + constructor(options: ClaudeCliProbeOptions = {}) { + this.executable = options.executable ?? 'claude'; + this.exec = options.exec ?? (async (file, args) => { + const result = await execFileAsync(file, args, { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; + }); + } + + async validate(): Promise<{ executable: string; version: string }> { + try { + const versionResult = await this.exec(this.executable, ['--version']); + const helpResult = await this.exec(this.executable, ['--help']); + const missing = REQUIRED.filter((capability) => !helpResult.stdout.includes(capability)); + if (missing.length > 0) { + throw new ClaudePrintError( + `Claude CLI does not support required print-mode capabilities: ${missing.join(', ')}.`, + 'CLAUDE_CLI_UNSUPPORTED', + ); + } + return { + executable: this.executable, + version: sanitize(versionResult.stdout, 256) || 'unknown', + }; + } catch (error) { + if (error instanceof ClaudePrintError) throw error; + throw new ClaudePrintError( + `Claude CLI validation failed: ${sanitize((error as Error).message, 512)}`, + 'CLAUDE_CLI_UNAVAILABLE', + ); + } + } +} + +function sanitize(value: string, max: number): string { + return Array.from(value, (character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127 ? ' ' : character; + }).join('').trim().slice(0, max); +} diff --git a/packages/agent-manager/src/providers/claude/durable/ClaudePrintAgentService.ts b/packages/agent-manager/src/providers/claude/durable/ClaudePrintAgentService.ts new file mode 100644 index 00000000..8e6c68e3 --- /dev/null +++ b/packages/agent-manager/src/providers/claude/durable/ClaudePrintAgentService.ts @@ -0,0 +1,94 @@ +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 '../../../durable/DurableAgentRepository.js'; + +interface RepositoryLike { + create(input: CreateDurableAgentInput): Promise; + list(): Promise; + resolve(reference: string): Promise; + acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }>; + recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; + completeRun(id: string, token: string, result: DurableRunCompletion): Promise; +} + +interface ProbeLike { validate(): Promise<{ executable: string; version: string }> } +interface RunnerLike { run(request: Parameters[0]): Promise } + +export interface ClaudePrintAgentServiceOptions { + repository?: RepositoryLike; + probe?: ProbeLike; + runner?: RunnerLike; + executable?: string; +} + +export interface ClaudePrintSendResult extends ClaudePrintRunResult { + agentId: string; + agentName: string; +} + +export class ClaudePrintAgentService { + readonly repository: RepositoryLike; + private readonly probe: ProbeLike; + private readonly runner: RunnerLike; + private readonly executable?: string; + + constructor(options: ClaudePrintAgentServiceOptions = {}) { + this.repository = options.repository ?? new DurableAgentRepository(); + this.probe = options.probe ?? new ClaudeCliProbe(); + this.runner = options.runner ?? new ClaudePrintRunner(); + this.executable = options.executable; + } + + async create(input: CreateDurableAgentInput): Promise { + await this.probe.validate(); + return this.repository.create(input); + } + + async send(reference: string, prompt: string): Promise { + const resolved = await this.repository.resolve(reference); + if (!resolved) throw new DurableAgentNotFoundError(reference); + if (Array.isArray(resolved)) { + throw new ClaudePrintError(`Multiple durable agents match "${reference}".`, 'DURABLE_AGENT_AMBIGUOUS'); + } + const acquired = await this.repository.acquireRun(resolved.id); + try { + const result = await this.runner.run({ + agent: acquired.agent, + prompt, + executable: this.executable, + firstRun: acquired.agent.sessionHealth === 'uninitialized', + onSpawn: (identity) => this.repository.recordProviderProcess(resolved.id, acquired.token, identity), + }); + await this.repository.completeRun(resolved.id, acquired.token, { + status: 'succeeded', + exitCode: result.exitCode, + summary: sanitize(result.result, 4096), + sessionHealth: 'healthy', + }); + return { ...result, agentId: resolved.id, agentName: resolved.name }; + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + const sessionHealth = error instanceof ClaudePrintError && error.code === 'CLAUDE_SESSION_MISMATCH' + ? 'mismatch' as const + : 'unknown' as const; + await this.repository.completeRun(resolved.id, acquired.token, { + status: 'failed', + exitCode: null, + summary: sanitize(failure.message, 4096), + sessionHealth, + }); + throw error; + } + } +} + +function sanitize(value: string, max: number): string { + return Array.from(value, (character) => { + const code = character.charCodeAt(0); + return (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) + ? ' ' + : character; + }).join('').trim().slice(0, max); +} diff --git a/packages/agent-manager/src/providers/claude/durable/ClaudePrintRunner.ts b/packages/agent-manager/src/providers/claude/durable/ClaudePrintRunner.ts new file mode 100644 index 00000000..5432d073 --- /dev/null +++ b/packages/agent-manager/src/providers/claude/durable/ClaudePrintRunner.ts @@ -0,0 +1,139 @@ +import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; +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, + args: readonly string[], + options: SpawnOptionsWithoutStdio & { stdio: ['pipe', 'pipe', 'pipe'] }, +) => ChildProcessWithoutNullStreams; + +export interface ClaudePrintRunRequest { + agent: DurableAgent; + prompt: string; + executable?: string; + firstRun: boolean; + onSpawn(identity: ProcessIdentity): Promise; +} + +export interface ClaudePrintRunResult { + sessionId: string; + result: string; + exitCode: number; +} + +export interface ClaudePrintRunnerOptions { + spawn?: Spawn; + processInspector?: ProcessInspector; + maxLineBytes?: number; +} + +export class ClaudePrintRunner { + private readonly spawn: Spawn; + private readonly processInspector: ProcessInspector; + private readonly maxLineBytes: number; + + constructor(options: ClaudePrintRunnerOptions = {}) { + this.spawn = options.spawn ?? (nodeSpawn as Spawn); + this.processInspector = options.processInspector ?? new LocalProcessInspector(); + this.maxLineBytes = options.maxLineBytes ?? 1024 * 1024; + } + + async run(request: ClaudePrintRunRequest): Promise { + const sessionArgs = request.firstRun + ? ['--session-id', request.agent.providerSessionId] + : ['--resume', request.agent.providerSessionId]; + const args = ['-p', ...sessionArgs, '--output-format', 'stream-json', '--verbose']; + const child = this.spawn(request.executable ?? 'claude', args, { + cwd: request.agent.cwd, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (!child.pid) { + child.kill(); + throw new ClaudePrintError('Claude process did not provide a PID.', 'CLAUDE_PROCESS_IDENTITY'); + } + const identity = this.processInspector.getIdentity(child.pid); + if (!identity) { + child.kill(); + throw new ClaudePrintError('Cannot verify Claude process identity.', 'CLAUDE_PROCESS_IDENTITY'); + } + + let buffer = Buffer.alloc(0); + let terminal: ClaudePrintRunResult | null = null; + let protocolError: ClaudePrintError | null = null; + + child.stdout.on('data', (chunk: Buffer | string) => { + if (protocolError) return; + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (buffer.length > this.maxLineBytes && buffer.indexOf(0x0a) < 0) { + protocolError = new ClaudePrintError('Claude stream line exceeded the safety limit.', 'CLAUDE_STREAM_OVERSIZED'); + return; + } + let newline: number; + while ((newline = buffer.indexOf(0x0a)) >= 0) { + const line = buffer.subarray(0, newline); + buffer = buffer.subarray(newline + 1); + if (line.length === 0) continue; + if (line.length > this.maxLineBytes) { + protocolError = new ClaudePrintError('Claude stream line exceeded the safety limit.', 'CLAUDE_STREAM_OVERSIZED'); + return; + } + try { + const value = JSON.parse(line.toString('utf8')) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ClaudePrintError('Claude emitted a non-object stream message.', 'CLAUDE_STREAM_INVALID'); + } + const event = value as Record; + if (typeof event.session_id === 'string' && event.session_id !== request.agent.providerSessionId) { + throw new ClaudePrintError('Claude returned a different session identity.', 'CLAUDE_SESSION_MISMATCH'); + } + if (event.type === 'result') { + if (terminal) throw new ClaudePrintError('Claude emitted more than one terminal result.', 'CLAUDE_STREAM_INVALID'); + if (typeof event.session_id !== 'string' || typeof event.result !== 'string') { + throw new ClaudePrintError('Claude emitted an invalid terminal result.', 'CLAUDE_STREAM_INVALID'); + } + terminal = { sessionId: event.session_id, result: event.result, exitCode: 0 }; + } + } catch (error) { + protocolError = error instanceof ClaudePrintError + ? error + : new ClaudePrintError('Claude emitted malformed stream JSON.', 'CLAUDE_STREAM_INVALID'); + return; + } + } + }); + // Drain provider diagnostics without reflecting potentially sensitive prompt/tool data. + child.stderr.resume(); + + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + + try { + await request.onSpawn(identity); + } catch (error) { + child.kill(); + throw error; + } + + child.stdin.end(request.prompt); + const { code, signal } = await closed; + + if (protocolError) throw protocolError; + if (buffer.length > 0) { + throw new ClaudePrintError('Claude stream ended with incomplete JSON.', 'CLAUDE_STREAM_INVALID'); + } + if (code !== 0) { + throw new ClaudePrintError( + `Claude print run failed${signal ? ` (${signal})` : '.'}`, + 'CLAUDE_PROCESS_FAILED', + ); + } + if (!terminal) throw new ClaudePrintError('Claude stream ended without a terminal result.', 'CLAUDE_RESULT_MISSING'); + const finalResult = terminal as ClaudePrintRunResult; + return { sessionId: finalResult.sessionId, result: finalResult.result, exitCode: code }; + } +} diff --git a/packages/agent-manager/src/utils/ClaudeSessionParser.ts b/packages/agent-manager/src/utils/ClaudeSessionParser.ts index 775199ff..27492f5b 100644 --- a/packages/agent-manager/src/utils/ClaudeSessionParser.ts +++ b/packages/agent-manager/src/utils/ClaudeSessionParser.ts @@ -1,448 +1,8 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import type { ConversationMessage } from '../adapters/AgentAdapter.js'; -import { AgentStatus } from '../adapters/AgentAdapter.js'; - -/** - * Content block within a Claude Code JSONL message entry. - * Handles text, tool_use, and tool_result block types. - */ -export interface ContentBlock { - type?: string; - text?: string; - content?: string; - name?: string; - input?: Record; - tool_use_id?: string; - is_error?: boolean; -} - -/** - * A single line entry in a Claude Code session JSONL file. - * - * Each line is an independent JSON object with a type discriminator: - * - "user" / "assistant" / "system" — conversation turns - * - "progress" / "thinking" — intermediate agent state - * - "last-prompt" / "file-history-snapshot" — metadata (not conversation state) - */ -export interface SessionEntry { - type?: string; - timestamp?: string; - cwd?: string; - message?: { - content?: string | ContentBlock[]; - }; -} - -/** - * Parsed session state extracted from a JSONL file. - * Aggregates data from all entries into a single summary. - */ -export interface ClaudeSession { - sessionId: string; - projectPath: string; - lastCwd?: string; - sessionStart: Date; - lastActive: Date; - lastEntryType?: string; - isInterrupted: boolean; - lastUserMessage?: string; - /** First meaningful user prompt in the session (post noise filter) */ - firstUserMessage?: string; -} - -/** - * Top-level JSONL entry types that represent conversation/agent state. - * - * Only these types update `lastEntryType` for status determination. All - * other types Claude Code emits (`attachment`, `permission-mode`, - * `ai-title`, `queued_command`, `tools_changed`, `model_changed`, - * `hook_progress`, …) are UI-state events that must not overwrite the - * last conversation turn — otherwise polling between writes lands on a - * UI-state entry and `determineStatus` falls through to UNKNOWN. - */ -const CONVERSATION_ENTRY_TYPES = new Set([ - 'user', - 'assistant', - 'system', - 'progress', - 'thinking', -]); - -/** - * Parses Claude Code session JSONL files into structured data. - * - * Session files live at ~/.claude/projects//.jsonl - * and contain one JSON object per line, each representing a conversation - * event (user turn, assistant response, tool call, etc.). - */ -export class ClaudeSessionParser { - /** - * Parse a session JSONL file into a ClaudeSession summary. - * - * Iterates all lines to extract: session start time (from first entry), - * last activity timestamp, last entry type (for status), whether the - * session was interrupted, and the last meaningful user message. - * - * Returns null if the file is unreadable or empty. - */ - readSession(filePath: string, projectPath: string): ClaudeSession | null { - const sessionId = path.basename(filePath, '.jsonl'); - - let content: string; - try { - content = fs.readFileSync(filePath, 'utf-8'); - } catch { - return null; - } - - const allLines = content.trim().split('\n'); - if (allLines.length === 0) { - return null; - } - - const sessionStart = this.parseSessionStart(allLines[0]); - - let lastEntryType: string | undefined; - let lastActive: Date | undefined; - let lastCwd: string | undefined; - let isInterrupted = false; - let lastUserMessage: string | undefined; - let firstUserMessage: string | undefined; - - for (const line of allLines) { - try { - const entry: SessionEntry = JSON.parse(line); - - if (entry.timestamp) { - const ts = new Date(entry.timestamp); - if (!Number.isNaN(ts.getTime())) { - lastActive = ts; - } - } - - if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) { - lastCwd = entry.cwd; - } - - if (entry.type && CONVERSATION_ENTRY_TYPES.has(entry.type)) { - lastEntryType = entry.type; - - if (entry.type === 'user') { - const msgContent = entry.message?.content; - isInterrupted = - Array.isArray(msgContent) && - msgContent.some( - (c) => - (c.type === 'text' && - c.text?.includes('[Request interrupted')) || - (c.type === 'tool_result' && - c.content?.includes('[Request interrupted')), - ); - - const text = this.extractUserMessageText(msgContent); - if (text) { - lastUserMessage = text; - if (!firstUserMessage) { - firstUserMessage = text; - } - } - } else { - isInterrupted = false; - } - } - } catch { - continue; - } - } - - return { - sessionId, - projectPath: projectPath || lastCwd || '', - lastCwd, - sessionStart: sessionStart || lastActive || new Date(), - lastActive: lastActive || new Date(), - lastEntryType, - isInterrupted, - lastUserMessage, - firstUserMessage, - }; - } - - /** - * Determine agent status from parsed session state. - * - * Status mapping: - * - "user" + interrupted → WAITING (agent finished, awaiting new input) - * - "user" + not interrupted → RUNNING (agent is processing) - * - "progress" / "thinking" → RUNNING - * - "assistant" → WAITING (agent responded, awaiting user) - * - "system" → IDLE - */ - determineStatus(session: ClaudeSession): AgentStatus { - if (!session.lastEntryType) { - return AgentStatus.UNKNOWN; - } - - if (session.lastEntryType === 'user') { - return session.isInterrupted - ? AgentStatus.WAITING - : AgentStatus.RUNNING; - } - - if ( - session.lastEntryType === 'progress' || - session.lastEntryType === 'thinking' - ) { - return AgentStatus.RUNNING; - } - - if (session.lastEntryType === 'assistant') { - return AgentStatus.WAITING; - } - - if (session.lastEntryType === 'system') { - return AgentStatus.IDLE; - } - - return AgentStatus.UNKNOWN; - } - - /** - * Read the full conversation from a session JSONL file. - * - * Default mode returns only text content from user/assistant/system messages. - * Verbose mode also includes tool_use and tool_result blocks. - */ - getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { - const verbose = options?.verbose ?? false; - - let content: string; - try { - content = fs.readFileSync(sessionFilePath, 'utf-8'); - } catch { - return []; - } - - const lines = content.trim().split('\n'); - const messages: ConversationMessage[] = []; - - for (const line of lines) { - let entry: SessionEntry; - try { - entry = JSON.parse(line); - } catch { - continue; - } - - let role: ConversationMessage['role']; - if (entry.type === 'user') { - role = 'user'; - } else if (entry.type === 'assistant') { - role = 'assistant'; - } else if (entry.type === 'system') { - role = 'system'; - } else { - continue; - } - - const text = this.extractConversationContent(entry.message?.content, role, verbose); - if (!text) continue; - - messages.push({ - role, - content: text, - timestamp: entry.timestamp, - }); - } - - return messages; - } - - /** - * Parse session start time from the first JSONL line. - * - * Claude Code may emit a "file-history-snapshot" as the first entry, - * which stores its timestamp inside "snapshot.timestamp" rather than - * at the root level. - */ - private parseSessionStart(firstLine: string): Date | null { - try { - const firstEntry = JSON.parse(firstLine); - const rawTs: string | undefined = - firstEntry.timestamp || firstEntry.snapshot?.timestamp; - if (rawTs) { - const ts = new Date(rawTs); - if (!Number.isNaN(ts.getTime())) { - return ts; - } - } - } catch { - /* malformed first line */ - } - return null; - } - - /** - * Extract meaningful text from a user message content field. - * - * Handles multiple formats: - * - Plain string content - * - Array of content blocks (extracts first text block) - * - Skill slash-commands ( tags) - * - Expanded skill content (extracts ARGUMENTS line) - * - Filters noise messages (interruptions, tool loaded, session continued) - */ - private extractUserMessageText( - content: string | Array<{ type?: string; text?: string }> | undefined, - ): string | undefined { - if (!content) { - return undefined; - } - - let raw: string | undefined; - - if (typeof content === 'string') { - raw = content.trim(); - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === 'text' && block.text?.trim()) { - raw = block.text.trim(); - break; - } - } - } - - if (!raw) { - return undefined; - } - - if (raw.startsWith('')) { - return this.parseCommandMessage(raw); - } - - if (raw.startsWith('Base directory for this skill:')) { - const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/); - return argsMatch?.[1]?.trim() || undefined; - } - - if (isNoiseMessage(raw)) { - return undefined; - } - - return raw; - } - - /** - * Parse a string into "/command args" format. - */ - private parseCommandMessage(raw: string): string | undefined { - const nameMatch = raw.match(/([^<]+)<\/command-name>/); - const argsMatch = raw.match(/([^<]+)<\/command-args>/); - const name = nameMatch?.[1]?.trim(); - if (!name) { - return undefined; - } - const args = argsMatch?.[1]?.trim(); - return args ? `${name} ${args}` : name; - } - - /** - * Extract displayable content from a message content field for conversation output. - */ - private extractConversationContent( - content: string | ContentBlock[] | undefined, - role: ConversationMessage['role'], - verbose: boolean, - ): string | undefined { - if (!content) return undefined; - - if (typeof content === 'string') { - const cleaned = stripHarnessTags(content); - if (role === 'user' && isNoiseMessage(cleaned)) return undefined; - return cleaned || undefined; - } - - if (!Array.isArray(content)) return undefined; - - const parts: string[] = []; - - for (const block of content) { - if (block.type === 'text' && block.text?.trim()) { - const cleaned = stripHarnessTags(block.text); - if (!cleaned) continue; - if (role === 'user' && isNoiseMessage(cleaned)) continue; - parts.push(cleaned); - } else if (block.type === 'tool_use' && verbose) { - const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || ''; - parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`); - } else if (block.type === 'tool_result' && verbose) { - const truncated = truncateToolResult(block.content || ''); - const prefix = block.is_error ? '[Tool Error]' : '[Tool Result]'; - parts.push(`${prefix} ${truncated}`); - } - } - - return parts.length > 0 ? parts.join('\n') : undefined; - } -} - -/** - * Tags whose entire block (including content) should be dropped — they are - * harness-injected prompt context (system reminders, hook output, command - * stdout), not meaningful conversation content. - */ -const HARNESS_DROP_TAGS = [ - 'system-reminder', - 'local-command-stdout', - 'local-command-stderr', - 'user-prompt-submit-hook', - 'command-stdout', - 'command-stderr', - 'bash-input', - 'bash-stdout', - 'bash-stderr', - 'command-message', -] as const; - -const HARNESS_DROP_RE = new RegExp( - `<(${HARNESS_DROP_TAGS.join('|')})>[\\s\\S]*?`, - 'g', -); - -const COMMAND_INVOCATION_RE = - /([^<]+)<\/command-name>(?:\s*([\s\S]*?)<\/command-args>)?/g; - -/** - * Remove harness-injected XML blocks from message text and collapse - * / pairs into a "/name args" shorthand. - * - * Returns the cleaned, trimmed text. Returns an empty string if nothing - * survives stripping. - */ -function stripHarnessTags(text: string): string { - let out = text.replace(HARNESS_DROP_RE, ''); - - out = out.replace(COMMAND_INVOCATION_RE, (_match, rawName: string, rawArgs?: string) => { - const name = rawName.trim(); - const args = rawArgs?.trim(); - return args ? `${name} ${args}` : name; - }); - - return out.replace(/\n{3,}/g, '\n\n').trim(); -} - -/** Check if a message is noise (not a meaningful user intent). */ -function isNoiseMessage(text: string): boolean { - return ( - text.startsWith('[Request interrupted') || - text === 'Tool loaded.' || - text.startsWith('This session is being continued') - ); -} - -function truncateToolResult(content: string, maxLength = 200): string { - const firstLine = content.split('\n')[0] || ''; - if (firstLine.length <= maxLength) return firstLine; - return firstLine.slice(0, maxLength - 3) + '...'; -} +export { + ClaudeSessionParser, +} from '../providers/claude/ClaudeSessionParser.js'; +export type { + ClaudeSession, + ContentBlock, + SessionEntry, +} from '../providers/claude/ClaudeSessionParser.js'; From 2205664d9fb857a1d2ec8556ac8495c710fd57ac Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 23 Aug 2026 17:34:55 +0200 Subject: [PATCH 2/4] refactor(agent-manager): remove Claude wrapper exports --- ...-08-23-feature-claude-provider-refactor.md | 29 ++++++++----------- ...-08-23-feature-claude-provider-refactor.md | 26 +++++++---------- ...-08-23-feature-claude-provider-refactor.md | 20 ++++++------- ...-08-23-feature-claude-provider-refactor.md | 10 +++---- ...-08-23-feature-claude-provider-refactor.md | 7 +++-- .../adapters/ClaudeCodeAdapter.test.ts | 2 +- .../utils/ClaudeSessionParser.test.ts | 4 +-- .../src/adapters/ClaudeCodeAdapter.ts | 1 - packages/agent-manager/src/adapters/index.ts | 2 +- .../src/durable/ClaudeCliProbe.ts | 2 -- .../src/durable/ClaudePrintAgentService.ts | 5 ---- .../src/durable/ClaudePrintRunner.ts | 6 ---- packages/agent-manager/src/index.ts | 14 ++++----- .../src/utils/ClaudeSessionParser.ts | 8 ----- 14 files changed, 53 insertions(+), 83 deletions(-) delete mode 100644 packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts delete mode 100644 packages/agent-manager/src/durable/ClaudeCliProbe.ts delete mode 100644 packages/agent-manager/src/durable/ClaudePrintAgentService.ts delete mode 100644 packages/agent-manager/src/durable/ClaudePrintRunner.ts delete mode 100644 packages/agent-manager/src/utils/ClaudeSessionParser.ts 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 index 867c9530..cb251830 100644 --- a/docs/ai/design/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/design/2026-08-23-feature-claude-provider-refactor.md @@ -1,20 +1,19 @@ --- phase: design title: Claude Provider Refactor Design -description: Provider-local Claude module boundary with compatibility exports +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 public adapter and durable exports. +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"] --> AdapterCompat["src/adapters/ClaudeCodeAdapter.ts"] - AdapterIndex["src/adapters/index.ts"] --> AdapterCompat - AdapterCompat --> ClaudeAdapter["src/providers/claude/ClaudeCodeAdapter.ts"] + PublicIndex["src/index.ts"] --> ClaudeAdapter["src/providers/claude/ClaudeCodeAdapter.ts"] + AdapterIndex["src/adapters/index.ts"] --> ClaudeAdapter ClaudeAdapter --> Locator["ClaudeSessionLocator"] ClaudeAdapter --> Parser["ClaudeSessionParser"] @@ -22,7 +21,7 @@ graph TD ClaudeAdapter --> SharedProcess["utils/process"] ClaudeAdapter --> SharedMatching["utils/matching"] - DurableExports["src/durable/*.ts compatibility exports"] --> ClaudeDurable["src/providers/claude/durable/*"] + PublicIndex --> ClaudeDurable["src/providers/claude/durable/*"] ClaudeDurable --> Database["database + DurableAgentRepository contracts"] ClaudeDurable --> ClaudeCli["Claude CLI"] ``` @@ -42,12 +41,6 @@ packages/agent-manager/src/ ClaudeCliProbe.ts ClaudePrintRunner.ts ClaudePrintAgentService.ts - adapters/ - ClaudeCodeAdapter.ts compatibility export - durable/ - ClaudeCliProbe.ts compatibility export or wrapper - ClaudePrintRunner.ts compatibility export or wrapper - ClaudePrintAgentService.ts compatibility export or wrapper ``` ## Data Models @@ -74,17 +67,19 @@ Existing public models remain unchanged: ### Public API -No public API change. +No public package-root API change. Existing exports stay valid: ```ts -export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js'; -export { ClaudeCliProbe } from './durable/ClaudeCliProbe.js'; -export { ClaudePrintRunner } from './durable/ClaudePrintRunner.js'; -export { ClaudePrintAgentService } from './durable/ClaudePrintAgentService.js'; +export { ClaudeCodeAdapter } from './providers/claude/ClaudeCodeAdapter.js'; +export { ClaudeCliProbe } from './providers/claude/durable/ClaudeCliProbe.js'; +export { ClaudePrintRunner } from './providers/claude/durable/ClaudePrintRunner.js'; +export { ClaudePrintAgentService } from './providers/claude/durable/ClaudePrintAgentService.js'; ``` +The implementation now exports these directly from `src/providers/claude/...` through `src/index.ts` and `src/adapters/index.ts`. 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: 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 index d47dd13a..38797d92 100644 --- a/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md @@ -27,15 +27,7 @@ packages/agent-manager/src/providers/claude/ durable/ ``` -Compatibility paths must remain: - -```text -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 -``` +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 @@ -50,7 +42,7 @@ packages/agent-manager/src/durable/ClaudePrintAgentService.ts ### Patterns & Best Practices -- Use compatibility re-exports instead of deleting old paths. +- 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. @@ -58,7 +50,7 @@ packages/agent-manager/src/durable/ClaudePrintAgentService.ts ## Integration Points -- `src/index.ts` and `src/adapters/index.ts` continue exporting `ClaudeCodeAdapter`. +- `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`, `ClaudeCliProbe`, and `ClaudePrintRunner` contracts. @@ -80,7 +72,7 @@ packages/agent-manager/src/durable/ClaudePrintAgentService.ts - 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. -- Compatibility wrappers must not duplicate or alter durable persistence behavior. +- Public exports must not duplicate or alter durable persistence behavior. ## Implementation Log @@ -90,10 +82,8 @@ packages/agent-manager/src/durable/ClaudePrintAgentService.ts - `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`. - - Kept `packages/agent-manager/src/utils/ClaudeSessionParser.ts` as a compatibility export. - Focused parser validation passed with 19 tests. - Moved `ClaudeCodeAdapter` implementation to `packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts`. - - Kept `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` as a compatibility export. - 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. @@ -103,8 +93,14 @@ packages/agent-manager/src/durable/ClaudePrintAgentService.ts - Added focused locator test covering resume matching plus live PID status metadata. - Kept adapter private compatibility proxies for existing tests that mutate fixture directories. - Moved Claude durable execution implementations under `packages/agent-manager/src/providers/claude/durable/`. - - Kept old `packages/agent-manager/src/durable/Claude*.ts` paths as compatibility exports. - 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. - 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. 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 index 5d8da974..3127919f 100644 --- a/docs/ai/planning/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/planning/2026-08-23-feature-claude-provider-refactor.md @@ -10,9 +10,9 @@ description: Ordered behavior-preserving extraction plan for Claude provider cod - [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 behind compatibility exports. +- [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 behind compatibility exports. +- [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 @@ -23,12 +23,12 @@ description: Ordered behavior-preserving extraction plan for Claude provider cod - 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` behind the old `utils/ClaudeSessionParser.ts` export. - - Outcome: existing parser tests and imports remain valid. +- [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 behind `src/adapters/ClaudeCodeAdapter.ts` compatibility export. - - Outcome: public and adapter barrel exports remain valid. +- [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. @@ -51,7 +51,7 @@ description: Ordered behavior-preserving extraction plan for Claude provider cod ### Phase 3: Durable Provider Locality -- [x] Task 3.1: Move Claude durable execution files under `providers/claude/durable/` with old `src/durable/*` paths as compatibility exports, if the move is low-risk after Phase 2. +- [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 public exports remain unchanged. - Dependencies: Phase 2 complete and green. - Validation: durable print tests pass. @@ -71,7 +71,7 @@ description: Ordered behavior-preserving extraction plan for Claude provider cod ## Dependencies -- Existing public exports in `src/index.ts`, `src/adapters/index.ts`, and `src/durable/*` must remain compatible throughout. +- 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. @@ -85,7 +85,7 @@ description: Ordered behavior-preserving extraction plan for Claude provider cod ## Risks & Mitigation -- **Risk:** Compatibility exports break declaration output or package barrels. +- **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. @@ -105,4 +105,4 @@ description: Ordered behavior-preserving extraction plan for Claude provider cod ## Progress Summary -Implementation completed. Claude interactive detection, parsing, mapping, session locating, and durable print-mode execution now live under `src/providers/claude/` with compatibility re-exports preserving old adapter, utility, and durable paths. The planned `types.ts` file was intentionally skipped because extracted modules did not need a shared provider-local type barrel. +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, while no-value path-level wrappers 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 index cceb9f5e..da96cf49 100644 --- a/docs/ai/requirements/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/requirements/2026-08-23-feature-claude-provider-refactor.md @@ -48,7 +48,7 @@ The current behavior works, but the structure makes future changes harder to rea - 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. -- Deleting compatibility re-exports during the first refactor. +- Preserving path-level compatibility re-exports that add no value after the provider-local shape is proven. ## User Stories & Use Cases @@ -56,12 +56,12 @@ The current behavior works, but the structure makes future changes harder to rea - 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` and `src/adapters/ClaudeCodeAdapter.js` continue to work. +- 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 `ClaudeCodeAdapter` from adapter paths must continue to compile. +- 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. @@ -71,11 +71,11 @@ The current behavior works, but the structure makes future changes harder to rea ## Success Criteria -1. Claude provider code is organized under a provider-local boundary, with compatibility exports preserving existing import paths. +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`. +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. 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 index c6688646..00f82c0a 100644 --- a/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md @@ -41,8 +41,8 @@ description: Characterization and validation plan for behavior-preserving Claude ### Claude Durable Provider Files -- [x] Existing `ClaudeCliProbe`, `ClaudePrintRunner`, and `ClaudePrintAgentService` tests continue to pass through compatibility exports. -- [x] If files move, tests import from the same public paths unless a new provider-local test is more direct. +- [x] Existing `ClaudeCliProbe`, `ClaudePrintRunner`, and `ClaudePrintAgentService` tests continue to pass through package-root exports. +- [x] Provider-local tests import provider-local modules directly. ## Integration Tests @@ -51,7 +51,7 @@ description: Characterization and validation plan for behavior-preserving Claude - [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 compatibility wrappers are introduced. +- [x] Adapter and package barrel exports compile after no-value path-level wrappers are removed. ## End-to-End Tests @@ -100,6 +100,7 @@ Any pre-existing baseline failures must be recorded before implementation and no ### Results - `npm run nx -- test agent-manager`: passed with 30 test files and 571 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. diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 5b8b1785..a497ef46 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'; 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/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts deleted file mode 100644 index ef657984..00000000 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ /dev/null @@ -1 +0,0 @@ -export { ClaudeCodeAdapter } from '../providers/claude/ClaudeCodeAdapter.js'; 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/durable/ClaudeCliProbe.ts b/packages/agent-manager/src/durable/ClaudeCliProbe.ts deleted file mode 100644 index 6784c72f..00000000 --- a/packages/agent-manager/src/durable/ClaudeCliProbe.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ClaudeCliProbe } from '../providers/claude/durable/ClaudeCliProbe.js'; -export type { ClaudeCliProbeOptions } from '../providers/claude/durable/ClaudeCliProbe.js'; diff --git a/packages/agent-manager/src/durable/ClaudePrintAgentService.ts b/packages/agent-manager/src/durable/ClaudePrintAgentService.ts deleted file mode 100644 index 4b026373..00000000 --- a/packages/agent-manager/src/durable/ClaudePrintAgentService.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { ClaudePrintAgentService } from '../providers/claude/durable/ClaudePrintAgentService.js'; -export type { - ClaudePrintAgentServiceOptions, - ClaudePrintSendResult, -} from '../providers/claude/durable/ClaudePrintAgentService.js'; diff --git a/packages/agent-manager/src/durable/ClaudePrintRunner.ts b/packages/agent-manager/src/durable/ClaudePrintRunner.ts deleted file mode 100644 index ec5c3b92..00000000 --- a/packages/agent-manager/src/durable/ClaudePrintRunner.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { ClaudePrintRunner } from '../providers/claude/durable/ClaudePrintRunner.js'; -export type { - ClaudePrintRunnerOptions, - ClaudePrintRunRequest, - ClaudePrintRunResult, -} from '../providers/claude/durable/ClaudePrintRunner.js'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index eccf4441..ae53a7be 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'; @@ -69,16 +69,16 @@ export type { 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 { ClaudeCliProbe } from './providers/claude/durable/ClaudeCliProbe.js'; +export type { ClaudeCliProbeOptions } from './providers/claude/durable/ClaudeCliProbe.js'; +export { ClaudePrintRunner } from './providers/claude/durable/ClaudePrintRunner.js'; export type { ClaudePrintRunnerOptions, ClaudePrintRunRequest, ClaudePrintRunResult, -} from './durable/ClaudePrintRunner.js'; -export { ClaudePrintAgentService } from './durable/ClaudePrintAgentService.js'; +} from './providers/claude/durable/ClaudePrintRunner.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/utils/ClaudeSessionParser.ts b/packages/agent-manager/src/utils/ClaudeSessionParser.ts deleted file mode 100644 index 27492f5b..00000000 --- a/packages/agent-manager/src/utils/ClaudeSessionParser.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - ClaudeSessionParser, -} from '../providers/claude/ClaudeSessionParser.js'; -export type { - ClaudeSession, - ContentBlock, - SessionEntry, -} from '../providers/claude/ClaudeSessionParser.js'; From e44acf4df5c5b1beb785c85ec3995d0cbffa1986 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 23 Aug 2026 17:41:45 +0200 Subject: [PATCH 3/4] refactor(agent-manager): narrow root exports --- .../2026-08-23-feature-claude-provider-refactor.md | 5 +---- .../2026-08-23-feature-claude-provider-refactor.md | 7 ++++++- .../2026-08-23-feature-claude-provider-refactor.md | 4 ++-- .../2026-08-23-feature-claude-provider-refactor.md | 4 +++- .../src/__tests__/print/ClaudeCliProbe.test.ts | 10 +++------- .../print/ClaudePrintAgent.integration.test.ts | 4 ++-- .../src/__tests__/print/ClaudePrintRunner.test.ts | 14 ++++---------- packages/agent-manager/src/index.ts | 12 ------------ 8 files changed, 21 insertions(+), 39 deletions(-) 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 index cb251830..74331e63 100644 --- a/docs/ai/design/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/design/2026-08-23-feature-claude-provider-refactor.md @@ -36,7 +36,6 @@ packages/agent-manager/src/ ClaudeSessionLocator.ts ClaudeSessionParser.ts ClaudeAgentMapper.ts - types.ts durable/ ClaudeCliProbe.ts ClaudePrintRunner.ts @@ -73,12 +72,10 @@ Existing exports stay valid: ```ts export { ClaudeCodeAdapter } from './providers/claude/ClaudeCodeAdapter.js'; -export { ClaudeCliProbe } from './providers/claude/durable/ClaudeCliProbe.js'; -export { ClaudePrintRunner } from './providers/claude/durable/ClaudePrintRunner.js'; export { ClaudePrintAgentService } from './providers/claude/durable/ClaudePrintAgentService.js'; ``` -The implementation now exports these directly from `src/providers/claude/...` through `src/index.ts` and `src/adapters/index.ts`. Thin path-level wrapper files were removed because they added no behavior or contract value. +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 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 index 38797d92..9b8c4d05 100644 --- a/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md @@ -53,7 +53,7 @@ Package-root exports remain the compatibility surface. Thin path-level wrappers - `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`, `ClaudeCliProbe`, and `ClaudePrintRunner` contracts. +- Durable service continues using `DurableAgentRepository`, provider-local `ClaudeCliProbe`, and provider-local `ClaudePrintRunner` contracts. ## Error Handling @@ -101,6 +101,11 @@ Package-root exports remain the compatibility surface. Thin path-level wrappers - `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. 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 index 3127919f..6704d26a 100644 --- a/docs/ai/planning/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/planning/2026-08-23-feature-claude-provider-refactor.md @@ -52,7 +52,7 @@ description: Ordered behavior-preserving extraction plan for Claude provider cod ### 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 public exports remain unchanged. + - 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. @@ -105,4 +105,4 @@ description: Ordered behavior-preserving extraction plan for Claude provider cod ## 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, while no-value path-level wrappers were removed. The planned `types.ts` file was intentionally skipped because extracted modules did not need a shared provider-local type barrel. +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/testing/2026-08-23-feature-claude-provider-refactor.md b/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md index 00f82c0a..b746cecb 100644 --- a/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/testing/2026-08-23-feature-claude-provider-refactor.md @@ -41,7 +41,8 @@ description: Characterization and validation plan for behavior-preserving Claude ### Claude Durable Provider Files -- [x] Existing `ClaudeCliProbe`, `ClaudePrintRunner`, and `ClaudePrintAgentService` tests continue to pass through package-root exports. +- [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 @@ -100,6 +101,7 @@ Any pre-existing baseline failures must be recorded before implementation and no ### 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. 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/index.ts b/packages/agent-manager/src/index.ts index ae53a7be..ea9d5424 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -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,21 +59,12 @@ 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 './providers/claude/durable/ClaudeCliProbe.js'; -export type { ClaudeCliProbeOptions } from './providers/claude/durable/ClaudeCliProbe.js'; -export { ClaudePrintRunner } from './providers/claude/durable/ClaudePrintRunner.js'; -export type { - ClaudePrintRunnerOptions, - ClaudePrintRunRequest, - ClaudePrintRunResult, -} from './providers/claude/durable/ClaudePrintRunner.js'; export { ClaudePrintAgentService } from './providers/claude/durable/ClaudePrintAgentService.js'; export type { ClaudePrintAgentServiceOptions, From 6404d261379ca857ab1cd60791730e24be4f462c Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 24 Aug 2026 19:20:25 +0000 Subject: [PATCH 4/4] refactor(agent-manager): remove adapter proxies and restore claude locator rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude provider reorganization left three private delegation methods on ClaudeCodeAdapter (discoverSessions, tryPidFileMatching, getProjectDir) with no production callers — they existed only so the adapter test suite could keep reaching them through `(adapter as any)`. Move those 19 tests onto ClaudeSessionLocator, delete the proxies, and re-narrow tryPidFileMatching and getProjectDir to private now that nothing outside the locator calls them. Also carry across the explanatory comments dropped during the extraction: the PidFileEntry field docs (including why live PID-file status beats JSONL-derived state), the PID_FILE_STALENESS_MS recycling rationale, the lossy path-encoding note on getProjectDir, the worktree caveat on discoverHistoricalSessionFiles, the resume-matching and readMatchingPidFile docs, and the live-status precedence comment on mapSessionToAgent. Co-Authored-By: Claude Opus 5 --- ...-08-23-feature-claude-provider-refactor.md | 4 +- .../adapters/ClaudeCodeAdapter.test.ts | 306 ---------------- .../claude/ClaudeSessionLocator.test.ts | 338 +++++++++++++++++- .../src/providers/claude/ClaudeAgentMapper.ts | 3 + .../src/providers/claude/ClaudeCodeAdapter.ts | 19 +- .../providers/claude/ClaudeSessionLocator.ts | 104 +++++- 6 files changed, 445 insertions(+), 329 deletions(-) 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 index 9b8c4d05..e320cfa7 100644 --- a/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md +++ b/docs/ai/implementation/2026-08-23-feature-claude-provider-refactor.md @@ -91,7 +91,7 @@ Package-root exports remain the compatibility surface. Thin path-level wrappers - 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. - - Kept adapter private compatibility proxies for existing tests that mutate fixture directories. + - 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: @@ -116,4 +116,4 @@ Package-root exports remain the compatibility surface. Thin path-level wrappers ## 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 remain for `discoverSessions`, `tryPidFileMatching`, and `getProjectDir` because the existing test suite exercises those hooks. They delegate to `ClaudeSessionLocator` and are not public package contracts. +- 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/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index a497ef46..074e2cee 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -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__/providers/claude/ClaudeSessionLocator.test.ts b/packages/agent-manager/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts index ff8542c6..eef05485 100644 --- a/packages/agent-manager/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts +++ b/packages/agent-manager/src/__tests__/providers/claude/ClaudeSessionLocator.test.ts @@ -1,10 +1,24 @@ +import type { MockedFunction } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +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[] = []; @@ -25,7 +39,28 @@ function makeProcess(overrides: Partial = {}): ProcessInfo { }; } +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 }); @@ -72,4 +107,305 @@ describe('ClaudeSessionLocator', () => { }, }); }); + 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/providers/claude/ClaudeAgentMapper.ts b/packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts index ad9d7a20..af1b1055 100644 --- a/packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts +++ b/packages/agent-manager/src/providers/claude/ClaudeAgentMapper.ts @@ -25,6 +25,9 @@ export class ClaudeAgentMapper { 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 diff --git a/packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts b/packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts index d1f4dfcc..e5e26eaf 100644 --- a/packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/providers/claude/ClaudeCodeAdapter.ts @@ -10,10 +10,9 @@ import type { } from '../../adapters/AgentAdapter.js'; import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../../utils/process.js'; import { safeStat } from '../../utils/session.js'; -import type { SessionFile } from '../../utils/session.js'; import { ClaudeSessionParser } from './ClaudeSessionParser.js'; import { ClaudeAgentMapper } from './ClaudeAgentMapper.js'; -import { ClaudeSessionLocator, type ClaudeDirectMatch } from './ClaudeSessionLocator.js'; +import { ClaudeSessionLocator } from './ClaudeSessionLocator.js'; /** * Claude Code Adapter @@ -121,21 +120,6 @@ export class ClaudeCodeAdapter implements AgentAdapter { }); } - private discoverSessions(processes: ProcessInfo[]): SessionFile[] { - return this.createLocator().discoverLiveSessions(processes); - } - - private tryPidFileMatching(processes: ProcessInfo[]): { - direct: ClaudeDirectMatch[]; - fallback: ProcessInfo[]; - } { - return this.createLocator().tryPidFileMatching(processes); - } - - private getProjectDir(cwd: string): string { - return this.createLocator().getProjectDir(cwd); - } - getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { return this.parser.getConversation(sessionFilePath, options); } @@ -173,5 +157,4 @@ export class ClaudeCodeAdapter implements AgentAdapter { return summaries; } - } diff --git a/packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts b/packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts index aecbfd54..1290dd90 100644 --- a/packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts +++ b/packages/agent-manager/src/providers/claude/ClaudeSessionLocator.ts @@ -12,17 +12,36 @@ import { type SessionFile, } from '../../utils/session.js'; +/** + * Entry in ~/.claude/sessions/.json written by Claude Code. + * Maps a running process to its session file via PID. + */ interface PidFileEntry { pid: number; sessionId: string; cwd: string; + /** Epoch milliseconds when the Claude Code process started */ startedAt: number; kind: string; entrypoint: string; + /** + * Authoritative live status published by the Claude Code process + * (e.g., 'running', 'waiting', 'idle'). Preferred over JSONL-derived + * status because trailing entries like 'permission-mode' / 'ai-title' + * can mask the real conversational state. + */ status?: string; + /** Short description of what the agent is waiting on (e.g., "approve Read"). */ waitingFor?: string; } +/** + * 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 the agent mapper can prefer them over + * the JSONL-derived heuristic. + */ export interface ClaudeDirectMatch { process: ProcessInfo; sessionFile: SessionFile; @@ -40,6 +59,7 @@ export interface ClaudeSessionLocatorOptions { sessionsDir?: string; } +/** Maximum allowed delta (ms) between process start time and PID file startedAt. */ const PID_FILE_STALENESS_MS = 60000; export class ClaudeSessionLocator { @@ -52,6 +72,16 @@ export class ClaudeSessionLocator { this.sessionsDir = options.sessionsDir ?? path.join(homeDir, '.claude', 'sessions'); } + /** + * 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); const { direct: pidDirect, fallback } = this.tryPidFileMatching(noResume); @@ -67,6 +97,17 @@ export class ClaudeSessionLocator { }; } + /** + * 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 }> = []; @@ -76,6 +117,10 @@ export class ClaudeSessionLocator { 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 }); @@ -85,11 +130,31 @@ export class ClaudeSessionLocator { return out; } - getProjectDir(cwd: string): string { + /** + * 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); } + /** + * Discover session files for the given processes. + * + * For each unique process CWD, encodes it to derive the expected + * ~/.claude/projects// directory, then gets session file birthtimes + * via a single batched stat call across all directories. + */ discoverLiveSessions(processes: ProcessInfo[]): SessionFile[] { const dirToCwd = new Map(); @@ -119,6 +184,12 @@ export class ClaudeSessionLocator { return files; } + /** + * Match processes via `claude --resume ` in their command line. + * This works for resumed sessions, where the JSONL was created earlier + * (so its birthtime is far from the process startTime and the legacy + * matcher can't pair them) and the PID file may also be misaligned. + */ private tryResumeMatching(processes: ProcessInfo[]): { direct: ClaudeDirectMatch[]; fallback: ProcessInfo[]; @@ -142,6 +213,10 @@ export class ClaudeSessionLocator { continue; } + // Best-effort: the PID file (if present for this proc) is the + // authoritative source of live status. We still match the session + // via --resume, but we read the PID file alongside to capture + // status/waitingFor. const pidEntry = this.readMatchingPidFile(proc.pid, proc.startTime); direct.push({ @@ -166,6 +241,15 @@ export class ClaudeSessionLocator { return match?.[1] ?? null; } + /** + * Read and parse ~/.claude/sessions/.json, returning null on any + * I/O / parse failure or when the file is stale relative to the live + * process. + * + * "Stale" means the PID file's startedAt diverges from the process's + * start time by more than {@link PID_FILE_STALENESS_MS} — typically + * a previous Claude Code process recycled the same PID without cleanup. + */ private readMatchingPidFile(pid: number, procStartTime?: Date): PidFileEntry | null { const pidFilePath = path.join(this.sessionsDir, `${pid}.json`); try { @@ -186,6 +270,12 @@ export class ClaudeSessionLocator { } } + /** + * Map the PID file's live status string to {@link AgentStatus}. + * + * Returns undefined for missing / unrecognized values so the caller + * can fall back to JSONL-derived heuristics. + */ private mapPidStatus(status: string | undefined): AgentStatus | undefined { switch (status) { case 'running': @@ -199,7 +289,17 @@ export class ClaudeSessionLocator { } } - tryPidFileMatching(processes: ProcessInfo[]): { + /** + * Attempt to match each process to its session via ~/.claude/sessions/.json. + * + * Returns: + * direct — processes matched authoritatively via PID file + * fallback — processes with no valid PID file (sent to legacy matching) + * + * Per-process fallback triggers on: file absent, malformed JSON, + * stale startedAt (>60s from proc.startTime), or missing JSONL. + */ + private tryPidFileMatching(processes: ProcessInfo[]): { direct: ClaudeDirectMatch[]; fallback: ProcessInfo[]; } {