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..48acf7a --- /dev/null +++ b/docs/ai/design/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,130 @@ +--- +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`, falls back to `lastCwd` when index missing + - `lastCwd`: from session JSONL entries + - `slug`: from session JSONL entries + - `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 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 + +### 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 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`) + - `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 (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) + - `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 — 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 + - 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` → `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: 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. + - 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 + +- 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..0d1f66a --- /dev/null +++ b/docs/ai/implementation/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,82 @@ +--- +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)` — bounded | +| — | `listClaudeProcesses()` — extracted | +| — | `calculateSessionScanLimit()` — new | +| — | `getProcessStartTimes()` — new | +| — | `findSessionFiles()` — adapted for Claude paths | +| `readSessionLog()` | `readSession()` — single session, returns `ClaudeSession` | +| `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 | +| `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, inlined name logic | +| `mapHistoryToAgent()` | Removed — integrated into session mapping | +| `determineStatus()` | `determineStatus()` — uses `lastEntryType` string | +| `generateAgentName()` | `generateAgentName()` — keeps slug disambiguation (session-backed agents only) | + +### Claude-Specific Adaptations + +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 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**: 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. 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 (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 + +- `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 new file mode 100644 index 0000000..58fad90 --- /dev/null +++ b/docs/ai/planning/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,137 @@ +--- +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 + +- [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 + +- [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` + +- [x] Task 1.2: Extract `listClaudeProcesses()` helper + - Mirror CodexAdapter's `listCodexProcesses()` pattern + - Files: `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` + +- [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 + - 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` + +- [x] Task 1.4: Rewrite `readSession()` for single session parsing + - 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 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()` + - 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 + +- [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` + +- [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` + +- [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 + +- [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` + +- [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` + +- [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` + +- [x] 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 + +## Progress Summary + +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 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 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 +- **`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 JSONL provides summaries directly (no history.jsonl dependency) +- Process-only agents show IDLE/Unknown instead of RUNNING/"Claude process running" + +## 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: 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 new file mode 100644 index 0000000..69301b4 --- /dev/null +++ b/docs/ai/requirements/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,61 @@ +--- +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` → `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 + +### 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` → `parent-child` with extracted helper methods + +### Secondary Goals +- Improve disambiguation when multiple Claude processes share the same CWD +- Extract summary from session JSONL directly (no history.jsonl dependency) + +### 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 optional `sessions-index.json` and `*.jsonl` files — this cannot change. +- **Process detection**: Uses existing `listProcesses()` utility — no changes. +- **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 + +- 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..77cc475 --- /dev/null +++ b/docs/ai/testing/feature-reimpl-claude-code-adapter.md @@ -0,0 +1,132 @@ +--- +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 + +- [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 + +- [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()` 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 + +- [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 +- [x] `findSessionFiles()` skips directories starting with dot + +### 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 + +- [x] `user` entry type → RUNNING +- [x] `user` with interrupted content → WAITING +- [x] `assistant` entry type → WAITING +- [x] `progress`/`thinking` → RUNNING +- [x] `system` → IDLE +- [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 + +- [x] Uses project basename as name +- [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] `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 +- 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` +- **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 0c8927a..0f3bb17 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,8 +19,7 @@ const mockedListProcesses = listProcesses as jest.MockedFunction unknown> = T; interface AdapterPrivates { - readSessions: PrivateMethod<() => unknown[]>; - readHistory: PrivateMethod<() => unknown[]>; + readSessions: PrivateMethod<(limit: number) => unknown[]>; } describe('ClaudeCodeAdapter', () => { @@ -47,10 +48,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 +80,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', () => { @@ -78,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, @@ -92,25 +115,17 @@ 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(), - }, - ]; - - const historyData = [ - { - display: 'Investigate failing tests in package', - timestamp: Date.now(), - project: '/Users/test/my-project', - sessionId: 'session-1', + lastEntryType: 'assistant', + isInterrupted: false, + 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(); @@ -127,7 +142,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([]); + + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'claude', + status: AgentStatus.IDLE, + pid: 777, + projectPath: '/project/without-session', + sessionId: 'pid-777', + summary: 'Unknown', + }); + }); + + it('should not match process to unrelated session from different project', async () => { mockedListProcesses.mockReturnValue([ { pid: 777, @@ -140,26 +179,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.IDLE, }); }); - 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,18 +212,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(), - }, - ]); - 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', + lastEntryType: 'assistant', + isInterrupted: false, }, ]); @@ -194,11 +227,10 @@ describe('ClaudeCodeAdapter', () => { pid: 888, sessionId: 'session-3', projectPath: '/Users/test/my-project', - summary: 'Refactor CLI command flow', }); }); - it('should use latest history entry for process-only fallback session id', async () => { + it('should show idle status with Unknown summary for process-only fallback when no sessions exist', async () => { mockedListProcesses.mockReturnValue([ { pid: 97529, @@ -208,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); @@ -223,14 +247,13 @@ 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 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,45 +264,142 @@ 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, + }, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + // Session matched via parent-child mode + expect(agents[0]).toMatchObject({ + type: 'claude', + pid: 97529, + 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, 'readHistory').mockReturnValue([ + jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ { - display: '/status', - timestamp: 1772122701536, - project: '/Users/test/my-project/packages/cli', - sessionId: '69237415-b0c3-4990-ba53-15882616509e', + sessionId: 'only-session', + projectPath: '/project-a', + sessionStart: new Date(), + lastActive: new Date(), + lastEntryType: 'assistant', + isInterrupted: false, }, ]); + + 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.IDLE, + summary: 'Unknown', + }); + }); + + 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([]); + const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); expect(agents[0]).toMatchObject({ - type: 'claude', - pid: 97529, - sessionId: '69237415-b0c3-4990-ba53-15882616509e', - projectPath: '/Users/test/my-project/packages/cli', - summary: '/status', + pid: 300, + sessionId: 'pid-300', + summary: 'Unknown', + 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, + }, + ]); + + + 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 +413,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 +430,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,16 +447,17 @@ 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); 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); @@ -348,14 +466,85 @@ describe('ClaudeCodeAdapter', () => { const session = { sessionId: 'test', projectPath: '/test', - sessionLogPath: '/test/log', - lastEntry: { type: 'assistant' }, + sessionStart: oldDate, lastActive: oldDate, + lastEntryType: 'assistant', + 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.WAITING); + }); + + 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 +555,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 +583,785 @@ 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 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); + + 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('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 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); + + 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('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; + + 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 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); + + 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..adf476b 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -1,16 +1,10 @@ -/** - * Claude Code Adapter - * - * Detects running Claude Code agents by reading session files - * from ~/.claude/ directory and correlating with running processes. - */ - import * as fs from 'fs'; import * as path from 'path'; +import { 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 { readJson } from '../utils/file'; /** * Structure of ~/.claude/projects/{path}/sessions-index.json @@ -19,43 +13,21 @@ interface SessionsIndex { originalPath: string; } -enum SessionEntryType { - ASSISTANT = 'assistant', - USER = 'user', - PROGRESS = 'progress', - THINKING = 'thinking', - SYSTEM = 'system', - MESSAGE = 'message', - TEXT = 'text', -} - /** * Entry in session JSONL file */ interface SessionEntry { - type?: SessionEntryType; + type?: string; timestamp?: string; slug?: string; cwd?: string; - sessionId?: string; message?: { - content?: Array<{ + content?: string | Array<{ type?: string; text?: string; content?: string; }>; }; - [key: string]: unknown; -} - -/** - * Entry in ~/.claude/history.jsonl - */ -interface HistoryEntry { - display: string; - timestamp: number; - project: string; - sessionId: string; } /** @@ -66,135 +38,122 @@ interface ClaudeSession { projectPath: string; lastCwd?: string; slug?: string; - sessionLogPath: string; - lastEntry?: SessionEntry; - lastActive?: Date; + sessionStart: Date; + lastActive: Date; + lastEntryType?: string; + isInterrupted: boolean; + lastUserMessage?: string; } -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 - * 5. Extracting summary from history.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 last user message in session JSONL */ export class ClaudeCodeAdapter implements AgentAdapter { readonly type = 'claude' as const; - /** Threshold in minutes before considering a session idle */ - 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; - 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'); } /** * 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 history = this.readHistory(); - const historyByProjectPath = this.indexHistoryByProjectPath(history); - const historyBySessionId = new Map(); - for (const entry of history) { - historyBySessionId.set(entry.sessionId, entry); - } + const processStartByPid = this.getProcessStartTimes( + claudeProcesses.map((p) => p.pid), + ); + const sessionScanLimit = this.calculateSessionScanLimit(claudeProcesses.length); + const sessions = this.readSessions(sessionScanLimit); - 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, []), + ); + } + const sortedSessions = [...sessions].sort( + (a, b) => b.lastActive.getTime() - a.lastActive.getTime(), + ); const usedSessionIds = new Set(); const assignedPids = new Set(); const agents: AgentInfo[] = []; - this.assignSessionsForMode( - 'cwd', - claudeProcesses, - sortedSessions, - usedSessionIds, - assignedPids, - historyBySessionId, - agents, - ); - this.assignHistoryEntriesForExactProcessCwd( - claudeProcesses, - assignedPids, - historyByProjectPath, - usedSessionIds, - agents, - ); - this.assignSessionsForMode( - 'project-parent', - claudeProcesses, - sortedSessions, - usedSessionIds, - assignedPids, - historyBySessionId, - 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; } assignedPids.add(processInfo.pid); - agents.push(this.mapProcessOnlyAgent(processInfo, agents, historyByProjectPath, usedSessionIds)); + agents.push(this.mapProcessOnlyAgent(processInfo, agents)); } 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,7 +162,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { sessions: ClaudeSession[], usedSessionIds: Set, assignedPids: Set, - historyBySessionId: Map, + processStartByPid: Map, agents: AgentInfo[], ): void { for (const processInfo of claudeProcesses) { @@ -211,321 +170,457 @@ 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)); + agents.push(this.mapSessionToAgent(session, processInfo, agents)); } } - private selectBestSession( - 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; - } - - const lastActiveA = a.lastActive?.getTime() || 0; - const lastActiveB = b.lastActive?.getTime() || 0; - return lastActiveB - lastActiveA; - })[0]; - } - 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, slug: session.slug, - lastActive: session.lastActive || new Date(), + lastActive: session.lastActive, }; } private mapProcessOnlyAgent( processInfo: ProcessInfo, existingAgents: AgentInfo[], - historyByProjectPath: Map, - usedSessionIds: Set, ): AgentInfo { - const projectPath = processInfo.cwd || ''; - const historyEntry = this.selectHistoryForProcess(projectPath, historyByProjectPath, usedSessionIds); - 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 = { - sessionId, - projectPath, - lastCwd: projectPath, - sessionLogPath: '', - lastActive, - }; + const processCwd = processInfo.cwd || ''; + const projectName = path.basename(processCwd) || 'claude'; + const hasDuplicate = existingAgents.some((a) => a.projectPath === processCwd); return { - name: this.generateAgentName(processSession, existingAgents), + name: hasDuplicate ? `${projectName} (pid-${processInfo.pid})` : projectName, type: this.type, - status: AgentStatus.RUNNING, - summary: historyEntry?.display || 'Claude process running', + status: AgentStatus.IDLE, + summary: 'Unknown', pid: processInfo.pid, - projectPath, - sessionId: processSession.sessionId, - lastActive: processSession.lastActive || new Date(), + projectPath: processCwd, + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), }; } - private mapHistoryToAgent( + 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; + } - private indexHistoryByProjectPath(historyEntries: HistoryEntry[]): Map { - const grouped = new Map(); + const processStart = processStartByPid.get(processInfo.pid); + if (!processStart) { + return candidates.sort( + (a, b) => b.lastActive.getTime() - a.lastActive.getTime(), + )[0]; + } - for (const entry of historyEntries) { - const key = this.normalizePath(entry.project); - const list = grouped.get(key) || []; - list.push(entry); - grouped.set(key, list); + const best = this.rankCandidatesByStartTime(candidates, processStart)[0]; + if (!best) { + return undefined; } - for (const [key, list] of grouped.entries()) { - grouped.set( - key, - [...list].sort((a, b) => b.timestamp - a.timestamp), + // 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 grouped; + return best; } - private selectHistoryForProcess( - processCwd: string, - historyByProjectPath: Map, + private filterCandidateSessions( + processInfo: ProcessInfo, + sessions: ClaudeSession[], usedSessionIds: Set, - ): HistoryEntry | undefined { - if (!processCwd) { - return undefined; + 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 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.pathRelated(processInfo.cwd, session.projectPath) || + this.pathRelated(processInfo.cwd, session.lastCwd) + ); + }); + } + + 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; + // 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; + }) + .map((ranked) => ranked.session); + } + + private getProcessStartTimes(pids: number[]): Map { + if (pids.length === 0 || process.env.JEST_WORKER_ID) { + return new Map(); } - const candidates = historyByProjectPath.get(this.normalizePath(processCwd)) || []; - return candidates.find((entry) => !usedSessionIds.has(entry.sessionId)); + try { + const output = execSync( + `ps -o pid=,etime= -p ${pids.join(',')}`, + { encoding: 'utf-8' }, + ); + const nowMs = Date.now(); + const startTimes = new Map(); + + for (const rawLine of output.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + const parts = line.split(/\s+/); + if (parts.length < 2) continue; + + const pid = Number.parseInt(parts[0], 10); + const elapsedSeconds = this.parseElapsedSeconds(parts[1]); + if (!Number.isFinite(pid) || elapsedSeconds === null) continue; + + startTimes.set(pid, new Date(nowMs - elapsedSeconds * 1000)); + } + + return startTimes; + } catch { + return new Map(); + } + } + + private parseElapsedSeconds(etime: string): number | null { + const match = etime + .trim() + .match(/^(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/); + if (!match) { + return null; + } + + const days = Number.parseInt(match[1] || '0', 10); + const hours = Number.parseInt(match[2] || '0', 10); + const minutes = Number.parseInt(match[3] || '0', 10); + const seconds = Number.parseInt(match[4] || '0', 10); + + return ((days * 24 + hours) * 60 + minutes) * 60 + seconds; } /** - * Read all Claude Code sessions + * Read Claude Code sessions with bounded scanning */ - private readSessions(): ClaudeSession[] { + 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); + } + } + + return sessions; + } + + /** + * Find session files bounded by mtime, sorted most-recent first + */ + 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 index = readJson(indexPath); + const projectPath = index?.originalPath || ''; - const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl')); - - for (const sessionFile of sessionFiles) { - const sessionId = sessionFile.replace('.jsonl', ''); - const sessionLogPath = path.join(projectDir, sessionFile); + 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; + // 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); } /** - * 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. + // 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 = 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; + } + } + } catch { + /* skip */ + } + + // Parse all lines for session state (file already in memory) let slug: string | undefined; - let lastEntry: SessionEntry | undefined; + let lastEntryType: string | undefined; let lastActive: Date | undefined; let lastCwd: string | undefined; + let isInterrupted = false; + let lastUserMessage: string | undefined; - for (const line of lines) { + for (const line of allLines) { 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 && !this.isMetadataEntryType(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')), + ); + + // Extract user message text for summary fallback + const text = this.extractUserMessageText(msgContent); + if (text) { + lastUserMessage = text; + } + } else { + isInterrupted = false; + } + } + } catch { continue; } } - return { slug, lastEntry, lastActive, lastCwd }; - } - - /** - * Read history.jsonl for user prompts - * Only reads last 100 lines for performance - */ - private readHistory(): HistoryEntry[] { - return readJsonLines(this.historyPath, 100); + return { + sessionId, + projectPath: projectPath || lastCwd || '', + lastCwd, + slug, + sessionStart: sessionStart || lastActive || new Date(), + lastActive: lastActive || new Date(), + lastEntryType, + isInterrupted, + lastUserMessage, + }; } /** - * 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; + // 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 (ageMinutes > ClaudeCodeAdapter.IDLE_THRESHOLD_MINUTES) { - return AgentStatus.IDLE; + if (session.lastEntryType === 'user') { + return session.isInterrupted + ? AgentStatus.WAITING + : AgentStatus.RUNNING; } - 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; - } + if ( + session.lastEntryType === 'progress' || + session.lastEntryType === 'thinking' + ) { return AgentStatus.RUNNING; } - if (entryType === SessionEntryType.PROGRESS || entryType === SessionEntryType.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,30 +631,35 @@ 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)})`; } + /** 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; @@ -575,23 +675,94 @@ 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 { - const resolved = path.resolve(value); - if (resolved.length > 1 && resolved.endsWith(path.sep)) { - return resolved.slice(0, -1); + /** + * 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; } - return resolved; + + 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; } - private pathDepth(value?: string): number { - if (!value) { - return 0; + /** + * 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') + ); + } - return this.normalizePath(value).split(path.sep).filter(Boolean).length; + /** + * 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)) { + return resolved.slice(0, -1); + } + return resolved; + } }