From 2ca76f0d86f57acdd0f8359b663d084c5368e3f2 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 10 Mar 2026 18:21:36 +0700 Subject: [PATCH 1/4] Reimplement Claude Code adapter --- .../feature-reimpl-claude-code-adapter.md | 111 ++++++++++++++++++ .../feature-reimpl-claude-code-adapter.md | 68 +++++++++++ .../feature-reimpl-claude-code-adapter.md | 105 +++++++++++++++++ .../feature-reimpl-claude-code-adapter.md | 62 ++++++++++ .../feature-reimpl-claude-code-adapter.md | 74 ++++++++++++ 5 files changed, 420 insertions(+) create mode 100644 docs/ai/design/feature-reimpl-claude-code-adapter.md create mode 100644 docs/ai/implementation/feature-reimpl-claude-code-adapter.md create mode 100644 docs/ai/planning/feature-reimpl-claude-code-adapter.md create mode 100644 docs/ai/requirements/feature-reimpl-claude-code-adapter.md create mode 100644 docs/ai/testing/feature-reimpl-claude-code-adapter.md diff --git a/docs/ai/design/feature-reimpl-claude-code-adapter.md b/docs/ai/design/feature-reimpl-claude-code-adapter.md new file mode 100644 index 0000000..0d3a367 --- /dev/null +++ b/docs/ai/design/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,111 @@ +--- +phase: design +title: "Re-implement Claude Code Adapter - Design" +feature: reimpl-claude-code-adapter +description: Architecture and implementation design for re-implementing ClaudeCodeAdapter using CodexAdapter patterns +--- + +# Design: Re-implement Claude Code Adapter + +## Architecture Overview + +```mermaid +graph TD + User[User runs ai-devkit agent list/open] --> Cmd[packages/cli/src/commands/agent.ts] + Cmd --> Manager[AgentManager] + + subgraph Pkg[@ai-devkit/agent-manager] + Manager --> Claude[ClaudeCodeAdapter ← reimplemented] + Manager --> Codex[CodexAdapter] + Claude --> Proc[process utils] + Claude --> File[file utils] + Claude --> Types[AgentAdapter/AgentInfo/AgentStatus] + Focus[TerminalFocusManager] + end + + Cmd --> Focus + Cmd --> Output[CLI table/json rendering] +``` + +Responsibilities: +- `ClaudeCodeAdapter`: discover running Claude processes, match with sessions via process start time + CWD, emit `AgentInfo` +- `AgentManager`: aggregate Claude + Codex adapter results (unchanged) +- CLI command: register adapters, display results (unchanged) + +## Data Models + +- Reuse existing `AgentAdapter`, `AgentInfo`, `AgentStatus`, and `AgentType` models — no changes +- `AgentType` already supports `claude`; adapter emits `type: 'claude'` +- Internal session model (`ClaudeSession`) updated to include `sessionStart` for time-based matching: + - `sessionId`: from JSONL filename + - `projectPath`: from `sessions-index.json` → `originalPath` + - `lastCwd`: from session JSONL entries + - `slug`: from session JSONL entries + - `sessionStart`: earliest timestamp in session (first entry or file creation) + - `lastActive`: latest timestamp in session + - `lastEntryType`: type of last session entry (for status determination) + - `summary`: from `history.jsonl` lookup (simple, not indexed) + +## API Design + +### Package Exports +- No changes to `packages/agent-manager/src/adapters/index.ts` +- No changes to `packages/agent-manager/src/index.ts` +- `ClaudeCodeAdapter` public API remains identical + +### CLI Integration +- No changes to `packages/cli/src/commands/agent.ts` + +## Component Breakdown + +1. `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` — full rewrite + - Adopt CodexAdapter's structural patterns: + - `listClaudeProcesses()`: extract process listing + - `calculateSessionScanLimit()`: bounded scanning + - `getProcessStartTimes()`: process elapsed time → start time mapping + - `findSessionFiles()`: bounded file discovery with recent + process-day windows + - `readSession()`: parse single session (meta + last entry + timestamps) + - `selectBestSession()`: filter + rank candidates by start time + - `filterCandidateSessions()`: mode-based filtering (`cwd` / `missing-cwd` / `any`) + - `rankCandidatesByStartTime()`: tolerance-based ranking + - `assignSessionsForMode()`: orchestrate matching per mode + - `addMappedSessionAgent()` / `addProcessOnlyAgent()`: tracking helpers + - `determineStatus()`: status from entry type + recency + - `generateAgentName()`: project basename + disambiguation + + - Claude-specific adaptations (differs from Codex): + - Session discovery: walk `~/.claude/projects/*/` reading `sessions-index.json` + `*.jsonl` (not date-based dirs) + - `sessionStart`: parsed from first JSONL entry timestamp (not `session_meta` type) + - Summary: lookup from `~/.claude/history.jsonl` by sessionId (simple scan, no complex indexing) + - Status: map Claude entry types (`user`, `assistant`, `progress`, `thinking`, `system`) to `AgentStatus` + - Name: use slug for disambiguation (Claude sessions have slugs) + +2. `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` — update tests + - Adapt mocking to match new internal structure + - Add tests for process start time matching + - Add tests for bounded session scanning + - Keep all existing behavioral assertions + +## Design Decisions + +- Decision: Rewrite ClaudeCodeAdapter internals, keep public API identical. + - Rationale: zero impact on consumers; purely structural improvement. +- Decision: Add process start time matching for session pairing. + - Rationale: improves accuracy when multiple Claude processes share the same CWD, consistent with CodexAdapter. +- Decision: Bound session scanning with MIN/MAX limits. + - Rationale: keeps latency predictable as history grows, consistent with CodexAdapter. +- Decision: Replace `cwd` → `history` → `project-parent` flow with `cwd` → `missing-cwd` → `any`. + - Rationale: simpler, consistent with CodexAdapter, and `project-parent` behavior is subsumed by start-time ranking in `any` mode. +- Decision: Keep history.jsonl summary lookup simple (scan last N entries, match by sessionId). + - Rationale: avoids complex indexing; keeps it simple at first per user request. +- Decision: Keep status-threshold values consistent across adapters (5-minute IDLE). + - Rationale: preserves cross-agent behavior consistency. +- Decision: Keep matching orchestration in explicit phases with extracted helper methods and PID/session tracking sets. + - Rationale: mirrors CodexAdapter structure for maintainability. + +## Non-Functional Requirements + +- Performance: bounded session scanning ensures `agent list` latency stays predictable. +- Reliability: adapter failures remain isolated (AgentManager catches per-adapter errors). +- Maintainability: structural alignment with CodexAdapter means one pattern to understand. +- Security: only reads local metadata/process info already permitted by existing CLI behavior. diff --git a/docs/ai/implementation/feature-reimpl-claude-code-adapter.md b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md new file mode 100644 index 0000000..1bc8308 --- /dev/null +++ b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,68 @@ +--- +phase: implementation +title: "Re-implement Claude Code Adapter - Implementation" +feature: reimpl-claude-code-adapter +description: Implementation notes for re-implementing ClaudeCodeAdapter +--- + +# Implementation Guide: Re-implement Claude Code Adapter + +## Development Setup + +- Worktree: `.worktrees/feature-reimpl-claude-code-adapter` +- Branch: `feature-reimpl-claude-code-adapter` +- Dependencies: `npm ci` in worktree root + +## Code Structure + +Single file rewrite: +``` +packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts ← full rewrite +packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts ← update tests +``` + +No changes to exports, index files, or CLI command. + +## Implementation Notes + +### Method Mapping (Current → New) + +| Current Method | New Method (CodexAdapter pattern) | +|---|---| +| `detectAgents()` | `detectAgents()` — restructured with 3-phase matching | +| `readSessions()` (reads all) | `readSessions(limit, processStartByPid)` — bounded | +| — | `listClaudeProcesses()` — extracted | +| — | `calculateSessionScanLimit()` — new | +| — | `getProcessStartTimes()` — new | +| — | `findSessionFiles()` — adapted for Claude paths | +| `readSessionLog()` | `readSession()` — single session, returns `ClaudeSession` | +| `readHistory()` + `indexHistoryByProjectPath()` | `readHistory()` — simple scan, no indexing | +| `selectBestSession()` | `selectBestSession()` — adds start-time ranking | +| — | `filterCandidateSessions()` — extracted | +| — | `rankCandidatesByStartTime()` — new | +| `assignSessionsForMode()` | `assignSessionsForMode()` — same structure | +| `assignHistoryEntriesForExactProcessCwd()` | Removed — subsumed by `any` mode | +| `mapSessionToAgent()` | `mapSessionToAgent()` — simplified | +| `mapProcessOnlyAgent()` | `mapProcessOnlyAgent()` — simplified | +| `mapHistoryToAgent()` | Removed — integrated into session mapping | +| `determineStatus()` | `determineStatus()` — uses `lastEntryType` string | +| `generateAgentName()` | `generateAgentName()` — keeps slug disambiguation | + +### Claude-Specific Adaptations + +1. **Session discovery**: Walk `~/.claude/projects/*/` dirs, read `sessions-index.json` for `originalPath`, collect `*.jsonl` files with mtime. Sort by mtime descending, take top N. + +2. **Session parsing**: Read first line for `sessionStart` timestamp. Read last 100 lines for `lastEntryType`, `lastActive`, `lastCwd`, `slug`. + +3. **Summary**: Read last 100 entries from `~/.claude/history.jsonl`, find matching `sessionId`. No grouping/indexing. + +4. **Status mapping**: `user` (+ interrupted check) → RUNNING/WAITING, `progress`/`thinking` → RUNNING, `assistant` → WAITING, `system` → IDLE, idle threshold → IDLE. + +5. **Name generation**: project basename + slug disambiguation (keep existing logic). + +## Error Handling + +- `readSession()`: try/catch per file, skip on error +- `getProcessStartTimes()`: return empty map on failure +- `findSessionFiles()`: return empty array if dirs don't exist +- All errors logged to `console.error`, never thrown to caller diff --git a/docs/ai/planning/feature-reimpl-claude-code-adapter.md b/docs/ai/planning/feature-reimpl-claude-code-adapter.md new file mode 100644 index 0000000..fd2c177 --- /dev/null +++ b/docs/ai/planning/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,105 @@ +--- +phase: planning +title: "Re-implement Claude Code Adapter - Planning" +feature: reimpl-claude-code-adapter +description: Task breakdown for re-implementing ClaudeCodeAdapter +--- + +# Planning: Re-implement Claude Code Adapter + +## Milestones + +- [ ] Milestone 1: Core rewrite — adapter compiles and passes existing tests +- [ ] Milestone 2: Process start time matching — improved accuracy +- [ ] Milestone 3: Bounded scanning + test coverage — performance + quality + +## Task Breakdown + +### Phase 1: Core Structural Rewrite + +- [ ] Task 1.1: Restructure `ClaudeCodeAdapter` internal session model + - Add `sessionStart`, `lastEntryType`, `summary` fields to `ClaudeSession` + - Remove `lastEntry` (replace with `lastEntryType`) + - Keep `slug` field (Claude-specific) + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +- [ ] Task 1.2: Extract `listClaudeProcesses()` helper + - Mirror CodexAdapter's `listCodexProcesses()` pattern + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +- [ ] Task 1.3: Rewrite `readSessions()` with bounded scanning + - Implement `calculateSessionScanLimit()` with same constants as CodexAdapter + - Implement `findSessionFiles()` adapted for Claude's `~/.claude/projects/*/` structure + - Limit to most-recent N session files by mtime + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +- [ ] Task 1.4: Rewrite `readSession()` for single session parsing + - Parse first entry for `sessionStart` timestamp + - Read last N lines for `lastEntryType`, `lastActive`, `lastCwd`, `slug` + - Simple history.jsonl lookup for summary + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +- [ ] Task 1.5: Rewrite matching flow to `cwd` → `missing-cwd` → `any` + - Implement `assignSessionsForMode()`, `filterCandidateSessions()`, `addMappedSessionAgent()`, `addProcessOnlyAgent()` + - Remove `assignHistoryEntriesForExactProcessCwd()` and `project-parent` mode + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +- [ ] Task 1.6: Rewrite `determineStatus()` and `generateAgentName()` + - Status: same logic but using `lastEntryType` string instead of `lastEntry` object + - Name: keep slug-based disambiguation + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +### Phase 2: Process Start Time Matching + +- [ ] Task 2.1: Implement `getProcessStartTimes()` + - Use `ps -o pid=,etime=` to get elapsed time, calculate start time + - Implement `parseElapsedSeconds()` helper + - Skip in test environment (`JEST_WORKER_ID`) + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +- [ ] Task 2.2: Implement `rankCandidatesByStartTime()` + - Tolerance-based ranking matching CodexAdapter pattern + - Use same `PROCESS_SESSION_TIME_TOLERANCE_MS` constant + - Integrate into `selectBestSession()` + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +- [ ] Task 2.3: Wire process start times into `detectAgents()` and `assignSessionsForMode()` + - Pass `processStartByPid` through matching pipeline + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +### Phase 3: Tests + Cleanup + +- [ ] Task 3.1: Update existing unit tests for new internal structure + - Update mocking to match new method signatures + - Keep all behavioral assertions + - Files: `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` + +- [ ] Task 3.2: Add tests for process start time matching + - Test `getProcessStartTimes()`, `parseElapsedSeconds()`, `rankCandidatesByStartTime()` + - Test multi-process same-CWD disambiguation + - Files: `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` + +- [ ] Task 3.3: Add tests for bounded session scanning + - Test `calculateSessionScanLimit()`, `findSessionFiles()` + - Verify scan limits are respected + - Files: `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` + +- [ ] Task 3.4: Verify CLI integration (manual smoke test) + - Run `agent list` with Claude processes, confirm output matches expectations + - No code changes expected + +## Dependencies + +- Task 1.1 → Tasks 1.2–1.6 (session model must be defined first) +- Tasks 1.2–1.6 can be done in any order after 1.1 +- Phase 2 depends on Phase 1 completion +- Phase 3 depends on Phase 2 completion + +## Risks & Mitigation + +- **Risk**: Session file format assumptions may differ from edge cases. + - Mitigation: Keep `readSession()` defensive with try/catch; test with varied fixtures. +- **Risk**: Process start time unavailable on some systems. + - Mitigation: Graceful fallback to recency-based ranking (same as CodexAdapter). +- **Risk**: Bounded scanning may miss relevant sessions. + - Mitigation: Include process-start-day window files (same as CodexAdapter pattern). diff --git a/docs/ai/requirements/feature-reimpl-claude-code-adapter.md b/docs/ai/requirements/feature-reimpl-claude-code-adapter.md new file mode 100644 index 0000000..5511428 --- /dev/null +++ b/docs/ai/requirements/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,62 @@ +--- +phase: requirements +title: "Re-implement Claude Code Adapter - Requirements" +feature: reimpl-claude-code-adapter +description: Requirements for re-implementing ClaudeCodeAdapter using the same architectural patterns as CodexAdapter +--- + +# Requirements: Re-implement Claude Code Adapter + +## Problem Statement + +The current `ClaudeCodeAdapter` (598 lines) uses a different architectural approach than the newer `CodexAdapter` (585 lines). Key issues: + +- **No process start time matching**: Claude adapter relies solely on CWD and parent-path matching to pair processes with sessions, which is fragile when multiple Claude processes share the same project directory. +- **Unbounded session scanning**: Reads all session files across all projects in `~/.claude/projects/`, which degrades performance as session history grows. +- **Inconsistent matching phases**: Uses `cwd` → `history` → `project-parent` → `process-only` flow, while Codex uses the cleaner `cwd` → `missing-cwd` → `any` pattern with extracted helpers and PID/session tracking sets. +- **Structural divergence**: Two adapters in the same package follow different patterns, making maintenance harder. + +## Goals & Objectives + +### Primary Goals +- Re-implement `ClaudeCodeAdapter` using the same architectural patterns as `CodexAdapter` +- Add process start time matching (`getProcessStartTimes` + `rankCandidatesByStartTime`) for accurate process-session pairing +- Introduce bounded session scanning to keep `agent list` latency predictable +- Align matching phases to `cwd` → `missing-cwd` → `any` with extracted helper methods + +### Secondary Goals +- Improve disambiguation when multiple Claude processes share the same CWD +- Keep history.jsonl integration for summaries (simple read, no complex indexing) + +### Non-Goals +- Changing Claude Code's session file structure (`~/.claude/projects/`) +- Modifying the public API (`AgentAdapter` interface, `AgentInfo` shape, constructor signature) +- Changing CLI output or UX behavior +- Adding new adapter capabilities beyond what CodexAdapter provides + +## User Stories & Use Cases + +- As a developer running multiple Claude Code sessions, I want `agent list` to accurately pair each process with its correct session, even when sessions share the same project directory. +- As a developer with large session history, I want `agent list` to remain fast regardless of how many past sessions exist. +- As a maintainer of `@ai-devkit/agent-manager`, I want both adapters to follow the same structural patterns so I can reason about and modify them consistently. + +## Success Criteria + +- All existing `ClaudeCodeAdapter` unit tests pass (with updated mocking as needed) +- Process-session matching accuracy improves for multi-session-same-CWD scenarios +- Session scanning is bounded (configurable limits, not reading all files) +- Code structure mirrors `CodexAdapter` (same matching phase flow, extracted helpers) +- No changes to public exports or `AgentInfo` output shape +- `agent list` latency does not regress + +## Constraints & Assumptions + +- **Session structure is fixed**: Claude Code stores sessions in `~/.claude/projects/{encoded-path}/` with `sessions-index.json` and `*.jsonl` files — this cannot change. +- **history.jsonl**: Available at `~/.claude/history.jsonl` for summary extraction — keep simple. +- **Process detection**: Uses existing `listProcesses()` utility — no changes. +- **Status thresholds**: Must remain aligned across adapters (5-minute IDLE threshold). +- **Platform**: macOS/Linux only (same as existing adapter). + +## Questions & Open Items + +- None — scope is well-defined as an internal refactor following established CodexAdapter patterns. diff --git a/docs/ai/testing/feature-reimpl-claude-code-adapter.md b/docs/ai/testing/feature-reimpl-claude-code-adapter.md new file mode 100644 index 0000000..1dbf5ba --- /dev/null +++ b/docs/ai/testing/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,74 @@ +--- +phase: testing +title: "Re-implement Claude Code Adapter - Testing" +feature: reimpl-claude-code-adapter +description: Testing strategy for re-implemented ClaudeCodeAdapter +--- + +# Testing Strategy: Re-implement Claude Code Adapter + +## Test Coverage Goals + +- Unit test coverage target: 100% of new/changed code +- All existing behavioral test assertions must continue to pass +- New tests for process start time matching and bounded scanning + +## Unit Tests + +### ClaudeCodeAdapter Core + +- [ ] Detects Claude processes and returns AgentInfo array +- [ ] Returns empty array when no Claude processes running +- [ ] Matches process to session by exact CWD +- [ ] Matches process to session when session has no CWD (missing-cwd mode) +- [ ] Falls back to any available session when no CWD match +- [ ] Handles process with no matching session (process-only agent) +- [ ] Multiple processes with different CWDs matched correctly +- [ ] Multiple processes with same CWD disambiguated by start time + +### Process Start Time Matching + +- [ ] `getProcessStartTimes()` parses `ps` output correctly +- [ ] `parseElapsedSeconds()` handles `MM:SS`, `HH:MM:SS`, `D-HH:MM:SS` formats +- [ ] `rankCandidatesByStartTime()` prefers sessions within tolerance window +- [ ] `rankCandidatesByStartTime()` falls back to recency when no start time +- [ ] Graceful fallback when `ps` command fails + +### Bounded Session Scanning + +- [ ] `calculateSessionScanLimit()` respects MIN/MAX bounds +- [ ] `findSessionFiles()` returns at most N files by mtime +- [ ] Session files from process-start-day window included + +### Status Determination + +- [ ] `user` entry type → RUNNING +- [ ] `user` with interrupted content → WAITING +- [ ] `assistant` entry type → WAITING +- [ ] `progress`/`thinking` → RUNNING +- [ ] `system` → IDLE +- [ ] Age > 5 minutes → IDLE (overrides entry type) +- [ ] No last entry → UNKNOWN + +### Name Generation + +- [ ] Uses project basename as name +- [ ] Appends slug when multiple sessions for same project +- [ ] Falls back to sessionId prefix when no slug + +### History Summary + +- [ ] Reads summary from history.jsonl by sessionId +- [ ] Falls back to default summary when no history match + +## Test Data + +- Mock `listProcesses()` to return controlled process lists +- Mock `fs` operations for session file reads +- Mock `execSync` for `ps` output in start time tests +- Use inline JSONL fixtures for session and history data + +## Test Reporting & Coverage + +- Run: `npx jest packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts --coverage` +- Target: 100% line/branch coverage for `ClaudeCodeAdapter.ts` From d738f38497266d49c6450f2e074dd314da35cf46 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 10 Mar 2026 22:32:47 +0700 Subject: [PATCH 2/4] Reimplement Claude Code adapter --- .../feature-reimpl-claude-code-adapter.md | 19 +- .../feature-reimpl-claude-code-adapter.md | 11 +- .../feature-reimpl-claude-code-adapter.md | 57 +- .../feature-reimpl-claude-code-adapter.md | 69 +- .../adapters/ClaudeCodeAdapter.test.ts | 779 +++++++++++++++++- .../src/adapters/ClaudeCodeAdapter.ts | 654 +++++++++------ 6 files changed, 1238 insertions(+), 351 deletions(-) diff --git a/docs/ai/design/feature-reimpl-claude-code-adapter.md b/docs/ai/design/feature-reimpl-claude-code-adapter.md index 0d3a367..c11af22 100644 --- a/docs/ai/design/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/design/feature-reimpl-claude-code-adapter.md @@ -38,7 +38,7 @@ Responsibilities: - `AgentType` already supports `claude`; adapter emits `type: 'claude'` - Internal session model (`ClaudeSession`) updated to include `sessionStart` for time-based matching: - `sessionId`: from JSONL filename - - `projectPath`: from `sessions-index.json` → `originalPath` + - `projectPath`: from `sessions-index.json` → `originalPath`, falls back to `lastCwd` when index missing - `lastCwd`: from session JSONL entries - `slug`: from session JSONL entries - `sessionStart`: earliest timestamp in session (first entry or file creation) @@ -66,7 +66,9 @@ Responsibilities: - `findSessionFiles()`: bounded file discovery with recent + process-day windows - `readSession()`: parse single session (meta + last entry + timestamps) - `selectBestSession()`: filter + rank candidates by start time - - `filterCandidateSessions()`: mode-based filtering (`cwd` / `missing-cwd` / `any`) + - `filterCandidateSessions()`: mode-based filtering (`cwd` / `missing-cwd` / `parent-child`) + - `isClaudeExecutable()`: precise executable detection (basename check, not substring) + - `isChildPath()`: parent-child path relationship check - `rankCandidatesByStartTime()`: tolerance-based ranking - `assignSessionsForMode()`: orchestrate matching per mode - `addMappedSessionAgent()` / `addProcessOnlyAgent()`: tracking helpers @@ -74,7 +76,8 @@ Responsibilities: - `generateAgentName()`: project basename + disambiguation - Claude-specific adaptations (differs from Codex): - - Session discovery: walk `~/.claude/projects/*/` reading `sessions-index.json` + `*.jsonl` (not date-based dirs) + - Session discovery: walk `~/.claude/projects/*/` reading `*.jsonl` files. Uses `sessions-index.json` for `originalPath` when available, falls back to `lastCwd` from session content when index is missing (common in practice) + - Bounded scanning: collect all `*.jsonl` files with mtime, sort by mtime descending, take top N. No process-day window (Claude sessions aren't organized by date — mtime-based cutoff is sufficient since we already stat files during discovery). - `sessionStart`: parsed from first JSONL entry timestamp (not `session_meta` type) - Summary: lookup from `~/.claude/history.jsonl` by sessionId (simple scan, no complex indexing) - Status: map Claude entry types (`user`, `assistant`, `progress`, `thinking`, `system`) to `AgentStatus` @@ -94,14 +97,20 @@ Responsibilities: - Rationale: improves accuracy when multiple Claude processes share the same CWD, consistent with CodexAdapter. - Decision: Bound session scanning with MIN/MAX limits. - Rationale: keeps latency predictable as history grows, consistent with CodexAdapter. -- Decision: Replace `cwd` → `history` → `project-parent` flow with `cwd` → `missing-cwd` → `any`. - - Rationale: simpler, consistent with CodexAdapter, and `project-parent` behavior is subsumed by start-time ranking in `any` mode. +- Decision: Replace `cwd` → `history` → `project-parent` flow with `cwd` → `missing-cwd` → `parent-child`. + - Rationale: simpler, consistent with CodexAdapter. `parent-child` mode matches sessions where process CWD is a parent or child of session project path, avoiding the greedy matching of `any` mode which caused cross-project session stealing. +- Decision: Use precise executable detection (`isClaudeExecutable`) instead of substring matching. + - Rationale: `command.includes('claude')` falsely matched processes whose path arguments contained "claude" (e.g., nx daemon in a worktree named `feature-reimpl-claude-code-adapter`). Checking the basename of the first command word (`claude` or `claude.exe`) matches CodexAdapter's `isCodexExecutable` pattern. +- Decision: Make `sessions-index.json` optional, fall back to `lastCwd` from session content. + - Rationale: most Claude project directories lack `sessions-index.json` in practice, causing entire projects to be skipped during session discovery. Using `lastCwd` from the JSONL entries provides a reliable fallback. - Decision: Keep history.jsonl summary lookup simple (scan last N entries, match by sessionId). - Rationale: avoids complex indexing; keeps it simple at first per user request. - Decision: Keep status-threshold values consistent across adapters (5-minute IDLE). - Rationale: preserves cross-agent behavior consistency. - Decision: Keep matching orchestration in explicit phases with extracted helper methods and PID/session tracking sets. - Rationale: mirrors CodexAdapter structure for maintainability. +- Decision: Use mtime-based bounded scanning without process-day window. + - Rationale: Claude sessions use project-based directories (not date-based like Codex), so date-window lookup isn't cheap. Mtime-based top-N is sufficient and simpler. ## Non-Functional Requirements diff --git a/docs/ai/implementation/feature-reimpl-claude-code-adapter.md b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md index 1bc8308..7ac1959 100644 --- a/docs/ai/implementation/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md @@ -41,7 +41,9 @@ No changes to exports, index files, or CLI command. | — | `filterCandidateSessions()` — extracted | | — | `rankCandidatesByStartTime()` — new | | `assignSessionsForMode()` | `assignSessionsForMode()` — same structure | -| `assignHistoryEntriesForExactProcessCwd()` | Removed — subsumed by `any` mode | +| `assignHistoryEntriesForExactProcessCwd()` | Removed — subsumed by `parent-child` mode | +| — | `isClaudeExecutable()` — precise executable basename check | +| — | `isChildPath()` — parent-child path relationship check | | `mapSessionToAgent()` | `mapSessionToAgent()` — simplified | | `mapProcessOnlyAgent()` | `mapProcessOnlyAgent()` — simplified | | `mapHistoryToAgent()` | Removed — integrated into session mapping | @@ -50,7 +52,7 @@ No changes to exports, index files, or CLI command. ### Claude-Specific Adaptations -1. **Session discovery**: Walk `~/.claude/projects/*/` dirs, read `sessions-index.json` for `originalPath`, collect `*.jsonl` files with mtime. Sort by mtime descending, take top N. +1. **Session discovery**: Walk `~/.claude/projects/*/` dirs, collect `*.jsonl` files with mtime. Use `sessions-index.json` for `originalPath` when available; when missing (common in practice), set `projectPath` to empty and derive from `lastCwd` in session content during `readSession()`. Sort by mtime descending, take top N. 2. **Session parsing**: Read first line for `sessionStart` timestamp. Read last 100 lines for `lastEntryType`, `lastActive`, `lastCwd`, `slug`. @@ -60,9 +62,14 @@ No changes to exports, index files, or CLI command. 5. **Name generation**: project basename + slug disambiguation (keep existing logic). +6. **Process detection**: `canHandle()` uses `isClaudeExecutable()` which checks `path.basename()` of the first word in the command. Only matches `claude` or `claude.exe`, not processes with "claude" in path arguments (e.g., nx daemon running in a worktree named `feature-reimpl-claude-code-adapter`). + +7. **Matching modes**: `cwd` → exact CWD match, `missing-cwd` → sessions with no `projectPath`, `parent-child` → process CWD is a parent or child of session project/lastCwd path. The `parent-child` mode replaces the original `any` mode which was too greedy and caused cross-project session stealing. + ## Error Handling - `readSession()`: try/catch per file, skip on error - `getProcessStartTimes()`: return empty map on failure - `findSessionFiles()`: return empty array if dirs don't exist +- `sessions-index.json` missing: graceful fallback to empty `projectPath`, filled from `lastCwd` - All errors logged to `console.error`, never thrown to caller diff --git a/docs/ai/planning/feature-reimpl-claude-code-adapter.md b/docs/ai/planning/feature-reimpl-claude-code-adapter.md index fd2c177..0a5e5e7 100644 --- a/docs/ai/planning/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/planning/feature-reimpl-claude-code-adapter.md @@ -9,82 +9,83 @@ description: Task breakdown for re-implementing ClaudeCodeAdapter ## Milestones -- [ ] Milestone 1: Core rewrite — adapter compiles and passes existing tests -- [ ] Milestone 2: Process start time matching — improved accuracy -- [ ] Milestone 3: Bounded scanning + test coverage — performance + quality +- [x] Milestone 1: Core rewrite — adapter compiles and passes existing tests +- [x] Milestone 2: Process start time matching — improved accuracy +- [x] Milestone 3: Bounded scanning + test coverage — performance + quality ## Task Breakdown ### Phase 1: Core Structural Rewrite -- [ ] Task 1.1: Restructure `ClaudeCodeAdapter` internal session model +- [x] Task 1.1: Restructure `ClaudeCodeAdapter` internal session model - Add `sessionStart`, `lastEntryType`, `summary` fields to `ClaudeSession` - Remove `lastEntry` (replace with `lastEntryType`) - Keep `slug` field (Claude-specific) - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` -- [ ] Task 1.2: Extract `listClaudeProcesses()` helper +- [x] Task 1.2: Extract `listClaudeProcesses()` helper - Mirror CodexAdapter's `listCodexProcesses()` pattern - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` -- [ ] Task 1.3: Rewrite `readSessions()` with bounded scanning +- [x] Task 1.3: Rewrite `readSessions()` with bounded scanning - Implement `calculateSessionScanLimit()` with same constants as CodexAdapter - Implement `findSessionFiles()` adapted for Claude's `~/.claude/projects/*/` structure - - Limit to most-recent N session files by mtime + - Collect all `*.jsonl` with mtime, sort descending, take top N (no process-day window — mtime sufficient for project-based dirs) - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` -- [ ] Task 1.4: Rewrite `readSession()` for single session parsing +- [x] Task 1.4: Rewrite `readSession()` for single session parsing - Parse first entry for `sessionStart` timestamp - Read last N lines for `lastEntryType`, `lastActive`, `lastCwd`, `slug` - Simple history.jsonl lookup for summary - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` -- [ ] Task 1.5: Rewrite matching flow to `cwd` → `missing-cwd` → `any` +- [x] Task 1.5: Rewrite matching flow to `cwd` → `missing-cwd` → `parent-child` - Implement `assignSessionsForMode()`, `filterCandidateSessions()`, `addMappedSessionAgent()`, `addProcessOnlyAgent()` - - Remove `assignHistoryEntriesForExactProcessCwd()` and `project-parent` mode + - Remove `assignHistoryEntriesForExactProcessCwd()` and old `project-parent` mode + - `parent-child` mode matches when process CWD is parent/child of session path (avoids greedy `any` mode) - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` -- [ ] Task 1.6: Rewrite `determineStatus()` and `generateAgentName()` +- [x] Task 1.6: Rewrite `determineStatus()` and `generateAgentName()` - Status: same logic but using `lastEntryType` string instead of `lastEntry` object - Name: keep slug-based disambiguation - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` ### Phase 2: Process Start Time Matching -- [ ] Task 2.1: Implement `getProcessStartTimes()` +- [x] Task 2.1: Implement `getProcessStartTimes()` - Use `ps -o pid=,etime=` to get elapsed time, calculate start time - Implement `parseElapsedSeconds()` helper - Skip in test environment (`JEST_WORKER_ID`) - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` -- [ ] Task 2.2: Implement `rankCandidatesByStartTime()` +- [x] Task 2.2: Implement `rankCandidatesByStartTime()` - Tolerance-based ranking matching CodexAdapter pattern - Use same `PROCESS_SESSION_TIME_TOLERANCE_MS` constant - Integrate into `selectBestSession()` - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` -- [ ] Task 2.3: Wire process start times into `detectAgents()` and `assignSessionsForMode()` +- [x] Task 2.3: Wire process start times into `detectAgents()` and `assignSessionsForMode()` - Pass `processStartByPid` through matching pipeline - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` ### Phase 3: Tests + Cleanup -- [ ] Task 3.1: Update existing unit tests for new internal structure +- [x] Task 3.1: Update existing unit tests for new internal structure - Update mocking to match new method signatures - Keep all behavioral assertions - Files: `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` -- [ ] Task 3.2: Add tests for process start time matching +- [x] Task 3.2: Add tests for process start time matching - Test `getProcessStartTimes()`, `parseElapsedSeconds()`, `rankCandidatesByStartTime()` - Test multi-process same-CWD disambiguation - Files: `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` -- [ ] Task 3.3: Add tests for bounded session scanning +- [x] Task 3.3: Add tests for bounded session scanning - Test `calculateSessionScanLimit()`, `findSessionFiles()` - Verify scan limits are respected - Files: `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` -- [ ] Task 3.4: Verify CLI integration (manual smoke test) +- [x] Task 3.4: Verify CLI integration (manual smoke test) - Run `agent list` with Claude processes, confirm output matches expectations - No code changes expected @@ -95,6 +96,26 @@ description: Task breakdown for re-implementing ClaudeCodeAdapter - Phase 2 depends on Phase 1 completion - Phase 3 depends on Phase 2 completion +## Progress Summary + +All tasks complete. ClaudeCodeAdapter rewritten from 598 to ~740 lines following CodexAdapter patterns. Key changes: +- Added process start time matching (`getProcessStartTimes`, `rankCandidatesByStartTime`) +- Bounded session scanning (`calculateSessionScanLimit`, `findSessionFiles` with mtime sort + limit) +- Restructured matching to `cwd` → `missing-cwd` → `parent-child` phases +- Simplified session model: `lastEntryType` + `isInterrupted` instead of full `lastEntry` object +- History.jsonl kept simple: summary lookup by sessionId for matched sessions, CWD lookup for process-only +- All 100 tests pass (4 suites), TypeScript compiles clean + +Runtime fixes discovered during integration testing: +- **`any` → `parent-child` mode**: `any` mode was too greedy, stealing sessions from unrelated projects. Replaced with `parent-child` mode that only matches when process CWD has a parent-child relationship with session path. +- **`isClaudeExecutable`**: `command.includes('claude')` falsely matched nx daemon processes whose path arguments contained "claude" (from worktree directory names). Changed to check basename of the first command word, matching CodexAdapter's `isCodexExecutable` pattern. +- **Optional `sessions-index.json`**: Most Claude project directories lack this file in practice. Made it optional — when missing, `projectPath` is derived from `lastCwd` in session JSONL content. + +Behavioral changes from original: +- `parent-child`-mode matching replaces `project-parent` and `assignHistoryEntriesForExactProcessCwd` +- Session matching now takes precedence over history-based matching in all cases +- Tests updated accordingly with new test cases for `parseElapsedSeconds`, `calculateSessionScanLimit`, `rankCandidatesByStartTime`, `isClaudeExecutable`, `parent-child` filtering + ## Risks & Mitigation - **Risk**: Session file format assumptions may differ from edge cases. diff --git a/docs/ai/testing/feature-reimpl-claude-code-adapter.md b/docs/ai/testing/feature-reimpl-claude-code-adapter.md index 1dbf5ba..f0eb983 100644 --- a/docs/ai/testing/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/testing/feature-reimpl-claude-code-adapter.md @@ -17,49 +17,56 @@ description: Testing strategy for re-implemented ClaudeCodeAdapter ### ClaudeCodeAdapter Core -- [ ] Detects Claude processes and returns AgentInfo array -- [ ] Returns empty array when no Claude processes running -- [ ] Matches process to session by exact CWD -- [ ] Matches process to session when session has no CWD (missing-cwd mode) -- [ ] Falls back to any available session when no CWD match -- [ ] Handles process with no matching session (process-only agent) -- [ ] Multiple processes with different CWDs matched correctly -- [ ] Multiple processes with same CWD disambiguated by start time +- [x] Detects Claude processes and returns AgentInfo array +- [x] Returns empty array when no Claude processes running +- [x] Matches process to session by exact CWD +- [x] Matches process to session when session has no CWD (missing-cwd mode) +- [x] Falls back to parent-child path match when no exact CWD match +- [x] Rejects unrelated sessions from different projects (no greedy `any` mode) +- [x] Handles process with no matching session (process-only agent) +- [x] Multiple processes with different CWDs matched correctly +- [x] Multiple processes with same CWD disambiguated by start time ### Process Start Time Matching -- [ ] `getProcessStartTimes()` parses `ps` output correctly -- [ ] `parseElapsedSeconds()` handles `MM:SS`, `HH:MM:SS`, `D-HH:MM:SS` formats -- [ ] `rankCandidatesByStartTime()` prefers sessions within tolerance window -- [ ] `rankCandidatesByStartTime()` falls back to recency when no start time -- [ ] Graceful fallback when `ps` command fails +- [x] `getProcessStartTimes()` parses `ps` output correctly +- [x] `parseElapsedSeconds()` handles `MM:SS`, `HH:MM:SS`, `D-HH:MM:SS` formats +- [x] `rankCandidatesByStartTime()` prefers sessions within tolerance window +- [x] `rankCandidatesByStartTime()` falls back to recency when no start time +- [x] Graceful fallback when `ps` command fails ### Bounded Session Scanning -- [ ] `calculateSessionScanLimit()` respects MIN/MAX bounds -- [ ] `findSessionFiles()` returns at most N files by mtime -- [ ] Session files from process-start-day window included +- [x] `calculateSessionScanLimit()` respects MIN/MAX bounds +- [x] `findSessionFiles()` returns at most N files by mtime +- [x] `findSessionFiles()` returns empty when session dir doesn't exist +- [x] `findSessionFiles()` includes dirs without `sessions-index.json` using empty projectPath + +### Process Detection + +- [x] `canHandle()` accepts commands where executable basename is `claude` or `claude.exe` +- [x] `canHandle()` rejects processes with "claude" only in path arguments (e.g., nx daemon in worktree) ### Status Determination -- [ ] `user` entry type → RUNNING -- [ ] `user` with interrupted content → WAITING -- [ ] `assistant` entry type → WAITING -- [ ] `progress`/`thinking` → RUNNING -- [ ] `system` → IDLE -- [ ] Age > 5 minutes → IDLE (overrides entry type) -- [ ] No last entry → UNKNOWN +- [x] `user` entry type → RUNNING +- [x] `user` with interrupted content → WAITING +- [x] `assistant` entry type → WAITING +- [x] `progress`/`thinking` → RUNNING +- [x] `system` → IDLE +- [x] Age > 5 minutes → IDLE (overrides entry type) +- [x] No last entry → UNKNOWN ### Name Generation -- [ ] Uses project basename as name -- [ ] Appends slug when multiple sessions for same project -- [ ] Falls back to sessionId prefix when no slug +- [x] Uses project basename as name +- [x] Appends slug when multiple sessions for same project +- [x] Falls back to sessionId prefix when no slug ### History Summary -- [ ] Reads summary from history.jsonl by sessionId -- [ ] Falls back to default summary when no history match +- [x] Reads summary from history.jsonl by sessionId +- [x] Falls back to default summary when no history match ## Test Data @@ -70,5 +77,7 @@ description: Testing strategy for re-implemented ClaudeCodeAdapter ## Test Reporting & Coverage -- Run: `npx jest packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts --coverage` -- Target: 100% line/branch coverage for `ClaudeCodeAdapter.ts` +- Run: `cd packages/agent-manager && npx jest --coverage src/__tests__/adapters/ClaudeCodeAdapter.test.ts` +- Uncoverable: `getProcessStartTimes` body (skipped in JEST_WORKER_ID — same pattern as CodexAdapter) +- All 100 tests pass across 4 suites +- Integration tested: `npm run build && node packages/cli/dist/cli.js agent list` verified with 9 concurrent Claude processes diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 0c8927a..2381daf 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -2,7 +2,9 @@ * Tests for ClaudeCodeAdapter */ -import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter'; import type { AgentInfo, ProcessInfo } from '../../adapters/AgentAdapter'; import { AgentStatus } from '../../adapters/AgentAdapter'; @@ -17,7 +19,7 @@ const mockedListProcesses = listProcesses as jest.MockedFunction unknown> = T; interface AdapterPrivates { - readSessions: PrivateMethod<() => unknown[]>; + readSessions: PrivateMethod<(limit: number) => unknown[]>; readHistory: PrivateMethod<() => unknown[]>; } @@ -47,10 +49,21 @@ describe('ClaudeCodeAdapter', () => { expect(adapter.canHandle(processInfo)).toBe(true); }); - it('should return true for processes with "claude" in command (case-insensitive)', () => { + it('should return true for claude executable with full path', () => { const processInfo = { pid: 12345, - command: '/usr/local/bin/CLAUDE --some-flag', + command: '/usr/local/bin/claude --some-flag', + cwd: '/test', + tty: 'ttys001', + }; + + expect(adapter.canHandle(processInfo)).toBe(true); + }); + + it('should return true for CLAUDE (case-insensitive)', () => { + const processInfo = { + pid: 12345, + command: '/usr/local/bin/CLAUDE --continue', cwd: '/test', tty: 'ttys001', }; @@ -68,6 +81,17 @@ describe('ClaudeCodeAdapter', () => { expect(adapter.canHandle(processInfo)).toBe(false); }); + + it('should return false for processes with "claude" only in path arguments', () => { + const processInfo = { + pid: 12345, + command: '/usr/local/bin/node /path/to/claude-worktree/node_modules/nx/start.js', + cwd: '/test', + tty: 'ttys001', + }; + + expect(adapter.canHandle(processInfo)).toBe(false); + }); }); describe('detectAgents', () => { @@ -92,10 +116,11 @@ describe('ClaudeCodeAdapter', () => { { sessionId: 'session-1', projectPath: '/Users/test/my-project', - sessionLogPath: '/mock/path/session-1.jsonl', slug: 'merry-dog', - lastEntry: { type: 'assistant' }, + sessionStart: new Date(), lastActive: new Date(), + lastEntryType: 'assistant', + isInterrupted: false, }, ]; @@ -127,7 +152,31 @@ describe('ClaudeCodeAdapter', () => { expect(agents[0].summary).toContain('Investigate failing tests in package'); }); - it('should include process-only entry when process cwd has no matching session', async () => { + it('should include process-only entry when no sessions exist', async () => { + mockedListProcesses.mockReturnValue([ + { + pid: 777, + command: 'claude', + cwd: '/project/without-session', + tty: 'ttys008', + }, + ]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'claude', + status: AgentStatus.RUNNING, + pid: 777, + projectPath: '/project/without-session', + sessionId: 'pid-777', + summary: 'Claude process running', + }); + }); + + it('should not match process to unrelated session from different project', async () => { mockedListProcesses.mockReturnValue([ { pid: 777, @@ -140,26 +189,27 @@ describe('ClaudeCodeAdapter', () => { { sessionId: 'session-2', projectPath: '/other/project', - sessionLogPath: '/mock/path/session-2.jsonl', - lastEntry: { type: 'assistant' }, + sessionStart: new Date(), lastActive: new Date(), + lastEntryType: 'assistant', + isInterrupted: false, }, ]); jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); + // Unrelated session should NOT match — falls to process-only expect(agents[0]).toMatchObject({ type: 'claude', - status: AgentStatus.RUNNING, pid: 777, - projectPath: '/project/without-session', sessionId: 'pid-777', - summary: 'Claude process running', + projectPath: '/project/without-session', + status: AgentStatus.RUNNING, }); }); - it('should match process in subdirectory to project-root session', async () => { + it('should match process in subdirectory to project-root session via parent-child mode', async () => { mockedListProcesses.mockReturnValue([ { pid: 888, @@ -172,10 +222,11 @@ describe('ClaudeCodeAdapter', () => { { sessionId: 'session-3', projectPath: '/Users/test/my-project', - sessionLogPath: '/mock/path/session-3.jsonl', slug: 'gentle-otter', - lastEntry: { type: 'assistant' }, + sessionStart: new Date(), lastActive: new Date(), + lastEntryType: 'assistant', + isInterrupted: false, }, ]); jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([ @@ -198,7 +249,7 @@ describe('ClaudeCodeAdapter', () => { }); }); - it('should use latest history entry for process-only fallback session id', async () => { + it('should use history entry for process-only fallback when no sessions exist', async () => { mockedListProcesses.mockReturnValue([ { pid: 97529, @@ -230,7 +281,7 @@ describe('ClaudeCodeAdapter', () => { expect(agents[0].lastActive.toISOString()).toBe('2026-02-26T16:18:21.536Z'); }); - it('should prefer exact-cwd history session over parent-project session match', async () => { + it('should match session via parent-child mode when process cwd is under session project path', async () => { mockedListProcesses.mockReturnValue([ { pid: 97529, @@ -241,12 +292,13 @@ describe('ClaudeCodeAdapter', () => { ]); jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ { - sessionId: 'old-parent-session', + sessionId: 'parent-session', projectPath: '/Users/test/my-project', - sessionLogPath: '/mock/path/old-parent-session.jsonl', slug: 'fluffy-brewing-kazoo', - lastEntry: { type: 'assistant' }, + sessionStart: new Date('2026-02-23T17:24:50.996Z'), lastActive: new Date('2026-02-23T17:24:50.996Z'), + lastEntryType: 'assistant', + isInterrupted: false, }, ]); jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([ @@ -260,26 +312,139 @@ describe('ClaudeCodeAdapter', () => { const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); + // Session matched via any-mode; history lookup is by session ID expect(agents[0]).toMatchObject({ type: 'claude', pid: 97529, - sessionId: '69237415-b0c3-4990-ba53-15882616509e', - projectPath: '/Users/test/my-project/packages/cli', - summary: '/status', + sessionId: 'parent-session', + projectPath: '/Users/test/my-project', + }); + }); + + it('should fall back to process-only when sessions exist but all are used', async () => { + mockedListProcesses.mockReturnValue([ + { + pid: 100, + command: 'claude', + cwd: '/project-a', + tty: 'ttys001', + }, + { + pid: 200, + command: 'claude', + cwd: '/project-b', + tty: 'ttys002', + }, + ]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ + { + sessionId: 'only-session', + projectPath: '/project-a', + sessionStart: new Date(), + lastActive: new Date(), + lastEntryType: 'assistant', + isInterrupted: false, + }, + ]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(2); + // First process matched via cwd + expect(agents[0]).toMatchObject({ + pid: 100, + sessionId: 'only-session', + }); + // Second process: session used, falls to process-only + expect(agents[1]).toMatchObject({ + pid: 200, + sessionId: 'pid-200', + status: AgentStatus.RUNNING, + summary: 'Claude process running', + }); + }); + + it('should handle process with empty cwd in process-only fallback', async () => { + mockedListProcesses.mockReturnValue([ + { + pid: 300, + command: 'claude', + cwd: '', + tty: 'ttys003', + }, + ]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([ + { + display: 'some task', + timestamp: Date.now(), + project: '/some/project', + sessionId: 'hist-1', + }, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + // Empty cwd → findHistoryForCwd returns undefined → pid-based sessionId + expect(agents[0]).toMatchObject({ + pid: 300, + sessionId: 'pid-300', + summary: 'Claude process running', + projectPath: '', + }); + }); + + it('should prefer cwd-matched session over any-mode session', async () => { + const now = new Date(); + mockedListProcesses.mockReturnValue([ + { + pid: 100, + command: 'claude', + cwd: '/Users/test/project-a', + tty: 'ttys001', + }, + ]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ + { + sessionId: 'exact-match', + projectPath: '/Users/test/project-a', + sessionStart: now, + lastActive: now, + lastEntryType: 'assistant', + isInterrupted: false, + }, + { + sessionId: 'other-project', + projectPath: '/Users/test/project-b', + sessionStart: now, + lastActive: new Date(now.getTime() + 1000), // more recent + lastEntryType: 'user', + isInterrupted: false, + }, + ]); + jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + sessionId: 'exact-match', + projectPath: '/Users/test/project-a', }); }); }); describe('helper methods', () => { describe('determineStatus', () => { - it('should return "unknown" for sessions with no last entry', () => { + it('should return "unknown" for sessions with no last entry type', () => { const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const session = { sessionId: 'test', projectPath: '/test', - sessionLogPath: '/test/log', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, }; const status = determineStatus(session); @@ -293,9 +458,10 @@ describe('ClaudeCodeAdapter', () => { const session = { sessionId: 'test', projectPath: '/test', - sessionLogPath: '/test/log', - lastEntry: { type: 'assistant' }, + sessionStart: new Date(), lastActive: new Date(), + lastEntryType: 'assistant', + isInterrupted: false, }; const status = determineStatus(session); @@ -309,14 +475,10 @@ describe('ClaudeCodeAdapter', () => { const session = { sessionId: 'test', projectPath: '/test', - sessionLogPath: '/test/log', - lastEntry: { - type: 'user', - message: { - content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }], - }, - }, + sessionStart: new Date(), lastActive: new Date(), + lastEntryType: 'user', + isInterrupted: true, }; const status = determineStatus(session); @@ -330,9 +492,10 @@ describe('ClaudeCodeAdapter', () => { const session = { sessionId: 'test', projectPath: '/test', - sessionLogPath: '/test/log', - lastEntry: { type: 'user' }, + sessionStart: new Date(), lastActive: new Date(), + lastEntryType: 'user', + isInterrupted: false, }; const status = determineStatus(session); @@ -348,14 +511,83 @@ describe('ClaudeCodeAdapter', () => { const session = { sessionId: 'test', projectPath: '/test', - sessionLogPath: '/test/log', - lastEntry: { type: 'assistant' }, + sessionStart: oldDate, lastActive: oldDate, + lastEntryType: 'assistant', + isInterrupted: false, + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.IDLE); + }); + + it('should return "idle" for system entries', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionStart: new Date(), + lastActive: new Date(), + lastEntryType: 'system', + isInterrupted: false, }; const status = determineStatus(session); expect(status).toBe(AgentStatus.IDLE); }); + + it('should return "running" for thinking entries', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionStart: new Date(), + lastActive: new Date(), + lastEntryType: 'thinking', + isInterrupted: false, + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.RUNNING); + }); + + it('should return "running" for progress entries', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionStart: new Date(), + lastActive: new Date(), + lastEntryType: 'progress', + isInterrupted: false, + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.RUNNING); + }); + + it('should return "unknown" for unrecognized entry types', () => { + const adapter = new ClaudeCodeAdapter(); + const determineStatus = (adapter as any).determineStatus.bind(adapter); + + const session = { + sessionId: 'test', + projectPath: '/test', + sessionStart: new Date(), + lastActive: new Date(), + lastEntryType: 'some_other_type', + isInterrupted: false, + }; + + const status = determineStatus(session); + expect(status).toBe(AgentStatus.UNKNOWN); + }); }); describe('generateAgentName', () => { @@ -366,7 +598,9 @@ describe('ClaudeCodeAdapter', () => { const session = { sessionId: 'test-123', projectPath: '/Users/test/my-project', - sessionLogPath: '/test/log', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, }; const name = generateAgentName(session, []); @@ -392,13 +626,478 @@ describe('ClaudeCodeAdapter', () => { const session = { sessionId: 'test-456', projectPath: '/Users/test/my-project', - sessionLogPath: '/test/log', slug: 'merry-dog', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, }; const name = generateAgentName(session, [existingAgent]); expect(name).toBe('my-project (merry)'); }); + + it('should use session ID prefix when no slug available', () => { + const adapter = new ClaudeCodeAdapter(); + const generateAgentName = (adapter as any).generateAgentName.bind(adapter); + + const existingAgent: AgentInfo = { + name: 'my-project', + projectPath: '/Users/test/my-project', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'Test', + pid: 123, + sessionId: 'existing-123', + lastActive: new Date(), + }; + + const session = { + sessionId: 'abcdef12-3456-7890', + projectPath: '/Users/test/my-project', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, + }; + + const name = generateAgentName(session, [existingAgent]); + expect(name).toBe('my-project (abcdef12)'); + }); + }); + + describe('parseElapsedSeconds', () => { + it('should parse MM:SS format', () => { + const adapter = new ClaudeCodeAdapter(); + const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter); + + expect(parseElapsedSeconds('05:30')).toBe(330); + }); + + it('should parse HH:MM:SS format', () => { + const adapter = new ClaudeCodeAdapter(); + const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter); + + expect(parseElapsedSeconds('02:30:15')).toBe(9015); + }); + + it('should parse D-HH:MM:SS format', () => { + const adapter = new ClaudeCodeAdapter(); + const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter); + + expect(parseElapsedSeconds('3-12:00:00')).toBe(302400); + }); + + it('should return null for invalid format', () => { + const adapter = new ClaudeCodeAdapter(); + const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter); + + expect(parseElapsedSeconds('invalid')).toBeNull(); + }); + }); + + describe('calculateSessionScanLimit', () => { + it('should return minimum for small process count', () => { + const adapter = new ClaudeCodeAdapter(); + const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter); + + // 1 process * 4 = 4, min(max(4, 12), 40) = 12 + expect(calculateSessionScanLimit(1)).toBe(12); + }); + + it('should scale with process count', () => { + const adapter = new ClaudeCodeAdapter(); + const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter); + + // 5 processes * 4 = 20, min(max(20, 12), 40) = 20 + expect(calculateSessionScanLimit(5)).toBe(20); + }); + + it('should cap at maximum', () => { + const adapter = new ClaudeCodeAdapter(); + const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter); + + // 15 processes * 4 = 60, min(max(60, 12), 40) = 40 + expect(calculateSessionScanLimit(15)).toBe(40); + }); + }); + + describe('rankCandidatesByStartTime', () => { + it('should prefer sessions within tolerance window', () => { + const adapter = new ClaudeCodeAdapter(); + const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter); + + const processStart = new Date('2026-03-10T10:00:00Z'); + const candidates = [ + { + sessionId: 'far', + projectPath: '/test', + sessionStart: new Date('2026-03-10T09:50:00Z'), // 10 min diff + lastActive: new Date('2026-03-10T10:05:00Z'), + isInterrupted: false, + }, + { + sessionId: 'close', + projectPath: '/test', + sessionStart: new Date('2026-03-10T10:00:30Z'), // 30s diff + lastActive: new Date('2026-03-10T10:03:00Z'), + isInterrupted: false, + }, + ]; + + const ranked = rankCandidatesByStartTime(candidates, processStart); + expect(ranked[0].sessionId).toBe('close'); + expect(ranked[1].sessionId).toBe('far'); + }); + + it('should fall back to recency when both outside tolerance', () => { + const adapter = new ClaudeCodeAdapter(); + const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter); + + const processStart = new Date('2026-03-10T10:00:00Z'); + const candidates = [ + { + sessionId: 'older', + projectPath: '/test', + sessionStart: new Date('2026-03-10T09:30:00Z'), + lastActive: new Date('2026-03-10T10:01:00Z'), + isInterrupted: false, + }, + { + sessionId: 'newer', + projectPath: '/test', + sessionStart: new Date('2026-03-10T09:40:00Z'), + lastActive: new Date('2026-03-10T10:05:00Z'), + isInterrupted: false, + }, + ]; + + const ranked = rankCandidatesByStartTime(candidates, processStart); + // Both outside tolerance (rank=1), newer has smaller diffMs + expect(ranked[0].sessionId).toBe('newer'); + }); + }); + + describe('findHistoryForCwd', () => { + it('should return undefined for empty cwd', () => { + const adapter = new ClaudeCodeAdapter(); + const findHistoryForCwd = (adapter as any).findHistoryForCwd.bind(adapter); + + expect(findHistoryForCwd('', [])).toBeUndefined(); + }); + + it('should find matching history entry by normalized path', () => { + const adapter = new ClaudeCodeAdapter(); + const findHistoryForCwd = (adapter as any).findHistoryForCwd.bind(adapter); + + const history = [ + { display: 'task 1', timestamp: 100, project: '/Users/test/my-project', sessionId: 'h1' }, + { display: 'task 2', timestamp: 200, project: '/Users/test/other', sessionId: 'h2' }, + ]; + + const result = findHistoryForCwd('/Users/test/my-project', history); + expect(result).toMatchObject({ sessionId: 'h1' }); + }); + + it('should return undefined when no match', () => { + const adapter = new ClaudeCodeAdapter(); + const findHistoryForCwd = (adapter as any).findHistoryForCwd.bind(adapter); + + const history = [ + { display: 'task', timestamp: 100, project: '/other/path', sessionId: 'h1' }, + ]; + + expect(findHistoryForCwd('/Users/test/my-project', history)).toBeUndefined(); + }); + }); + + describe('filterCandidateSessions', () => { + it('should match by lastCwd in cwd mode', () => { + const adapter = new ClaudeCodeAdapter(); + const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter); + + const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }; + const sessions = [ + { + sessionId: 's1', + projectPath: '/different/path', + lastCwd: '/my/project', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, + }, + ]; + + const result = filterCandidateSessions(processInfo, sessions, new Set(), 'cwd'); + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('s1'); + }); + + it('should match sessions with no projectPath in missing-cwd mode', () => { + const adapter = new ClaudeCodeAdapter(); + const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter); + + const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }; + const sessions = [ + { + sessionId: 's1', + projectPath: '', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, + }, + { + sessionId: 's2', + projectPath: '/has/path', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, + }, + ]; + + const result = filterCandidateSessions(processInfo, sessions, new Set(), 'missing-cwd'); + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('s1'); + }); + + it('should skip used sessions', () => { + const adapter = new ClaudeCodeAdapter(); + const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter); + + const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }; + const sessions = [ + { + sessionId: 's1', + projectPath: '/my/project', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, + }, + ]; + + const result = filterCandidateSessions(processInfo, sessions, new Set(['s1']), 'cwd'); + expect(result).toHaveLength(0); + }); + }); + }); + + describe('file I/O methods', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('readSession', () => { + it('should parse session file with timestamps, slug, cwd, and entry type', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + const filePath = path.join(tmpDir, 'test-session.jsonl'); + const lines = [ + JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project', slug: 'happy-dog' }), + JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }), + ]; + fs.writeFileSync(filePath, lines.join('\n')); + + const session = readSession(filePath, '/my/project'); + expect(session).toMatchObject({ + sessionId: 'test-session', + projectPath: '/my/project', + slug: 'happy-dog', + lastCwd: '/my/project', + lastEntryType: 'assistant', + isInterrupted: false, + }); + expect(session.sessionStart.toISOString()).toBe('2026-03-10T10:00:00.000Z'); + expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z'); + }); + + it('should detect user interruption', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + const filePath = path.join(tmpDir, 'interrupted.jsonl'); + const lines = [ + JSON.stringify({ + type: 'user', + timestamp: '2026-03-10T10:00:00Z', + message: { + content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }], + }, + }), + ]; + fs.writeFileSync(filePath, lines.join('\n')); + + const session = readSession(filePath, '/test'); + expect(session.isInterrupted).toBe(true); + expect(session.lastEntryType).toBe('user'); + }); + + it('should return session with defaults for empty file', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + const filePath = path.join(tmpDir, 'empty.jsonl'); + fs.writeFileSync(filePath, ''); + + const session = readSession(filePath, '/test'); + // Empty file content trims to '' which splits to [''] — no valid entries parsed + expect(session).not.toBeNull(); + expect(session.lastEntryType).toBeUndefined(); + expect(session.slug).toBeUndefined(); + }); + + it('should return null for non-existent file', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull(); + }); + + it('should handle malformed JSON lines gracefully', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + const filePath = path.join(tmpDir, 'malformed.jsonl'); + const lines = [ + 'not json', + JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:00:00Z' }), + ]; + fs.writeFileSync(filePath, lines.join('\n')); + + const session = readSession(filePath, '/test'); + expect(session).not.toBeNull(); + expect(session.lastEntryType).toBe('assistant'); + }); + }); + + describe('findSessionFiles', () => { + it('should return empty when projects dir does not exist', () => { + const adapter = new ClaudeCodeAdapter(); + (adapter as any).projectsDir = path.join(tmpDir, 'nonexistent'); + const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter); + + expect(findSessionFiles(10)).toEqual([]); + }); + + it('should find and sort session files by mtime', () => { + const adapter = new ClaudeCodeAdapter(); + const projectsDir = path.join(tmpDir, 'projects'); + (adapter as any).projectsDir = projectsDir; + const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter); + + // Create project dir with sessions-index.json and JSONL files + const projDir = path.join(projectsDir, 'encoded-path'); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, 'sessions-index.json'), + JSON.stringify({ originalPath: '/my/project' }), + ); + + const file1 = path.join(projDir, 'session-old.jsonl'); + const file2 = path.join(projDir, 'session-new.jsonl'); + fs.writeFileSync(file1, '{}'); + // Ensure different mtime + const past = new Date(Date.now() - 10000); + fs.utimesSync(file1, past, past); + fs.writeFileSync(file2, '{}'); + + const files = findSessionFiles(10); + expect(files).toHaveLength(2); + // Sorted by mtime desc — new first + expect(files[0].filePath).toContain('session-new'); + expect(files[0].projectPath).toBe('/my/project'); + expect(files[1].filePath).toContain('session-old'); + }); + + it('should respect scan limit', () => { + const adapter = new ClaudeCodeAdapter(); + const projectsDir = path.join(tmpDir, 'projects'); + (adapter as any).projectsDir = projectsDir; + const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter); + + const projDir = path.join(projectsDir, 'proj'); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, 'sessions-index.json'), + JSON.stringify({ originalPath: '/proj' }), + ); + + for (let i = 0; i < 5; i++) { + fs.writeFileSync(path.join(projDir, `session-${i}.jsonl`), '{}'); + } + + const files = findSessionFiles(3); + expect(files).toHaveLength(3); + }); + + it('should skip directories starting with dot', () => { + const adapter = new ClaudeCodeAdapter(); + const projectsDir = path.join(tmpDir, 'projects'); + (adapter as any).projectsDir = projectsDir; + const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter); + + const hiddenDir = path.join(projectsDir, '.hidden'); + fs.mkdirSync(hiddenDir, { recursive: true }); + fs.writeFileSync( + path.join(hiddenDir, 'sessions-index.json'), + JSON.stringify({ originalPath: '/hidden' }), + ); + fs.writeFileSync(path.join(hiddenDir, 'session.jsonl'), '{}'); + + const files = findSessionFiles(10); + expect(files).toEqual([]); + }); + + it('should include project dirs without sessions-index.json using empty projectPath', () => { + const adapter = new ClaudeCodeAdapter(); + const projectsDir = path.join(tmpDir, 'projects'); + (adapter as any).projectsDir = projectsDir; + const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter); + + const projDir = path.join(projectsDir, 'no-index'); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync(path.join(projDir, 'session.jsonl'), '{}'); + + const files = findSessionFiles(10); + expect(files).toHaveLength(1); + expect(files[0].projectPath).toBe(''); + expect(files[0].filePath).toContain('session.jsonl'); + }); + }); + + describe('readSessions', () => { + it('should parse valid sessions and skip invalid ones', () => { + const adapter = new ClaudeCodeAdapter(); + const projectsDir = path.join(tmpDir, 'projects'); + (adapter as any).projectsDir = projectsDir; + const readSessions = (adapter as any).readSessions.bind(adapter); + + const projDir = path.join(projectsDir, 'proj'); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, 'sessions-index.json'), + JSON.stringify({ originalPath: '/my/project' }), + ); + + // Valid session + fs.writeFileSync( + path.join(projDir, 'valid.jsonl'), + JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:00:00Z' }), + ); + // Empty session (will return null from readSession) + fs.writeFileSync(path.join(projDir, 'empty.jsonl'), ''); + + const sessions = readSessions(10); + expect(sessions).toHaveLength(2); + // Both are valid (empty file still produces a session with defaults) + const validSession = sessions.find((s: any) => s.sessionId === 'valid'); + expect(validSession).toBeDefined(); + expect(validSession.lastEntryType).toBe('assistant'); + }); }); }); }); diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index 82a4d43..fc38cb3 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -1,16 +1,19 @@ /** * Claude Code Adapter - * - * Detects running Claude Code agents by reading session files - * from ~/.claude/ directory and correlating with running processes. + * + * Detects running Claude Code agents by combining: + * 1. Running `claude` processes + * 2. Session metadata under ~/.claude/projects + * 3. History entries from ~/.claude/history.jsonl */ import * as fs from 'fs'; import * as path from 'path'; +import { execSync } from 'child_process'; import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter'; import { AgentStatus } from './AgentAdapter'; import { listProcesses } from '../utils/process'; -import { readLastLines, readJsonLines, readJson } from '../utils/file'; +import { readJsonLines, readJson } from '../utils/file'; /** * Structure of ~/.claude/projects/{path}/sessions-index.json @@ -19,25 +22,14 @@ interface SessionsIndex { originalPath: string; } -enum SessionEntryType { - ASSISTANT = 'assistant', - USER = 'user', - PROGRESS = 'progress', - THINKING = 'thinking', - SYSTEM = 'system', - MESSAGE = 'message', - TEXT = 'text', -} - /** * Entry in session JSONL file */ interface SessionEntry { - type?: SessionEntryType; + type?: string; timestamp?: string; slug?: string; cwd?: string; - sessionId?: string; message?: { content?: Array<{ type?: string; @@ -45,7 +37,6 @@ interface SessionEntry { content?: string; }>; }; - [key: string]: unknown; } /** @@ -66,28 +57,35 @@ interface ClaudeSession { projectPath: string; lastCwd?: string; slug?: string; - sessionLogPath: string; - lastEntry?: SessionEntry; - lastActive?: Date; + sessionStart: Date; + lastActive: Date; + lastEntryType?: string; + isInterrupted: boolean; } -type SessionMatchMode = 'cwd' | 'project-parent'; +type SessionMatchMode = 'cwd' | 'missing-cwd' | 'parent-child'; /** * Claude Code Adapter - * + * * Detects Claude Code agents by: * 1. Finding running claude processes - * 2. Reading session files from ~/.claude/projects/ - * 3. Matching sessions to processes via CWD - * 4. Extracting status from session JSONL + * 2. Getting process start times for accurate session matching + * 3. Reading bounded session files from ~/.claude/projects/ + * 4. Matching sessions to processes via CWD then start time ranking * 5. Extracting summary from history.jsonl */ export class ClaudeCodeAdapter implements AgentAdapter { readonly type = 'claude' as const; - /** Threshold in minutes before considering a session idle */ + /** Keep status thresholds aligned across adapters. */ private static readonly IDLE_THRESHOLD_MINUTES = 5; + /** Limit session parsing per run to keep list latency bounded. */ + private static readonly MIN_SESSION_SCAN = 12; + private static readonly MAX_SESSION_SCAN = 40; + private static readonly SESSION_SCAN_MULTIPLIER = 4; + /** Matching tolerance between process start time and session start time. */ + private static readonly PROCESS_SESSION_TIME_TOLERANCE_MS = 2 * 60 * 1000; private claudeDir: string; private projectsDir: string; @@ -104,35 +102,44 @@ export class ClaudeCodeAdapter implements AgentAdapter { * Check if this adapter can handle a given process */ canHandle(processInfo: ProcessInfo): boolean { - return processInfo.command.toLowerCase().includes('claude'); + return this.isClaudeExecutable(processInfo.command); + } + + private isClaudeExecutable(command: string): boolean { + const executable = command.trim().split(/\s+/)[0] || ''; + const base = path.basename(executable).toLowerCase(); + return base === 'claude' || base === 'claude.exe'; } /** * Detect running Claude Code agents */ async detectAgents(): Promise { - const claudeProcesses = listProcesses({ namePattern: 'claude' }).filter((processInfo) => - this.canHandle(processInfo), - ); - + const claudeProcesses = this.listClaudeProcesses(); if (claudeProcesses.length === 0) { return []; } - const sessions = this.readSessions(); + const processStartByPid = this.getProcessStartTimes( + claudeProcesses.map((p) => p.pid), + ); + const sessionScanLimit = this.calculateSessionScanLimit(claudeProcesses.length); + const sessions = this.readSessions(sessionScanLimit); const history = this.readHistory(); - const historyByProjectPath = this.indexHistoryByProjectPath(history); const historyBySessionId = new Map(); for (const entry of history) { historyBySessionId.set(entry.sessionId, entry); } - const sortedSessions = [...sessions].sort((a, b) => { - const timeA = a.lastActive?.getTime() || 0; - const timeB = b.lastActive?.getTime() || 0; - return timeB - timeA; - }); + if (sessions.length === 0) { + return claudeProcesses.map((p) => + this.mapProcessOnlyAgent(p, [], history), + ); + } + const sortedSessions = [...sessions].sort( + (a, b) => b.lastActive.getTime() - a.lastActive.getTime(), + ); const usedSessionIds = new Set(); const assignedPids = new Set(); const agents: AgentInfo[] = []; @@ -143,58 +150,56 @@ export class ClaudeCodeAdapter implements AgentAdapter { sortedSessions, usedSessionIds, assignedPids, + processStartByPid, historyBySessionId, agents, ); - this.assignHistoryEntriesForExactProcessCwd( + this.assignSessionsForMode( + 'missing-cwd', claudeProcesses, - assignedPids, - historyByProjectPath, + sortedSessions, usedSessionIds, + assignedPids, + processStartByPid, + historyBySessionId, agents, ); this.assignSessionsForMode( - 'project-parent', + 'parent-child', claudeProcesses, sortedSessions, usedSessionIds, assignedPids, + processStartByPid, historyBySessionId, agents, ); + for (const processInfo of claudeProcesses) { if (assignedPids.has(processInfo.pid)) { continue; } - assignedPids.add(processInfo.pid); - agents.push(this.mapProcessOnlyAgent(processInfo, agents, historyByProjectPath, usedSessionIds)); + this.addProcessOnlyAgent(processInfo, assignedPids, agents, history); } return agents; } - private assignHistoryEntriesForExactProcessCwd( - claudeProcesses: ProcessInfo[], - assignedPids: Set, - historyByProjectPath: Map, - usedSessionIds: Set, - agents: AgentInfo[], - ): void { - for (const processInfo of claudeProcesses) { - if (assignedPids.has(processInfo.pid)) { - continue; - } - - const historyEntry = this.selectHistoryForProcess(processInfo.cwd || '', historyByProjectPath, usedSessionIds); - if (!historyEntry) { - continue; - } + private listClaudeProcesses(): ProcessInfo[] { + return listProcesses({ namePattern: 'claude' }).filter((p) => + this.canHandle(p), + ); + } - assignedPids.add(processInfo.pid); - usedSessionIds.add(historyEntry.sessionId); - agents.push(this.mapHistoryToAgent(processInfo, historyEntry, agents)); - } + private calculateSessionScanLimit(processCount: number): number { + return Math.min( + Math.max( + processCount * ClaudeCodeAdapter.SESSION_SCAN_MULTIPLIER, + ClaudeCodeAdapter.MIN_SESSION_SCAN, + ), + ClaudeCodeAdapter.MAX_SESSION_SCAN, + ); } private assignSessionsForMode( @@ -203,6 +208,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { sessions: ClaudeSession[], usedSessionIds: Set, assignedPids: Set, + processStartByPid: Map, historyBySessionId: Map, agents: AgentInfo[], ): void { @@ -211,60 +217,49 @@ export class ClaudeCodeAdapter implements AgentAdapter { continue; } - const session = this.selectBestSession(processInfo, sessions, usedSessionIds, mode); + const session = this.selectBestSession( + processInfo, + sessions, + usedSessionIds, + processStartByPid, + mode, + ); if (!session) { continue; } - usedSessionIds.add(session.sessionId); - assignedPids.add(processInfo.pid); - agents.push(this.mapSessionToAgent(session, processInfo, historyBySessionId, agents)); + this.addMappedSessionAgent( + session, + processInfo, + usedSessionIds, + assignedPids, + historyBySessionId, + agents, + ); } } - private selectBestSession( + private addMappedSessionAgent( + session: ClaudeSession, processInfo: ProcessInfo, - sessions: ClaudeSession[], usedSessionIds: Set, - mode: SessionMatchMode, - ): ClaudeSession | null { - const candidates = sessions.filter((session) => { - if (usedSessionIds.has(session.sessionId)) { - return false; - } - - if (mode === 'cwd') { - return this.pathEquals(processInfo.cwd, session.lastCwd) - || this.pathEquals(processInfo.cwd, session.projectPath); - } - - if (mode === 'project-parent') { - return this.isChildPath(processInfo.cwd, session.projectPath) - || this.isChildPath(processInfo.cwd, session.lastCwd); - } - - return false; - }); - - if (candidates.length === 0) { - return null; - } - - if (mode !== 'project-parent') { - return candidates[0]; - } - - return candidates.sort((a, b) => { - const depthA = Math.max(this.pathDepth(a.projectPath), this.pathDepth(a.lastCwd)); - const depthB = Math.max(this.pathDepth(b.projectPath), this.pathDepth(b.lastCwd)); - if (depthA !== depthB) { - return depthB - depthA; - } + assignedPids: Set, + historyBySessionId: Map, + agents: AgentInfo[], + ): void { + usedSessionIds.add(session.sessionId); + assignedPids.add(processInfo.pid); + agents.push(this.mapSessionToAgent(session, processInfo, historyBySessionId, agents)); + } - const lastActiveA = a.lastActive?.getTime() || 0; - const lastActiveB = b.lastActive?.getTime() || 0; - return lastActiveB - lastActiveA; - })[0]; + private addProcessOnlyAgent( + processInfo: ProcessInfo, + assignedPids: Set, + agents: AgentInfo[], + history: HistoryEntry[], + ): void { + assignedPids.add(processInfo.pid); + agents.push(this.mapProcessOnlyAgent(processInfo, agents, history)); } private mapSessionToAgent( @@ -284,208 +279,366 @@ export class ClaudeCodeAdapter implements AgentAdapter { projectPath: session.projectPath || processInfo.cwd || '', sessionId: session.sessionId, slug: session.slug, - lastActive: session.lastActive || new Date(), + lastActive: session.lastActive, }; } private mapProcessOnlyAgent( processInfo: ProcessInfo, existingAgents: AgentInfo[], - historyByProjectPath: Map, - usedSessionIds: Set, + history: HistoryEntry[], ): AgentInfo { - const projectPath = processInfo.cwd || ''; - const historyEntry = this.selectHistoryForProcess(projectPath, historyByProjectPath, usedSessionIds); + const processCwd = processInfo.cwd || ''; + const historyEntry = this.findHistoryForCwd(processCwd, history); const sessionId = historyEntry?.sessionId || `pid-${processInfo.pid}`; const lastActive = historyEntry ? new Date(historyEntry.timestamp) : new Date(); - if (historyEntry) { - usedSessionIds.add(historyEntry.sessionId); - } - const processSession: ClaudeSession = { + const syntheticSession: ClaudeSession = { sessionId, - projectPath, - lastCwd: projectPath, - sessionLogPath: '', + projectPath: processCwd, + lastCwd: processCwd, + sessionStart: lastActive, lastActive, + isInterrupted: false, }; return { - name: this.generateAgentName(processSession, existingAgents), + name: this.generateAgentName(syntheticSession, existingAgents), type: this.type, status: AgentStatus.RUNNING, summary: historyEntry?.display || 'Claude process running', pid: processInfo.pid, - projectPath, - sessionId: processSession.sessionId, - lastActive: processSession.lastActive || new Date(), + projectPath: processCwd, + sessionId, + lastActive, }; } - private mapHistoryToAgent( + private findHistoryForCwd( + cwd: string, + history: HistoryEntry[], + ): HistoryEntry | undefined { + if (!cwd) { + return undefined; + } + + const normalizedCwd = this.normalizePath(cwd); + return history.find( + (entry) => this.normalizePath(entry.project) === normalizedCwd, + ); + } + + private selectBestSession( processInfo: ProcessInfo, - historyEntry: HistoryEntry, - existingAgents: AgentInfo[], - ): AgentInfo { - const projectPath = processInfo.cwd || historyEntry.project; - const historySession: ClaudeSession = { - sessionId: historyEntry.sessionId, - projectPath, - lastCwd: projectPath, - sessionLogPath: '', - lastActive: new Date(historyEntry.timestamp), - }; + sessions: ClaudeSession[], + usedSessionIds: Set, + processStartByPid: Map, + mode: SessionMatchMode, + ): ClaudeSession | undefined { + const candidates = this.filterCandidateSessions( + processInfo, + sessions, + usedSessionIds, + mode, + ); - return { - name: this.generateAgentName(historySession, existingAgents), - type: this.type, - status: AgentStatus.RUNNING, - summary: historyEntry.display || 'Claude process running', - pid: processInfo.pid, - projectPath, - sessionId: historySession.sessionId, - lastActive: historySession.lastActive || new Date(), - }; + if (candidates.length === 0) { + return undefined; + } + + const processStart = processStartByPid.get(processInfo.pid); + if (!processStart) { + return candidates.sort( + (a, b) => b.lastActive.getTime() - a.lastActive.getTime(), + )[0]; + } + + return this.rankCandidatesByStartTime(candidates, processStart)[0]; + } + + private filterCandidateSessions( + processInfo: ProcessInfo, + sessions: ClaudeSession[], + usedSessionIds: Set, + mode: SessionMatchMode, + ): ClaudeSession[] { + return sessions.filter((session) => { + if (usedSessionIds.has(session.sessionId)) { + return false; + } + + if (mode === 'cwd') { + return ( + this.pathEquals(processInfo.cwd, session.projectPath) || + this.pathEquals(processInfo.cwd, session.lastCwd) + ); + } + + if (mode === 'missing-cwd') { + return !session.projectPath; + } + + // parent-child mode: match if process CWD is under session project or vice versa + return ( + this.isChildPath(processInfo.cwd, session.projectPath) || + this.isChildPath(processInfo.cwd, session.lastCwd) || + this.isChildPath(session.projectPath, processInfo.cwd) || + this.isChildPath(session.lastCwd, processInfo.cwd) + ); + }); } - private indexHistoryByProjectPath(historyEntries: HistoryEntry[]): Map { - const grouped = new Map(); + private rankCandidatesByStartTime( + candidates: ClaudeSession[], + processStart: Date, + ): ClaudeSession[] { + const toleranceMs = ClaudeCodeAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS; + + return candidates + .map((session) => { + const diffMs = Math.abs( + session.sessionStart.getTime() - processStart.getTime(), + ); + const outsideTolerance = diffMs > toleranceMs ? 1 : 0; + return { + session, + rank: outsideTolerance, + diffMs, + recency: session.lastActive.getTime(), + }; + }) + .sort((a, b) => { + if (a.rank !== b.rank) return a.rank - b.rank; + if (a.diffMs !== b.diffMs) return a.diffMs - b.diffMs; + return b.recency - a.recency; + }) + .map((ranked) => ranked.session); + } - for (const entry of historyEntries) { - const key = this.normalizePath(entry.project); - const list = grouped.get(key) || []; - list.push(entry); - grouped.set(key, list); + private getProcessStartTimes(pids: number[]): Map { + if (pids.length === 0 || process.env.JEST_WORKER_ID) { + return new Map(); } - for (const [key, list] of grouped.entries()) { - grouped.set( - key, - [...list].sort((a, b) => b.timestamp - a.timestamp), + try { + const output = execSync( + `ps -o pid=,etime= -p ${pids.join(',')}`, + { encoding: 'utf-8' }, ); + const nowMs = Date.now(); + const startTimes = new Map(); + + for (const rawLine of output.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + const parts = line.split(/\s+/); + if (parts.length < 2) continue; + + const pid = Number.parseInt(parts[0], 10); + const elapsedSeconds = this.parseElapsedSeconds(parts[1]); + if (!Number.isFinite(pid) || elapsedSeconds === null) continue; + + startTimes.set(pid, new Date(nowMs - elapsedSeconds * 1000)); + } + + return startTimes; + } catch { + return new Map(); + } + } + + private parseElapsedSeconds(etime: string): number | null { + const match = etime + .trim() + .match(/^(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/); + if (!match) { + return null; } - return grouped; + const days = Number.parseInt(match[1] || '0', 10); + const hours = Number.parseInt(match[2] || '0', 10); + const minutes = Number.parseInt(match[3] || '0', 10); + const seconds = Number.parseInt(match[4] || '0', 10); + + return ((days * 24 + hours) * 60 + minutes) * 60 + seconds; } - private selectHistoryForProcess( - processCwd: string, - historyByProjectPath: Map, - usedSessionIds: Set, - ): HistoryEntry | undefined { - if (!processCwd) { - return undefined; + /** + * Read Claude Code sessions with bounded scanning + */ + private readSessions(limit: number): ClaudeSession[] { + const sessionFiles = this.findSessionFiles(limit); + const sessions: ClaudeSession[] = []; + + for (const file of sessionFiles) { + try { + const session = this.readSession(file.filePath, file.projectPath); + if (session) { + sessions.push(session); + } + } catch (error) { + console.error(`Failed to parse Claude session ${file.filePath}:`, error); + } } - const candidates = historyByProjectPath.get(this.normalizePath(processCwd)) || []; - return candidates.find((entry) => !usedSessionIds.has(entry.sessionId)); + return sessions; } /** - * Read all Claude Code sessions + * Find session files bounded by mtime, sorted most-recent first */ - private readSessions(): ClaudeSession[] { + private findSessionFiles( + limit: number, + ): Array<{ filePath: string; projectPath: string; mtimeMs: number }> { if (!fs.existsSync(this.projectsDir)) { return []; } - const sessions: ClaudeSession[] = []; - const projectDirs = fs.readdirSync(this.projectsDir); + const files: Array<{ + filePath: string; + projectPath: string; + mtimeMs: number; + }> = []; - for (const dirName of projectDirs) { + for (const dirName of fs.readdirSync(this.projectsDir)) { if (dirName.startsWith('.')) { continue; } const projectDir = path.join(this.projectsDir, dirName); - if (!fs.statSync(projectDir).isDirectory()) { + try { + if (!fs.statSync(projectDir).isDirectory()) continue; + } catch { continue; } - // Read sessions-index.json to get original project path const indexPath = path.join(projectDir, 'sessions-index.json'); - if (!fs.existsSync(indexPath)) { - continue; - } - - const sessionsIndex = readJson(indexPath); - if (!sessionsIndex) { - console.error(`Failed to parse ${indexPath}`); - continue; - } - - const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl')); + const index = readJson(indexPath); + const projectPath = index?.originalPath || ''; - for (const sessionFile of sessionFiles) { - const sessionId = sessionFile.replace('.jsonl', ''); - const sessionLogPath = path.join(projectDir, sessionFile); + for (const entry of fs.readdirSync(projectDir)) { + if (!entry.endsWith('.jsonl')) { + continue; + } + const filePath = path.join(projectDir, entry); try { - const sessionData = this.readSessionLog(sessionLogPath); - - sessions.push({ - sessionId, - projectPath: sessionsIndex.originalPath, - lastCwd: sessionData.lastCwd, - slug: sessionData.slug, - sessionLogPath, - lastEntry: sessionData.lastEntry, - lastActive: sessionData.lastActive, + files.push({ + filePath, + projectPath, + mtimeMs: fs.statSync(filePath).mtimeMs, }); - } catch (error) { - console.error(`Failed to read session ${sessionId}:`, error); + } catch { continue; } } } - return sessions; + return files + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(0, limit); } /** - * Read a session JSONL file - * Only reads last 100 lines for performance with large files + * Parse a single session file into ClaudeSession */ - private readSessionLog(logPath: string): { - slug?: string; - lastEntry?: SessionEntry; - lastActive?: Date; - lastCwd?: string; - } { - const lines = readLastLines(logPath, 100); + private 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; + } + + // Parse first line for sessionStart + let sessionStart: Date | null = null; + try { + const firstEntry: SessionEntry = JSON.parse(allLines[0]); + if (firstEntry.timestamp) { + const ts = new Date(firstEntry.timestamp); + if (!Number.isNaN(ts.getTime())) { + sessionStart = ts; + } + } + } catch { + /* skip */ + } + // Parse last N lines for recent state + const recentLines = allLines.slice(-100); let slug: string | undefined; - let lastEntry: SessionEntry | undefined; + let lastEntryType: string | undefined; let lastActive: Date | undefined; let lastCwd: string | undefined; + let isInterrupted = false; - for (const line of lines) { + for (const line of recentLines) { try { const entry: SessionEntry = JSON.parse(line); - if (entry.slug && !slug) { - slug = entry.slug; + if (entry.timestamp) { + const ts = new Date(entry.timestamp); + if (!Number.isNaN(ts.getTime())) { + lastActive = ts; + } } - lastEntry = entry; - - if (entry.timestamp) { - lastActive = new Date(entry.timestamp); + if (entry.slug && !slug) { + slug = entry.slug; } if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) { lastCwd = entry.cwd; } - } catch (error) { + + if (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')), + ); + } else { + isInterrupted = false; + } + } + } catch { continue; } } - return { slug, lastEntry, lastActive, lastCwd }; + return { + sessionId, + projectPath: projectPath || lastCwd || '', + lastCwd, + slug, + sessionStart: sessionStart || lastActive || new Date(), + lastActive: lastActive || new Date(), + lastEntryType, + isInterrupted, + }; } /** - * Read history.jsonl for user prompts + * Read history.jsonl for summaries * Only reads last 100 lines for performance */ private readHistory(): HistoryEntry[] { @@ -493,39 +646,37 @@ export class ClaudeCodeAdapter implements AgentAdapter { } /** - * Determine agent status from session entry + * Determine agent status from session state */ private determineStatus(session: ClaudeSession): AgentStatus { - if (!session.lastEntry) { + if (!session.lastEntryType) { return AgentStatus.UNKNOWN; } - const entryType = session.lastEntry.type; - const lastActive = session.lastActive || new Date(0); - const ageMinutes = (Date.now() - lastActive.getTime()) / 1000 / 60; - + const ageMinutes = + (Date.now() - session.lastActive.getTime()) / 60000; if (ageMinutes > ClaudeCodeAdapter.IDLE_THRESHOLD_MINUTES) { return AgentStatus.IDLE; } - if (entryType === SessionEntryType.USER) { - // Check if user interrupted manually - this puts agent back in waiting state - const content = session.lastEntry.message?.content; - if (Array.isArray(content)) { - const isInterrupted = content.some(c => - (c.type === SessionEntryType.TEXT && c.text?.includes('[Request interrupted')) || - (c.type === 'tool_result' && c.content?.includes('[Request interrupted')) - ); - if (isInterrupted) return AgentStatus.WAITING; - } - return AgentStatus.RUNNING; + if (session.lastEntryType === 'user') { + return session.isInterrupted + ? AgentStatus.WAITING + : AgentStatus.RUNNING; } - if (entryType === SessionEntryType.PROGRESS || entryType === SessionEntryType.THINKING) { + if ( + session.lastEntryType === 'progress' || + session.lastEntryType === 'thinking' + ) { return AgentStatus.RUNNING; - } else if (entryType === SessionEntryType.ASSISTANT) { + } + + if (session.lastEntryType === 'assistant') { return AgentStatus.WAITING; - } else if (entryType === SessionEntryType.SYSTEM) { + } + + if (session.lastEntryType === 'system') { return AgentStatus.IDLE; } @@ -536,27 +687,27 @@ export class ClaudeCodeAdapter implements AgentAdapter { * Generate unique agent name * Uses project basename, appends slug if multiple sessions for same project */ - private generateAgentName(session: ClaudeSession, existingAgents: AgentInfo[]): string { + private generateAgentName( + session: ClaudeSession, + existingAgents: AgentInfo[], + ): string { const projectName = path.basename(session.projectPath) || 'claude'; const sameProjectAgents = existingAgents.filter( - a => a.projectPath === session.projectPath + (a) => a.projectPath === session.projectPath, ); if (sameProjectAgents.length === 0) { return projectName; } - // Multiple sessions for same project, append slug if (session.slug) { - // Use first word of slug for brevity (with safety check for format) const slugPart = session.slug.includes('-') ? session.slug.split('-')[0] : session.slug.slice(0, 8); return `${projectName} (${slugPart})`; } - // No slug available, use session ID prefix return `${projectName} (${session.sessionId.slice(0, 8)})`; } @@ -575,7 +726,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { const normalizedChild = this.normalizePath(child); const normalizedParent = this.normalizePath(parent); - return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}${path.sep}`); + return normalizedChild.startsWith(`${normalizedParent}${path.sep}`); } private normalizePath(value: string): string { @@ -585,13 +736,4 @@ export class ClaudeCodeAdapter implements AgentAdapter { } return resolved; } - - private pathDepth(value?: string): number { - if (!value) { - return 0; - } - - return this.normalizePath(value).split(path.sep).filter(Boolean).length; - } - } From a1f4d1ff1b8bdb2f3cd7eb51d55130dd190365cf Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 11 Mar 2026 16:49:52 +0700 Subject: [PATCH 3/4] Update Claude Code adapter --- .../feature-reimpl-claude-code-adapter.md | 38 ++- .../feature-reimpl-claude-code-adapter.md | 18 +- .../feature-reimpl-claude-code-adapter.md | 43 +-- .../feature-reimpl-claude-code-adapter.md | 11 +- .../feature-reimpl-claude-code-adapter.md | 19 +- .../adapters/ClaudeCodeAdapter.test.ts | 118 ++------ .../src/adapters/ClaudeCodeAdapter.ts | 266 ++++++++++++------ 7 files changed, 276 insertions(+), 237 deletions(-) diff --git a/docs/ai/design/feature-reimpl-claude-code-adapter.md b/docs/ai/design/feature-reimpl-claude-code-adapter.md index c11af22..99aeca9 100644 --- a/docs/ai/design/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/design/feature-reimpl-claude-code-adapter.md @@ -41,10 +41,10 @@ Responsibilities: - `projectPath`: from `sessions-index.json` → `originalPath`, falls back to `lastCwd` when index missing - `lastCwd`: from session JSONL entries - `slug`: from session JSONL entries - - `sessionStart`: earliest timestamp in session (first entry or file creation) + - `sessionStart`: from first JSONL entry timestamp (supports both top-level `timestamp` and `snapshot.timestamp` for `file-history-snapshot` entries) - `lastActive`: latest timestamp in session - - `lastEntryType`: type of last session entry (for status determination) - - `summary`: from `history.jsonl` lookup (simple, not indexed) + - `lastEntryType`: type of last non-metadata session entry (excludes `last-prompt`, `file-history-snapshot`; used for status determination) + - `lastUserMessage`: last meaningful user message from session JSONL (with command parsing and noise filtering) ## API Design @@ -63,7 +63,7 @@ Responsibilities: - `listClaudeProcesses()`: extract process listing - `calculateSessionScanLimit()`: bounded scanning - `getProcessStartTimes()`: process elapsed time → start time mapping - - `findSessionFiles()`: bounded file discovery with recent + process-day windows + - `findSessionFiles()`: bounded file discovery with breadth-first scanning (one per project, then fill globally by mtime) - `readSession()`: parse single session (meta + last entry + timestamps) - `selectBestSession()`: filter + rank candidates by start time - `filterCandidateSessions()`: mode-based filtering (`cwd` / `missing-cwd` / `parent-child`) @@ -72,15 +72,19 @@ Responsibilities: - `rankCandidatesByStartTime()`: tolerance-based ranking - `assignSessionsForMode()`: orchestrate matching per mode - `addMappedSessionAgent()` / `addProcessOnlyAgent()`: tracking helpers - - `determineStatus()`: status from entry type + recency + - `extractUserMessageText()`: extract meaningful text from user messages (string or array content) + - `parseCommandMessage()`: parse `` tags into `/command args` format + - `isNoiseMessage()`: filter out non-meaningful messages (interruptions, tool loads, continuations) + - `isMetadataEntryType()`: skip metadata entry types (`last-prompt`, `file-history-snapshot`) when tracking `lastEntryType` + - `determineStatus()`: status from entry type (no age override) - `generateAgentName()`: project basename + disambiguation - Claude-specific adaptations (differs from Codex): - Session discovery: walk `~/.claude/projects/*/` reading `*.jsonl` files. Uses `sessions-index.json` for `originalPath` when available, falls back to `lastCwd` from session content when index is missing (common in practice) - Bounded scanning: collect all `*.jsonl` files with mtime, sort by mtime descending, take top N. No process-day window (Claude sessions aren't organized by date — mtime-based cutoff is sufficient since we already stat files during discovery). - - `sessionStart`: parsed from first JSONL entry timestamp (not `session_meta` type) - - Summary: lookup from `~/.claude/history.jsonl` by sessionId (simple scan, no complex indexing) - - Status: map Claude entry types (`user`, `assistant`, `progress`, `thinking`, `system`) to `AgentStatus` + - `sessionStart`: parsed from first JSONL entry — checks `entry.timestamp` then `entry.snapshot.timestamp` (for `file-history-snapshot` entries common in practice) + - Summary: extracted from last user message in session JSONL (no history.jsonl dependency). Handles `` tags for slash commands, filters skill expansions and noise messages + - Status: map Claude entry types (`user`, `assistant`, `progress`, `thinking`, `system`) to `AgentStatus`. Metadata types (`last-prompt`, `file-history-snapshot`) are excluded. No age-based IDLE override - Name: use slug for disambiguation (Claude sessions have slugs) 2. `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` — update tests @@ -97,16 +101,22 @@ Responsibilities: - Rationale: improves accuracy when multiple Claude processes share the same CWD, consistent with CodexAdapter. - Decision: Bound session scanning with MIN/MAX limits. - Rationale: keeps latency predictable as history grows, consistent with CodexAdapter. -- Decision: Replace `cwd` → `history` → `project-parent` flow with `cwd` → `missing-cwd` → `parent-child`. - - Rationale: simpler, consistent with CodexAdapter. `parent-child` mode matches sessions where process CWD is a parent or child of session project path, avoiding the greedy matching of `any` mode which caused cross-project session stealing. +- Decision: Replace `cwd` → `history` → `project-parent` flow with `cwd` → `missing-cwd` → `parent-child`, with tolerance-gated deferral in early modes. + - Rationale: simpler, consistent with CodexAdapter. `cwd` and `missing-cwd` modes defer assignment when the best candidate is outside start-time tolerance, allowing `parent-child` mode to find a better match (e.g., worktree sessions). `parent-child` mode matches sessions where process CWD equals, is a parent, or child of session project path — it includes exact CWD as a safety net for deferred matches. This avoids the greedy matching of the original `any` mode which caused cross-project session stealing. +- Decision: Within start-time tolerance, rank by recency (`lastActive`) instead of smallest time difference. + - Rationale: a 6s vs 45s start-time diff is noise within the 2-minute window. The session with more recent activity is the correct one — prevents stub sessions from beating real work sessions. - Decision: Use precise executable detection (`isClaudeExecutable`) instead of substring matching. - Rationale: `command.includes('claude')` falsely matched processes whose path arguments contained "claude" (e.g., nx daemon in a worktree named `feature-reimpl-claude-code-adapter`). Checking the basename of the first command word (`claude` or `claude.exe`) matches CodexAdapter's `isCodexExecutable` pattern. - Decision: Make `sessions-index.json` optional, fall back to `lastCwd` from session content. - Rationale: most Claude project directories lack `sessions-index.json` in practice, causing entire projects to be skipped during session discovery. Using `lastCwd` from the JSONL entries provides a reliable fallback. -- Decision: Keep history.jsonl summary lookup simple (scan last N entries, match by sessionId). - - Rationale: avoids complex indexing; keeps it simple at first per user request. -- Decision: Keep status-threshold values consistent across adapters (5-minute IDLE). - - Rationale: preserves cross-agent behavior consistency. +- Decision: Remove history.jsonl dependency, extract summary from session JSONL directly. + - Rationale: session JSONL already contains the conversation. Extracting the last user message is more reliable than history.jsonl which only covers recent sessions. Includes command tag parsing for slash commands and noise filtering. +- Decision: Process-only agents (no session file) show IDLE status with "Unknown" summary. + - Rationale: without session data, we can't determine actual status or task. IDLE + Unknown is more honest than RUNNING + "Claude process running". +- Decision: Ensure breadth in bounded scanning — at least one session per project directory. + - Rationale: projects with many sessions (e.g., ai-devkit with 20+ files) consumed all scan slots, starving other projects. Two-pass scanning (one per project, then fill globally) ensures every project is represented. +- Decision: No age-based IDLE override for process-backed agents. + - Rationale: every agent in the list is backed by a running process found via `ps`. The session entry type (`user`/`assistant`/`progress`/`system`) is a more accurate status indicator than a time threshold. Removed the 5-minute IDLE override. - Decision: Keep matching orchestration in explicit phases with extracted helper methods and PID/session tracking sets. - Rationale: mirrors CodexAdapter structure for maintainability. - Decision: Use mtime-based bounded scanning without process-day window. diff --git a/docs/ai/implementation/feature-reimpl-claude-code-adapter.md b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md index 7ac1959..a48d813 100644 --- a/docs/ai/implementation/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md @@ -30,13 +30,17 @@ No changes to exports, index files, or CLI command. | Current Method | New Method (CodexAdapter pattern) | |---|---| | `detectAgents()` | `detectAgents()` — restructured with 3-phase matching | -| `readSessions()` (reads all) | `readSessions(limit, processStartByPid)` — bounded | +| `readSessions()` (reads all) | `readSessions(limit)` — bounded | | — | `listClaudeProcesses()` — extracted | | — | `calculateSessionScanLimit()` — new | | — | `getProcessStartTimes()` — new | | — | `findSessionFiles()` — adapted for Claude paths | | `readSessionLog()` | `readSession()` — single session, returns `ClaudeSession` | -| `readHistory()` + `indexHistoryByProjectPath()` | `readHistory()` — simple scan, no indexing | +| `readHistory()` + `indexHistoryByProjectPath()` | Removed — summary from `lastUserMessage` in session JSONL | +| — | `extractUserMessageText()` — parse user message with command/noise handling | +| — | `parseCommandMessage()` — extract `/command args` from `` tags | +| — | `isNoiseMessage()` — filter interruptions, tool loads, continuations | +| — | `isMetadataEntryType()` — skip `last-prompt`, `file-history-snapshot` for status tracking | | `selectBestSession()` | `selectBestSession()` — adds start-time ranking | | — | `filterCandidateSessions()` — extracted | | — | `rankCandidatesByStartTime()` — new | @@ -54,17 +58,19 @@ No changes to exports, index files, or CLI command. 1. **Session discovery**: Walk `~/.claude/projects/*/` dirs, collect `*.jsonl` files with mtime. Use `sessions-index.json` for `originalPath` when available; when missing (common in practice), set `projectPath` to empty and derive from `lastCwd` in session content during `readSession()`. Sort by mtime descending, take top N. -2. **Session parsing**: Read first line for `sessionStart` timestamp. Read last 100 lines for `lastEntryType`, `lastActive`, `lastCwd`, `slug`. +2. **Session parsing**: Read entire file. Parse first line for `sessionStart` timestamp (handles both top-level `timestamp` and `snapshot.timestamp` for `file-history-snapshot` entries). Parse all lines for `lastEntryType`, `lastActive`, `lastCwd`, `slug`, `lastUserMessage`. -3. **Summary**: Read last 100 entries from `~/.claude/history.jsonl`, find matching `sessionId`. No grouping/indexing. +3. **Summary**: Extracted from `lastUserMessage` in session JSONL. No history.jsonl dependency. Handles: `` tags → `/command args`; skill expansions → ARGUMENTS extraction; noise filtering (interruptions, tool loads, continuations). Fallback chain: lastUserMessage → "Session started" (matched sessions) or "Unknown" (process-only). -4. **Status mapping**: `user` (+ interrupted check) → RUNNING/WAITING, `progress`/`thinking` → RUNNING, `assistant` → WAITING, `system` → IDLE, idle threshold → IDLE. +4. **Status mapping**: `user` (+ interrupted check) → RUNNING/WAITING, `progress`/`thinking` → RUNNING, `assistant` → WAITING, `system` → IDLE. No age-based IDLE override (every listed agent is backed by a running process). 5. **Name generation**: project basename + slug disambiguation (keep existing logic). 6. **Process detection**: `canHandle()` uses `isClaudeExecutable()` which checks `path.basename()` of the first word in the command. Only matches `claude` or `claude.exe`, not processes with "claude" in path arguments (e.g., nx daemon running in a worktree named `feature-reimpl-claude-code-adapter`). -7. **Matching modes**: `cwd` → exact CWD match, `missing-cwd` → sessions with no `projectPath`, `parent-child` → process CWD is a parent or child of session project/lastCwd path. The `parent-child` mode replaces the original `any` mode which was too greedy and caused cross-project session stealing. +7. **Matching modes**: `cwd` → exact CWD match (with start-time tolerance gate), `missing-cwd` → sessions with no `projectPath` (with tolerance gate), `parent-child` → process CWD equals, is a parent, or is a child of session project/lastCwd path (no tolerance gate — acts as fallback). The `cwd` and `missing-cwd` modes defer assignment when the best candidate is outside start-time tolerance, allowing `parent-child` mode to find a better match (e.g., worktree sessions). The `parent-child` mode replaces the original `any` mode which was too greedy and caused cross-project session stealing. + +8. **Start-time ranking refinement**: Within tolerance (rank 0), candidates are sorted by `lastActive` (most recently active first) rather than smallest `diffMs`. The exact time difference within 2 minutes is noise; the session with recent activity is more likely correct. Outside tolerance (rank 1), smallest `diffMs` is used as primary sort. ## Error Handling diff --git a/docs/ai/planning/feature-reimpl-claude-code-adapter.md b/docs/ai/planning/feature-reimpl-claude-code-adapter.md index 0a5e5e7..1bceccb 100644 --- a/docs/ai/planning/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/planning/feature-reimpl-claude-code-adapter.md @@ -34,15 +34,15 @@ description: Task breakdown for re-implementing ClaudeCodeAdapter - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` - [x] Task 1.4: Rewrite `readSession()` for single session parsing - - Parse first entry for `sessionStart` timestamp - - Read last N lines for `lastEntryType`, `lastActive`, `lastCwd`, `slug` - - Simple history.jsonl lookup for summary + - Parse first entry for `sessionStart` timestamp (including `snapshot.timestamp` for `file-history-snapshot` entries) + - Read all lines for `lastEntryType`, `lastActive`, `lastCwd`, `slug` (skip metadata entry types) + - Extract `lastUserMessage` from session JSONL with command parsing and noise filtering - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` - [x] Task 1.5: Rewrite matching flow to `cwd` → `missing-cwd` → `parent-child` - Implement `assignSessionsForMode()`, `filterCandidateSessions()`, `addMappedSessionAgent()`, `addProcessOnlyAgent()` - Remove `assignHistoryEntriesForExactProcessCwd()` and old `project-parent` mode - - `parent-child` mode matches when process CWD is parent/child of session path (avoids greedy `any` mode) + - `parent-child` mode matches when process CWD equals, is parent, or child of session path (avoids greedy `any` mode) - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` - [x] Task 1.6: Rewrite `determineStatus()` and `generateAgentName()` @@ -98,23 +98,34 @@ description: Task breakdown for re-implementing ClaudeCodeAdapter ## Progress Summary -All tasks complete. ClaudeCodeAdapter rewritten from 598 to ~740 lines following CodexAdapter patterns. Key changes: +All tasks complete. ClaudeCodeAdapter rewritten from 598 to ~800 lines following CodexAdapter patterns. Key changes: - Added process start time matching (`getProcessStartTimes`, `rankCandidatesByStartTime`) -- Bounded session scanning (`calculateSessionScanLimit`, `findSessionFiles` with mtime sort + limit) -- Restructured matching to `cwd` → `missing-cwd` → `parent-child` phases -- Simplified session model: `lastEntryType` + `isInterrupted` instead of full `lastEntry` object -- History.jsonl kept simple: summary lookup by sessionId for matched sessions, CWD lookup for process-only -- All 100 tests pass (4 suites), TypeScript compiles clean +- Bounded session scanning with breadth guarantee (`findSessionFiles` ensures one session per project dir) +- Restructured matching to `cwd` → `missing-cwd` → `parent-child` phases with tolerance-gated deferral +- Simplified session model: `lastEntryType` + `isInterrupted` + `lastUserMessage` +- Removed history.jsonl dependency — summary extracted from session JSONL `lastUserMessage` +- Smart message extraction: parses `` tags, extracts ARGUMENTS from skill expansions, filters noise +- Metadata entry types (`last-prompt`, `file-history-snapshot`) excluded from status tracking +- Process-only agents show IDLE status with "Unknown" summary +- All 51 tests pass in ClaudeCodeAdapter suite, TypeScript compiles clean Runtime fixes discovered during integration testing: -- **`any` → `parent-child` mode**: `any` mode was too greedy, stealing sessions from unrelated projects. Replaced with `parent-child` mode that only matches when process CWD has a parent-child relationship with session path. -- **`isClaudeExecutable`**: `command.includes('claude')` falsely matched nx daemon processes whose path arguments contained "claude" (from worktree directory names). Changed to check basename of the first command word, matching CodexAdapter's `isCodexExecutable` pattern. -- **Optional `sessions-index.json`**: Most Claude project directories lack this file in practice. Made it optional — when missing, `projectPath` is derived from `lastCwd` in session JSONL content. +- **`any` → `parent-child` mode**: `any` mode was too greedy, stealing sessions from unrelated projects +- **`isClaudeExecutable`**: precise basename check instead of `command.includes('claude')` +- **Optional `sessions-index.json`**: falls back to `lastCwd` from session JSONL content +- **Breadth-first scanning**: ensures at least one session per project directory before filling remaining slots +- **Full-file user message scan**: parses all session lines (not just last 100) to find meaningful user messages +- **`file-history-snapshot` timestamp**: first JSONL entry may be `file-history-snapshot` with timestamp nested in `snapshot.timestamp` instead of top-level `timestamp`. Falling back to `lastActive` caused wrong session matching when a stale session's last activity coincided with a new process start +- **No age-based IDLE override**: removed 5-minute IDLE threshold since every listed agent is backed by a running process — entry type is the correct status indicator +- **Metadata entry type filtering**: `last-prompt` and `file-history-snapshot` entries are metadata, not conversation state. Skipping them via `isMetadataEntryType()` prevents them from overwriting `lastEntryType` and causing UNKNOWN status +- **Tolerance-gated CWD matching**: `cwd` and `missing-cwd` modes now defer assignment when the best candidate is outside start-time tolerance. This prevents stale sessions from being matched when a better match exists in `parent-child` mode (e.g., worktree sessions where process CWD is the main repo but session CWD is the worktree) +- **Recency-first ranking within tolerance**: when multiple sessions are within the 2-minute tolerance, sort by `lastActive` (most recent first) instead of smallest `diffMs`. Fixes stub sessions (3 lines) beating real sessions (270+ lines) due to a few seconds' start-time advantage +- **`parent-child` mode includes exact CWD**: expanded to also accept exact CWD matches as a safety net for deferred `cwd` assignments. Old processes whose sessions were created days later get matched correctly Behavioral changes from original: - `parent-child`-mode matching replaces `project-parent` and `assignHistoryEntriesForExactProcessCwd` -- Session matching now takes precedence over history-based matching in all cases -- Tests updated accordingly with new test cases for `parseElapsedSeconds`, `calculateSessionScanLimit`, `rankCandidatesByStartTime`, `isClaudeExecutable`, `parent-child` filtering +- Session JSONL provides summaries directly (no history.jsonl dependency) +- Process-only agents show IDLE/Unknown instead of RUNNING/"Claude process running" ## Risks & Mitigation @@ -123,4 +134,4 @@ Behavioral changes from original: - **Risk**: Process start time unavailable on some systems. - Mitigation: Graceful fallback to recency-based ranking (same as CodexAdapter). - **Risk**: Bounded scanning may miss relevant sessions. - - Mitigation: Include process-start-day window files (same as CodexAdapter pattern). + - Mitigation: Breadth-first scanning ensures at least one session per project directory; mtime-based top-N covers most-recently-active sessions. diff --git a/docs/ai/requirements/feature-reimpl-claude-code-adapter.md b/docs/ai/requirements/feature-reimpl-claude-code-adapter.md index 5511428..69301b4 100644 --- a/docs/ai/requirements/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/requirements/feature-reimpl-claude-code-adapter.md @@ -13,7 +13,7 @@ The current `ClaudeCodeAdapter` (598 lines) uses a different architectural appro - **No process start time matching**: Claude adapter relies solely on CWD and parent-path matching to pair processes with sessions, which is fragile when multiple Claude processes share the same project directory. - **Unbounded session scanning**: Reads all session files across all projects in `~/.claude/projects/`, which degrades performance as session history grows. -- **Inconsistent matching phases**: Uses `cwd` → `history` → `project-parent` → `process-only` flow, while Codex uses the cleaner `cwd` → `missing-cwd` → `any` pattern with extracted helpers and PID/session tracking sets. +- **Inconsistent matching phases**: Uses `cwd` → `history` → `project-parent` → `process-only` flow, while Codex uses the cleaner `cwd` → `missing-cwd` → `parent-child` pattern with extracted helpers and PID/session tracking sets. - **Structural divergence**: Two adapters in the same package follow different patterns, making maintenance harder. ## Goals & Objectives @@ -22,11 +22,11 @@ The current `ClaudeCodeAdapter` (598 lines) uses a different architectural appro - Re-implement `ClaudeCodeAdapter` using the same architectural patterns as `CodexAdapter` - Add process start time matching (`getProcessStartTimes` + `rankCandidatesByStartTime`) for accurate process-session pairing - Introduce bounded session scanning to keep `agent list` latency predictable -- Align matching phases to `cwd` → `missing-cwd` → `any` with extracted helper methods +- Align matching phases to `cwd` → `missing-cwd` → `parent-child` with extracted helper methods ### Secondary Goals - Improve disambiguation when multiple Claude processes share the same CWD -- Keep history.jsonl integration for summaries (simple read, no complex indexing) +- Extract summary from session JSONL directly (no history.jsonl dependency) ### Non-Goals - Changing Claude Code's session file structure (`~/.claude/projects/`) @@ -51,10 +51,9 @@ The current `ClaudeCodeAdapter` (598 lines) uses a different architectural appro ## Constraints & Assumptions -- **Session structure is fixed**: Claude Code stores sessions in `~/.claude/projects/{encoded-path}/` with `sessions-index.json` and `*.jsonl` files — this cannot change. -- **history.jsonl**: Available at `~/.claude/history.jsonl` for summary extraction — keep simple. +- **Session structure is fixed**: Claude Code stores sessions in `~/.claude/projects/{encoded-path}/` with optional `sessions-index.json` and `*.jsonl` files — this cannot change. - **Process detection**: Uses existing `listProcesses()` utility — no changes. -- **Status thresholds**: Must remain aligned across adapters (5-minute IDLE threshold). +- **Status determination**: Based on session entry type; no age-based IDLE override since every listed agent is backed by a running process. - **Platform**: macOS/Linux only (same as existing adapter). ## Questions & Open Items diff --git a/docs/ai/testing/feature-reimpl-claude-code-adapter.md b/docs/ai/testing/feature-reimpl-claude-code-adapter.md index f0eb983..22cb0aa 100644 --- a/docs/ai/testing/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/testing/feature-reimpl-claude-code-adapter.md @@ -32,7 +32,9 @@ description: Testing strategy for re-implemented ClaudeCodeAdapter - [x] `getProcessStartTimes()` parses `ps` output correctly - [x] `parseElapsedSeconds()` handles `MM:SS`, `HH:MM:SS`, `D-HH:MM:SS` formats - [x] `rankCandidatesByStartTime()` prefers sessions within tolerance window +- [x] `rankCandidatesByStartTime()` within tolerance, ranks by recency not diffMs - [x] `rankCandidatesByStartTime()` falls back to recency when no start time +- [x] `selectBestSession()` defers `cwd` mode when outside tolerance (falls through to `parent-child`) - [x] Graceful fallback when `ps` command fails ### Bounded Session Scanning @@ -54,7 +56,8 @@ description: Testing strategy for re-implemented ClaudeCodeAdapter - [x] `assistant` entry type → WAITING - [x] `progress`/`thinking` → RUNNING - [x] `system` → IDLE -- [x] Age > 5 minutes → IDLE (overrides entry type) +- [x] No age-based IDLE override (process is running, entry type is authoritative) +- [x] Metadata entry types (`last-prompt`, `file-history-snapshot`) do not affect status - [x] No last entry → UNKNOWN ### Name Generation @@ -63,21 +66,25 @@ description: Testing strategy for re-implemented ClaudeCodeAdapter - [x] Appends slug when multiple sessions for same project - [x] Falls back to sessionId prefix when no slug -### History Summary +### Summary Extraction -- [x] Reads summary from history.jsonl by sessionId -- [x] Falls back to default summary when no history match +- [x] Uses `lastUserMessage` from session JSONL as "Working On" text +- [x] Parses `` tags into `/command args` format for slash commands +- [x] Extracts ARGUMENTS from skill expansion content ("Base directory for this skill:...") +- [x] Filters noise messages: "[Request interrupted...]", "Tool loaded.", continuation summaries +- [x] Falls back to "Session started" when no meaningful user message found +- [x] Process-only agents show IDLE status with "Unknown" summary ## Test Data - Mock `listProcesses()` to return controlled process lists - Mock `fs` operations for session file reads - Mock `execSync` for `ps` output in start time tests -- Use inline JSONL fixtures for session and history data +- Use inline JSONL fixtures for session data ## Test Reporting & Coverage - Run: `cd packages/agent-manager && npx jest --coverage src/__tests__/adapters/ClaudeCodeAdapter.test.ts` - Uncoverable: `getProcessStartTimes` body (skipped in JEST_WORKER_ID — same pattern as CodexAdapter) -- All 100 tests pass across 4 suites +- All 51 tests pass in ClaudeCodeAdapter suite - Integration tested: `npm run build && node packages/cli/dist/cli.js agent list` verified with 9 concurrent Claude processes diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 2381daf..270aeaf 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -20,7 +20,6 @@ type PrivateMethod unknown> = T; interface AdapterPrivates { readSessions: PrivateMethod<(limit: number) => unknown[]>; - readHistory: PrivateMethod<() => unknown[]>; } describe('ClaudeCodeAdapter', () => { @@ -102,7 +101,7 @@ describe('ClaudeCodeAdapter', () => { expect(agents).toEqual([]); }); - it('should detect agents using mocked process/session/history data', async () => { + it('should detect agents using mocked process/session data', async () => { const processData: ProcessInfo[] = [ { pid: 12345, @@ -121,21 +120,12 @@ describe('ClaudeCodeAdapter', () => { lastActive: new Date(), lastEntryType: 'assistant', isInterrupted: false, - }, - ]; - - const historyData = [ - { - display: 'Investigate failing tests in package', - timestamp: Date.now(), - project: '/Users/test/my-project', - sessionId: 'session-1', + lastUserMessage: 'Investigate failing tests in package', }, ]; mockedListProcesses.mockReturnValue(processData); jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue(sessionData); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue(historyData); const agents = await adapter.detectAgents(); @@ -162,17 +152,17 @@ describe('ClaudeCodeAdapter', () => { }, ]); jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); + const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); expect(agents[0]).toMatchObject({ type: 'claude', - status: AgentStatus.RUNNING, + status: AgentStatus.IDLE, pid: 777, projectPath: '/project/without-session', sessionId: 'pid-777', - summary: 'Claude process running', + summary: 'Unknown', }); }); @@ -195,7 +185,7 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }, ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); + const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); @@ -205,7 +195,7 @@ describe('ClaudeCodeAdapter', () => { pid: 777, sessionId: 'pid-777', projectPath: '/project/without-session', - status: AgentStatus.RUNNING, + status: AgentStatus.IDLE, }); }); @@ -229,14 +219,6 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }, ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([ - { - display: 'Refactor CLI command flow', - timestamp: Date.now(), - project: '/Users/test/my-project', - sessionId: 'session-3', - }, - ]); const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); @@ -245,11 +227,10 @@ describe('ClaudeCodeAdapter', () => { pid: 888, sessionId: 'session-3', projectPath: '/Users/test/my-project', - summary: 'Refactor CLI command flow', }); }); - it('should use history entry for process-only fallback when no sessions exist', async () => { + it('should show idle status with Unknown summary for process-only fallback when no sessions exist', async () => { mockedListProcesses.mockReturnValue([ { pid: 97529, @@ -259,14 +240,6 @@ describe('ClaudeCodeAdapter', () => { }, ]); jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([ - { - display: '/status', - timestamp: 1772122701536, - project: '/Users/test/my-project/packages/cli', - sessionId: '69237415-b0c3-4990-ba53-15882616509e', - }, - ]); const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); @@ -274,11 +247,10 @@ describe('ClaudeCodeAdapter', () => { type: 'claude', pid: 97529, projectPath: '/Users/test/my-project/packages/cli', - sessionId: '69237415-b0c3-4990-ba53-15882616509e', - summary: '/status', - status: AgentStatus.RUNNING, + sessionId: 'pid-97529', + summary: 'Unknown', + status: AgentStatus.IDLE, }); - expect(agents[0].lastActive.toISOString()).toBe('2026-02-26T16:18:21.536Z'); }); it('should match session via parent-child mode when process cwd is under session project path', async () => { @@ -301,18 +273,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }, ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([ - { - display: '/status', - timestamp: 1772122701536, - project: '/Users/test/my-project/packages/cli', - sessionId: '69237415-b0c3-4990-ba53-15882616509e', - }, - ]); const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); - // Session matched via any-mode; history lookup is by session ID + // Session matched via parent-child mode expect(agents[0]).toMatchObject({ type: 'claude', pid: 97529, @@ -346,7 +310,7 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }, ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); + const agents = await adapter.detectAgents(); expect(agents).toHaveLength(2); @@ -359,8 +323,8 @@ describe('ClaudeCodeAdapter', () => { expect(agents[1]).toMatchObject({ pid: 200, sessionId: 'pid-200', - status: AgentStatus.RUNNING, - summary: 'Claude process running', + status: AgentStatus.IDLE, + summary: 'Unknown', }); }); @@ -374,22 +338,13 @@ describe('ClaudeCodeAdapter', () => { }, ]); jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([ - { - display: 'some task', - timestamp: Date.now(), - project: '/some/project', - sessionId: 'hist-1', - }, - ]); const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); - // Empty cwd → findHistoryForCwd returns undefined → pid-based sessionId expect(agents[0]).toMatchObject({ pid: 300, sessionId: 'pid-300', - summary: 'Claude process running', + summary: 'Unknown', projectPath: '', }); }); @@ -422,7 +377,7 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }, ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]); + const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); @@ -502,7 +457,7 @@ describe('ClaudeCodeAdapter', () => { expect(status).toBe(AgentStatus.RUNNING); }); - it('should return "idle" for old sessions', () => { + it('should not override status based on age (process is running)', () => { const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); @@ -517,8 +472,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; + // Even with old lastActive, entry type determines status + // because the process is known to be running const status = determineStatus(session); - expect(status).toBe(AgentStatus.IDLE); + expect(status).toBe(AgentStatus.WAITING); }); it('should return "idle" for system entries', () => { @@ -776,39 +733,6 @@ describe('ClaudeCodeAdapter', () => { }); }); - describe('findHistoryForCwd', () => { - it('should return undefined for empty cwd', () => { - const adapter = new ClaudeCodeAdapter(); - const findHistoryForCwd = (adapter as any).findHistoryForCwd.bind(adapter); - - expect(findHistoryForCwd('', [])).toBeUndefined(); - }); - - it('should find matching history entry by normalized path', () => { - const adapter = new ClaudeCodeAdapter(); - const findHistoryForCwd = (adapter as any).findHistoryForCwd.bind(adapter); - - const history = [ - { display: 'task 1', timestamp: 100, project: '/Users/test/my-project', sessionId: 'h1' }, - { display: 'task 2', timestamp: 200, project: '/Users/test/other', sessionId: 'h2' }, - ]; - - const result = findHistoryForCwd('/Users/test/my-project', history); - expect(result).toMatchObject({ sessionId: 'h1' }); - }); - - it('should return undefined when no match', () => { - const adapter = new ClaudeCodeAdapter(); - const findHistoryForCwd = (adapter as any).findHistoryForCwd.bind(adapter); - - const history = [ - { display: 'task', timestamp: 100, project: '/other/path', sessionId: 'h1' }, - ]; - - expect(findHistoryForCwd('/Users/test/my-project', history)).toBeUndefined(); - }); - }); - describe('filterCandidateSessions', () => { it('should match by lastCwd in cwd mode', () => { const adapter = new ClaudeCodeAdapter(); diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index fc38cb3..17ad7cd 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -4,7 +4,6 @@ * Detects running Claude Code agents by combining: * 1. Running `claude` processes * 2. Session metadata under ~/.claude/projects - * 3. History entries from ~/.claude/history.jsonl */ import * as fs from 'fs'; @@ -13,7 +12,7 @@ import { execSync } from 'child_process'; import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter'; import { AgentStatus } from './AgentAdapter'; import { listProcesses } from '../utils/process'; -import { readJsonLines, readJson } from '../utils/file'; +import { readJson } from '../utils/file'; /** * Structure of ~/.claude/projects/{path}/sessions-index.json @@ -31,7 +30,7 @@ interface SessionEntry { slug?: string; cwd?: string; message?: { - content?: Array<{ + content?: string | Array<{ type?: string; text?: string; content?: string; @@ -39,16 +38,6 @@ interface SessionEntry { }; } -/** - * Entry in ~/.claude/history.jsonl - */ -interface HistoryEntry { - display: string; - timestamp: number; - project: string; - sessionId: string; -} - /** * Claude Code session information */ @@ -61,6 +50,7 @@ interface ClaudeSession { lastActive: Date; lastEntryType?: string; isInterrupted: boolean; + lastUserMessage?: string; } type SessionMatchMode = 'cwd' | 'missing-cwd' | 'parent-child'; @@ -73,13 +63,11 @@ type SessionMatchMode = 'cwd' | 'missing-cwd' | 'parent-child'; * 2. Getting process start times for accurate session matching * 3. Reading bounded session files from ~/.claude/projects/ * 4. Matching sessions to processes via CWD then start time ranking - * 5. Extracting summary from history.jsonl + * 5. Extracting summary from last user message in session JSONL */ export class ClaudeCodeAdapter implements AgentAdapter { readonly type = 'claude' as const; - /** Keep status thresholds aligned across adapters. */ - private static readonly IDLE_THRESHOLD_MINUTES = 5; /** Limit session parsing per run to keep list latency bounded. */ private static readonly MIN_SESSION_SCAN = 12; private static readonly MAX_SESSION_SCAN = 40; @@ -87,15 +75,11 @@ export class ClaudeCodeAdapter implements AgentAdapter { /** Matching tolerance between process start time and session start time. */ private static readonly PROCESS_SESSION_TIME_TOLERANCE_MS = 2 * 60 * 1000; - private claudeDir: string; private projectsDir: string; - private historyPath: string; constructor() { const homeDir = process.env.HOME || process.env.USERPROFILE || ''; - this.claudeDir = path.join(homeDir, '.claude'); - this.projectsDir = path.join(this.claudeDir, 'projects'); - this.historyPath = path.join(this.claudeDir, 'history.jsonl'); + this.projectsDir = path.join(homeDir, '.claude', 'projects'); } /** @@ -125,15 +109,10 @@ export class ClaudeCodeAdapter implements AgentAdapter { ); const sessionScanLimit = this.calculateSessionScanLimit(claudeProcesses.length); const sessions = this.readSessions(sessionScanLimit); - const history = this.readHistory(); - const historyBySessionId = new Map(); - for (const entry of history) { - historyBySessionId.set(entry.sessionId, entry); - } if (sessions.length === 0) { return claudeProcesses.map((p) => - this.mapProcessOnlyAgent(p, [], history), + this.mapProcessOnlyAgent(p, []), ); } @@ -151,7 +130,6 @@ export class ClaudeCodeAdapter implements AgentAdapter { usedSessionIds, assignedPids, processStartByPid, - historyBySessionId, agents, ); this.assignSessionsForMode( @@ -161,7 +139,6 @@ export class ClaudeCodeAdapter implements AgentAdapter { usedSessionIds, assignedPids, processStartByPid, - historyBySessionId, agents, ); this.assignSessionsForMode( @@ -171,7 +148,6 @@ export class ClaudeCodeAdapter implements AgentAdapter { usedSessionIds, assignedPids, processStartByPid, - historyBySessionId, agents, ); @@ -180,7 +156,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { continue; } - this.addProcessOnlyAgent(processInfo, assignedPids, agents, history); + this.addProcessOnlyAgent(processInfo, assignedPids, agents); } return agents; @@ -209,7 +185,6 @@ export class ClaudeCodeAdapter implements AgentAdapter { usedSessionIds: Set, assignedPids: Set, processStartByPid: Map, - historyBySessionId: Map, agents: AgentInfo[], ): void { for (const processInfo of claudeProcesses) { @@ -233,7 +208,6 @@ export class ClaudeCodeAdapter implements AgentAdapter { processInfo, usedSessionIds, assignedPids, - historyBySessionId, agents, ); } @@ -244,37 +218,32 @@ export class ClaudeCodeAdapter implements AgentAdapter { processInfo: ProcessInfo, usedSessionIds: Set, assignedPids: Set, - historyBySessionId: Map, agents: AgentInfo[], ): void { usedSessionIds.add(session.sessionId); assignedPids.add(processInfo.pid); - agents.push(this.mapSessionToAgent(session, processInfo, historyBySessionId, agents)); + agents.push(this.mapSessionToAgent(session, processInfo, agents)); } private addProcessOnlyAgent( processInfo: ProcessInfo, assignedPids: Set, agents: AgentInfo[], - history: HistoryEntry[], ): void { assignedPids.add(processInfo.pid); - agents.push(this.mapProcessOnlyAgent(processInfo, agents, history)); + agents.push(this.mapProcessOnlyAgent(processInfo, agents)); } private mapSessionToAgent( session: ClaudeSession, processInfo: ProcessInfo, - historyBySessionId: Map, existingAgents: AgentInfo[], ): AgentInfo { - const historyEntry = historyBySessionId.get(session.sessionId); - return { name: this.generateAgentName(session, existingAgents), type: this.type, status: this.determineStatus(session), - summary: historyEntry?.display || 'Session started', + summary: session.lastUserMessage || 'Session started', pid: processInfo.pid, projectPath: session.projectPath || processInfo.cwd || '', sessionId: session.sessionId, @@ -286,48 +255,30 @@ export class ClaudeCodeAdapter implements AgentAdapter { private mapProcessOnlyAgent( processInfo: ProcessInfo, existingAgents: AgentInfo[], - history: HistoryEntry[], ): AgentInfo { const processCwd = processInfo.cwd || ''; - const historyEntry = this.findHistoryForCwd(processCwd, history); - const sessionId = historyEntry?.sessionId || `pid-${processInfo.pid}`; - const lastActive = historyEntry ? new Date(historyEntry.timestamp) : new Date(); const syntheticSession: ClaudeSession = { - sessionId, + sessionId: `pid-${processInfo.pid}`, projectPath: processCwd, lastCwd: processCwd, - sessionStart: lastActive, - lastActive, + sessionStart: new Date(), + lastActive: new Date(), isInterrupted: false, }; return { name: this.generateAgentName(syntheticSession, existingAgents), type: this.type, - status: AgentStatus.RUNNING, - summary: historyEntry?.display || 'Claude process running', + status: AgentStatus.IDLE, + summary: 'Unknown', pid: processInfo.pid, projectPath: processCwd, - sessionId, - lastActive, + sessionId: syntheticSession.sessionId, + lastActive: syntheticSession.lastActive, }; } - private findHistoryForCwd( - cwd: string, - history: HistoryEntry[], - ): HistoryEntry | undefined { - if (!cwd) { - return undefined; - } - - const normalizedCwd = this.normalizePath(cwd); - return history.find( - (entry) => this.normalizePath(entry.project) === normalizedCwd, - ); - } - private selectBestSession( processInfo: ProcessInfo, sessions: ClaudeSession[], @@ -353,7 +304,24 @@ export class ClaudeCodeAdapter implements AgentAdapter { )[0]; } - return this.rankCandidatesByStartTime(candidates, processStart)[0]; + const best = this.rankCandidatesByStartTime(candidates, processStart)[0]; + if (!best) { + return undefined; + } + + // In early modes (cwd/missing-cwd), defer assignment when the best + // candidate is outside start-time tolerance — a closer match may + // exist in parent-child mode (e.g., worktree sessions). + if (mode !== 'parent-child') { + const diffMs = Math.abs( + best.sessionStart.getTime() - processStart.getTime(), + ); + if (diffMs > ClaudeCodeAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS) { + return undefined; + } + } + + return best; } private filterCandidateSessions( @@ -378,8 +346,12 @@ export class ClaudeCodeAdapter implements AgentAdapter { return !session.projectPath; } - // parent-child mode: match if process CWD is under session project or vice versa + // parent-child mode: match if process CWD equals, is under, or is + // a parent of session project/lastCwd. This also catches exact CWD + // matches that were deferred from `cwd` mode due to start-time tolerance. return ( + this.pathEquals(processInfo.cwd, session.projectPath) || + this.pathEquals(processInfo.cwd, session.lastCwd) || this.isChildPath(processInfo.cwd, session.projectPath) || this.isChildPath(processInfo.cwd, session.lastCwd) || this.isChildPath(session.projectPath, processInfo.cwd) || @@ -409,6 +381,11 @@ export class ClaudeCodeAdapter implements AgentAdapter { }) .sort((a, b) => { if (a.rank !== b.rank) return a.rank - b.rank; + // Within tolerance (rank 0): prefer most recently active session. + // The exact diff is noise — a 6s vs 45s difference is meaningless, + // but the session with recent activity is more likely the real one. + if (a.rank === 0) return b.recency - a.recency; + // Outside tolerance: prefer smallest time difference, then recency. if (a.diffMs !== b.diffMs) return a.diffMs - b.diffMs; return b.recency - a.recency; }) @@ -535,9 +512,33 @@ export class ClaudeCodeAdapter implements AgentAdapter { } } - return files - .sort((a, b) => b.mtimeMs - a.mtimeMs) - .slice(0, limit); + // Ensure breadth: include at least the most recent session per project, + // then fill remaining slots with globally most-recent sessions. + const sorted = files.sort((a, b) => b.mtimeMs - a.mtimeMs); + const result: typeof files = []; + const seenProjects = new Set(); + + // First pass: one most-recent session per project directory + for (const file of sorted) { + const projDir = path.dirname(file.filePath); + if (!seenProjects.has(projDir)) { + seenProjects.add(projDir); + result.push(file); + } + } + + // Second pass: fill remaining slots with globally most-recent + if (result.length < limit) { + const resultSet = new Set(result.map((f) => f.filePath)); + for (const file of sorted) { + if (result.length >= limit) break; + if (!resultSet.has(file.filePath)) { + result.push(file); + } + } + } + + return result.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, limit); } /** @@ -561,12 +562,16 @@ export class ClaudeCodeAdapter implements AgentAdapter { return null; } - // Parse first line for sessionStart + // Parse first line for sessionStart. + // Claude Code may emit a "file-history-snapshot" as the first entry, which + // stores its timestamp inside "snapshot.timestamp" rather than at the root. let sessionStart: Date | null = null; try { - const firstEntry: SessionEntry = JSON.parse(allLines[0]); - if (firstEntry.timestamp) { - const ts = new Date(firstEntry.timestamp); + const firstEntry = JSON.parse(allLines[0]); + const rawTs: string | undefined = + firstEntry.timestamp || firstEntry.snapshot?.timestamp; + if (rawTs) { + const ts = new Date(rawTs); if (!Number.isNaN(ts.getTime())) { sessionStart = ts; } @@ -575,15 +580,15 @@ export class ClaudeCodeAdapter implements AgentAdapter { /* skip */ } - // Parse last N lines for recent state - const recentLines = allLines.slice(-100); + // Parse all lines for session state (file already in memory) let slug: string | undefined; let lastEntryType: string | undefined; let lastActive: Date | undefined; let lastCwd: string | undefined; let isInterrupted = false; + let lastUserMessage: string | undefined; - for (const line of recentLines) { + for (const line of allLines) { try { const entry: SessionEntry = JSON.parse(line); @@ -602,7 +607,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { lastCwd = entry.cwd; } - if (entry.type) { + if (entry.type && !this.isMetadataEntryType(entry.type)) { lastEntryType = entry.type; if (entry.type === 'user') { @@ -616,6 +621,12 @@ export class ClaudeCodeAdapter implements AgentAdapter { (c.type === 'tool_result' && c.content?.includes('[Request interrupted')), ); + + // Extract user message text for summary fallback + const text = this.extractUserMessageText(msgContent); + if (text) { + lastUserMessage = text; + } } else { isInterrupted = false; } @@ -634,17 +645,10 @@ export class ClaudeCodeAdapter implements AgentAdapter { lastActive: lastActive || new Date(), lastEntryType, isInterrupted, + lastUserMessage, }; } - /** - * Read history.jsonl for summaries - * Only reads last 100 lines for performance - */ - private readHistory(): HistoryEntry[] { - return readJsonLines(this.historyPath, 100); - } - /** * Determine agent status from session state */ @@ -653,11 +657,9 @@ export class ClaudeCodeAdapter implements AgentAdapter { return AgentStatus.UNKNOWN; } - const ageMinutes = - (Date.now() - session.lastActive.getTime()) / 60000; - if (ageMinutes > ClaudeCodeAdapter.IDLE_THRESHOLD_MINUTES) { - return AgentStatus.IDLE; - } + // No age-based IDLE override: every agent in the list is backed by + // a running process (found via ps), so the entry type is the best + // indicator of actual state. if (session.lastEntryType === 'user') { return session.isInterrupted @@ -729,6 +731,86 @@ export class ClaudeCodeAdapter implements AgentAdapter { return normalizedChild.startsWith(`${normalizedParent}${path.sep}`); } + /** + * Extract meaningful text from a user message content. + * Handles string and array formats, skill command expansion, and noise filtering. + */ + 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; + } + + // Skill slash-command: extract /command-name and args + if (raw.startsWith('')) { + return this.parseCommandMessage(raw); + } + + // Expanded skill content: extract ARGUMENTS line if present, skip otherwise + if (raw.startsWith('Base directory for this skill:')) { + const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/); + return argsMatch?.[1]?.trim() || undefined; + } + + // Filter noise + if (this.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; + } + + /** + * Check if a message is noise (not a meaningful user intent). + */ + private isNoiseMessage(text: string): boolean { + return ( + text.startsWith('[Request interrupted') || + text === 'Tool loaded.' || + text.startsWith('This session is being continued') + ); + } + + /** + * Check if an entry type is metadata (not conversation state). + * These should not overwrite lastEntryType used for status determination. + */ + private isMetadataEntryType(type: string): boolean { + return type === 'last-prompt' || type === 'file-history-snapshot'; + } + private normalizePath(value: string): string { const resolved = path.resolve(value); if (resolved.length > 1 && resolved.endsWith(path.sep)) { From 28b8b6b1abe8cb4a271be4c8aa751037f4ed1f94 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 11 Mar 2026 17:13:05 +0700 Subject: [PATCH 4/4] Update Claude Code adapter --- .../feature-reimpl-claude-code-adapter.md | 4 +- .../feature-reimpl-claude-code-adapter.md | 7 +- .../feature-reimpl-claude-code-adapter.md | 2 +- .../feature-reimpl-claude-code-adapter.md | 60 +++- .../adapters/ClaudeCodeAdapter.test.ts | 340 ++++++++++++++++++ .../src/adapters/ClaudeCodeAdapter.ts | 111 ++---- 6 files changed, 427 insertions(+), 97 deletions(-) diff --git a/docs/ai/design/feature-reimpl-claude-code-adapter.md b/docs/ai/design/feature-reimpl-claude-code-adapter.md index 99aeca9..48acf7a 100644 --- a/docs/ai/design/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/design/feature-reimpl-claude-code-adapter.md @@ -69,9 +69,9 @@ Responsibilities: - `filterCandidateSessions()`: mode-based filtering (`cwd` / `missing-cwd` / `parent-child`) - `isClaudeExecutable()`: precise executable detection (basename check, not substring) - `isChildPath()`: parent-child path relationship check + - `pathRelated()`: combined equals/parent/child check for path matching - `rankCandidatesByStartTime()`: tolerance-based ranking - - `assignSessionsForMode()`: orchestrate matching per mode - - `addMappedSessionAgent()` / `addProcessOnlyAgent()`: tracking helpers + - `assignSessionsForMode()`: orchestrate matching per mode (tracking inlined) - `extractUserMessageText()`: extract meaningful text from user messages (string or array content) - `parseCommandMessage()`: parse `` tags into `/command args` format - `isNoiseMessage()`: filter out non-meaningful messages (interruptions, tool loads, continuations) diff --git a/docs/ai/implementation/feature-reimpl-claude-code-adapter.md b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md index a48d813..0d1f66a 100644 --- a/docs/ai/implementation/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md @@ -44,15 +44,16 @@ No changes to exports, index files, or CLI command. | `selectBestSession()` | `selectBestSession()` — adds start-time ranking | | — | `filterCandidateSessions()` — extracted | | — | `rankCandidatesByStartTime()` — new | -| `assignSessionsForMode()` | `assignSessionsForMode()` — same structure | +| `assignSessionsForMode()` | `assignSessionsForMode()` — same structure, tracking inlined | | `assignHistoryEntriesForExactProcessCwd()` | Removed — subsumed by `parent-child` mode | | — | `isClaudeExecutable()` — precise executable basename check | | — | `isChildPath()` — parent-child path relationship check | +| — | `pathRelated()` — combined equals/parent/child check | | `mapSessionToAgent()` | `mapSessionToAgent()` — simplified | -| `mapProcessOnlyAgent()` | `mapProcessOnlyAgent()` — simplified | +| `mapProcessOnlyAgent()` | `mapProcessOnlyAgent()` — simplified, inlined name logic | | `mapHistoryToAgent()` | Removed — integrated into session mapping | | `determineStatus()` | `determineStatus()` — uses `lastEntryType` string | -| `generateAgentName()` | `generateAgentName()` — keeps slug disambiguation | +| `generateAgentName()` | `generateAgentName()` — keeps slug disambiguation (session-backed agents only) | ### Claude-Specific Adaptations diff --git a/docs/ai/planning/feature-reimpl-claude-code-adapter.md b/docs/ai/planning/feature-reimpl-claude-code-adapter.md index 1bceccb..58fad90 100644 --- a/docs/ai/planning/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/planning/feature-reimpl-claude-code-adapter.md @@ -107,7 +107,7 @@ All tasks complete. ClaudeCodeAdapter rewritten from 598 to ~800 lines following - Smart message extraction: parses `` tags, extracts ARGUMENTS from skill expansions, filters noise - Metadata entry types (`last-prompt`, `file-history-snapshot`) excluded from status tracking - Process-only agents show IDLE status with "Unknown" summary -- All 51 tests pass in ClaudeCodeAdapter suite, TypeScript compiles clean +- All 71 tests pass in ClaudeCodeAdapter suite, TypeScript compiles clean Runtime fixes discovered during integration testing: - **`any` → `parent-child` mode**: `any` mode was too greedy, stealing sessions from unrelated projects diff --git a/docs/ai/testing/feature-reimpl-claude-code-adapter.md b/docs/ai/testing/feature-reimpl-claude-code-adapter.md index 22cb0aa..77cc475 100644 --- a/docs/ai/testing/feature-reimpl-claude-code-adapter.md +++ b/docs/ai/testing/feature-reimpl-claude-code-adapter.md @@ -33,8 +33,11 @@ description: Testing strategy for re-implemented ClaudeCodeAdapter - [x] `parseElapsedSeconds()` handles `MM:SS`, `HH:MM:SS`, `D-HH:MM:SS` formats - [x] `rankCandidatesByStartTime()` prefers sessions within tolerance window - [x] `rankCandidatesByStartTime()` within tolerance, ranks by recency not diffMs +- [x] `rankCandidatesByStartTime()` breaks ties by recency when outside tolerance with same diffMs - [x] `rankCandidatesByStartTime()` falls back to recency when no start time - [x] `selectBestSession()` defers `cwd` mode when outside tolerance (falls through to `parent-child`) +- [x] `selectBestSession()` accepts in `cwd` mode when within tolerance +- [x] `selectBestSession()` falls back to recency when no processStart available - [x] Graceful fallback when `ps` command fails ### Bounded Session Scanning @@ -43,6 +46,7 @@ description: Testing strategy for re-implemented ClaudeCodeAdapter - [x] `findSessionFiles()` returns at most N files by mtime - [x] `findSessionFiles()` returns empty when session dir doesn't exist - [x] `findSessionFiles()` includes dirs without `sessions-index.json` using empty projectPath +- [x] `findSessionFiles()` skips directories starting with dot ### Process Detection @@ -66,25 +70,63 @@ description: Testing strategy for re-implemented ClaudeCodeAdapter - [x] Appends slug when multiple sessions for same project - [x] Falls back to sessionId prefix when no slug +### Session Parsing + +- [x] Parses timestamps, slug, cwd, and entry type from session file +- [x] Detects user interruption from `[Request interrupted` content +- [x] Parses `snapshot.timestamp` from `file-history-snapshot` first entries +- [x] Skips metadata entry types (`last-prompt`, `file-history-snapshot`) for `lastEntryType` +- [x] Extracts `lastUserMessage` from session entries (latest user message wins) +- [x] Uses `lastCwd` as `projectPath` fallback when `projectPath` is empty +- [x] Returns session with defaults for empty file +- [x] Returns null for non-existent file +- [x] Handles malformed JSON lines gracefully + ### Summary Extraction -- [x] Uses `lastUserMessage` from session JSONL as "Working On" text -- [x] Parses `` tags into `/command args` format for slash commands -- [x] Extracts ARGUMENTS from skill expansion content ("Base directory for this skill:...") -- [x] Filters noise messages: "[Request interrupted...]", "Tool loaded.", continuation summaries +- [x] `extractUserMessageText()` extracts plain string content +- [x] `extractUserMessageText()` extracts text from array content blocks +- [x] `extractUserMessageText()` returns undefined for empty/null content +- [x] `extractUserMessageText()` parses `` tags into `command args` format +- [x] `extractUserMessageText()` parses command-message without args +- [x] `extractUserMessageText()` extracts ARGUMENTS from skill expansion content +- [x] `extractUserMessageText()` returns undefined for skill expansion without ARGUMENTS +- [x] `extractUserMessageText()` filters noise messages +- [x] `parseCommandMessage()` returns undefined for malformed command-message - [x] Falls back to "Session started" when no meaningful user message found - [x] Process-only agents show IDLE status with "Unknown" summary +### Path Matching + +- [x] `filterCandidateSessions()` matches by `lastCwd` in cwd mode +- [x] `filterCandidateSessions()` matches sessions with no `projectPath` in missing-cwd mode +- [x] `filterCandidateSessions()` includes exact CWD matches in parent-child mode +- [x] `filterCandidateSessions()` matches parent-child path relationships +- [x] `filterCandidateSessions()` skips already-used sessions + ## Test Data - Mock `listProcesses()` to return controlled process lists -- Mock `fs` operations for session file reads -- Mock `execSync` for `ps` output in start time tests -- Use inline JSONL fixtures for session data +- Temp directories with inline JSONL fixtures for file I/O tests +- Direct private method access via `(adapter as any)` for unit-level testing +- No mock needed for `execSync` — `getProcessStartTimes` is skipped via `JEST_WORKER_ID` + +## Test File + +- `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` ## Test Reporting & Coverage - Run: `cd packages/agent-manager && npx jest --coverage src/__tests__/adapters/ClaudeCodeAdapter.test.ts` -- Uncoverable: `getProcessStartTimes` body (skipped in JEST_WORKER_ID — same pattern as CodexAdapter) -- All 51 tests pass in ClaudeCodeAdapter suite +- **71 tests pass** in ClaudeCodeAdapter suite +- Coverage for `ClaudeCodeAdapter.ts`: + - Statements: **90.8%** + - Branches: **87.0%** + - Functions: **100%** + - Lines: **92.0%** +- Intentionally uncovered: + - `getProcessStartTimes()` body (lines 400-424): skipped when `JEST_WORKER_ID` is set (same pattern as CodexAdapter) + - File I/O error catch paths (lines 458, 490, 510, 562): defensive error handling for corrupted/inaccessible files + - `normalizePath` trailing separator branch (line 817): OS-dependent edge case + - Dead code guard in `selectBestSession` (line 309): empty `rankCandidatesByStartTime` result cannot occur with non-empty candidates - Integration tested: `npm run build && node packages/cli/dist/cli.js agent list` verified with 9 concurrent Claude processes diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 270aeaf..0f3bb17 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -705,6 +705,61 @@ describe('ClaudeCodeAdapter', () => { expect(ranked[1].sessionId).toBe('far'); }); + it('should prefer recency over diffMs when both within tolerance', () => { + const adapter = new ClaudeCodeAdapter(); + const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter); + + const processStart = new Date('2026-03-10T10:00:00Z'); + const candidates = [ + { + sessionId: 'closer-but-stale', + projectPath: '/test', + sessionStart: new Date('2026-03-10T10:00:06Z'), // 6s diff + lastActive: new Date('2026-03-10T10:00:10Z'), // older activity + isInterrupted: false, + }, + { + sessionId: 'farther-but-active', + projectPath: '/test', + sessionStart: new Date('2026-03-10T10:00:45Z'), // 45s diff + lastActive: new Date('2026-03-10T10:30:00Z'), // much more recent + isInterrupted: false, + }, + ]; + + const ranked = rankCandidatesByStartTime(candidates, processStart); + // Both within tolerance — recency wins over smaller diffMs + expect(ranked[0].sessionId).toBe('farther-but-active'); + expect(ranked[1].sessionId).toBe('closer-but-stale'); + }); + + it('should break ties by recency when outside tolerance with same diffMs', () => { + const adapter = new ClaudeCodeAdapter(); + const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter); + + const processStart = new Date('2026-03-10T10:00:00Z'); + const candidates = [ + { + sessionId: 'older-activity', + projectPath: '/test', + sessionStart: new Date('2026-03-10T09:50:00Z'), // 10min diff + lastActive: new Date('2026-03-10T10:01:00Z'), + isInterrupted: false, + }, + { + sessionId: 'newer-activity', + projectPath: '/test', + sessionStart: new Date('2026-03-10T10:10:00Z'), // 10min diff (same abs) + lastActive: new Date('2026-03-10T10:30:00Z'), + isInterrupted: false, + }, + ]; + + const ranked = rankCandidatesByStartTime(candidates, processStart); + // Both outside tolerance, same diffMs — recency wins + expect(ranked[0].sessionId).toBe('newer-activity'); + }); + it('should fall back to recency when both outside tolerance', () => { const adapter = new ClaudeCodeAdapter(); const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter); @@ -782,6 +837,55 @@ describe('ClaudeCodeAdapter', () => { expect(result[0].sessionId).toBe('s1'); }); + it('should include exact CWD matches in parent-child mode', () => { + const adapter = new ClaudeCodeAdapter(); + const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter); + + const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }; + const sessions = [ + { + sessionId: 's1', + projectPath: '/my/project', + lastCwd: '/my/project', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, + }, + ]; + + const result = filterCandidateSessions(processInfo, sessions, new Set(), 'parent-child'); + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('s1'); + }); + + it('should match parent-child relationships', () => { + const adapter = new ClaudeCodeAdapter(); + const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter); + + const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }; + const sessions = [ + { + sessionId: 'child-session', + projectPath: '/my/project/packages/sub', + lastCwd: '/my/project/packages/sub', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, + }, + { + sessionId: 'parent-session', + projectPath: '/my', + lastCwd: '/my', + sessionStart: new Date(), + lastActive: new Date(), + isInterrupted: false, + }, + ]; + + const result = filterCandidateSessions(processInfo, sessions, new Set(), 'parent-child'); + expect(result).toHaveLength(2); + }); + it('should skip used sessions', () => { const adapter = new ClaudeCodeAdapter(); const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter); @@ -801,8 +905,173 @@ describe('ClaudeCodeAdapter', () => { expect(result).toHaveLength(0); }); }); + + describe('extractUserMessageText', () => { + it('should extract plain string content', () => { + const adapter = new ClaudeCodeAdapter(); + const extract = (adapter as any).extractUserMessageText.bind(adapter); + + expect(extract('hello world')).toBe('hello world'); + }); + + it('should extract text from array content blocks', () => { + const adapter = new ClaudeCodeAdapter(); + const extract = (adapter as any).extractUserMessageText.bind(adapter); + + const content = [ + { type: 'tool_result', content: 'some result' }, + { type: 'text', text: 'user question' }, + ]; + expect(extract(content)).toBe('user question'); + }); + + it('should return undefined for empty/null content', () => { + const adapter = new ClaudeCodeAdapter(); + const extract = (adapter as any).extractUserMessageText.bind(adapter); + + expect(extract(undefined)).toBeUndefined(); + expect(extract('')).toBeUndefined(); + expect(extract([])).toBeUndefined(); + }); + + it('should parse command-message tags', () => { + const adapter = new ClaudeCodeAdapter(); + const extract = (adapter as any).extractUserMessageText.bind(adapter); + + const msg = 'commitfix bug'; + expect(extract(msg)).toBe('commit fix bug'); + }); + + it('should parse command-message without args', () => { + const adapter = new ClaudeCodeAdapter(); + const extract = (adapter as any).extractUserMessageText.bind(adapter); + + const msg = 'help'; + expect(extract(msg)).toBe('help'); + }); + + it('should extract ARGUMENTS from skill expansion', () => { + const adapter = new ClaudeCodeAdapter(); + const extract = (adapter as any).extractUserMessageText.bind(adapter); + + const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature'; + expect(extract(msg)).toBe('implement the feature'); + }); + + it('should return undefined for skill expansion without ARGUMENTS', () => { + const adapter = new ClaudeCodeAdapter(); + const extract = (adapter as any).extractUserMessageText.bind(adapter); + + const msg = 'Base directory for this skill: /some/path\n\nSome instructions only'; + expect(extract(msg)).toBeUndefined(); + }); + + it('should filter noise messages', () => { + const adapter = new ClaudeCodeAdapter(); + const extract = (adapter as any).extractUserMessageText.bind(adapter); + + expect(extract('[Request interrupted by user]')).toBeUndefined(); + expect(extract('Tool loaded.')).toBeUndefined(); + expect(extract('This session is being continued from a previous conversation')).toBeUndefined(); + }); + }); + + describe('parseCommandMessage', () => { + it('should return undefined for malformed command-message', () => { + const adapter = new ClaudeCodeAdapter(); + const parse = (adapter as any).parseCommandMessage.bind(adapter); + + expect(parse('no tags')).toBeUndefined(); + }); + }); }); + describe('selectBestSession', () => { + it('should defer in cwd mode when best candidate is outside tolerance', () => { + const adapter = new ClaudeCodeAdapter(); + const selectBestSession = (adapter as any).selectBestSession.bind(adapter); + + const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }; + const processStart = new Date('2026-03-10T10:00:00Z'); + const processStartByPid = new Map([[1, processStart]]); + + const sessions = [ + { + sessionId: 'stale-exact-cwd', + projectPath: '/my/project', + lastCwd: '/my/project', + sessionStart: new Date('2026-03-07T10:00:00Z'), // 3 days old — outside tolerance + lastActive: new Date('2026-03-10T10:05:00Z'), + isInterrupted: false, + }, + ]; + + // In cwd mode, should defer (return undefined) because outside tolerance + const cwdResult = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd'); + expect(cwdResult).toBeUndefined(); + + // In parent-child mode, should accept the same candidate (no tolerance gate) + const parentChildResult = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'parent-child'); + expect(parentChildResult).toBeDefined(); + expect(parentChildResult.sessionId).toBe('stale-exact-cwd'); + }); + + it('should fall back to recency when no processStart available', () => { + const adapter = new ClaudeCodeAdapter(); + const selectBestSession = (adapter as any).selectBestSession.bind(adapter); + + const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }; + const processStartByPid = new Map(); // empty — no start time + + const sessions = [ + { + sessionId: 'older', + projectPath: '/my/project', + lastCwd: '/my/project', + sessionStart: new Date('2026-03-10T09:00:00Z'), + lastActive: new Date('2026-03-10T09:30:00Z'), + isInterrupted: false, + }, + { + sessionId: 'newer', + projectPath: '/my/project', + lastCwd: '/my/project', + sessionStart: new Date('2026-03-10T10:00:00Z'), + lastActive: new Date('2026-03-10T10:30:00Z'), + isInterrupted: false, + }, + ]; + + const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd'); + expect(result).toBeDefined(); + expect(result.sessionId).toBe('newer'); + }); + + it('should accept in cwd mode when best candidate is within tolerance', () => { + const adapter = new ClaudeCodeAdapter(); + const selectBestSession = (adapter as any).selectBestSession.bind(adapter); + + const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }; + const processStart = new Date('2026-03-10T10:00:00Z'); + const processStartByPid = new Map([[1, processStart]]); + + const sessions = [ + { + sessionId: 'fresh-exact-cwd', + projectPath: '/my/project', + lastCwd: '/my/project', + sessionStart: new Date('2026-03-10T10:00:30Z'), // 30s — within tolerance + lastActive: new Date('2026-03-10T10:05:00Z'), + isInterrupted: false, + }, + ]; + + const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd'); + expect(result).toBeDefined(); + expect(result.sessionId).toBe('fresh-exact-cwd'); + }); + }); + describe('file I/O methods', () => { let tmpDir: string; @@ -881,6 +1150,77 @@ describe('ClaudeCodeAdapter', () => { expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull(); }); + it('should skip metadata entry types for lastEntryType', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + const filePath = path.join(tmpDir, 'metadata-test.jsonl'); + const lines = [ + JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'hello' } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }), + JSON.stringify({ type: 'last-prompt', timestamp: '2026-03-10T10:02:00Z' }), + JSON.stringify({ type: 'file-history-snapshot', timestamp: '2026-03-10T10:03:00Z' }), + ]; + fs.writeFileSync(filePath, lines.join('\n')); + + const session = readSession(filePath, '/test'); + // lastEntryType should be 'assistant', not 'last-prompt' or 'file-history-snapshot' + expect(session.lastEntryType).toBe('assistant'); + }); + + it('should parse snapshot.timestamp from file-history-snapshot first entry', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + const filePath = path.join(tmpDir, 'snapshot-ts.jsonl'); + const lines = [ + JSON.stringify({ + type: 'file-history-snapshot', + snapshot: { timestamp: '2026-03-10T09:55:00Z', files: [] }, + }), + JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'test' } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }), + ]; + fs.writeFileSync(filePath, lines.join('\n')); + + const session = readSession(filePath, '/test'); + // sessionStart should come from snapshot.timestamp, not lastActive + expect(session.sessionStart.toISOString()).toBe('2026-03-10T09:55:00.000Z'); + expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z'); + }); + + it('should extract lastUserMessage from session entries', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + const filePath = path.join(tmpDir, 'user-msg.jsonl'); + const lines = [ + JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'first question' } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }), + JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:02:00Z', message: { content: [{ type: 'text', text: 'second question' }] } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:03:00Z' }), + ]; + fs.writeFileSync(filePath, lines.join('\n')); + + const session = readSession(filePath, '/test'); + // Last user message should be the most recent one + expect(session.lastUserMessage).toBe('second question'); + }); + + it('should use lastCwd as projectPath when projectPath is empty', () => { + const adapter = new ClaudeCodeAdapter(); + const readSession = (adapter as any).readSession.bind(adapter); + + const filePath = path.join(tmpDir, 'no-project.jsonl'); + const lines = [ + JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/derived/path', message: { content: 'test' } }), + ]; + fs.writeFileSync(filePath, lines.join('\n')); + + const session = readSession(filePath, ''); + expect(session.projectPath).toBe('/derived/path'); + }); + it('should handle malformed JSON lines gracefully', () => { const adapter = new ClaudeCodeAdapter(); const readSession = (adapter as any).readSession.bind(adapter); diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index 17ad7cd..adf476b 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -1,11 +1,3 @@ -/** - * Claude Code Adapter - * - * Detects running Claude Code agents by combining: - * 1. Running `claude` processes - * 2. Session metadata under ~/.claude/projects - */ - import * as fs from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; @@ -123,40 +115,26 @@ export class ClaudeCodeAdapter implements AgentAdapter { const assignedPids = new Set(); const agents: AgentInfo[] = []; - this.assignSessionsForMode( - 'cwd', - claudeProcesses, - sortedSessions, - usedSessionIds, - assignedPids, - processStartByPid, - agents, - ); - this.assignSessionsForMode( - 'missing-cwd', - claudeProcesses, - sortedSessions, - usedSessionIds, - assignedPids, - processStartByPid, - agents, - ); - this.assignSessionsForMode( - 'parent-child', - claudeProcesses, - sortedSessions, - usedSessionIds, - assignedPids, - processStartByPid, - agents, - ); + const modes: SessionMatchMode[] = ['cwd', 'missing-cwd', 'parent-child']; + for (const mode of modes) { + this.assignSessionsForMode( + mode, + claudeProcesses, + sortedSessions, + usedSessionIds, + assignedPids, + processStartByPid, + agents, + ); + } for (const processInfo of claudeProcesses) { if (assignedPids.has(processInfo.pid)) { continue; } - this.addProcessOnlyAgent(processInfo, assignedPids, agents); + assignedPids.add(processInfo.pid); + agents.push(this.mapProcessOnlyAgent(processInfo, agents)); } return agents; @@ -203,37 +181,12 @@ export class ClaudeCodeAdapter implements AgentAdapter { continue; } - this.addMappedSessionAgent( - session, - processInfo, - usedSessionIds, - assignedPids, - agents, - ); + usedSessionIds.add(session.sessionId); + assignedPids.add(processInfo.pid); + agents.push(this.mapSessionToAgent(session, processInfo, agents)); } } - private addMappedSessionAgent( - session: ClaudeSession, - processInfo: ProcessInfo, - usedSessionIds: Set, - assignedPids: Set, - agents: AgentInfo[], - ): void { - usedSessionIds.add(session.sessionId); - assignedPids.add(processInfo.pid); - agents.push(this.mapSessionToAgent(session, processInfo, agents)); - } - - private addProcessOnlyAgent( - processInfo: ProcessInfo, - assignedPids: Set, - agents: AgentInfo[], - ): void { - assignedPids.add(processInfo.pid); - agents.push(this.mapProcessOnlyAgent(processInfo, agents)); - } - private mapSessionToAgent( session: ClaudeSession, processInfo: ProcessInfo, @@ -257,25 +210,18 @@ export class ClaudeCodeAdapter implements AgentAdapter { existingAgents: AgentInfo[], ): AgentInfo { const processCwd = processInfo.cwd || ''; - - const syntheticSession: ClaudeSession = { - sessionId: `pid-${processInfo.pid}`, - projectPath: processCwd, - lastCwd: processCwd, - sessionStart: new Date(), - lastActive: new Date(), - isInterrupted: false, - }; + const projectName = path.basename(processCwd) || 'claude'; + const hasDuplicate = existingAgents.some((a) => a.projectPath === processCwd); return { - name: this.generateAgentName(syntheticSession, existingAgents), + name: hasDuplicate ? `${projectName} (pid-${processInfo.pid})` : projectName, type: this.type, status: AgentStatus.IDLE, summary: 'Unknown', pid: processInfo.pid, projectPath: processCwd, - sessionId: syntheticSession.sessionId, - lastActive: syntheticSession.lastActive, + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), }; } @@ -350,12 +296,8 @@ export class ClaudeCodeAdapter implements AgentAdapter { // a parent of session project/lastCwd. This also catches exact CWD // matches that were deferred from `cwd` mode due to start-time tolerance. return ( - this.pathEquals(processInfo.cwd, session.projectPath) || - this.pathEquals(processInfo.cwd, session.lastCwd) || - this.isChildPath(processInfo.cwd, session.projectPath) || - this.isChildPath(processInfo.cwd, session.lastCwd) || - this.isChildPath(session.projectPath, processInfo.cwd) || - this.isChildPath(session.lastCwd, processInfo.cwd) + this.pathRelated(processInfo.cwd, session.projectPath) || + this.pathRelated(processInfo.cwd, session.lastCwd) ); }); } @@ -713,6 +655,11 @@ export class ClaudeCodeAdapter implements AgentAdapter { return `${projectName} (${session.sessionId.slice(0, 8)})`; } + /** Check if two paths are equal, or one is a parent/child of the other. */ + private pathRelated(a?: string, b?: string): boolean { + return this.pathEquals(a, b) || this.isChildPath(a, b) || this.isChildPath(b, a); + } + private pathEquals(a?: string, b?: string): boolean { if (!a || !b) { return false;