From 14d82aae545b534963c2861116fba591fe70b55c Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 27 Mar 2026 15:57:34 +0700 Subject: [PATCH 1/2] feat(agent-manager): update session matching for Claude Code --- .../feature-claude-sessions-pid-matching.md | 97 +++++++++++++++++++ .../feature-claude-sessions-pid-matching.md | 96 ++++++++++++++++++ .../feature-claude-sessions-pid-matching.md | 47 +++++++++ .../feature-claude-sessions-pid-matching.md | 64 ++++++++++++ .../feature-claude-sessions-pid-matching.md | 52 ++++++++++ 5 files changed, 356 insertions(+) create mode 100644 docs/ai/design/feature-claude-sessions-pid-matching.md create mode 100644 docs/ai/implementation/feature-claude-sessions-pid-matching.md create mode 100644 docs/ai/planning/feature-claude-sessions-pid-matching.md create mode 100644 docs/ai/requirements/feature-claude-sessions-pid-matching.md create mode 100644 docs/ai/testing/feature-claude-sessions-pid-matching.md diff --git a/docs/ai/design/feature-claude-sessions-pid-matching.md b/docs/ai/design/feature-claude-sessions-pid-matching.md new file mode 100644 index 0000000..5676035 --- /dev/null +++ b/docs/ai/design/feature-claude-sessions-pid-matching.md @@ -0,0 +1,97 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture + +## Architecture Overview + +The change is localised to `ClaudeCodeAdapter`. The detection flow always attempts a PID-file lookup for every process first; only processes whose PID file cannot be found fall through to the existing legacy matching step. + +```mermaid +flowchart TD + A[detectAgents] --> B[listAgentProcesses - ps aux] + B --> C[enrichProcesses - lsof + ps] + C --> D[For each PID: try read ~/.claude/sessions/PID.json] + D --> E{PID file found?} + E -->|No| G[Add to legacy-fallback set] + E -->|Yes| F{startedAt within 60s\nof proc.startTime?} + F -->|No - stale| G + F -->|Yes| H[Resolve JSONL path from sessionId + cwd] + H --> I{JSONL exists?} + I -->|No| G + I -->|Yes| J[Direct match: process → session] + G --> K[discoverSessions for fallback processes] + K --> L[matchProcessesToSessions - existing algo] + J --> M[Merge direct matches + legacy matches] + L --> M + M --> N[Read sessions and build AgentInfo] +``` + +## Data Models + +### PID file schema (`~/.claude/sessions/.json`) +```typescript +interface PidFileEntry { + pid: number; + sessionId: string; // filename without .jsonl + cwd: string; // working directory when Claude started + startedAt: number; // epoch milliseconds + kind: string; // e.g. "interactive" — not used + entrypoint: string; // e.g. "cli" — not used +} +``` + +### New internal type: `DirectMatch` +```typescript +interface DirectMatch { + process: ProcessInfo; + sessionFile: SessionFile; // reuse existing SessionFile shape +} +``` + +## Component Breakdown + +### Modified: `ClaudeCodeAdapter` + +**New private method**: `tryPidFileMatching(processes: ProcessInfo[]): { direct: DirectMatch[]; fallback: ProcessInfo[] }` +- For each process, attempts to read `~/.claude/sessions/.json`. + - If the file is absent or unreadable: process goes to `fallback`. + - If the file is present: + - Cross-checks `entry.startedAt` (epoch ms) against `proc.startTime.getTime()`; if delta > 60 s, file is stale → process goes to `fallback`. + - Resolves the JSONL path: `~/.claude/projects//.jsonl` using the `cwd` from the PID file. + - Verifies the JSONL exists; if missing: process goes to `fallback`. + - If JSONL exists: process goes to `direct`. +- There is **no upfront directory-existence check** — each PID is always tried individually. Missing files are handled per-process via try/catch. + +**Modified**: `detectAgents()` +- Calls `tryPidFileMatching()` after enrichment. +- Passes only `fallback` processes to the existing `discoverSessions()` + `matchProcessesToSessions()` pipeline. +- Merges `direct` matches with legacy match results before building `AgentInfo` objects. + +### Unchanged +- `utils/process.ts` — process listing and enrichment unchanged. +- `utils/session.ts` — session file discovery unchanged. +- `utils/matching.ts` — matching algorithm unchanged. +- All other adapters — untouched. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Where to do PID file lookup | Inside `ClaudeCodeAdapter` as a private method | Keeps the change isolated; other adapters don't need it | +| CWD source for JSONL path encoding | PID file's `cwd` field | PID file is authoritative; lsof cwd may differ (symlinks, etc.) | +| `startedAt` type | Epoch milliseconds (`number`) | Verified from real files — not an ISO string | +| Stale file guard | Cross-check `entry.startedAt` vs `proc.startTime` (60 s tolerance) | Catches PID reuse without false positives from normal startup delays | +| `enrichProcesses()` scope | Run on all processes before the split | `proc.startTime` is needed for the stale-file guard; batched call is cheap | +| Error handling for malformed PID files | Catch + fall back to legacy | Avoids crashing; older or corrupt files handled gracefully | +| Batching PID file reads | No batching (sequential per PID) | Files are tiny JSON; overhead is negligible | +| Reuse `SessionFile` shape for direct matches | Yes | Avoids new types; existing `readSession` and `buildAgentInfo` code works unchanged | + +## Non-Functional Requirements + +- **No performance regression**: PID file reads add at most one `fs.readFileSync` + `fs.existsSync` per process, which is negligible. +- **Backward compatibility**: All existing behaviour is preserved when no PID files exist (older Claude Code installs). Each missing file falls through to the legacy algorithm per-process. +- **No new external dependencies**. diff --git a/docs/ai/implementation/feature-claude-sessions-pid-matching.md b/docs/ai/implementation/feature-claude-sessions-pid-matching.md new file mode 100644 index 0000000..0b879e8 --- /dev/null +++ b/docs/ai/implementation/feature-claude-sessions-pid-matching.md @@ -0,0 +1,96 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Code Structure + +All changes are in `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts`. + +## Implementation Notes + +### `tryPidFileMatching()` + +No upfront directory check — each PID is always tried individually via try/catch. + +```typescript +private tryPidFileMatching(processes: ProcessInfo[]): { + direct: Array<{ process: ProcessInfo; sessionFile: SessionFile }>; + fallback: ProcessInfo[]; +} { + const sessionsDir = path.join(os.homedir(), '.claude', 'sessions'); + const direct: Array<{ process: ProcessInfo; sessionFile: SessionFile }> = []; + const fallback: ProcessInfo[] = []; + + for (const proc of processes) { + const pidFilePath = path.join(sessionsDir, `${proc.pid}.json`); + try { + const raw = fs.readFileSync(pidFilePath, 'utf-8'); + const entry = JSON.parse(raw) as PidFileEntry; + + // Stale-file guard: reject if startedAt diverges from enriched proc.startTime by > 60 s + if (proc.startTime) { + const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt); + if (deltaMs > 60_000) { + fallback.push(proc); + continue; + } + } + + const projectDir = this.getProjectDir(entry.cwd); + const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`); + + if (!fs.existsSync(jsonlPath)) { + fallback.push(proc); + continue; + } + + const sessionFile: SessionFile = { + sessionId: entry.sessionId, + filePath: jsonlPath, + projectDir, + birthtimeMs: 0, // not used for direct matches + resolvedCwd: entry.cwd, + }; + direct.push({ process: proc, sessionFile }); + } catch { + // PID file absent, unreadable, or malformed → fall back per-process + fallback.push(proc); + } + } + + return { direct, fallback }; +} +``` + +### `detectAgents()` changes + +After `enrichProcesses(processes)`: + +1. Call `tryPidFileMatching(processes)` → `{ direct, fallback }`. +2. Run existing `discoverSessions(fallback)` + `matchProcessesToSessions(fallback, sessions)` only on `fallback`. +3. Merge `direct` matches and `legacyMatches` into a single list before iterating to build `AgentInfo`. + +### `PidFileEntry` interface + +Add near the top of `ClaudeCodeAdapter.ts`: + +```typescript +interface PidFileEntry { + pid: number; + sessionId: string; + cwd: string; + startedAt: number; // epoch milliseconds + kind: string; + entrypoint: string; +} +``` + +## Error Handling + +- Any `fs.readFileSync` failure (file not found, permission denied) → catch → push to fallback. +- JSON parse failure → catch → push to fallback. +- `fs.existsSync` on JSONL → false → push to fallback. diff --git a/docs/ai/planning/feature-claude-sessions-pid-matching.md b/docs/ai/planning/feature-claude-sessions-pid-matching.md new file mode 100644 index 0000000..5b12bc0 --- /dev/null +++ b/docs/ai/planning/feature-claude-sessions-pid-matching.md @@ -0,0 +1,47 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] Milestone 1: Implementation — `ClaudeCodeAdapter` updated with PID-file matching +- [x] Milestone 2: Tests — unit tests for new code paths pass, existing tests remain green +- [ ] Milestone 3: Review — code review complete, ready to merge + +## Task Breakdown + +### Phase 1: Implementation + +- [x] Task 1.1: Add `tryPidFileMatching()` private method to `ClaudeCodeAdapter` +- [x] Task 1.2: Integrate `tryPidFileMatching()` into `detectAgents()` +- [x] Task 1.3: Define `PidFileEntry` and `DirectMatch` interfaces (internal to `ClaudeCodeAdapter.ts`) + +### Phase 2: Tests + +- [x] Task 2.1: Unit tests for `tryPidFileMatching()` — 8 cases covering all branches +- [x] Task 2.2: Integration tests for `detectAgents()` — direct-only and mixed scenarios +- [x] Task 2.3: All 156 tests pass (145 existing + 11 new) + +### Phase 3: Cleanup & Review + +- [x] Task 3.1: Run `npx ai-devkit@latest lint --feature claude-sessions-pid-matching` +- [ ] Task 3.2: Code review + +## Dependencies + +- Tasks 1.2 and 1.3 depend on Task 1.1. +- Task 2.1 depends on Task 1.1. +- Task 2.2 depends on Tasks 1.2 + 1.3. +- Task 2.3 can run in parallel with Task 2.1/2.2 as a sanity check. + +## Risks & Mitigation + +| Risk | Likelihood | Mitigation | +|------|-----------|------------| +| PID file `cwd` encoding differs from lsof cwd (e.g. symlinks) | Low | Use PID file cwd for encoding; document this as the authoritative source | +| `~/.claude/sessions/` path differs across Claude Code versions | Low | Derive path from `os.homedir()` same as existing `~/.claude/projects/` | +| Race condition: process exits between ps and PID file read | Very low | `fs.existsSync` + try-catch; treat as fallback | diff --git a/docs/ai/requirements/feature-claude-sessions-pid-matching.md b/docs/ai/requirements/feature-claude-sessions-pid-matching.md new file mode 100644 index 0000000..b6c1524 --- /dev/null +++ b/docs/ai/requirements/feature-claude-sessions-pid-matching.md @@ -0,0 +1,64 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement +**What problem are we solving?** + +- Newer versions of Claude Code write a file at `~/.claude/sessions/.json` for each running process. This file contains `{ pid, sessionId, cwd, startedAt }`. +- The current Claude adapter in agent-manager matches processes to sessions by encoding the process CWD into a `~/.claude/projects//` directory path and then finding the closest JSONL session file by birthtime (within a 3-minute tolerance). +- This birthtime-based heuristic can produce incorrect matches when multiple Claude processes share the same CWD, or when the session file birthtime diverges significantly from the process start time. +- Users of the agent-manager CLI (`agent list`) may see stale, mismatched, or missing session data as a result. + +## Goals & Objectives +**What do we want to achieve?** + +- **Primary**: Use `~/.claude/sessions/.json` as the authoritative source for process-to-session mapping when the file exists for a given PID. +- **Secondary**: Fall back to the existing CWD-encoding + birthtime heuristic for processes where no `~/.claude/sessions/.json` file is present (older Claude Code versions or sessions not yet written). +- **Non-goals**: + - Changing how session JSONL content is parsed or how status is determined. + - Modifying any adapter other than `ClaudeCodeAdapter`. + - Supporting Windows-specific paths (existing macOS/Linux conventions apply). + +## User Stories & Use Cases +**How will users interact with the solution?** + +- As an agent-manager user, I want `agent list` to correctly associate each running Claude process with its active session, so that I see accurate status and message summaries. +- As a developer running multiple Claude instances in the same directory, I want each instance to be matched to its own session (not mixed up), so the list output is unambiguous. + +**Edge cases to consider:** +- PID file exists but references a `sessionId` whose JSONL does not exist → fall back to legacy matching for that process. +- PID file exists but `cwd` in the file differs from the process's actual CWD reported by `lsof` → trust the PID file's `sessionId` and `cwd` (it is authoritative). +- Stale PID file (process exited, PID reused by a new Claude process) → cross-check `startedAt` (epoch ms) against `proc.startTime` from enrichment; if the delta exceeds 60 seconds, treat as stale and fall back to legacy matching for that process. +- PID file absent for a given process (e.g. older Claude Code) → fall back to legacy matching for that process only. No directory-level check is needed; each PID is tried individually. +- Multiple processes; only some have PID files → use PID files for those that have them, legacy matching for the rest. + +## Success Criteria +**How will we know when we're done?** + +- `ClaudeCodeAdapter.detectAgents()` reads `~/.claude/sessions/.json` for each discovered PID and uses the `sessionId` from the file to locate the correct JSONL in `~/.claude/projects/`. +- Processes without a matching PID file are matched via the existing legacy algorithm without regression. +- All existing tests continue to pass. +- New unit tests cover: PID-file happy path, PID-file missing JSONL fallback, directory absent, mixed (some PIDs have files, some don't). + +## Constraints & Assumptions +**What limitations do we need to work within?** + +- `~/.claude/sessions/.json` schema (verified from real files): + ```json + { "pid": 81665, "sessionId": "87ada2e7-...", "cwd": "/Users/...", "startedAt": 1774598167519, "kind": "interactive", "entrypoint": "cli" } + ``` + - `startedAt` is **epoch milliseconds** (not an ISO string). + - `kind` and `entrypoint` fields are present but not used by this feature. +- The JSONL for a session lives at `~/.claude/projects//.jsonl` — the same location the legacy algorithm already discovers. +- Reading individual small JSON files per PID is acceptable; no batching of the PID file reads is required (files are tiny). +- `enrichProcesses()` continues to run on all processes (direct + fallback) before the PID-file split — the batched `lsof`/`ps` call is cheap and `proc.startTime` is needed for the stale-file guard. +- The feature must remain backward-compatible with older Claude Code installs that do not write PID files. + +## Questions & Open Items + +- None — requirements are clear from the user's description and existing code analysis. diff --git a/docs/ai/testing/feature-claude-sessions-pid-matching.md b/docs/ai/testing/feature-claude-sessions-pid-matching.md new file mode 100644 index 0000000..01ca7f6 --- /dev/null +++ b/docs/ai/testing/feature-claude-sessions-pid-matching.md @@ -0,0 +1,52 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy + +## Test Coverage Goals + +- 100% branch coverage of `tryPidFileMatching()` +- `detectAgents()` integration paths for direct-match and fallback-only scenarios +- No regression in existing tests + +## Unit Tests + +### `tryPidFileMatching()` + +- [x] PID file present + JSONL exists + `startedAt` within 60 s of `proc.startTime` → process in `direct` with correct `sessionId` and `resolvedCwd` +- [x] PID file present + JSONL missing → process in `fallback` +- [x] PID file present but `startedAt` > 60 s from `proc.startTime` (stale/reused PID) → process in `fallback` +- [x] `startedAt` within 30 s (boundary) → accepted as direct match +- [x] PID file absent for a PID (file not found) → process in `fallback`, no crash +- [x] PID file contains malformed JSON → process in `fallback` (no throw) +- [x] Sessions dir entirely absent (no PID file for any process) → all processes in `fallback`, no crash +- [x] Mixed: 2 PIDs with files, 1 without → correct split across `direct` and `fallback` +- [x] `proc.startTime` is undefined (enrichment failed) → stale-file check skipped, proceed normally + +### `detectAgents()` integration + +- [x] All direct matches: `discoverSessions` and `matchProcessesToSessions` not called +- [x] Mixed: direct matches merged correctly with legacy matches in final `AgentInfo` list +- [x] Direct match produces `AgentInfo` with correct `sessionId` +- [x] Direct-matched JSONL becomes unreadable after existence check → process falls back to IDLE +- [x] Legacy-matched JSONL becomes unreadable after match → process falls back to IDLE + +## Test Data + +Real `tmp` directories with JSON/JSONL fixtures. `jest.spyOn` used only for race-condition branches (lines 128, 141). + +## Test Reporting & Coverage + +Run: `cd packages/agent-manager && npm test -- --coverage --collectCoverageFrom='src/adapters/ClaudeCodeAdapter.ts'` + +| Metric | Result | +|--------|--------| +| Statements | 98.73% | +| Branches | 89.79% | +| Functions | 100% | +| Lines | 99.35% | + +**Remaining gap — line 314** (`return null` after `allLines.length === 0` in `readSession`): dead code. `''.trim().split('\n')` always returns `['']` (length ≥ 1), so this condition is structurally unreachable. No test can cover it without modifying the source. From 79c7f48deee8cb683cb101283049ee7045b55c24 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 27 Mar 2026 15:58:16 +0700 Subject: [PATCH 2/2] feat(agent-manager): implement new session matching for Claude Code --- .../adapters/ClaudeCodeAdapter.test.ts | 363 ++++++++++++++++++ .../src/adapters/ClaudeCodeAdapter.ts | 119 +++++- 2 files changed, 473 insertions(+), 9 deletions(-) diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index f03d7c2..909310d 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -285,6 +285,210 @@ describe('ClaudeCodeAdapter', () => { projectPath: '', }); }); + + it('should use PID file for direct match and skip legacy matching for that process', async () => { + const startTime = new Date(); + const processes: ProcessInfo[] = [ + { pid: 55001, command: 'claude', cwd: '/project/direct', tty: 'ttys001', startTime }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-test-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const projectsDir = path.join(tmpDir, 'projects'); + const projDir = path.join(projectsDir, '-project-direct'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(projDir, { recursive: true }); + + const sessionId = 'pid-file-session'; + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + fs.writeFileSync(jsonlPath, [ + JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/direct', message: { content: 'hello from pid file' } }), + JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }), + ].join('\n')); + + fs.writeFileSync( + path.join(sessionsDir, '55001.json'), + JSON.stringify({ pid: 55001, sessionId, cwd: '/project/direct', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }), + ); + + (adapter as any).sessionsDir = sessionsDir; + (adapter as any).projectsDir = projectsDir; + + const agents = await adapter.detectAgents(); + + // Legacy matching utilities should NOT have been called (all processes matched via PID file) + expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled(); + expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'claude', + pid: 55001, + sessionId, + projectPath: '/project/direct', + status: AgentStatus.WAITING, + }); + expect(agents[0].summary).toContain('hello from pid file'); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => { + const startTime = new Date(); + const processes: ProcessInfo[] = [ + { pid: 66001, command: 'claude', cwd: '/project/gone', tty: 'ttys001', startTime }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-gone-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const projectsDir = path.join(tmpDir, 'projects'); + const projDir = path.join(projectsDir, '-project-gone'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(projDir, { recursive: true }); + + const sessionId = 'gone-session'; + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() })); + fs.writeFileSync( + path.join(sessionsDir, '66001.json'), + JSON.stringify({ pid: 66001, sessionId, cwd: '/project/gone', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }), + ); + + (adapter as any).sessionsDir = sessionsDir; + (adapter as any).projectsDir = projectsDir; + + // Simulate JSONL disappearing between existence check and read + jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null); + + const agents = await adapter.detectAgents(); + + // matchedPids.delete called → process falls back to IDLE + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe('pid-66001'); + expect(agents[0].status).toBe(AgentStatus.IDLE); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + it('should fall back to process-only when legacy-matched JSONL becomes unreadable', async () => { + const startTime = new Date(); + const processes: ProcessInfo[] = [ + { pid: 66002, command: 'claude', cwd: '/project/legacy-gone', tty: 'ttys001', startTime }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-lgone-')); + const projectsDir = path.join(tmpDir, 'projects'); + const projDir = path.join(projectsDir, '-project-legacy-gone'); + fs.mkdirSync(projDir, { recursive: true }); + + const sessionId = 'legacy-gone-session'; + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() })); + + // No PID file → process goes to legacy fallback + (adapter as any).sessionsDir = path.join(tmpDir, 'no-sessions'); + (adapter as any).projectsDir = projectsDir; + + const legacySessionFile = { + sessionId, + filePath: jsonlPath, + projectDir: projDir, + birthtimeMs: startTime.getTime(), + resolvedCwd: '/project/legacy-gone', + }; + mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]); + mockedMatchProcessesToSessions.mockReturnValue([ + { process: processes[0], session: legacySessionFile, deltaMs: 500 }, + ]); + + // Simulate JSONL disappearing between match and read + jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe('pid-66002'); + expect(agents[0].status).toBe(AgentStatus.IDLE); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + it('should mix direct PID-file matches and legacy matches across processes', async () => { + const startTime = new Date(); + const processes: ProcessInfo[] = [ + { pid: 55002, command: 'claude', cwd: '/project/alpha', tty: 'ttys001', startTime }, + { pid: 55003, command: 'claude', cwd: '/project/beta', tty: 'ttys002', startTime }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-mix-test-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const projectsDir = path.join(tmpDir, 'projects'); + const projAlpha = path.join(projectsDir, '-project-alpha'); + const projBeta = path.join(projectsDir, '-project-beta'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(projAlpha, { recursive: true }); + fs.mkdirSync(projBeta, { recursive: true }); + + // PID file only for process 55002 + const directSessionId = 'direct-session'; + const directJsonl = path.join(projAlpha, `${directSessionId}.jsonl`); + fs.writeFileSync(directJsonl, [ + JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/alpha', message: { content: 'direct question' } }), + JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }), + ].join('\n')); + fs.writeFileSync( + path.join(sessionsDir, '55002.json'), + JSON.stringify({ pid: 55002, sessionId: directSessionId, cwd: '/project/alpha', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }), + ); + + // Legacy session file for process 55003 + const legacySessionId = 'legacy-session'; + const legacyJsonl = path.join(projBeta, `${legacySessionId}.jsonl`); + fs.writeFileSync(legacyJsonl, [ + JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/beta', message: { content: 'legacy question' } }), + JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }), + ].join('\n')); + + (adapter as any).sessionsDir = sessionsDir; + (adapter as any).projectsDir = projectsDir; + + // Mock legacy matching for process 55003 + const legacySessionFile = { + sessionId: legacySessionId, + filePath: legacyJsonl, + projectDir: projBeta, + birthtimeMs: startTime.getTime(), + resolvedCwd: '/project/beta', + }; + mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]); + mockedMatchProcessesToSessions.mockReturnValue([ + { process: processes[1], session: legacySessionFile, deltaMs: 1000 }, + ]); + + const agents = await adapter.detectAgents(); + + // Legacy matching called only for fallback process (55003) + expect(mockedMatchProcessesToSessions).toHaveBeenCalledTimes(1); + expect(mockedMatchProcessesToSessions.mock.calls[0][0]).toEqual([processes[1]]); + + expect(agents).toHaveLength(2); + const alpha = agents.find(a => a.pid === 55002); + const beta = agents.find(a => a.pid === 55003); + expect(alpha?.sessionId).toBe(directSessionId); + expect(beta?.sessionId).toBe(legacySessionId); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); }); describe('discoverSessions', () => { @@ -613,6 +817,165 @@ describe('ClaudeCodeAdapter', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + describe('tryPidFileMatching', () => { + let sessionsDir: string; + let projectsDir: string; + + beforeEach(() => { + sessionsDir = path.join(tmpDir, 'sessions'); + projectsDir = path.join(tmpDir, 'projects'); + fs.mkdirSync(sessionsDir, { recursive: true }); + (adapter as any).sessionsDir = sessionsDir; + (adapter as any).projectsDir = projectsDir; + }); + + const makeProc = (pid: number, cwd = '/project/test', startTime?: Date): ProcessInfo => ({ + pid, command: 'claude', cwd, tty: 'ttys001', startTime, + }); + + const writePidFile = (pid: number, sessionId: string, cwd: string, startedAt: number) => { + fs.writeFileSync( + path.join(sessionsDir, `${pid}.json`), + JSON.stringify({ pid, sessionId, cwd, startedAt, kind: 'interactive', entrypoint: 'cli' }), + ); + }; + + const writeJsonl = (cwd: string, sessionId: string) => { + const encoded = cwd.replace(/\//g, '-'); + const projDir = path.join(projectsDir, encoded); + fs.mkdirSync(projDir, { recursive: true }); + const filePath = path.join(projDir, `${sessionId}.jsonl`); + fs.writeFileSync(filePath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() })); + return filePath; + }; + + it('should return direct match when PID file and JSONL both exist within time tolerance', () => { + const startTime = new Date(); + const proc = makeProc(1001, '/project/test', startTime); + writePidFile(1001, 'session-abc', '/project/test', startTime.getTime()); + writeJsonl('/project/test', 'session-abc'); + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(1); + expect(fallback).toHaveLength(0); + expect(direct[0].sessionFile.sessionId).toBe('session-abc'); + expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test'); + expect(direct[0].process.pid).toBe(1001); + }); + + it('should fall back when PID file exists but JSONL is missing', () => { + const startTime = new Date(); + const proc = makeProc(1002, '/project/test', startTime); + writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime()); + // No JSONL file written + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(1); + expect(fallback[0].pid).toBe(1002); + }); + + it('should fall back when startedAt is stale (>60s from proc.startTime)', () => { + const startTime = new Date(); + const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier + const proc = makeProc(1003, '/project/test', startTime); + writePidFile(1003, 'stale-session', '/project/test', staleTime); + writeJsonl('/project/test', 'stale-session'); + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(1); + }); + + it('should accept PID file when startedAt is within 60s tolerance', () => { + const startTime = new Date(); + const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance + const proc = makeProc(1004, '/project/test', startTime); + writePidFile(1004, 'close-session', '/project/test', closeTime); + writeJsonl('/project/test', 'close-session'); + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(1); + expect(fallback).toHaveLength(0); + }); + + it('should fall back when PID file is absent', () => { + const proc = makeProc(1005, '/project/test', new Date()); + // No PID file written + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch([proc]); + + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(1); + }); + + it('should fall back when PID file contains malformed JSON', () => { + const proc = makeProc(1006, '/project/test', new Date()); + fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{'); + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + expect(() => { + const { direct, fallback } = tryMatch([proc]); + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(1); + }).not.toThrow(); + }); + + it('should fall back for all processes when sessions dir does not exist', () => { + (adapter as any).sessionsDir = path.join(tmpDir, 'nonexistent-sessions'); + const processes = [makeProc(2001, '/a', new Date()), makeProc(2002, '/b', new Date())]; + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch(processes); + + expect(direct).toHaveLength(0); + expect(fallback).toHaveLength(2); + }); + + it('should correctly split mixed processes (some with PID files, some without)', () => { + const startTime = new Date(); + const proc1 = makeProc(3001, '/project/one', startTime); + const proc2 = makeProc(3002, '/project/two', startTime); + const proc3 = makeProc(3003, '/project/three', startTime); + + writePidFile(3001, 'session-one', '/project/one', startTime.getTime()); + writeJsonl('/project/one', 'session-one'); + writePidFile(3003, 'session-three', '/project/three', startTime.getTime()); + writeJsonl('/project/three', 'session-three'); + // proc2 has no PID file + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch([proc1, proc2, proc3]); + + expect(direct).toHaveLength(2); + expect(fallback).toHaveLength(1); + expect(direct.map((d: any) => d.process.pid).sort()).toEqual([3001, 3003]); + expect(fallback[0].pid).toBe(3002); + }); + + it('should skip stale-file check when proc.startTime is undefined', () => { + const proc = makeProc(4001, '/project/test', undefined); // no startTime + writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999); + writeJsonl('/project/test', 'no-time-session'); + + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch([proc]); + + // startTime undefined → stale check skipped → direct match + expect(direct).toHaveLength(1); + expect(fallback).toHaveLength(0); + }); + }); + describe('readSession', () => { it('should parse session file with timestamps, slug, cwd, and entry type', () => { const readSession = (adapter as any).readSession.bind(adapter); diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index 419aab9..5baa8f1 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -23,6 +23,26 @@ interface SessionEntry { }; } +/** + * Entry in ~/.claude/sessions/.json written by Claude Code + */ +interface PidFileEntry { + pid: number; + sessionId: string; + cwd: string; + startedAt: number; // epoch milliseconds + kind: string; + entrypoint: string; +} + +/** + * A process directly matched to a session via PID file (authoritative path) + */ +interface DirectMatch { + process: ProcessInfo; + sessionFile: SessionFile; +} + /** * Claude Code session information */ @@ -44,18 +64,20 @@ interface ClaudeSession { * Detects Claude Code agents by: * 1. Finding running claude processes via shared listAgentProcesses() * 2. Enriching with CWD and start times via shared enrichProcesses() - * 3. Discovering session files from ~/.claude/projects/ via shared batchGetSessionFileBirthtimes() - * 4. Matching sessions to processes via shared matchProcessesToSessions() + * 3. Attempting authoritative PID-file matching via ~/.claude/sessions/.json + * 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file * 5. Extracting summary from last user message in session JSONL */ export class ClaudeCodeAdapter implements AgentAdapter { readonly type = 'claude' as const; private projectsDir: string; + private sessionsDir: string; constructor() { const homeDir = process.env.HOME || process.env.USERPROFILE || ''; this.projectsDir = path.join(homeDir, '.claude', 'projects'); + this.sessionsDir = path.join(homeDir, '.claude', 'sessions'); } /** @@ -80,17 +102,35 @@ export class ClaudeCodeAdapter implements AgentAdapter { return []; } - const sessions = this.discoverSessions(processes); + // Step 1: try authoritative PID-file matching for every process + const { direct, fallback } = this.tryPidFileMatching(processes); - if (sessions.length === 0) { - return processes.map((p) => this.mapProcessOnlyAgent(p)); - } + // Step 2: run legacy CWD+birthtime matching only for processes without a PID file + const legacySessions = this.discoverSessions(fallback); + const legacyMatches = + fallback.length > 0 && legacySessions.length > 0 + ? matchProcessesToSessions(fallback, legacySessions) + : []; + + const matchedPids = new Set([ + ...direct.map((d) => d.process.pid), + ...legacyMatches.map((m) => m.process.pid), + ]); - const matches = matchProcessesToSessions(processes, sessions); - const matchedPids = new Set(matches.map((m) => m.process.pid)); const agents: AgentInfo[] = []; - for (const match of matches) { + // Build agents from direct (PID-file) matches + for (const { process: proc, sessionFile } of direct) { + const sessionData = this.readSession(sessionFile.filePath, sessionFile.resolvedCwd); + if (sessionData) { + agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile)); + } else { + matchedPids.delete(proc.pid); + } + } + + // Build agents from legacy matches + for (const match of legacyMatches) { const sessionData = this.readSession( match.session.filePath, match.session.resolvedCwd, @@ -102,6 +142,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { } } + // Any process with no match (direct or legacy) appears as IDLE for (const proc of processes) { if (!matchedPids.has(proc.pid)) { agents.push(this.mapProcessOnlyAgent(proc)); @@ -150,6 +191,66 @@ export class ClaudeCodeAdapter implements AgentAdapter { return files; } + /** + * Attempt to match each process to its session via ~/.claude/sessions/.json. + * + * Returns: + * direct — processes matched authoritatively via PID file + * fallback — processes with no valid PID file (sent to legacy matching) + * + * Per-process fallback triggers on: file absent, malformed JSON, + * stale startedAt (>60 s from proc.startTime), or missing JSONL. + */ + private tryPidFileMatching(processes: ProcessInfo[]): { + direct: DirectMatch[]; + fallback: ProcessInfo[]; + } { + const direct: DirectMatch[] = []; + const fallback: ProcessInfo[] = []; + + for (const proc of processes) { + const pidFilePath = path.join(this.sessionsDir, `${proc.pid}.json`); + try { + const entry = JSON.parse( + fs.readFileSync(pidFilePath, 'utf-8'), + ) as PidFileEntry; + + // Stale-file guard: reject PID files from a previous process with the same PID + if (proc.startTime) { + const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt); + if (deltaMs > 60000) { + fallback.push(proc); + continue; + } + } + + const projectDir = this.getProjectDir(entry.cwd); + const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`); + + if (!fs.existsSync(jsonlPath)) { + fallback.push(proc); + continue; + } + + direct.push({ + process: proc, + sessionFile: { + sessionId: entry.sessionId, + filePath: jsonlPath, + projectDir, + birthtimeMs: entry.startedAt, + resolvedCwd: entry.cwd, + }, + }); + } catch { + // PID file absent, unreadable, or malformed — fall back per-process + fallback.push(proc); + } + } + + return { direct, fallback }; + } + /** * Derive the Claude Code project directory for a given CWD. *