diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c825bc2a..b90569c3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.17.0", + "version": "0.18.0", "description": "Structured AI-assisted development with phase workflows, persistent memory, and reusable skills", "author": { "name": "Hoang Nguyen", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 156aa753..a03a34c6 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.17.0", + "version": "0.18.0", "description": "AI-assisted development toolkit with structured SDLC workflows, persistent memory, and reusable skills", "author": { "name": "Hoang Nguyen", diff --git a/CHANGELOG.md b/CHANGELOG.md index b051ce8f..b58816d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.18.0] - 2026-03-10 ### Added diff --git a/commands/review-design.md b/commands/review-design.md index ea55cc56..8ae1048c 100644 --- a/commands/review-design.md +++ b/commands/review-design.md @@ -13,6 +13,9 @@ Review the design documentation in `docs/ai/design/feature-{name}.md` (and the p - API/interface contracts (inputs, outputs, auth) - Major design decisions and trade-offs - Non-functional requirements that must be preserved -3. Highlight inconsistencies, missing sections, or diagrams that need updates. +3. **Clarify and explore (loop until converged)**: + - **Ask clarification questions** for every gap, inconsistency, or misalignment between requirements and design. Do not just list issues — actively ask specific questions to resolve them. + - **Brainstorm and explore options** — For key architecture decisions, trade-offs, or areas with multiple viable approaches, proactively brainstorm alternatives. Present options with pros/cons and trade-offs. Challenge assumptions and surface creative alternatives. + - **Repeat** — Continue looping until the user is satisfied with the chosen approach and no open questions remain. 4. **Store Reusable Knowledge** — Persist approved design patterns/constraints with `npx ai-devkit@latest memory store ...` when they will help future work. 5. **Next Command Guidance** — If requirements gaps are found, return to `/review-requirements`; if design is sound, continue to `/execute-plan`. diff --git a/commands/review-requirements.md b/commands/review-requirements.md index 36e84e79..15d53723 100644 --- a/commands/review-requirements.md +++ b/commands/review-requirements.md @@ -11,6 +11,9 @@ Review `docs/ai/requirements/feature-{name}.md` and the project-level template ` - Primary user stories & critical flows - Constraints, assumptions, open questions - Any missing sections or deviations from the template -3. Identify gaps or contradictions and suggest clarifications. +3. **Clarify and explore (loop until converged)**: + - **Ask clarification questions** for every gap, contradiction, or ambiguity. Do not just list issues — actively ask specific questions to resolve them. + - **Brainstorm and explore options** — For key decisions, trade-offs, or areas with multiple viable approaches, proactively brainstorm alternatives. Present options with pros/cons and trade-offs. Challenge assumptions and surface creative alternatives. + - **Repeat** — Continue looping until the user is satisfied with the chosen approach and no open questions remain. 4. **Store Reusable Knowledge** — If new reusable requirement conventions are agreed, store them with `npx ai-devkit@latest memory store ...`. 5. **Next Command Guidance** — If fundamentals are missing, go back to `/new-requirement`; otherwise continue to `/review-design`. diff --git a/docs/ai/design/feature-agent-list-cwd.md b/docs/ai/design/feature-agent-list-cwd.md new file mode 100644 index 00000000..7664625e --- /dev/null +++ b/docs/ai/design/feature-agent-list-cwd.md @@ -0,0 +1,55 @@ +--- +phase: design +title: Display CWD in Agent List — Design +description: Technical design for adding CWD column to agent list table output +--- + +# Display CWD in Agent List — Design + +## Architecture Overview + +No new components are needed. This feature modifies the existing CLI table rendering in the `agent list` command. + +```mermaid +graph LR + AgentAdapter -->|AgentInfo.projectPath| AgentManager + AgentManager -->|agents array| CLI["agent list command"] + CLI -->|formatCwd| TableRenderer["ui.table()"] +``` + +## Data Models + +No changes to `AgentInfo`. The existing `projectPath: string` field is used as-is. + +## Component Changes + +### `packages/cli/src/commands/agent.ts` + +1. **New helper function** — `formatCwd(projectPath: string): string` + - Replaces home directory prefix with `~` using `os.homedir()` + - Returns the shortened path or the original if no substitution applies + - Returns empty string for empty/undefined input + +2. **Table modification** — Add "CWD" column: + - **Position**: Column index 1 (after "Agent", before "Type") + - **Data**: `formatCwd(agent.projectPath)` + - **Style**: `chalk.dim` for subdued visual weight + +### Updated table structure + +| Agent | CWD | Type | Status | Working On | Active | +|-------|-----|------|--------|------------|--------| +| my-project | ~/Code/my-project | Claude Code | 🟢 run | Investigating... | 5m ago | + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Path format | `~` substitution | Compact, familiar to CLI users | +| Column position | After Agent | CWD is a project identifier, logically grouped with name | +| Column style | `chalk.dim` | Secondary info, shouldn't dominate the table | + +## Non-Functional Requirements + +- No performance impact — `os.homedir()` is a synchronous, cached call +- No new dependencies required 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 00000000..5676035d --- /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/design/feature-generalize-session-mapping.md b/docs/ai/design/feature-generalize-session-mapping.md new file mode 100644 index 00000000..02836077 --- /dev/null +++ b/docs/ai/design/feature-generalize-session-mapping.md @@ -0,0 +1,283 @@ +--- +phase: design +title: Generalize Process-to-Session Mapping — Design +description: Architecture for shared process detection, session matching, and per-agent adapters +--- + +# System Design & Architecture + +## Architecture Overview + +```mermaid +graph TD + subgraph "Shared Utilities" + P["utils/process.ts
ps aux | grep, lsof batch,
ps lstart, enrichProcesses"] + S["utils/session.ts
stat birthtime"] + M["utils/matching.ts
1:1 greedy matching + agent naming"] + end + + subgraph "Adapters (per-agent)" + CA["ClaudeCodeAdapter"] + XA["CodexAdapter"] + GA["Future adapters..."] + end + + CA -->|uses| P + CA -->|uses| S + CA -->|uses| M + XA -->|uses| P + XA -->|uses| S + XA -->|uses| M + GA -->|uses| P + GA -->|uses| S + GA -->|uses| M + + CA -->|implements| AI["AgentAdapter interface"] + XA -->|implements| AI + GA -->|implements| AI +``` + +Each adapter implements `AgentAdapter` (unchanged interface), owns its detection flow and session scanning, and calls shared utilities for OS-level commands and matching. + +## Data Flow + +```mermaid +sequenceDiagram + participant A as Adapter + participant P as utils/process + participant S as utils/session + participant M as utils/matching + + A->>P: listAgentProcesses('claude') + Note right of P: ps aux | grep claude
+ post-filter executable name + P-->>A: ProcessInfo[] (pid, command, tty) + + A->>P: enrichProcesses(processes) + Note right of P: batchGetProcessCwds (1 lsof)
+ batchGetProcessStartTimes (1 ps lstart)
→ populates cwd + startTime + P-->>A: ProcessInfo[] (fully populated) + + A->>A: discoverSessions(processes) + Note right of A: Adapter-specific:
CWD → session dir path(s)
Sets resolvedCwd on each SessionFile
CodexAdapter also caches file content + + A->>S: batchGetSessionFileBirthtimes(dirs) + Note right of S: stat -f '%B %N' (macOS)
stat --format='%W %n' (Linux)
Single call across all dirs + S-->>A: SessionFile[] + + A->>M: matchProcessesToSessions(processes, sessions) + Note right of M: Filter: process.cwd === session.resolvedCwd
Filter: deltaMs <= 180s
Filter: startTime must exist
1:1 greedy by smallest delta + M-->>A: MatchResult[] + + A->>A: parseSession / readSession per match + Note right of A: Adapter-specific:
Read JSONL for status/summary
Only matched files
CodexAdapter uses cached content + + A->>M: generateAgentName(cwd, pid) + M-->>A: "folderName (pid)" + + A-->>A: AgentInfo[] +``` + +## Data Models + +### ProcessInfo (existing, extended) + +```typescript +interface ProcessInfo { + pid: number; + command: string; + cwd: string; // populated by enrichProcesses + tty: string; + startTime?: Date; // populated by enrichProcesses +} +``` + +Adding `startTime?: Date` to the existing `ProcessInfo` in `AgentAdapter.ts`. This is a public type change — accepted since it's additive (optional field). + +### SessionFile (new, shared) + +```typescript +interface SessionFile { + sessionId: string; // filename without .jsonl + filePath: string; // full path + projectDir: string; // parent directory + birthtimeMs: number; // from stat (epoch seconds × 1000 → milliseconds) + resolvedCwd: string; // set by adapter: the CWD this session maps to +} +``` + +`resolvedCwd` is set by the adapter after calling `batchGetSessionFileBirthtimes()`. This keeps the CWD↔session mapping adapter-specific while allowing the shared matcher to compare `process.cwd === session.resolvedCwd` without callbacks or maps. + +### MatchResult (new, shared) + +```typescript +interface MatchResult { + process: ProcessInfo; + session: SessionFile; + deltaMs: number; // |process.startTime - session.birthtimeMs| +} +``` + +## Component Breakdown + +### `utils/process.ts` — Shell command wrappers for process data + +Extended from existing file. All `execSync` calls for process data live here. + +| Function | Shell command | Returns | +|----------|-------------|---------| +| `listAgentProcesses(namePattern)` | `ps aux \| grep ` + post-filter executable basename | `ProcessInfo[]` (pid, command, tty — cwd/startTime empty) | +| `batchGetProcessCwds(pids)` | `lsof -a -d cwd -Fn -p PID1,PID2,...` | `Map` | +| `batchGetProcessStartTimes(pids)` | `ps -o pid=,lstart= -p PID1,PID2,...` | `Map` | +| `enrichProcesses(processes)` | Calls `batchGetProcessCwds` + `batchGetProcessStartTimes` | `ProcessInfo[]` with cwd and startTime populated | + +Notes: +- `listAgentProcesses` uses `grep` at shell level for performance, then post-filters by checking `path.basename(executable)` matches exactly (avoids matching `claude-helper`, `vscode-claude-extension`, or the grep process itself) +- `enrichProcesses` is a convenience that calls both batch functions and merges results into each `ProcessInfo`. Returns partial results — if `lsof` fails for a PID, that process gets empty cwd; if `ps lstart` fails for a PID, that process gets no `startTime` +- `batchGetProcessStartTimes` uses `lstart` format (full timestamp like `Thu Feb 5 16:00:57 2026`) instead of lossy `etime` + +### `utils/session.ts` — Shell command wrappers for session files + +New file. + +| Function | Shell command | Returns | +|----------|-------------|---------| +| `batchGetSessionFileBirthtimes(dirs)` | `stat -f '%B %N' dir1/*.jsonl dir2/*.jsonl ...` (macOS) or `stat --format='%W %n' ...` (Linux) | `SessionFile[]` | + +Notes: +- Combines all directory globs into a single `stat` call +- Uses `stat` instead of `ls -lU` — gives epoch seconds (exact, no parsing ambiguity) +- Platform detection via `process.platform` +- Returns empty array if directories don't exist, have no `.jsonl` files, or command fails +- `resolvedCwd` is left empty — adapter must set it after calling this function + +### `utils/matching.ts` — Shared matching algorithm and naming + +New file. + +| Function | Description | +|----------|-------------| +| `matchProcessesToSessions(processes, sessions)` | 1:1 greedy assignment by closest birthtimeMs | +| `generateAgentName(cwd, pid)` | Returns `basename(cwd) (pid)` | + +#### Matching algorithm + +``` +Input: + processes: ProcessInfo[] (with cwd and startTime populated) + sessions: SessionFile[] (with resolvedCwd set by adapter) + +1. Filter processes: exclude any where startTime is undefined + (→ these become process-only fallback in the adapter) + +2. Build candidate pairs: + for each process P, for each session S: + if P.cwd === S.resolvedCwd: + deltaMs = |P.startTime - S.birthtimeMs| + if deltaMs <= 180_000 (3 minutes): + add (P, S, deltaMs) to candidates + +3. Sort candidates by deltaMs ascending (best matches first) + +4. Greedy assign: + matchedPids = Set() + matchedSessionIds = Set() + results = [] + + for each (P, S, deltaMs) in candidates: + if P.pid in matchedPids → skip + if S.sessionId in matchedSessionIds → skip + assign P ↔ S + results.push({ process: P, session: S, deltaMs }) + +5. Return results +``` + +Unmatched processes (no session within tolerance, or no startTime) → adapter creates process-only fallback AgentInfo. + +### Per-adapter responsibilities + +| Responsibility | Stays in adapter | Reason | +|---|---|---| +| `canHandle(command)` | Yes (interface contract) | Kept for interface, but `listAgentProcesses` already filters | +| Session dir scanning | Yes | Claude: `~/.claude/projects//`, Codex: `~/.codex/sessions/YYYY/MM/DD/` | +| CWD → session dir mapping | Yes | Adapter sets `resolvedCwd` on each SessionFile | +| Session parsing (`parseSession`/`readSession`) | Yes | JSONL schema differs per agent. CodexAdapter supports cached content to avoid double I/O. | +| `determineStatus(session)` | Yes | Entry types and status mapping differ | +| Summary extraction | Yes | Content structure differs | + +#### Codex date-dir scanning + +Codex stores sessions in `~/.codex/sessions/YYYY/MM/DD/*.jsonl`. The adapter will: +1. Use process start times (from `enrichProcesses`) to determine date dirs +2. Scan date directories around each process start date (±1 day window) +3. Call `batchGetSessionFileBirthtimes(dateDirs)` once with all date directories +4. Read each file once and cache content in `Map` for later parsing +5. Set `resolvedCwd` from the session_meta first line's `cwd` field + +## Design Decisions + +### Adapter pattern over base class / plugin + +- Adapters own their full flow and can diverge freely +- Shared logic pulled in as utility functions, not inherited +- No inversion of control — adapter calls utils, not the other way around + +### birthtimeMs via `stat` over JSONL first-entry timestamp + +- Zero file I/O for matching — `stat` gives epoch seconds directly +- No date format parsing ambiguity (unlike `ls -lU` which shows `MMM DD HH:MM` lossy format) +- OS-level timestamp, no app-level lag +- Dry-run validated: 6/8 exact matches, 2/8 within 3min tolerance +- Known limitation: session resumption without process restart (accepted) + +### `stat` over `ls -lU` + +- `ls -lU` date format is lossy — no seconds for recent files, no year for old files +- `stat -f '%B %N'` (macOS) and `stat --format='%W %n'` (Linux) give epoch seconds +- Exact timestamps, trivial to parse (split on space, `parseInt`) + +### `resolvedCwd` on SessionFile over callback/map + +- Adapter sets `resolvedCwd` after getting birthtimes, before calling matcher +- Matcher compares `process.cwd === session.resolvedCwd` — pure, no adapter-specific logic +- No callback indirection, no map lookup + +### `enrichProcesses` convenience function + +- Adapter calls `listAgentProcesses` then `enrichProcesses` — two calls instead of managing 3 separate maps +- Returns partial results — if one PID fails, others still get populated +- Processes without `startTime` are excluded from matching (→ process-only fallback) + +### Greedy 1:1 over multi-pass modes + +- Single greedy pass sorted by delta ascending +- Simpler, deterministic, no pass-ordering side effects +- Parent-child matching dropped — exact CWD match only + +### Agent naming: `folderName (pid)` + +- Deterministic, no JSONL parse needed +- PID always included for uniqueness +- Breaking change from slug-based naming — accepted + +### Batched shell calls + +- 1 `lsof` for all PIDs vs N per-PID calls +- 1 `ps -o lstart` for all PIDs vs N `ps -o etime` calls +- grep at shell level vs list-all-then-filter-in-code + +### 3-minute tolerance + +- Covers all observed deltas (23s to 2m24s) with margin +- Beyond tolerance → process-only fallback (wrong match worse than no match) + +### Error handling + +- Shell command utils return partial results — if lsof fails for 1 of 5 PIDs, the other 4 still return +- Future: `--verbose` mode will log matching details (which candidates were considered, why matches were rejected) to log files for debugging + +## Non-Functional Requirements + +- **Performance**: Detection < 500ms for 10 processes, 50 session files +- **Correctness**: Identical output for non-edge-case scenarios +- **Portability**: macOS and Linux (no Windows) +- **Testability**: Shared utils independently testable — mock `execSync` at module level with `jest.mock` diff --git a/docs/ai/implementation/feature-agent-list-cwd.md b/docs/ai/implementation/feature-agent-list-cwd.md new file mode 100644 index 00000000..a61ea816 --- /dev/null +++ b/docs/ai/implementation/feature-agent-list-cwd.md @@ -0,0 +1,38 @@ +--- +phase: implementation +title: Display CWD in Agent List — Implementation +description: Implementation notes for CWD column feature +--- + +# Display CWD in Agent List — Implementation + +## Files to Modify + +| File | Change | +|------|--------| +| `packages/cli/src/commands/agent.ts` | Add `formatCwd()` helper, add CWD column to table | +| `packages/cli/src/__tests__/commands/agent.test.ts` | Update tests for new column | + +## Implementation Notes + +### `formatCwd(projectPath: string): string` + +```typescript +import os from 'os'; + +function formatCwd(projectPath?: string): string { + if (!projectPath) return ''; + const home = os.homedir(); + if (projectPath.startsWith(home)) { + return '~' + projectPath.slice(home.length); + } + return projectPath; +} +``` + +### Table changes + +- Insert at index 1 in: headers, rows mapping, columnStyles +- Header: `'CWD'` +- Row value: `formatCwd(agent.projectPath)` +- Style: `(text) => chalk.dim(text)` 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 00000000..0b879e81 --- /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/implementation/feature-generalize-session-mapping.md b/docs/ai/implementation/feature-generalize-session-mapping.md new file mode 100644 index 00000000..a026624c --- /dev/null +++ b/docs/ai/implementation/feature-generalize-session-mapping.md @@ -0,0 +1,82 @@ +--- +phase: implementation +title: Generalize Process-to-Session Mapping — Implementation +description: Implementation notes for shared utilities and adapter refactoring +--- + +# Implementation Guide + +## Code Structure + +``` +packages/agent-manager/src/ +├── adapters/ +│ ├── AgentAdapter.ts # Interface + ProcessInfo (added startTime?) +│ ├── ClaudeCodeAdapter.ts # ~419 lines — session dir via path encoding +│ └── CodexAdapter.ts # ~319 lines — session dir via date dirs +├── utils/ +│ ├── process.ts # Shell wrappers: ps aux, lsof, ps lstart, getProcessTty +│ ├── session.ts # Shell wrappers: stat for birthtimes +│ ├── matching.ts # 1:1 greedy matching + agent naming +│ └── index.ts # Re-exports +└── AgentManager.ts # Orchestrates adapters +``` + +## Implementation Notes + +### Shared Utilities + +**`utils/process.ts`** — All `execSync` calls for process data: +- `listAgentProcesses(namePattern)`: Uses `[c]laude` grep trick to avoid matching grep itself. Post-filters by `path.basename(executable)` for exact match. Input validated against `/^[a-zA-Z0-9_-]+$/` to prevent shell injection. +- `batchGetProcessCwds(pids)`: Single `lsof -a -d cwd -Fn -p PID1,PID2,...`. Falls back to per-PID `pwdx` on Linux if lsof fails. +- `batchGetProcessStartTimes(pids)`: Single `ps -o pid=,lstart=`. Parses full timestamp via `new Date(dateStr)`. +- `enrichProcesses(processes)`: Convenience — calls both batch functions, populates in-place. + +**`utils/session.ts`** — Session file discovery: +- `batchGetSessionFileBirthtimes(dirs)`: Combines all dir globs into single `stat` call. Uses `|| true` to handle empty globs gracefully. + +**`utils/matching.ts`** — Matching algorithm: +- `matchProcessesToSessions`: Builds candidate pairs (CWD match + within 3min tolerance), sorts by delta ascending, greedy 1:1 assign. +- `generateAgentName(cwd, pid)`: Returns `basename(cwd) (pid)` or `unknown (pid)`. + +### Adapter-Specific Logic + +**ClaudeCodeAdapter**: +- Session dir: `~/.claude/projects//` where encoded = `cwd.replace(/\//g, '-')` +- `discoverSessions`: Encodes each unique process CWD, checks if dir exists, calls `batchGetSessionFileBirthtimes`, sets `resolvedCwd` from dir-to-CWD mapping +- `readSession(filePath, projectPath)`: Parses all JSONL lines for timestamps, slug, cwd, entry type, interruption state, user message text +- Status: Based on `lastEntryType` (user/assistant/progress/thinking/system). No age-based override since process is confirmed running. + +**CodexAdapter**: +- Session dir: `~/.codex/sessions/YYYY/MM/DD/` +- `discoverSessions`: Scans ±1 day window around each process start time. Reads each file once into `contentCache: Map`. Sets `resolvedCwd` from `session_meta` first line. +- `parseSession(cachedContent, filePath)`: Uses cached content when available, falls back to disk read. Extracts session ID, project path, summary, timestamps, last payload type. +- Status: Based on `lastPayloadType` and 5-minute idle threshold. + +## Error Handling + +- Shell command utils return partial results — if lsof/ps fails for one PID, others still return +- Session file read failures are silently skipped (file may have been deleted between stat and read) +- Adapters fall back to process-only AgentInfo for unmatched processes +- `listAgentProcesses` rejects patterns with shell metacharacters (returns `[]`) + +## Performance + +- 1 `ps aux | grep` per adapter (not per process) +- 1 `lsof` for all PIDs (not per PID) +- 1 `ps -o lstart` for all PIDs +- 1 `stat` per adapter across all session directories +- JSONL files only read for matched sessions (CodexAdapter caches content from discovery phase) +- Legacy `listProcesses`, `getProcessCwd`, `getSessionFileBirthtimes` removed — no consumers +- `getProcessTty` kept — used by `TerminalFocusManager` + +## Dead Code Removed + +**agent-manager package:** +- `utils/file.ts` — entire file (`readLastLines`, `readJsonLines`) — no production callers +- `utils/process.ts` — `listProcesses`, `getProcessCwd`, `ListProcessesOptions` — deprecated, no callers +- `utils/session.ts` — `getSessionFileBirthtimes` — unused wrapper, all callers use batch version + +**CLI package:** +- `util/process.ts` — entire file (`listProcesses`, `getProcessCwd`, `getProcessTty`, `isProcessRunning`, `getProcessInfo`) — zero production imports +- `util/file.ts` — entire file (`readLastLines`, `readJsonLines`, `fileExists`, `readJson`) — zero production imports diff --git a/docs/ai/planning/feature-agent-list-cwd.md b/docs/ai/planning/feature-agent-list-cwd.md new file mode 100644 index 00000000..f88d372f --- /dev/null +++ b/docs/ai/planning/feature-agent-list-cwd.md @@ -0,0 +1,44 @@ +--- +phase: planning +title: Display CWD in Agent List — Planning +description: Task breakdown for adding CWD column to agent list +--- + +# Display CWD in Agent List — Planning + +## Milestones + +- [ ] Milestone 1: CWD column visible in `agent list` output + +## Task Breakdown + +### Phase 1: Implementation + +- [ ] Task 1.1: Add `formatCwd()` helper function to `packages/cli/src/commands/agent.ts` + - Import `os` module + - Implement home directory `~` substitution +- [ ] Task 1.2: Add CWD column to table rendering + - Add `formatCwd(agent.projectPath)` to rows array (index 1) + - Add "CWD" to headers array (index 1) + - Add `chalk.dim` column style (index 1) + +### Phase 2: Testing + +- [ ] Task 2.1: Update existing agent list tests to include CWD column +- [ ] Task 2.2: Add unit tests for `formatCwd()` helper + +## Dependencies + +- None — all data is already available in `AgentInfo.projectPath` + +## Timeline & Estimates + +- Total effort: Small (< 1 hour) +- Task 1.1 + 1.2: ~15 min implementation +- Task 2.1 + 2.2: ~15 min testing + +## Risks & Mitigation + +| Risk | Mitigation | +|------|------------| +| Table width with long paths | `~` substitution reduces length; terminal handles wrapping | 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 00000000..5b12bc04 --- /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/planning/feature-generalize-session-mapping.md b/docs/ai/planning/feature-generalize-session-mapping.md new file mode 100644 index 00000000..10088ce5 --- /dev/null +++ b/docs/ai/planning/feature-generalize-session-mapping.md @@ -0,0 +1,98 @@ +--- +phase: planning +title: Generalize Process-to-Session Mapping — Planning +description: Task breakdown for extracting shared matching logic into utilities +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] Milestone 1: Shared utilities created and tested +- [x] Milestone 2: ClaudeCodeAdapter refactored to use shared utilities +- [x] Milestone 3: CodexAdapter refactored to use shared utilities +- [x] Milestone 4: Full test suite passes, dead code removed + +## Task Breakdown + +### Phase 1: Shared Utilities + +- [x] Task 1.1: Extend `utils/process.ts` — add `listAgentProcesses(namePattern)` (runs `ps aux | grep `, post-filters by `path.basename(executable)` match, returns `ProcessInfo[]` with pid, command, tty). Add `batchGetProcessCwds(pids)` (single `lsof -a -d cwd -Fn -p PID1,PID2,...`). Add `batchGetProcessStartTimes(pids)` (single `ps -o pid=,lstart= -p PID1,...`, parses full timestamp). Add `enrichProcesses(processes)` convenience that calls both batch functions and populates cwd + startTime on each ProcessInfo. Returns partial results on failure. Remove per-PID `getProcessCwd()`. + +- [x] Task 1.2: Create `utils/session.ts` — implement `batchGetSessionFileBirthtimes(dirs)` using `stat -f '%B %N'` on macOS or `stat --format='%W %n'` on Linux. Combines all dir globs into single shell call. Parse epoch seconds + filename. Return `SessionFile[]` with `resolvedCwd` left empty (adapter sets it). Platform detection via `process.platform`. Return empty array on failure. + +- [x] Task 1.3: Create `utils/matching.ts` — implement `matchProcessesToSessions(processes, sessions)`: exclude processes without `startTime`, build candidate pairs where `process.cwd === session.resolvedCwd` and `deltaMs <= 180_000`, sort by deltaMs ascending, greedy 1:1 assign. Implement `generateAgentName(cwd, pid)` returning `basename(cwd) (pid)`. + +- [x] Task 1.4: Write unit tests for all new utilities — mock `execSync` at module level with `jest.mock`. Test cases: no processes, no sessions, multiple processes same CWD, no match within tolerance, exact 1:1, more sessions than processes, more processes than sessions, partial lsof failure, process without startTime excluded, platform detection for stat command. + +### Phase 2: Refactor ClaudeCodeAdapter + +- [x] Task 2.1: Replace process detection — use `listAgentProcesses('claude')` + `enrichProcesses()`. Remove `listClaudeProcesses()`, `getProcessStartTimes()`, `parseElapsedSeconds()`. + +- [x] Task 2.2: Replace session file scanning — use `getSessionFileBirthtimes(dir)` for listing files. Adapter derives project dir via path encoding (`cwd.replace(/\//g, '-')`) instead of scanning all dirs with `sessions-index.json`. Sets `resolvedCwd` on each SessionFile. Remove `findSessionFiles()`, `calculateSessionScanLimit()`, `readSessions()`, `SessionsIndex`, `readJson` import. + +- [x] Task 2.3: Replace matching — use `matchProcessesToSessions()`. Remove `assignSessionsForMode()`, `selectBestSession()`, `rankCandidatesByStartTime()`, `filterCandidateSessions()`. Remove parent-child/missing-cwd modes. + +- [x] Task 2.4: Replace naming — use `generateAgentName(cwd, pid)`. Remove adapter's `generateAgentName()`. + +- [x] Task 2.5: Keep adapter-specific: `canHandle()`, session dir derivation, `readSession()` (JSONL parsing), `determineStatus()`, `extractUserMessageText()`, `mapSessionToAgent()`, `mapProcessOnlyAgent()`. + +- [x] Task 2.6: Remove all `execSync` calls and path comparison helpers (`pathEquals`, `pathRelated`, `isChildPath`, `normalizePath`). + +- [x] Task 2.7: Update ClaudeCodeAdapter tests — mock shared util imports instead of internal methods. + +### Phase 3: Refactor CodexAdapter + +- [x] Task 3.1: Replace process detection — use `listAgentProcesses('codex')` + `enrichProcesses()`. Remove `listCodexProcesses()`, `getProcessStartTimes()`, `parseElapsedSeconds()`. + +- [x] Task 3.2: Replace session file listing — use `batchGetSessionFileBirthtimes()` across date directories. Date-dir scanning uses process start times (±1 day window). Adapter sets `resolvedCwd` from session_meta first line. Remove `findSessionFiles()`, `findProcessDaySessionFiles()`, `readSessions()`, `calculateSessionScanLimit()`. + +- [x] Task 3.3: Replace matching — use `matchProcessesToSessions()`. Remove `assignSessionsForMode()`, `selectBestSession()`, `filterCandidateSessions()`, `rankCandidatesByStartTime()`, `addMappedSessionAgent()`, `addProcessOnlyAgent()`. + +- [x] Task 3.4: Replace naming — use shared `generateAgentName(cwd, pid)`. Remove adapter's `generateAgentName()`. + +- [x] Task 3.5: Keep adapter-specific: `canHandle()`, date-dir scanning (`getDateDirs`), `discoverSessions()` (reads files once, caches content, sets resolvedCwd from session_meta), `parseSession(cachedContent, filePath)`, `determineStatus()`, `extractSummary()`. + +- [x] Task 3.6: Remove all `execSync` calls from CodexAdapter. + +- [x] Task 3.7: Update CodexAdapter tests — mock shared util imports instead of internal methods. + +### Phase 4: Cleanup + +- [x] Task 4.1: Remove dead code from agent-manager — removed `listProcesses()`, `getProcessCwd()`, `ListProcessesOptions` (deprecated, no callers), `getSessionFileBirthtimes()` (unused wrapper), entire `utils/file.ts` (`readLastLines`, `readJsonLines` — no production callers). Kept `getProcessTty` (used by TerminalFocusManager). Updated exports in utils/index.ts and src/index.ts. + +- [x] Task 4.2: Remove dead code from CLI — removed entire `util/process.ts` (`listProcesses`, `getProcessCwd`, `getProcessTty`, `isProcessRunning`, `getProcessInfo`) and `util/file.ts` (`readLastLines`, `readJsonLines`, `fileExists`, `readJson`) — zero production imports in CLI package. + +- [x] Task 4.3: Run full test suite — 145 agent-manager tests (7 suites) + 348 CLI tests (24 suites) = 493 total, all passing. + +## Dependencies + +```mermaid +graph LR + T11["1.1 utils/process"] --> T21["2.1 Claude process"] + T11 --> T31["3.1 Codex process"] + T12["1.2 utils/session"] --> T22["2.2 Claude session"] + T12 --> T32["3.2 Codex session"] + T13["1.3 utils/matching"] --> T23["2.3 Claude matching"] + T13 --> T24["2.4 Claude naming"] + T13 --> T33["3.3 Codex matching"] + T13 --> T34["3.4 Codex naming"] + T14["1.4 Utils tests"] + T27["2.7 Claude tests"] --> T42["4.2 Full suite"] + T37["3.7 Codex tests"] --> T42 + T41["4.1 Dead code"] --> T42 +``` + +- Phase 1 (1.1-1.3) must complete before Phase 2 and Phase 3 +- Phase 2 and Phase 3 are independent (can run in parallel) +- Task 1.4 can run in parallel with Phase 2/3 + +## Risks & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| `stat` birthtime returns 0 on older Linux | Medium | Low | Fallback to `mtimeMs` in `utils/session.ts` | +| `ps -o lstart` format differs on Linux vs macOS | Medium | Medium | Test on both platforms, use `Date.parse()` with fallback parser | +| `stat` output format differs across distributions | Low | Medium | Parse defensively, test with sample output | +| Agent name format change breaks downstream | Low | Medium | Accepted as intentional breaking change | +| Partial shell command failure | Medium | Low | Return partial results, future `--verbose` mode for logging | diff --git a/docs/ai/requirements/feature-agent-list-cwd.md b/docs/ai/requirements/feature-agent-list-cwd.md new file mode 100644 index 00000000..b8195447 --- /dev/null +++ b/docs/ai/requirements/feature-agent-list-cwd.md @@ -0,0 +1,46 @@ +--- +phase: requirements +title: Display CWD in Agent List Command +description: Add a CWD column to the agent list table showing each agent's working directory +--- + +# Display CWD in Agent List Command + +## Problem Statement + +When running `ai-devkit agent list`, users see a table with Agent, Type, Status, Working On, and Active columns. However, there is no way to tell **which directory** each agent is working in. This makes it difficult to distinguish between multiple agents of the same type running in different projects. + +The `projectPath` field already exists in the `AgentInfo` data model and is populated by adapters, but it is not surfaced in the table output. + +## Goals & Objectives + +**Primary goals:** +- Display each agent's current working directory (cwd) in the `agent list` table output + +**Non-goals:** +- Changing the `--json` output format (it already includes `projectPath`) +- Adding filtering/sorting by cwd +- Modifying how `projectPath` is collected by adapters + +## User Stories & Use Cases + +- As a developer running multiple agents across projects, I want to see each agent's working directory so I can quickly identify which agent belongs to which project. +- As a developer with agents in nested directories, I want the path displayed in a compact, readable format (shortened with `~` for home directory). + +## Success Criteria + +- [ ] `agent list` table includes a "CWD" column showing the agent's `projectPath` +- [ ] Long paths are shortened (home directory replaced with `~`) +- [ ] Column is positioned after "Agent" name for quick visual association +- [ ] Existing tests updated to cover the new column +- [ ] No regressions in existing agent list functionality + +## Constraints & Assumptions + +- The `projectPath` field is already available in `AgentInfo` — no adapter changes needed +- Path shortening uses `os.homedir()` for `~` substitution +- Column styling uses `chalk.dim` to keep focus on agent name and status + +## Questions & Open Items + +- None — straightforward display addition using existing data. 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 00000000..b6c15241 --- /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/requirements/feature-generalize-session-mapping.md b/docs/ai/requirements/feature-generalize-session-mapping.md new file mode 100644 index 00000000..84d2a9c5 --- /dev/null +++ b/docs/ai/requirements/feature-generalize-session-mapping.md @@ -0,0 +1,95 @@ +--- +phase: requirements +title: Generalize Process-to-Session Mapping +description: Extract shared matching logic from adapters into reusable utilities +--- + +# Generalize Process-to-Session Mapping + +## Problem Statement + +ClaudeCodeAdapter (768 lines) and CodexAdapter (573 lines) duplicate ~70% of their logic: +- Process discovery (`ps aux`, `lsof` for CWD) +- Process start time calculation (`ps -o etime=`) +- Session scan limiting +- Multi-pass session-to-process matching (CWD filter → start-time ranking → greedy assignment) +- Agent name generation and fallback handling + +Adding a new CLI agent requires rewriting all of this. Shell command calls (`execSync`) are scattered inside each adapter instead of being centralized. Per-PID `lsof` calls are not batched. + +## Goals & Objectives + +**Primary goals:** +- Extract all shell command wrappers into shared utils — no adapter calls `execSync` directly + - `ps -o pid=,lstart=,comm= -p PID1,...` for process start times (single batched call, full timestamp) + - `lsof -a -d cwd -Fn -p PID1,PID2,...` for CWDs (single batched call) + - `stat -f '%B %N' /*.jsonl` (macOS) / `stat --format='%W %n'` (Linux) for session file birthtimes (batched across all directories) + - `ps aux | grep ` for process discovery (filtered at shell level, not in code) +- Extract shared matching algorithm into `utils/matching.ts` +- Use file `birthtimeMs` as the primary matching signal +- Enforce 1:1 process-to-session constraint with greedy assignment +- Only parse JSONL content for matched files (status, summary) +- Move `generateAgentName()` to shared utils — naming convention: `cwdFolderName (pid)` + +**Secondary goals:** +- Make adding a new adapter require only agent-specific logic (~50-100 lines) + +**Non-goals:** +- Changing the `AgentAdapter` interface +- Adding new agent adapters in this PR +- Solving the session-resumption edge case — accepted as known limitation +- Windows support — macOS and Linux only +- Exposing unmatched/inactive sessions + +## User Stories & Use Cases + +1. **As a maintainer**, I add a new CLI agent adapter by implementing only: executable name matching, session directory scanning, JSONL parsing, and status determination. + +2. **As a user**, I get faster agent listing — fewer shell calls, no unnecessary file reads. + +3. **As a maintainer**, I fix a matching bug once in `utils/matching.ts`. + +4. **As an adapter author**, I call shared utils for all OS-level commands. + +**Edge cases:** +- Multiple processes with same CWD — disambiguated by birthtime proximity to process start time +- Process with no matching session file — falls back to process-only agent (name + pid) +- Session file with no matching process — ignored +- Process that created a new session without restarting — birthtime matches original session (accepted) +- No session within tolerance — process-only fallback (not a wrong match) + +## Success Criteria + +- Equivalent test coverage with updated mocks (existing tests will change due to refactored internals) +- Runtime output identical to current adapters for non-edge-case scenarios +- No adapter contains direct `execSync` calls +- Shared `matchProcessesToSessions()` used by both adapters +- JSONL files only read for matched sessions + +## Constraints & Assumptions + +- **macOS and Linux only**: No Windows support. `birthtimeMs` reliable on APFS/HFS+ and modern ext4 (kernel 4.11+). +- **`AgentAdapter` interface is frozen**: No changes to `type`, `detectAgents()`, or `canHandle()`. +- **Platform differences**: `stat -f '%B %N'` (macOS) vs `stat --format='%W %n'` (Linux) — utils handle internally. +- **birthtimeMs limitation**: accepted — process switching sessions without restart matches original session. +- **Tolerance threshold**: 3-minute maximum delta between process start time and session file birthtime. Beyond this → process-only fallback. + +## Clarified Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | Batch shell commands wherever possible | Performance — 1 `lsof` for N PIDs instead of N calls | +| 2 | Agent name = `cwdFolderName (pid)` | Simpler, deterministic, no JSONL parse needed for naming. Breaking change from current slug-based naming — accepted. | +| 3 | Use `grep` at shell level for process filtering | `ps aux \| grep claude` instead of listing all processes and filtering in code | +| 4 | Use `ps -o pid=,lstart=,comm=` for start times | Full timestamp (not lossy `START` column from `ps aux`). Single batched call for all PIDs. | +| 5 | Session scanning logic stays in adapter | Only exec functions (`ps`, `ls`, `lsof`) move to shared utils. Adapters control which dirs to scan. | +| 6 | No session scan limit | `stat` is cheap (no file reads). List all `.jsonl` files in project dirs. | +| 7 | Drop parent-child path matching | Simpler. Process CWD must match session project dir exactly. | +| 8 | Remove redundant `canHandle()` logic | `listAgentProcesses` already filters by executable name via grep. `canHandle()` kept for interface contract only. | +| 9 | 3-minute tolerance, then process-only fallback | Covers all observed deltas (max 2m24s). Wrong match is worse than no match. | +| 10 | JSONL parsing stays per-adapter | Each agent has different JSONL schema, status mapping, summary extraction. | +| 11 | Focus on active sessions only | Unmatched sessions ignored — not running agents. | + +## Questions & Open Items + +None — all decisions resolved. diff --git a/docs/ai/testing/feature-agent-list-cwd.md b/docs/ai/testing/feature-agent-list-cwd.md new file mode 100644 index 00000000..2125cd5a --- /dev/null +++ b/docs/ai/testing/feature-agent-list-cwd.md @@ -0,0 +1,29 @@ +--- +phase: testing +title: Display CWD in Agent List — Testing +description: Test strategy for CWD column feature +--- + +# Display CWD in Agent List — Testing + +## Test Coverage Goals + +- 100% coverage of `formatCwd()` helper +- Verify table output includes CWD column + +## Unit Tests + +### `formatCwd()` helper +- [ ] Returns `~`-prefixed path when projectPath starts with home directory +- [ ] Returns original path when projectPath doesn't start with home directory +- [ ] Returns empty string for empty/undefined input + +### Table rendering +- [ ] Table headers include "CWD" column +- [ ] Table rows include formatted projectPath values +- [ ] CWD column appears in correct position (after Agent) + +## Integration Tests + +- [ ] `agent list` with agents shows CWD column in output +- [ ] `agent list --json` still returns full `projectPath` (no regression) 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 00000000..01ca7f6d --- /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. diff --git a/docs/ai/testing/feature-generalize-session-mapping.md b/docs/ai/testing/feature-generalize-session-mapping.md new file mode 100644 index 00000000..82f5d5ca --- /dev/null +++ b/docs/ai/testing/feature-generalize-session-mapping.md @@ -0,0 +1,75 @@ +--- +phase: testing +title: Generalize Process-to-Session Mapping — Testing +description: Test inventory and coverage for shared utilities and adapter refactoring +--- + +# Testing Strategy + +## Test Coverage Goals + +- 100% of new/changed code in shared utilities +- Adapter tests mock shared utils at module level (`jest.mock`) +- File I/O tests use real temp directories (`fs.mkdtempSync`) + +## Test Suites (145 agent-manager + 348 CLI = 493 total) + +### agent-manager: 145 tests, 7 suites + +#### `utils/process.test.ts` (14 tests) + +- **listAgentProcesses**: Parse ps aux output, post-filter by executable name, filter non-matching, empty output, command failure, empty pattern rejection, shell injection rejection, valid patterns with dashes/underscores +- **batchGetProcessCwds**: Parse lsof output, empty pids, partial results, total failure +- **batchGetProcessStartTimes**: Parse ps lstart output, empty pids, unparseable dates, failure +- **enrichProcesses**: Populate cwd + startTime, empty input, partial failures + +#### `utils/session.test.ts` (9 tests) + +- **batchGetSessionFileBirthtimes**: Parse stat output, empty dirs, command failure, invalid epochs, non-jsonl files, empty output, UUID session IDs, resolvedCwd left empty, multiple directories single call + +#### `utils/matching.test.ts` (17 tests) + +- **matchProcessesToSessions**: Empty processes, empty sessions, single match closest, 1:1 constraint, disambiguation by birthtime, exclude without startTime, exclude without cwd, CWD mismatch, delta exceeds tolerance, exact 3-min boundary, more sessions than processes, more processes than sessions, empty resolvedCwd, greedy ordering preference +- **generateAgentName**: Standard path, root path, empty cwd, nested paths + +#### `adapters/ClaudeCodeAdapter.test.ts` (41 tests) + +- **canHandle**: claude, full path, case-insensitive, non-claude, "claude" in path args +- **detectAgents**: No processes, no sessions (process-only fallback), matched sessions, unmatched processes fallback, empty cwd fallback +- **discoverSessions**: Non-existent projects dir, scan matching CWDs, non-existent encoded dir, dedup same CWD, skip empty cwd +- **determineStatus**: unknown, waiting (assistant), waiting (interrupted user), running (user/progress/thinking), idle (system), unknown (unrecognized), no age override +- **extractUserMessageText**: Plain string, array blocks, empty/null, command-message tags, command without args, skill ARGUMENTS, skill without ARGUMENTS, noise messages +- **readSession**: Full parse, interruption detection, empty file, non-existent, metadata entry skip, snapshot.timestamp, lastUserMessage, lastCwd as projectPath, malformed JSON + +#### `adapters/CodexAdapter.test.ts` (27 tests) + +- **canHandle**: codex, full path case-insensitive, non-codex, "codex" in path args +- **detectAgents**: No processes, no sessions, matched sessions, unmatched fallback +- **discoverSessions**: Non-existent dir, scan date dirs, ±1 day window, session without session_meta +- **determineStatus**: waiting (agent_message/task_complete/turn_aborted), running, idle (threshold) +- **parseSession**: Full parse, cached content, non-existent file, no session_meta, no id, summary extraction, malformed JSON, default summary, empty content, truncation + +#### `AgentManager.test.ts` (13 tests) + +- **registerAdapter**: Register, duplicate error, multiple types +- **unregisterAdapter**: Remove, non-existent +- **getAdapters**: Empty, all registered +- **listAgents**: Empty, single adapter, multiple adapters, status sort, adapter error handling, all fail +- **clear**: Remove all +- **resolveAgent**: Empty input, exact match, partial match, ambiguous, no match, prefer exact + +#### `terminal/TtyWriter.test.ts` (24 tests) + +- Terminal output formatting tests (unchanged by this feature) + +### CLI: 348 tests, 24 suites + +- All existing CLI tests pass after removing dead utility files (`util/process.ts`, `util/file.ts`) + +## Running Tests + +```bash +cd packages/agent-manager +npx jest # all tests +npx jest --coverage # with coverage report +``` diff --git a/package-lock.json b/package-lock.json index e3d2715e..4b550da0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-devkit", - "version": "0.17.0", + "version": "0.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-devkit", - "version": "0.17.0", + "version": "0.18.0", "license": "MIT", "workspaces": [ "apps/*", @@ -13021,7 +13021,7 @@ }, "packages/agent-manager": { "name": "@ai-devkit/agent-manager", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "devDependencies": { "@types/jest": "^30.0.0", @@ -13039,10 +13039,10 @@ }, "packages/cli": { "name": "ai-devkit", - "version": "0.18.0", + "version": "0.19.0", "license": "MIT", "dependencies": { - "@ai-devkit/agent-manager": "0.4.0", + "@ai-devkit/agent-manager": "0.5.0", "@ai-devkit/memory": "0.7.0", "chalk": "^4.1.2", "commander": "^11.1.0", diff --git a/package.json b/package.json index 568730f4..55c2dae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.17.0", + "version": "0.18.0", "private": true, "description": "A CLI toolkit for AI-assisted software development with phase templates and environment setup", "scripts": { diff --git a/packages/agent-manager/package.json b/packages/agent-manager/package.json index 0f0cca1c..1180950d 100644 --- a/packages/agent-manager/package.json +++ b/packages/agent-manager/package.json @@ -1,6 +1,6 @@ { "name": "@ai-devkit/agent-manager", - "version": "0.4.0", + "version": "0.5.0", "description": "Standalone agent detection and management utilities for AI DevKit", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index 04b30aa5..fa1dc8aa 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -130,17 +130,6 @@ describe('AgentManager', () => { }); }); - describe('hasAdapter', () => { - it('should return true for registered adapter', () => { - manager.registerAdapter(new MockAdapter('claude')); - expect(manager.hasAdapter('claude')).toBe(true); - }); - - it('should return false for non-registered adapter', () => { - expect(manager.hasAdapter('claude')).toBe(false); - }); - }); - describe('listAgents', () => { it('should return empty array when no adapters registered', async () => { const agents = await manager.listAgents(); @@ -222,20 +211,6 @@ describe('AgentManager', () => { }); }); - describe('getAdapterCount', () => { - it('should return 0 when no adapters registered', () => { - expect(manager.getAdapterCount()).toBe(0); - }); - - it('should return correct count', () => { - manager.registerAdapter(new MockAdapter('claude')); - expect(manager.getAdapterCount()).toBe(1); - - manager.registerAdapter(new MockAdapter('gemini_cli')); - expect(manager.getAdapterCount()).toBe(2); - }); - }); - describe('clear', () => { it('should remove all adapters', () => { manager.registerAdapter(new MockAdapter('claude')); diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 0f3bb176..909310d9 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -6,28 +6,49 @@ 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 type { ProcessInfo } from '../../adapters/AgentAdapter'; import { AgentStatus } from '../../adapters/AgentAdapter'; -import { listProcesses } from '../../utils/process'; - +import { listAgentProcesses, enrichProcesses } from '../../utils/process'; +import { batchGetSessionFileBirthtimes } from '../../utils/session'; +import type { SessionFile } from '../../utils/session'; +import { matchProcessesToSessions, generateAgentName } from '../../utils/matching'; +import type { MatchResult } from '../../utils/matching'; jest.mock('../../utils/process', () => ({ - listProcesses: jest.fn(), + listAgentProcesses: jest.fn(), + enrichProcesses: jest.fn(), })); -const mockedListProcesses = listProcesses as jest.MockedFunction; - -type PrivateMethod unknown> = T; +jest.mock('../../utils/session', () => ({ + batchGetSessionFileBirthtimes: jest.fn(), +})); -interface AdapterPrivates { - readSessions: PrivateMethod<(limit: number) => unknown[]>; -} +jest.mock('../../utils/matching', () => ({ + matchProcessesToSessions: jest.fn(), + generateAgentName: jest.fn(), +})); +const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction; +const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction; +const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as jest.MockedFunction; +const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction; +const mockedGenerateAgentName = generateAgentName as jest.MockedFunction; describe('ClaudeCodeAdapter', () => { let adapter: ClaudeCodeAdapter; beforeEach(() => { adapter = new ClaudeCodeAdapter(); - mockedListProcesses.mockReset(); + mockedListAgentProcesses.mockReset(); + mockedEnrichProcesses.mockReset(); + mockedBatchGetSessionFileBirthtimes.mockReset(); + mockedMatchProcessesToSessions.mockReset(); + mockedGenerateAgentName.mockReset(); + // Default: enrichProcesses returns what it receives + mockedEnrichProcesses.mockImplementation((procs) => procs); + // Default: generateAgentName returns "folder (pid)" + mockedGenerateAgentName.mockImplementation((cwd, pid) => { + const folder = path.basename(cwd) || 'unknown'; + return `${folder} (${pid})`; + }); }); describe('initialization', () => { @@ -95,43 +116,88 @@ describe('ClaudeCodeAdapter', () => { describe('detectAgents', () => { it('should return empty array if no claude processes running', async () => { - mockedListProcesses.mockReturnValue([]); + mockedListAgentProcesses.mockReturnValue([]); const agents = await adapter.detectAgents(); expect(agents).toEqual([]); + expect(mockedListAgentProcesses).toHaveBeenCalledWith('claude'); + }); + + it('should return process-only agents when no sessions discovered', async () => { + const processes: ProcessInfo[] = [ + { pid: 777, command: 'claude', cwd: '/project/app', tty: 'ttys001' }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + // No projects dir → discoverSessions returns [] + (adapter as any).projectsDir = '/nonexistent/path'; + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'claude', + status: AgentStatus.IDLE, + pid: 777, + projectPath: '/project/app', + sessionId: 'pid-777', + summary: 'Unknown', + }); }); - it('should detect agents using mocked process/session data', async () => { - const processData: ProcessInfo[] = [ + it('should detect agents with matched sessions', async () => { + const processes: ProcessInfo[] = [ { pid: 12345, - command: 'claude --continue', + command: 'claude', cwd: '/Users/test/my-project', tty: 'ttys001', + startTime: new Date('2026-03-18T23:18:01.000Z'), }, ]; - - const sessionData = [ + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + // Set up projects dir with encoded directory name + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-')); + const projectsDir = path.join(tmpDir, 'projects'); + // Claude encodes /Users/test/my-project → -Users-test-my-project + const projDir = path.join(projectsDir, '-Users-test-my-project'); + fs.mkdirSync(projDir, { recursive: true }); + + // Create session file + const sessionFile = path.join(projDir, 'session-1.jsonl'); + fs.writeFileSync(sessionFile, [ + JSON.stringify({ type: 'user', timestamp: '2026-03-18T23:18:44Z', cwd: '/Users/test/my-project', slug: 'merry-dog', message: { content: 'Investigate failing tests' } }), + JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }), + ].join('\n')); + + (adapter as any).projectsDir = projectsDir; + + const sessionFiles: SessionFile[] = [ { sessionId: 'session-1', - projectPath: '/Users/test/my-project', - slug: 'merry-dog', - sessionStart: new Date(), - lastActive: new Date(), - lastEntryType: 'assistant', - isInterrupted: false, - lastUserMessage: 'Investigate failing tests in package', + filePath: sessionFile, + projectDir: projDir, + birthtimeMs: new Date('2026-03-18T23:18:44Z').getTime(), + resolvedCwd: '', }, ]; + mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles); - mockedListProcesses.mockReturnValue(processData); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue(sessionData); + const matches: MatchResult[] = [ + { + process: processes[0], + session: { ...sessionFiles[0], resolvedCwd: '/Users/test/my-project' }, + deltaMs: 43000, + }, + ]; + mockedMatchProcessesToSessions.mockReturnValue(matches); const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); expect(agents[0]).toMatchObject({ - name: 'my-project', type: 'claude', status: AgentStatus.WAITING, pid: 12345, @@ -139,259 +205,403 @@ describe('ClaudeCodeAdapter', () => { sessionId: 'session-1', slug: 'merry-dog', }); - expect(agents[0].summary).toContain('Investigate failing tests in package'); + expect(agents[0].summary).toContain('Investigate failing tests'); + + fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('should include process-only entry when no sessions exist', async () => { - mockedListProcesses.mockReturnValue([ + it('should fall back to process-only for unmatched processes', async () => { + const processes: ProcessInfo[] = [ + { pid: 100, command: 'claude', cwd: '/project-a', tty: 'ttys001', startTime: new Date() }, + { pid: 200, command: 'claude', cwd: '/project-b', tty: 'ttys002', startTime: new Date() }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + // Set up projects dir with encoded directory names + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-')); + const projectsDir = path.join(tmpDir, 'projects'); + // /project-a → -project-a, /project-b → -project-b + const projDirA = path.join(projectsDir, '-project-a'); + const projDirB = path.join(projectsDir, '-project-b'); + fs.mkdirSync(projDirA, { recursive: true }); + fs.mkdirSync(projDirB, { recursive: true }); + + const sessionFile = path.join(projDirA, 'only-session.jsonl'); + fs.writeFileSync(sessionFile, + JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }), + ); + + (adapter as any).projectsDir = projectsDir; + + const sessionFiles: SessionFile[] = [ { - pid: 777, - command: 'claude', - cwd: '/project/without-session', - tty: 'ttys008', + sessionId: 'only-session', + filePath: sessionFile, + projectDir: projDirA, + birthtimeMs: Date.now(), + resolvedCwd: '', }, - ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]); + ]; + mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles); + // Only process 100 matches + const matches: MatchResult[] = [ + { + process: processes[0], + session: { ...sessionFiles[0], resolvedCwd: '/project-a' }, + deltaMs: 5000, + }, + ]; + mockedMatchProcessesToSessions.mockReturnValue(matches); 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', - }); + expect(agents).toHaveLength(2); + + const matched = agents.find(a => a.pid === 100); + const unmatched = agents.find(a => a.pid === 200); + expect(matched?.sessionId).toBe('only-session'); + expect(unmatched?.sessionId).toBe('pid-200'); + expect(unmatched?.status).toBe(AgentStatus.IDLE); + + fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('should not match process to unrelated session from different project', async () => { - mockedListProcesses.mockReturnValue([ - { - pid: 777, - command: 'claude', - cwd: '/project/without-session', - tty: 'ttys008', - }, - ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ - { - sessionId: 'session-2', - projectPath: '/other/project', - sessionStart: new Date(), - lastActive: new Date(), - lastEntryType: 'assistant', - isInterrupted: false, - }, - ]); + it('should handle process with empty cwd in process-only fallback', async () => { + const processes: ProcessInfo[] = [ + { pid: 300, command: 'claude', cwd: '', tty: 'ttys003' }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + (adapter as any).projectsDir = '/nonexistent'; const agents = await adapter.detectAgents(); expect(agents).toHaveLength(1); - // Unrelated session should NOT match — falls to process-only expect(agents[0]).toMatchObject({ - type: 'claude', - pid: 777, - sessionId: 'pid-777', - projectPath: '/project/without-session', - status: AgentStatus.IDLE, + pid: 300, + sessionId: 'pid-300', + summary: 'Unknown', + projectPath: '', }); }); - it('should match process in subdirectory to project-root session via parent-child mode', async () => { - mockedListProcesses.mockReturnValue([ - { - pid: 888, - command: 'claude', - cwd: '/Users/test/my-project/packages/cli', - tty: 'ttys009', - }, - ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ - { - sessionId: 'session-3', - projectPath: '/Users/test/my-project', - slug: 'gentle-otter', - sessionStart: new Date(), - lastActive: new Date(), - lastEntryType: 'assistant', - isInterrupted: false, - }, - ]); + 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: 888, - sessionId: 'session-3', - projectPath: '/Users/test/my-project', + 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 show idle status with Unknown summary for process-only fallback when no sessions exist', async () => { - mockedListProcesses.mockReturnValue([ - { - pid: 97529, - command: 'claude', - cwd: '/Users/test/my-project/packages/cli', - tty: 'ttys021', - }, - ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]); + 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]).toMatchObject({ - type: 'claude', - pid: 97529, - projectPath: '/Users/test/my-project/packages/cli', - sessionId: 'pid-97529', - summary: 'Unknown', - status: AgentStatus.IDLE, - }); + 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 match session via parent-child mode when process cwd is under session project path', async () => { - mockedListProcesses.mockReturnValue([ - { - pid: 97529, - command: 'claude', - cwd: '/Users/test/my-project/packages/cli', - tty: 'ttys021', - }, - ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ - { - sessionId: 'parent-session', - projectPath: '/Users/test/my-project', - slug: 'fluffy-brewing-kazoo', - sessionStart: new Date('2026-02-23T17:24:50.996Z'), - lastActive: new Date('2026-02-23T17:24:50.996Z'), - lastEntryType: 'assistant', - isInterrupted: false, - }, + 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); - // Session matched via parent-child mode - expect(agents[0]).toMatchObject({ - type: 'claude', - pid: 97529, - sessionId: 'parent-session', - projectPath: '/Users/test/my-project', - }); + 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 fall back to process-only when sessions exist but all are used', async () => { - mockedListProcesses.mockReturnValue([ - { - pid: 100, - command: 'claude', - cwd: '/project-a', - tty: 'ttys001', - }, - { - pid: 200, - command: 'claude', - cwd: '/project-b', - tty: 'ttys002', - }, - ]); - jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([ - { - sessionId: 'only-session', - projectPath: '/project-a', - sessionStart: new Date(), - lastActive: new Date(), - lastEntryType: 'assistant', - isInterrupted: false, - }, + 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); - // 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', - }); + 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 }); }); + }); - 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([]); + describe('discoverSessions', () => { + let tmpDir: string; - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0]).toMatchObject({ - pid: 300, - sessionId: 'pid-300', - summary: 'Unknown', - projectPath: '', - }); + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-')); }); - 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', - }, + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return empty when projects dir does not exist', () => { + (adapter as any).projectsDir = path.join(tmpDir, 'nonexistent'); + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); + + const result = discoverSessions([ + { pid: 1, command: 'claude', cwd: '/test', tty: '' }, ]); - 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, - }, + expect(result).toEqual([]); + }); + + it('should scan only directories matching process CWDs', () => { + const projectsDir = path.join(tmpDir, 'projects'); + (adapter as any).projectsDir = projectsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); + + // /my/project → -my-project (encoded dir) + const encodedDir = path.join(projectsDir, '-my-project'); + fs.mkdirSync(encodedDir, { recursive: true }); + + // Also create another dir that should NOT be scanned + const otherDir = path.join(projectsDir, '-other-project'); + fs.mkdirSync(otherDir, { recursive: true }); + + const mockFiles: SessionFile[] = [ { - sessionId: 'other-project', - projectPath: '/Users/test/project-b', - sessionStart: now, - lastActive: new Date(now.getTime() + 1000), // more recent - lastEntryType: 'user', - isInterrupted: false, + sessionId: 's1', + filePath: path.join(encodedDir, 's1.jsonl'), + projectDir: encodedDir, + birthtimeMs: 1710800324000, + resolvedCwd: '', }, + ]; + mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles); + + const processes = [ + { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }, + ]; + + const result = discoverSessions(processes); + expect(result).toHaveLength(1); + expect(result[0].resolvedCwd).toBe('/my/project'); + // batchGetSessionFileBirthtimes called once with all dirs + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1); + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]); + }); + + it('should return empty when encoded dir does not exist', () => { + const projectsDir = path.join(tmpDir, 'projects'); + fs.mkdirSync(projectsDir, { recursive: true }); + (adapter as any).projectsDir = projectsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); + + // Process CWD /test encodes to -test, but that dir doesn't exist + const result = discoverSessions([ + { pid: 1, command: 'claude', cwd: '/test', tty: '' }, ]); + expect(result).toEqual([]); + expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled(); + }); + it('should deduplicate when multiple processes share same CWD', () => { + const projectsDir = path.join(tmpDir, 'projects'); + (adapter as any).projectsDir = projectsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0]).toMatchObject({ - sessionId: 'exact-match', - projectPath: '/Users/test/project-a', - }); + const encodedDir = path.join(projectsDir, '-my-project'); + fs.mkdirSync(encodedDir, { recursive: true }); + + mockedBatchGetSessionFileBirthtimes.mockReturnValue([ + { sessionId: 's1', filePath: path.join(encodedDir, 's1.jsonl'), projectDir: encodedDir, birthtimeMs: 1710800324000, resolvedCwd: '' }, + ]); + + const processes = [ + { pid: 1, command: 'claude', cwd: '/my/project', tty: '' }, + { pid: 2, command: 'claude', cwd: '/my/project', tty: '' }, + ]; + + const result = discoverSessions(processes); + // Should only call batch once with deduplicated dir + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1); + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]); + expect(result).toHaveLength(1); + }); + + it('should skip processes with empty cwd', () => { + const projectsDir = path.join(tmpDir, 'projects'); + fs.mkdirSync(projectsDir, { recursive: true }); + (adapter as any).projectsDir = projectsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); + + const result = discoverSessions([ + { pid: 1, command: 'claude', cwd: '', tty: '' }, + ]); + expect(result).toEqual([]); }); }); describe('helper methods', () => { describe('determineStatus', () => { it('should return "unknown" for sessions with no last entry type', () => { - const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const session = { @@ -402,12 +612,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; - const status = determineStatus(session); - expect(status).toBe(AgentStatus.UNKNOWN); + expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN); }); it('should return "waiting" for assistant entries', () => { - const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const session = { @@ -419,12 +627,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; - const status = determineStatus(session); - expect(status).toBe(AgentStatus.WAITING); + expect(determineStatus(session)).toBe(AgentStatus.WAITING); }); it('should return "waiting" for user interruption', () => { - const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const session = { @@ -436,12 +642,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: true, }; - const status = determineStatus(session); - expect(status).toBe(AgentStatus.WAITING); + expect(determineStatus(session)).toBe(AgentStatus.WAITING); }); it('should return "running" for user/progress entries', () => { - const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const session = { @@ -453,16 +657,13 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; - const status = determineStatus(session); - expect(status).toBe(AgentStatus.RUNNING); + expect(determineStatus(session)).toBe(AgentStatus.RUNNING); }); it('should not override status based on age (process is running)', () => { - const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const oldDate = new Date(Date.now() - 10 * 60 * 1000); - const session = { sessionId: 'test', projectPath: '/test', @@ -472,14 +673,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; - // Even with old lastActive, entry type determines status - // because the process is known to be running - const status = determineStatus(session); - expect(status).toBe(AgentStatus.WAITING); + expect(determineStatus(session)).toBe(AgentStatus.WAITING); }); it('should return "idle" for system entries', () => { - const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const session = { @@ -491,12 +688,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; - const status = determineStatus(session); - expect(status).toBe(AgentStatus.IDLE); + expect(determineStatus(session)).toBe(AgentStatus.IDLE); }); it('should return "running" for thinking entries', () => { - const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const session = { @@ -508,12 +703,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; - const status = determineStatus(session); - expect(status).toBe(AgentStatus.RUNNING); + expect(determineStatus(session)).toBe(AgentStatus.RUNNING); }); it('should return "running" for progress entries', () => { - const adapter = new ClaudeCodeAdapter(); const determineStatus = (adapter as any).determineStatus.bind(adapter); const session = { @@ -525,12 +718,10 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; - const status = determineStatus(session); - expect(status).toBe(AgentStatus.RUNNING); + expect(determineStatus(session)).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 = { @@ -542,380 +733,17 @@ describe('ClaudeCodeAdapter', () => { isInterrupted: false, }; - const status = determineStatus(session); - expect(status).toBe(AgentStatus.UNKNOWN); - }); - }); - - describe('generateAgentName', () => { - it('should use project name for first session', () => { - const adapter = new ClaudeCodeAdapter(); - const generateAgentName = (adapter as any).generateAgentName.bind(adapter); - - const session = { - sessionId: 'test-123', - projectPath: '/Users/test/my-project', - sessionStart: new Date(), - lastActive: new Date(), - isInterrupted: false, - }; - - const name = generateAgentName(session, []); - expect(name).toBe('my-project'); - }); - - it('should append slug for duplicate projects', () => { - 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', - slug: 'happy-cat', - lastActive: new Date(), - }; - - const session = { - sessionId: 'test-456', - projectPath: '/Users/test/my-project', - 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); + expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN); }); }); 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 = [ @@ -926,7 +754,6 @@ describe('ClaudeCodeAdapter', () => { }); it('should return undefined for empty/null content', () => { - const adapter = new ClaudeCodeAdapter(); const extract = (adapter as any).extractUserMessageText.bind(adapter); expect(extract(undefined)).toBeUndefined(); @@ -935,7 +762,6 @@ describe('ClaudeCodeAdapter', () => { }); it('should parse command-message tags', () => { - const adapter = new ClaudeCodeAdapter(); const extract = (adapter as any).extractUserMessageText.bind(adapter); const msg = 'commitfix bug'; @@ -943,7 +769,6 @@ describe('ClaudeCodeAdapter', () => { }); it('should parse command-message without args', () => { - const adapter = new ClaudeCodeAdapter(); const extract = (adapter as any).extractUserMessageText.bind(adapter); const msg = 'help'; @@ -951,7 +776,6 @@ describe('ClaudeCodeAdapter', () => { }); 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'; @@ -959,7 +783,6 @@ describe('ClaudeCodeAdapter', () => { }); 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'; @@ -967,7 +790,6 @@ describe('ClaudeCodeAdapter', () => { }); it('should filter noise messages', () => { - const adapter = new ClaudeCodeAdapter(); const extract = (adapter as any).extractUserMessageText.bind(adapter); expect(extract('[Request interrupted by user]')).toBeUndefined(); @@ -978,114 +800,184 @@ describe('ClaudeCodeAdapter', () => { 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, - }, - ]; + describe('file I/O methods', () => { + let tmpDir: string; - // In cwd mode, should defer (return undefined) because outside tolerance - const cwdResult = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd'); - expect(cwdResult).toBeUndefined(); + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('tryPidFileMatching', () => { + let sessionsDir: string; + let projectsDir: string; - // 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'); + 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; }); - 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 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; + }; - const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd'); - expect(result).toBeDefined(); - expect(result.sessionId).toBe('newer'); + 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 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, - }, - ]; + 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 result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd'); - expect(result).toBeDefined(); - expect(result.sessionId).toBe('fresh-exact-cwd'); + 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); }); - }); - describe('file I/O methods', () => { - let tmpDir: string; + 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'); - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-')); - }); + const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter); + const { direct, fallback } = tryMatch([proc]); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + 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 adapter = new ClaudeCodeAdapter(); const readSession = (adapter as any).readSession.bind(adapter); const filePath = path.join(tmpDir, 'test-session.jsonl'); @@ -1109,7 +1001,6 @@ describe('ClaudeCodeAdapter', () => { }); it('should detect user interruption', () => { - const adapter = new ClaudeCodeAdapter(); const readSession = (adapter as any).readSession.bind(adapter); const filePath = path.join(tmpDir, 'interrupted.jsonl'); @@ -1130,28 +1021,23 @@ describe('ClaudeCodeAdapter', () => { }); 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'); @@ -1164,12 +1050,10 @@ describe('ClaudeCodeAdapter', () => { 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'); @@ -1184,13 +1068,11 @@ describe('ClaudeCodeAdapter', () => { 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'); @@ -1203,12 +1085,10 @@ describe('ClaudeCodeAdapter', () => { 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'); @@ -1222,7 +1102,6 @@ describe('ClaudeCodeAdapter', () => { }); 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'); @@ -1237,131 +1116,5 @@ describe('ClaudeCodeAdapter', () => { 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/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts index f5086080..3491fd5d 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -1,325 +1,518 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +/** + * Tests for CodexAdapter + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { beforeEach, afterEach, describe, expect, it, jest } from '@jest/globals'; import { CodexAdapter } from '../../adapters/CodexAdapter'; import type { ProcessInfo } from '../../adapters/AgentAdapter'; import { AgentStatus } from '../../adapters/AgentAdapter'; -import { listProcesses } from '../../utils/process'; +import { listAgentProcesses, enrichProcesses } from '../../utils/process'; +import { batchGetSessionFileBirthtimes } from '../../utils/session'; +import type { SessionFile } from '../../utils/session'; +import { matchProcessesToSessions, generateAgentName } from '../../utils/matching'; +import type { MatchResult } from '../../utils/matching'; jest.mock('../../utils/process', () => ({ - listProcesses: jest.fn(), + listAgentProcesses: jest.fn(), + enrichProcesses: jest.fn(), +})); + +jest.mock('../../utils/session', () => ({ + batchGetSessionFileBirthtimes: jest.fn(), })); -const mockedListProcesses = listProcesses as jest.MockedFunction; +jest.mock('../../utils/matching', () => ({ + matchProcessesToSessions: jest.fn(), + generateAgentName: jest.fn(), +})); -interface MockSession { - sessionId: string; - projectPath: string; - summary: string; - sessionStart?: Date; - lastActive: Date; - lastPayloadType?: string; -} +const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction; +const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction; +const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as jest.MockedFunction; +const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction; +const mockedGenerateAgentName = generateAgentName as jest.MockedFunction; describe('CodexAdapter', () => { let adapter: CodexAdapter; beforeEach(() => { adapter = new CodexAdapter(); - mockedListProcesses.mockReset(); + mockedListAgentProcesses.mockReset(); + mockedEnrichProcesses.mockReset(); + mockedBatchGetSessionFileBirthtimes.mockReset(); + mockedMatchProcessesToSessions.mockReset(); + mockedGenerateAgentName.mockReset(); + // Default: enrichProcesses returns what it receives + mockedEnrichProcesses.mockImplementation((procs) => procs); + // Default: generateAgentName returns "folder (pid)" + mockedGenerateAgentName.mockImplementation((cwd, pid) => { + const folder = path.basename(cwd) || 'unknown'; + return `${folder} (${pid})`; + }); }); - it('should expose codex type', () => { - expect(adapter.type).toBe('codex'); + describe('initialization', () => { + it('should expose codex type', () => { + expect(adapter.type).toBe('codex'); + }); }); - it('should match codex commands in canHandle', () => { - expect( - adapter.canHandle({ - pid: 1, - command: 'codex', - cwd: '/repo', - tty: 'ttys001', - }), - ).toBe(true); + describe('canHandle', () => { + it('should return true for codex commands', () => { + expect(adapter.canHandle({ pid: 1, command: 'codex', cwd: '/repo', tty: 'ttys001' })).toBe(true); + }); - expect( - adapter.canHandle({ + it('should return true for codex with full path (case-insensitive)', () => { + expect(adapter.canHandle({ pid: 2, command: '/usr/local/bin/CODEX --sandbox workspace-write', cwd: '/repo', tty: 'ttys002', - }), - ).toBe(true); + })).toBe(true); + }); + + it('should return false for non-codex processes', () => { + expect(adapter.canHandle({ pid: 3, command: 'node app.js', cwd: '/repo', tty: 'ttys003' })).toBe(false); + }); - expect( - adapter.canHandle({ + it('should return false for processes with "codex" only in path arguments', () => { + expect(adapter.canHandle({ pid: 4, command: 'node /worktrees/feature-codex-adapter-agent-manager-package/node_modules/nx/src/daemon/server/start.js', cwd: '/repo', tty: 'ttys004', - }), - ).toBe(false); - - expect( - adapter.canHandle({ - pid: 3, - command: 'node app.js', - cwd: '/repo', - tty: 'ttys003', - }), - ).toBe(false); + })).toBe(false); + }); }); - it('should return empty list when no codex process is running', async () => { - mockedListProcesses.mockReturnValue([]); + describe('detectAgents', () => { + it('should return empty list when no codex process is running', async () => { + mockedListAgentProcesses.mockReturnValue([]); - const agents = await adapter.detectAgents(); - expect(agents).toEqual([]); - }); + const agents = await adapter.detectAgents(); + expect(agents).toEqual([]); + expect(mockedListAgentProcesses).toHaveBeenCalledWith('codex'); + }); - it('should map active codex sessions to matching processes by cwd', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001' }, - ] as ProcessInfo[]); + it('should return process-only agents when no sessions discovered', async () => { + const processes: ProcessInfo[] = [ + { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001' }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + // No sessions dir → discoverSessions returns [] + (adapter as any).codexSessionsDir = '/nonexistent/sessions'; + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'codex', + status: AgentStatus.RUNNING, + pid: 100, + projectPath: '/repo-a', + sessionId: 'pid-100', + summary: 'Codex process running', + }); + }); - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'abc12345-session', + it('should detect agents with matched sessions', async () => { + const processes: ProcessInfo[] = [ + { + pid: 100, + command: 'codex', + cwd: '/repo-a', + tty: 'ttys001', + startTime: new Date('2026-03-18T15:00:00.000Z'), + }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + // Set up sessions dir with date directory + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const dateDir = path.join(sessionsDir, '2026', '03', '18'); + fs.mkdirSync(dateDir, { recursive: true }); + + // Create session file with recent timestamps so status isn't idle + const now = new Date(); + const recentTs = now.toISOString(); + const sessionFile = path.join(dateDir, 'sess-abc.jsonl'); + fs.writeFileSync(sessionFile, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-abc', timestamp: recentTs, cwd: '/repo-a' } }), + JSON.stringify({ type: 'event', timestamp: recentTs, payload: { type: 'token_count', message: 'Implement adapter flow' } }), + ].join('\n')); + + (adapter as any).codexSessionsDir = sessionsDir; + + const sessionFiles: SessionFile[] = [ + { + sessionId: 'sess-abc', + filePath: sessionFile, + projectDir: dateDir, + birthtimeMs: new Date('2026-03-18T15:00:05Z').getTime(), + resolvedCwd: '', + }, + ]; + mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles); + + const matches: MatchResult[] = [ + { + process: processes[0], + session: { ...sessionFiles[0], resolvedCwd: '/repo-a' }, + deltaMs: 5000, + }, + ]; + mockedMatchProcessesToSessions.mockReturnValue(matches); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'codex', + status: AgentStatus.RUNNING, + pid: 100, projectPath: '/repo-a', + sessionId: 'sess-abc', summary: 'Implement adapter flow', - sessionStart: new Date('2026-02-26T15:00:00.000Z'), - lastActive: new Date(), - lastPayloadType: 'token_count', - } as MockSession, - ]); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0]).toMatchObject({ - name: 'repo-a', - type: 'codex', - status: AgentStatus.RUNNING, - summary: 'Implement adapter flow', - pid: 100, - projectPath: '/repo-a', - sessionId: 'abc12345-session', + }); + + fs.rmSync(tmpDir, { recursive: true, force: true }); }); - }); - it('should still map sessions with task_complete as waiting when process is running', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 101, command: 'codex', cwd: '/repo-b', tty: 'ttys001' }, - ] as ProcessInfo[]); - - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'ended-1111', - projectPath: '/repo-b', - summary: 'Ended turn but process still alive', - sessionStart: new Date('2026-02-26T15:00:00.000Z'), - lastActive: new Date(), - lastPayloadType: 'task_complete', - } as MockSession, - ]); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0].sessionId).toBe('ended-1111'); - expect(agents[0].status).toBe(AgentStatus.WAITING); + it('should fall back to process-only for unmatched processes', async () => { + const processes: ProcessInfo[] = [ + { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() }, + { pid: 200, command: 'codex', cwd: '/repo-b', tty: 'ttys002', startTime: new Date() }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const now = new Date(); + const dateDir = path.join( + sessionsDir, + String(now.getFullYear()), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + ); + fs.mkdirSync(dateDir, { recursive: true }); + + const sessionFile = path.join(dateDir, 'only-session.jsonl'); + fs.writeFileSync(sessionFile, + JSON.stringify({ type: 'session_meta', payload: { id: 'only-session', timestamp: now.toISOString(), cwd: '/repo-a' } }), + ); + + (adapter as any).codexSessionsDir = sessionsDir; + + const sessionFiles: SessionFile[] = [ + { + sessionId: 'only-session', + filePath: sessionFile, + projectDir: dateDir, + birthtimeMs: Date.now(), + resolvedCwd: '', + }, + ]; + mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles); + + // Only process 100 matches + const matches: MatchResult[] = [ + { + process: processes[0], + session: { ...sessionFiles[0], resolvedCwd: '/repo-a' }, + deltaMs: 5000, + }, + ]; + mockedMatchProcessesToSessions.mockReturnValue(matches); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(2); + + const matched = agents.find((a) => a.pid === 100); + const unmatched = agents.find((a) => a.pid === 200); + expect(matched?.sessionId).toBe('only-session'); + expect(unmatched?.sessionId).toBe('pid-200'); + expect(unmatched?.status).toBe(AgentStatus.RUNNING); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); }); - it('should use codex-session-id-prefix fallback name when cwd is missing', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 102, command: 'codex', cwd: '', tty: 'ttys009' }, - ] as ProcessInfo[]); - - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'abcdef123456', - projectPath: '', - summary: 'No cwd available', - sessionStart: new Date('2026-02-26T15:00:00.000Z'), - lastActive: new Date(), - lastPayloadType: 'agent_reasoning', - } as MockSession, - ]); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0].name).toBe('codex-abcdef12'); - }); + describe('discoverSessions', () => { + let tmpDir: string; - it('should report waiting status for recent agent_message events', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 103, command: 'codex', cwd: '/repo-c', tty: 'ttys010' }, - ] as ProcessInfo[]); - - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'waiting-1234', - projectPath: '/repo-c', - summary: 'Waiting', - sessionStart: new Date('2026-02-26T15:00:00.000Z'), - lastActive: new Date(), - lastPayloadType: 'agent_message', - } as MockSession, - ]); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0].status).toBe(AgentStatus.WAITING); - }); + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-')); + }); - it('should report idle status when session exceeds shared threshold', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 104, command: 'codex', cwd: '/repo-d', tty: 'ttys011' }, - ] as ProcessInfo[]); - - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'idle-5678', - projectPath: '/repo-d', - summary: 'Idle', - sessionStart: new Date('2026-02-26T15:00:00.000Z'), - lastActive: new Date(Date.now() - 10 * 60 * 1000), - lastPayloadType: 'token_count', - } as MockSession, - ]); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0].status).toBe(AgentStatus.IDLE); - }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return empty when sessions dir does not exist', () => { + (adapter as any).codexSessionsDir = path.join(tmpDir, 'nonexistent'); + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); - it('should list unmatched running codex process even when no session matches', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 105, command: 'codex', cwd: '/repo-x', tty: 'ttys012' }, - ] as ProcessInfo[]); - - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'other-session', - projectPath: '/repo-y', - summary: 'Other repo', - sessionStart: new Date('2026-02-26T15:00:00.000Z'), - lastActive: new Date(), - lastPayloadType: 'agent_message', - } as MockSession, - ]); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0]).toMatchObject({ - pid: 105, - name: 'repo-x', - summary: 'Codex process running', - projectPath: '/repo-x', + const { sessions } = discoverSessions([ + { pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date() }, + ]); + expect(sessions).toEqual([]); }); - expect(agents[0].sessionId).toBe('pid-105'); - }); - it('should list process when session metadata is unavailable', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 106, command: 'codex', cwd: '/repo-z', tty: 'ttys013' }, - ] as ProcessInfo[]); - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([]); + it('should scan date directories based on process start times', () => { + const sessionsDir = path.join(tmpDir, 'sessions'); + (adapter as any).codexSessionsDir = sessionsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); + + // Create date dir for 2026-03-18 + const dateDir = path.join(sessionsDir, '2026', '03', '18'); + fs.mkdirSync(dateDir, { recursive: true }); + + // Create session file with meta + const sessionFile = path.join(dateDir, 'sess1.jsonl'); + fs.writeFileSync(sessionFile, + JSON.stringify({ type: 'session_meta', payload: { id: 'sess1', cwd: '/repo-a' } }), + ); + + const mockFiles: SessionFile[] = [ + { + sessionId: 'sess1', + filePath: sessionFile, + projectDir: dateDir, + birthtimeMs: 1710800324000, + resolvedCwd: '', + }, + ]; + mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles); + + const processes = [ + { pid: 1, command: 'codex', cwd: '/repo-a', tty: '', startTime: new Date('2026-03-18T15:00:00Z') }, + ]; + + const { sessions, contentCache } = discoverSessions(processes); + expect(sessions).toHaveLength(1); + expect(sessions[0].resolvedCwd).toBe('/repo-a'); + expect(contentCache.has(sessionFile)).toBe(true); + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1); + }); - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0].pid).toBe(106); - expect(agents[0].summary).toContain('No Codex session metadata'); - }); + it('should scan ±1 day window around process start time', () => { + const sessionsDir = path.join(tmpDir, 'sessions'); + (adapter as any).codexSessionsDir = sessionsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); - it('should choose same-cwd session closest to process start time', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 107, command: 'codex', cwd: '/repo-time', tty: 'ttys014' }, - ] as ProcessInfo[]); - - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'far-session', - projectPath: '/repo-time', - summary: 'Far start time', - sessionStart: new Date('2026-02-26T14:00:00.000Z'), - lastActive: new Date('2026-02-26T15:10:00.000Z'), - lastPayloadType: 'agent_message', - } as MockSession, - { - sessionId: 'near-session', - projectPath: '/repo-time', - summary: 'Near start time', - sessionStart: new Date('2026-02-26T15:00:20.000Z'), - lastActive: new Date('2026-02-26T15:11:00.000Z'), - lastPayloadType: 'agent_message', - } as MockSession, - ]); - jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue( - new Map([[107, new Date('2026-02-26T15:00:00.000Z')]]), - ); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0].sessionId).toBe('near-session'); - }); + // Create date dirs for 17, 18, 19 + for (const day of ['17', '18', '19']) { + fs.mkdirSync(path.join(sessionsDir, '2026', '03', day), { recursive: true }); + } + + mockedBatchGetSessionFileBirthtimes.mockReturnValue([]); + + const processes = [ + { pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date('2026-03-18T15:00:00Z') }, + ]; - it('should prefer missing-cwd session before any-session fallback for unmatched process', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 108, command: 'codex', cwd: '/repo-missing-cwd', tty: 'ttys015' }, - ] as ProcessInfo[]); - - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'any-session', - projectPath: '/another-repo', - summary: 'Any session fallback', - sessionStart: new Date('2026-02-26T15:00:00.000Z'), - lastActive: new Date('2026-02-26T15:12:00.000Z'), - lastPayloadType: 'agent_message', - } as MockSession, - { - sessionId: 'missing-cwd-session', - projectPath: '', - summary: 'Missing cwd session', - sessionStart: new Date('2026-02-26T15:00:10.000Z'), - lastActive: new Date('2026-02-26T15:11:00.000Z'), - lastPayloadType: 'agent_message', - } as MockSession, - ]); - jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue( - new Map([[108, new Date('2026-02-26T15:00:00.000Z')]]), - ); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(1); - expect(agents[0].sessionId).toBe('missing-cwd-session'); + discoverSessions(processes); + expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1); + // Should scan all 3 date dirs + const dirs = mockedBatchGetSessionFileBirthtimes.mock.calls[0][0] as string[]; + expect(dirs).toHaveLength(3); + }); + + it('should handle session files without session_meta', () => { + const sessionsDir = path.join(tmpDir, 'sessions'); + (adapter as any).codexSessionsDir = sessionsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); + + const dateDir = path.join(sessionsDir, '2026', '03', '18'); + fs.mkdirSync(dateDir, { recursive: true }); + + const sessionFile = path.join(dateDir, 'bad.jsonl'); + fs.writeFileSync(sessionFile, JSON.stringify({ type: 'event', payload: {} })); + + mockedBatchGetSessionFileBirthtimes.mockReturnValue([ + { sessionId: 'bad', filePath: sessionFile, projectDir: dateDir, birthtimeMs: 1710800324000, resolvedCwd: '' }, + ]); + + const processes = [ + { pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date('2026-03-18T15:00:00Z') }, + ]; + + const { sessions } = discoverSessions(processes); + expect(sessions[0].resolvedCwd).toBe(''); + }); }); - it('should not reuse the same session for multiple running processes', async () => { - mockedListProcesses.mockReturnValue([ - { pid: 109, command: 'codex', cwd: '/repo-shared', tty: 'ttys016' }, - { pid: 110, command: 'codex', cwd: '/repo-shared', tty: 'ttys017' }, - ] as ProcessInfo[]); - - jest.spyOn(adapter as any, 'readSessions').mockReturnValue([ - { - sessionId: 'shared-session', - projectPath: '/repo-shared', - summary: 'Only one session exists', - sessionStart: new Date('2026-02-26T15:00:00.000Z'), - lastActive: new Date('2026-02-26T15:11:00.000Z'), - lastPayloadType: 'agent_message', - } as MockSession, - ]); - jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue( - new Map([ - [109, new Date('2026-02-26T15:00:00.000Z')], - [110, new Date('2026-02-26T15:00:30.000Z')], - ]), - ); - - const agents = await adapter.detectAgents(); - expect(agents).toHaveLength(2); - const mappedAgents = agents.filter((agent) => agent.sessionId === 'shared-session'); - expect(mappedAgents).toHaveLength(1); - expect(agents.some((agent) => agent.sessionId.startsWith('pid-'))).toBe(true); + describe('helper methods', () => { + describe('determineStatus', () => { + it('should return "waiting" for agent_message events', () => { + const determineStatus = (adapter as any).determineStatus.bind(adapter); + expect(determineStatus({ + lastActive: new Date(), + lastPayloadType: 'agent_message', + })).toBe(AgentStatus.WAITING); + }); + + it('should return "waiting" for task_complete events', () => { + const determineStatus = (adapter as any).determineStatus.bind(adapter); + expect(determineStatus({ + lastActive: new Date(), + lastPayloadType: 'task_complete', + })).toBe(AgentStatus.WAITING); + }); + + it('should return "waiting" for turn_aborted events', () => { + const determineStatus = (adapter as any).determineStatus.bind(adapter); + expect(determineStatus({ + lastActive: new Date(), + lastPayloadType: 'turn_aborted', + })).toBe(AgentStatus.WAITING); + }); + + it('should return "running" for active events', () => { + const determineStatus = (adapter as any).determineStatus.bind(adapter); + expect(determineStatus({ + lastActive: new Date(), + lastPayloadType: 'token_count', + })).toBe(AgentStatus.RUNNING); + }); + + it('should return "idle" when session exceeds threshold', () => { + const determineStatus = (adapter as any).determineStatus.bind(adapter); + expect(determineStatus({ + lastActive: new Date(Date.now() - 10 * 60 * 1000), + lastPayloadType: 'token_count', + })).toBe(AgentStatus.IDLE); + }); + }); + + describe('parseSession', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should parse session file with meta and events', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + const filePath = path.join(tmpDir, 'session.jsonl'); + fs.writeFileSync(filePath, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-1', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }), + JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_reasoning', message: 'Working on feature' } }), + ].join('\n')); + + const session = parseSession(undefined, filePath); + expect(session).toMatchObject({ + sessionId: 'sess-1', + projectPath: '/repo', + summary: 'Working on feature', + lastPayloadType: 'agent_reasoning', + }); + expect(session.sessionStart.toISOString()).toBe('2026-03-18T15:00:00.000Z'); + }); + + it('should parse from cached content without reading disk', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + const content = [ + JSON.stringify({ type: 'session_meta', payload: { id: 'cached-1', timestamp: '2026-03-18T15:00:00Z', cwd: '/cached' } }), + JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_message', message: 'Cached result' } }), + ].join('\n'); + + const session = parseSession(content, '/nonexistent/path.jsonl'); + expect(session).toMatchObject({ + sessionId: 'cached-1', + projectPath: '/cached', + summary: 'Cached result', + }); + }); + + it('should return null for non-existent file', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + expect(parseSession(undefined, path.join(tmpDir, 'nonexistent.jsonl'))).toBeNull(); + }); + + it('should return null when first line is not session_meta', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + const filePath = path.join(tmpDir, 'bad.jsonl'); + fs.writeFileSync(filePath, JSON.stringify({ type: 'event', payload: {} })); + expect(parseSession(undefined, filePath)).toBeNull(); + }); + + it('should return null when session_meta has no id', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + const filePath = path.join(tmpDir, 'no-id.jsonl'); + fs.writeFileSync(filePath, JSON.stringify({ type: 'session_meta', payload: { cwd: '/repo' } })); + expect(parseSession(undefined, filePath)).toBeNull(); + }); + + it('should extract summary from last event message', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + const filePath = path.join(tmpDir, 'summary.jsonl'); + fs.writeFileSync(filePath, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-2', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }), + JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_reasoning', message: 'First message' } }), + JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:02:00Z', payload: { type: 'agent_message', message: 'Last message' } }), + ].join('\n')); + + const session = parseSession(undefined, filePath); + expect(session.summary).toBe('Last message'); + }); + + it('should handle malformed JSON lines gracefully', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + const filePath = path.join(tmpDir, 'malformed.jsonl'); + fs.writeFileSync(filePath, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-m', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }), + 'not valid json', + '{"incomplete": true', + JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_message', message: 'Valid message' } }), + ].join('\n')); + + const session = parseSession(undefined, filePath); + expect(session).not.toBeNull(); + expect(session.sessionId).toBe('sess-m'); + expect(session.summary).toBe('Valid message'); + }); + + it('should default summary when no messages found', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + const filePath = path.join(tmpDir, 'no-msg.jsonl'); + fs.writeFileSync(filePath, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-3', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }), + JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'token_count' } }), + ].join('\n')); + + const session = parseSession(undefined, filePath); + expect(session.summary).toBe('Codex session active'); + }); + + it('should return null for empty content', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + expect(parseSession('', '/fake/path.jsonl')).toBeNull(); + expect(parseSession(' \n \n ', '/fake/path.jsonl')).toBeNull(); + }); + + it('should truncate long summary to 120 chars', () => { + const parseSession = (adapter as any).parseSession.bind(adapter); + const longMsg = 'A'.repeat(200); + const content = [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-t', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }), + JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_message', message: longMsg } }), + ].join('\n'); + + const session = parseSession(content, '/fake/path.jsonl'); + expect(session.summary).toHaveLength(120); + expect(session.summary.endsWith('...')).toBe(true); + }); + }); }); }); diff --git a/packages/agent-manager/src/__tests__/utils/matching.test.ts b/packages/agent-manager/src/__tests__/utils/matching.test.ts new file mode 100644 index 00000000..8c3b46e5 --- /dev/null +++ b/packages/agent-manager/src/__tests__/utils/matching.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for utils/matching.ts + */ + +import { describe, it, expect } from '@jest/globals'; +import { matchProcessesToSessions, generateAgentName } from '../../utils/matching'; +import type { ProcessInfo } from '../../adapters/AgentAdapter'; +import type { SessionFile } from '../../utils/session'; + +function makeProcess(overrides: Partial & { pid: number }): ProcessInfo { + return { + command: 'claude', + cwd: '/projects/my-app', + tty: 'ttys001', + startTime: new Date('2026-03-18T23:18:01.000Z'), + ...overrides, + }; +} + +function makeSession(overrides: Partial & { sessionId: string }): SessionFile { + return { + filePath: `/home/.claude/projects/my-app/${overrides.sessionId}.jsonl`, + projectDir: '/home/.claude/projects/my-app', + birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime(), + resolvedCwd: '/projects/my-app', + ...overrides, + }; +} + +describe('matchProcessesToSessions', () => { + it('should return empty array when no processes', () => { + const sessions = [makeSession({ sessionId: 's1' })]; + expect(matchProcessesToSessions([], sessions)).toEqual([]); + }); + + it('should return empty array when no sessions', () => { + const processes = [makeProcess({ pid: 100 })]; + expect(matchProcessesToSessions(processes, [])).toEqual([]); + }); + + it('should match a single process to closest session', () => { + const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') }); + const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() }); + const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:20:00.000Z').getTime() }); + + const results = matchProcessesToSessions([proc], [s1, s2]); + expect(results).toHaveLength(1); + expect(results[0].session.sessionId).toBe('s1'); + expect(results[0].deltaMs).toBe(43000); + }); + + it('should enforce 1:1 constraint — each process matches only one session', () => { + const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') }); + const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:18:30.000Z') }); + const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:10.000Z').getTime() }); + const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:18:35.000Z').getTime() }); + + const results = matchProcessesToSessions([p1, p2], [s1, s2]); + expect(results).toHaveLength(2); + + const pids = results.map(r => r.process.pid).sort(); + const sids = results.map(r => r.session.sessionId).sort(); + expect(pids).toEqual([100, 200]); + expect(sids).toEqual(['s1', 's2']); + }); + + it('should disambiguate multiple processes with same CWD by birthtime', () => { + const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') }); + const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-19T08:53:11.000Z') }); + const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() }); + const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-19T08:55:35.000Z').getTime() }); + + const results = matchProcessesToSessions([p1, p2], [s1, s2]); + expect(results).toHaveLength(2); + + const match1 = results.find(r => r.process.pid === 100); + const match2 = results.find(r => r.process.pid === 200); + expect(match1?.session.sessionId).toBe('s1'); + expect(match2?.session.sessionId).toBe('s2'); + }); + + it('should exclude processes without startTime', () => { + const proc = makeProcess({ pid: 100, startTime: undefined }); + const session = makeSession({ sessionId: 's1' }); + + const results = matchProcessesToSessions([proc], [session]); + expect(results).toEqual([]); + }); + + it('should exclude processes without cwd', () => { + const proc = makeProcess({ pid: 100, cwd: '' }); + const session = makeSession({ sessionId: 's1' }); + + const results = matchProcessesToSessions([proc], [session]); + expect(results).toEqual([]); + }); + + it('should not match when CWD does not match resolvedCwd', () => { + const proc = makeProcess({ pid: 100, cwd: '/projects/app-a' }); + const session = makeSession({ sessionId: 's1', resolvedCwd: '/projects/app-b' }); + + const results = matchProcessesToSessions([proc], [session]); + expect(results).toEqual([]); + }); + + it('should not match when delta exceeds 3-minute tolerance', () => { + const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') }); + const session = makeSession({ + sessionId: 's1', + birthtimeMs: new Date('2026-03-18T23:22:00.000Z').getTime(), // ~4 min later + }); + + const results = matchProcessesToSessions([proc], [session]); + expect(results).toEqual([]); + }); + + it('should match at exactly 3-minute boundary', () => { + const startTime = new Date('2026-03-18T23:18:00.000Z'); + const proc = makeProcess({ pid: 100, startTime }); + const session = makeSession({ + sessionId: 's1', + birthtimeMs: startTime.getTime() + 180_000, // exactly 3 min + }); + + const results = matchProcessesToSessions([proc], [session]); + expect(results).toHaveLength(1); + }); + + it('should handle more sessions than processes', () => { + const proc = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') }); + const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() }); + const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:19:00.000Z').getTime() }); + const s3 = makeSession({ sessionId: 's3', birthtimeMs: new Date('2026-03-18T23:19:30.000Z').getTime() }); + + const results = matchProcessesToSessions([proc], [s1, s2, s3]); + expect(results).toHaveLength(1); + expect(results[0].session.sessionId).toBe('s1'); // closest + }); + + it('should handle more processes than sessions', () => { + const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:01.000Z') }); + const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:20:01.000Z') }); + const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:44.000Z').getTime() }); + + const results = matchProcessesToSessions([p1, p2], [s1]); + expect(results).toHaveLength(1); + expect(results[0].process.pid).toBe(100); // closest + }); + + it('should skip sessions with empty resolvedCwd', () => { + const proc = makeProcess({ pid: 100 }); + const session = makeSession({ sessionId: 's1', resolvedCwd: '' }); + + const results = matchProcessesToSessions([proc], [session]); + expect(results).toEqual([]); + }); + + it('should prefer best match when greedy ordering matters', () => { + // p1 is 10s from s2, p2 is 5s from s2 — p2 should win s2, p1 gets s1 + const p1 = makeProcess({ pid: 100, startTime: new Date('2026-03-18T23:18:00.000Z') }); + const p2 = makeProcess({ pid: 200, startTime: new Date('2026-03-18T23:18:25.000Z') }); + const s1 = makeSession({ sessionId: 's1', birthtimeMs: new Date('2026-03-18T23:18:08.000Z').getTime() }); + const s2 = makeSession({ sessionId: 's2', birthtimeMs: new Date('2026-03-18T23:18:30.000Z').getTime() }); + + const results = matchProcessesToSessions([p1, p2], [s1, s2]); + expect(results).toHaveLength(2); + + const match1 = results.find(r => r.process.pid === 200); + const match2 = results.find(r => r.process.pid === 100); + expect(match1?.session.sessionId).toBe('s2'); // 5s delta + expect(match2?.session.sessionId).toBe('s1'); // 8s delta + }); +}); + +describe('generateAgentName', () => { + it('should return folderName (pid)', () => { + expect(generateAgentName('/projects/my-app', 12345)).toBe('my-app (12345)'); + }); + + it('should handle root path', () => { + expect(generateAgentName('/', 100)).toBe('unknown (100)'); + }); + + it('should handle empty cwd', () => { + expect(generateAgentName('', 100)).toBe('unknown (100)'); + }); + + it('should handle nested paths', () => { + expect(generateAgentName('/home/user/projects/ai-devkit', 78070)).toBe('ai-devkit (78070)'); + }); +}); diff --git a/packages/agent-manager/src/__tests__/utils/process.test.ts b/packages/agent-manager/src/__tests__/utils/process.test.ts new file mode 100644 index 00000000..12aa6cbc --- /dev/null +++ b/packages/agent-manager/src/__tests__/utils/process.test.ts @@ -0,0 +1,202 @@ +/** + * Tests for new functions in utils/process.ts + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { execSync } from 'child_process'; +import { + listAgentProcesses, + batchGetProcessCwds, + batchGetProcessStartTimes, + enrichProcesses, +} from '../../utils/process'; + +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); + +const mockedExecSync = execSync as jest.MockedFunction; + +describe('listAgentProcesses', () => { + beforeEach(() => { + mockedExecSync.mockReset(); + }); + + it('should parse ps aux | grep output and post-filter by executable name', () => { + mockedExecSync.mockReturnValue( + 'user 78070 1.0 0.5 485636016 245952 s018 S+ 11:18PM 1:55.14 claude\n' + + 'user 55106 0.1 0.4 485620368 72496 s015 S+ 9Mar26 8:06.36 claude\n', + ); + + const processes = listAgentProcesses('claude'); + expect(processes).toHaveLength(2); + expect(processes[0].pid).toBe(78070); + expect(processes[0].command).toBe('claude'); + expect(processes[0].tty).toBe('s018'); + expect(processes[0].cwd).toBe(''); // not populated yet + expect(processes[1].pid).toBe(55106); + }); + + it('should filter out non-matching executables', () => { + mockedExecSync.mockReturnValue( + 'user 100 0.0 0.0 0 0 s001 S 1:00PM 0:00 claude\n' + + 'user 200 0.0 0.0 0 0 s002 S 1:00PM 0:00 claude-helper --pid 100\n' + + 'user 300 0.0 0.0 0 0 s003 S 1:00PM 0:00 /usr/bin/claude\n', + ); + + const processes = listAgentProcesses('claude'); + expect(processes).toHaveLength(2); + expect(processes.map(p => p.pid)).toEqual([100, 300]); + }); + + it('should return empty array on command failure', () => { + mockedExecSync.mockImplementation(() => { throw new Error('fail'); }); + expect(listAgentProcesses('claude')).toEqual([]); + }); + + it('should handle empty output', () => { + mockedExecSync.mockReturnValue(''); + expect(listAgentProcesses('claude')).toEqual([]); + }); + + it('should reject empty pattern', () => { + expect(listAgentProcesses('')).toEqual([]); + expect(mockedExecSync).not.toHaveBeenCalled(); + }); + + it('should reject patterns with shell injection characters', () => { + expect(listAgentProcesses('claude; rm -rf /')).toEqual([]); + expect(listAgentProcesses("claude' || true")).toEqual([]); + expect(listAgentProcesses('$(whoami)')).toEqual([]); + expect(mockedExecSync).not.toHaveBeenCalled(); + }); + + it('should accept valid patterns with dashes and underscores', () => { + mockedExecSync.mockReturnValue(''); + listAgentProcesses('claude-code'); + expect(mockedExecSync).toHaveBeenCalled(); + + mockedExecSync.mockReset(); + mockedExecSync.mockReturnValue(''); + listAgentProcesses('my_agent'); + expect(mockedExecSync).toHaveBeenCalled(); + }); +}); + +describe('batchGetProcessCwds', () => { + beforeEach(() => { + mockedExecSync.mockReset(); + }); + + it('should parse batched lsof output', () => { + mockedExecSync.mockReturnValue( + 'p78070\nn/Users/user/ai-devkit\np55106\nn/Users/user/other-project\n', + ); + + const cwds = batchGetProcessCwds([78070, 55106]); + expect(cwds.get(78070)).toBe('/Users/user/ai-devkit'); + expect(cwds.get(55106)).toBe('/Users/user/other-project'); + }); + + it('should return empty map for empty pids', () => { + expect(batchGetProcessCwds([])).toEqual(new Map()); + }); + + it('should return partial results when lsof succeeds for some PIDs', () => { + // lsof might not return entries for dead processes + mockedExecSync.mockReturnValue( + 'p78070\nn/Users/user/ai-devkit\n', + ); + + const cwds = batchGetProcessCwds([78070, 99999]); + expect(cwds.size).toBe(1); + expect(cwds.get(78070)).toBe('/Users/user/ai-devkit'); + }); + + it('should return empty map on total failure', () => { + mockedExecSync.mockImplementation(() => { throw new Error('fail'); }); + const cwds = batchGetProcessCwds([78070]); + // Falls through to pwdx fallback which also fails + expect(cwds.size).toBe(0); + }); +}); + +describe('batchGetProcessStartTimes', () => { + beforeEach(() => { + mockedExecSync.mockReset(); + }); + + it('should parse ps lstart output', () => { + mockedExecSync.mockReturnValue( + ' 78070 Wed Mar 18 23:18:01 2026\n' + + ' 55106 Mon Mar 9 21:41:42 2026\n', + ); + + const times = batchGetProcessStartTimes([78070, 55106]); + expect(times.size).toBe(2); + expect(times.get(78070)?.getFullYear()).toBe(2026); + expect(times.get(55106)?.getMonth()).toBe(2); // March = 2 + }); + + it('should return empty map for empty pids', () => { + expect(batchGetProcessStartTimes([])).toEqual(new Map()); + }); + + it('should skip lines with unparseable dates', () => { + mockedExecSync.mockReturnValue( + ' 78070 Wed Mar 18 23:18:01 2026\n' + + ' 99999 INVALID_DATE\n', + ); + + const times = batchGetProcessStartTimes([78070, 99999]); + expect(times.size).toBe(1); + expect(times.has(78070)).toBe(true); + }); + + it('should return empty map on failure', () => { + mockedExecSync.mockImplementation(() => { throw new Error('fail'); }); + expect(batchGetProcessStartTimes([78070])).toEqual(new Map()); + }); +}); + +describe('enrichProcesses', () => { + beforeEach(() => { + mockedExecSync.mockReset(); + }); + + it('should populate cwd and startTime on processes', () => { + // First call: batchGetProcessCwds (lsof) + // Second call: batchGetProcessStartTimes (ps lstart) + mockedExecSync + .mockReturnValueOnce('p100\nn/projects/app\n') + .mockReturnValueOnce(' 100 Wed Mar 18 23:18:01 2026\n'); + + const processes = [ + { pid: 100, command: 'claude', cwd: '', tty: 's001' }, + ]; + + const enriched = enrichProcesses(processes); + expect(enriched[0].cwd).toBe('/projects/app'); + expect(enriched[0].startTime).toBeDefined(); + }); + + it('should return empty array for empty input', () => { + expect(enrichProcesses([])).toEqual([]); + expect(mockedExecSync).not.toHaveBeenCalled(); + }); + + it('should handle partial failures', () => { + // lsof succeeds, ps lstart fails + mockedExecSync + .mockReturnValueOnce('p100\nn/projects/app\n') + .mockImplementationOnce(() => { throw new Error('fail'); }); + + const processes = [ + { pid: 100, command: 'claude', cwd: '', tty: 's001' }, + ]; + + const enriched = enrichProcesses(processes); + expect(enriched[0].cwd).toBe('/projects/app'); + expect(enriched[0].startTime).toBeUndefined(); + }); +}); diff --git a/packages/agent-manager/src/__tests__/utils/session.test.ts b/packages/agent-manager/src/__tests__/utils/session.test.ts new file mode 100644 index 00000000..c2773bc1 --- /dev/null +++ b/packages/agent-manager/src/__tests__/utils/session.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for utils/session.ts + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { execSync } from 'child_process'; +import { batchGetSessionFileBirthtimes } from '../../utils/session'; + +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); + +const mockedExecSync = execSync as jest.MockedFunction; + +describe('batchGetSessionFileBirthtimes', () => { + beforeEach(() => { + mockedExecSync.mockReset(); + }); + + it('should parse stat output correctly', () => { + mockedExecSync.mockReturnValue( + '1710800324 /home/.claude/projects/my-app/abc123.jsonl\n' + + '1710800500 /home/.claude/projects/my-app/def456.jsonl\n', + ); + + const results = batchGetSessionFileBirthtimes(['/home/.claude/projects/my-app']); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + sessionId: 'abc123', + filePath: '/home/.claude/projects/my-app/abc123.jsonl', + projectDir: '/home/.claude/projects/my-app', + birthtimeMs: 1710800324000, + resolvedCwd: '', + }); + expect(results[1].sessionId).toBe('def456'); + expect(results[1].birthtimeMs).toBe(1710800500000); + }); + + it('should return empty array for empty dirs list', () => { + expect(batchGetSessionFileBirthtimes([])).toEqual([]); + expect(mockedExecSync).not.toHaveBeenCalled(); + }); + + it('should return empty array on command failure', () => { + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + expect(batchGetSessionFileBirthtimes(['/some/dir'])).toEqual([]); + }); + + it('should skip lines with invalid epoch (0 or negative)', () => { + mockedExecSync.mockReturnValue( + '0 /dir/bad.jsonl\n' + + '-1 /dir/negative.jsonl\n' + + '1710800324 /dir/good.jsonl\n', + ); + + const results = batchGetSessionFileBirthtimes(['/dir']); + expect(results).toHaveLength(1); + expect(results[0].sessionId).toBe('good'); + }); + + it('should skip non-jsonl files in output', () => { + mockedExecSync.mockReturnValue( + '1710800324 /dir/sessions-index.json\n' + + '1710800500 /dir/abc123.jsonl\n', + ); + + const results = batchGetSessionFileBirthtimes(['/dir']); + expect(results).toHaveLength(1); + expect(results[0].sessionId).toBe('abc123'); + }); + + it('should handle empty output', () => { + mockedExecSync.mockReturnValue(''); + expect(batchGetSessionFileBirthtimes(['/dir'])).toEqual([]); + }); + + it('should handle UUID session IDs', () => { + mockedExecSync.mockReturnValue( + '1710800324 /dir/068e7b1f-cff5-4c94-bf69-b9acd32d765c.jsonl\n', + ); + + const results = batchGetSessionFileBirthtimes(['/dir']); + expect(results).toHaveLength(1); + expect(results[0].sessionId).toBe('068e7b1f-cff5-4c94-bf69-b9acd32d765c'); + }); + + it('should leave resolvedCwd empty', () => { + mockedExecSync.mockReturnValue('1710800324 /dir/abc.jsonl\n'); + + const results = batchGetSessionFileBirthtimes(['/dir']); + expect(results[0].resolvedCwd).toBe(''); + }); + + it('should combine multiple directories into a single stat call', () => { + mockedExecSync.mockReturnValue( + '1710800324 /projects/app-a/sess1.jsonl\n' + + '1710800400 /projects/app-b/sess2.jsonl\n' + + '1710800500 /projects/app-a/sess3.jsonl\n', + ); + + const results = batchGetSessionFileBirthtimes(['/projects/app-a', '/projects/app-b']); + + expect(mockedExecSync).toHaveBeenCalledTimes(1); + const cmd = mockedExecSync.mock.calls[0][0] as string; + expect(cmd).toContain('"/projects/app-a"/*.jsonl'); + expect(cmd).toContain('"/projects/app-b"/*.jsonl'); + + expect(results).toHaveLength(3); + expect(results[0].projectDir).toBe('/projects/app-a'); + expect(results[1].projectDir).toBe('/projects/app-b'); + expect(results[2].projectDir).toBe('/projects/app-a'); + }); +}); diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index 31310180..be6d5547 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -68,6 +68,9 @@ export interface ProcessInfo { /** Terminal TTY (e.g., "ttys030") */ tty: string; + + /** Process start time, populated by enrichProcesses */ + startTime?: Date; } /** diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index adf476b8..5baa8f17 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -1,18 +1,11 @@ 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 { readJson } from '../utils/file'; - -/** - * Structure of ~/.claude/projects/{path}/sessions-index.json - */ -interface SessionsIndex { - originalPath: string; -} - +import { listAgentProcesses, enrichProcesses } from '../utils/process'; +import { batchGetSessionFileBirthtimes } from '../utils/session'; +import type { SessionFile } from '../utils/session'; +import { matchProcessesToSessions, generateAgentName } from '../utils/matching'; /** * Entry in session JSONL file */ @@ -30,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 */ @@ -45,33 +58,26 @@ interface ClaudeSession { lastUserMessage?: string; } -type SessionMatchMode = 'cwd' | 'missing-cwd' | 'parent-child'; - /** * Claude Code Adapter * * Detects Claude Code agents by: - * 1. Finding running claude processes - * 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 + * 1. Finding running claude processes via shared listAgentProcesses() + * 2. Enriching with CWD and start times via shared enrichProcesses() + * 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; - /** 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 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'); } /** @@ -91,398 +97,202 @@ export class ClaudeCodeAdapter implements AgentAdapter { * Detect running Claude Code agents */ async detectAgents(): Promise { - const claudeProcesses = this.listClaudeProcesses(); - if (claudeProcesses.length === 0) { + const processes = enrichProcesses(listAgentProcesses('claude')); + if (processes.length === 0) { return []; } - const processStartByPid = this.getProcessStartTimes( - claudeProcesses.map((p) => p.pid), - ); - const sessionScanLimit = this.calculateSessionScanLimit(claudeProcesses.length); - const sessions = this.readSessions(sessionScanLimit); + // Step 1: try authoritative PID-file matching for every process + const { direct, fallback } = this.tryPidFileMatching(processes); - if (sessions.length === 0) { - return claudeProcesses.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 sortedSessions = [...sessions].sort( - (a, b) => b.lastActive.getTime() - a.lastActive.getTime(), - ); - const usedSessionIds = new Set(); - const assignedPids = new Set(); const agents: AgentInfo[] = []; - const modes: SessionMatchMode[] = ['cwd', 'missing-cwd', 'parent-child']; - for (const mode of modes) { - this.assignSessionsForMode( - mode, - claudeProcesses, - sortedSessions, - usedSessionIds, - assignedPids, - processStartByPid, - agents, - ); + // 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); + } } - for (const processInfo of claudeProcesses) { - if (assignedPids.has(processInfo.pid)) { - continue; + // Build agents from legacy matches + for (const match of legacyMatches) { + const sessionData = this.readSession( + match.session.filePath, + match.session.resolvedCwd, + ); + if (sessionData) { + agents.push(this.mapSessionToAgent(sessionData, match.process, match.session)); + } else { + matchedPids.delete(match.process.pid); } + } - assignedPids.add(processInfo.pid); - agents.push(this.mapProcessOnlyAgent(processInfo, agents)); + // Any process with no match (direct or legacy) appears as IDLE + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapProcessOnlyAgent(proc)); + } } return agents; } - private listClaudeProcesses(): ProcessInfo[] { - return listProcesses({ namePattern: 'claude' }).filter((p) => - this.canHandle(p), - ); - } + /** + * Discover session files for the given processes. + * + * For each unique process CWD, encodes it to derive the expected + * ~/.claude/projects// directory, then gets session file birthtimes + * via a single batched stat call across all directories. + */ + private discoverSessions(processes: ProcessInfo[]): SessionFile[] { + // Collect valid project dirs and map them back to their CWD + const dirToCwd = new Map(); - private calculateSessionScanLimit(processCount: number): number { - return Math.min( - Math.max( - processCount * ClaudeCodeAdapter.SESSION_SCAN_MULTIPLIER, - ClaudeCodeAdapter.MIN_SESSION_SCAN, - ), - ClaudeCodeAdapter.MAX_SESSION_SCAN, - ); - } + for (const proc of processes) { + if (!proc.cwd) continue; - private assignSessionsForMode( - mode: SessionMatchMode, - claudeProcesses: ProcessInfo[], - sessions: ClaudeSession[], - usedSessionIds: Set, - assignedPids: Set, - processStartByPid: Map, - agents: AgentInfo[], - ): void { - for (const processInfo of claudeProcesses) { - if (assignedPids.has(processInfo.pid)) { - continue; - } + const projectDir = this.getProjectDir(proc.cwd); + if (dirToCwd.has(projectDir)) continue; - const session = this.selectBestSession( - processInfo, - sessions, - usedSessionIds, - processStartByPid, - mode, - ); - if (!session) { + try { + if (!fs.statSync(projectDir).isDirectory()) continue; + } catch { continue; } - usedSessionIds.add(session.sessionId); - assignedPids.add(processInfo.pid); - agents.push(this.mapSessionToAgent(session, processInfo, agents)); + dirToCwd.set(projectDir, proc.cwd); + } + + if (dirToCwd.size === 0) return []; + + // Single batched stat call across all directories + const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]); + + // Set resolvedCwd based on which project dir the file belongs to + for (const file of files) { + file.resolvedCwd = dirToCwd.get(file.projectDir) || ''; + } + + 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. + * + * Claude Code encodes paths by replacing '/' with '-': + * /Users/foo/bar → ~/.claude/projects/-Users-foo-bar/ + */ + private getProjectDir(cwd: string): string { + const encoded = cwd.replace(/\//g, '-'); + return path.join(this.projectsDir, encoded); } private mapSessionToAgent( session: ClaudeSession, processInfo: ProcessInfo, - existingAgents: AgentInfo[], + sessionFile: SessionFile, ): AgentInfo { return { - name: this.generateAgentName(session, existingAgents), + name: generateAgentName(processInfo.cwd, processInfo.pid), type: this.type, status: this.determineStatus(session), summary: session.lastUserMessage || 'Session started', pid: processInfo.pid, - projectPath: session.projectPath || processInfo.cwd || '', - sessionId: session.sessionId, + projectPath: sessionFile.resolvedCwd || processInfo.cwd || '', + sessionId: sessionFile.sessionId, slug: session.slug, lastActive: session.lastActive, }; } - private mapProcessOnlyAgent( - processInfo: ProcessInfo, - existingAgents: AgentInfo[], - ): AgentInfo { - const processCwd = processInfo.cwd || ''; - const projectName = path.basename(processCwd) || 'claude'; - const hasDuplicate = existingAgents.some((a) => a.projectPath === processCwd); - + private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { return { - name: hasDuplicate ? `${projectName} (pid-${processInfo.pid})` : projectName, + name: generateAgentName(processInfo.cwd || '', processInfo.pid), type: this.type, status: AgentStatus.IDLE, summary: 'Unknown', pid: processInfo.pid, - projectPath: processCwd, + projectPath: processInfo.cwd || '', sessionId: `pid-${processInfo.pid}`, lastActive: new Date(), }; } - private selectBestSession( - processInfo: ProcessInfo, - sessions: ClaudeSession[], - usedSessionIds: Set, - processStartByPid: Map, - mode: SessionMatchMode, - ): ClaudeSession | undefined { - const candidates = this.filterCandidateSessions( - processInfo, - sessions, - usedSessionIds, - mode, - ); - - if (candidates.length === 0) { - return undefined; - } - - const processStart = processStartByPid.get(processInfo.pid); - if (!processStart) { - return candidates.sort( - (a, b) => b.lastActive.getTime() - a.lastActive.getTime(), - )[0]; - } - - const best = this.rankCandidatesByStartTime(candidates, processStart)[0]; - if (!best) { - return undefined; - } - - // In early modes (cwd/missing-cwd), defer assignment when the best - // candidate is outside start-time tolerance — a closer match may - // exist in parent-child mode (e.g., worktree sessions). - if (mode !== 'parent-child') { - const diffMs = Math.abs( - best.sessionStart.getTime() - processStart.getTime(), - ); - if (diffMs > ClaudeCodeAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS) { - return undefined; - } - } - - return best; - } - - private filterCandidateSessions( - processInfo: ProcessInfo, - sessions: ClaudeSession[], - usedSessionIds: Set, - mode: SessionMatchMode, - ): ClaudeSession[] { - return sessions.filter((session) => { - if (usedSessionIds.has(session.sessionId)) { - return false; - } - - if (mode === 'cwd') { - return ( - this.pathEquals(processInfo.cwd, session.projectPath) || - this.pathEquals(processInfo.cwd, session.lastCwd) - ); - } - - if (mode === 'missing-cwd') { - return !session.projectPath; - } - - // parent-child mode: match if process CWD 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(); - } - - 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 Claude Code sessions with bounded scanning - */ - private readSessions(limit: number): ClaudeSession[] { - const sessionFiles = this.findSessionFiles(limit); - const sessions: ClaudeSession[] = []; - - for (const file of sessionFiles) { - try { - const session = this.readSession(file.filePath, file.projectPath); - if (session) { - sessions.push(session); - } - } catch (error) { - console.error(`Failed to parse Claude session ${file.filePath}:`, error); - } - } - - 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 files: Array<{ - filePath: string; - projectPath: string; - mtimeMs: number; - }> = []; - - for (const dirName of fs.readdirSync(this.projectsDir)) { - if (dirName.startsWith('.')) { - continue; - } - - const projectDir = path.join(this.projectsDir, dirName); - try { - if (!fs.statSync(projectDir).isDirectory()) continue; - } catch { - continue; - } - - const indexPath = path.join(projectDir, 'sessions-index.json'); - const index = readJson(indexPath); - const projectPath = index?.originalPath || ''; - - for (const entry of fs.readdirSync(projectDir)) { - if (!entry.endsWith('.jsonl')) { - continue; - } - - const filePath = path.join(projectDir, entry); - try { - files.push({ - filePath, - projectPath, - mtimeMs: fs.statSync(filePath).mtimeMs, - }); - } catch { - continue; - } - } - } - - // 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); - } - /** * Parse a single session file into ClaudeSession */ @@ -627,57 +437,6 @@ export class ClaudeCodeAdapter implements AgentAdapter { return AgentStatus.UNKNOWN; } - /** - * Generate unique agent name - * Uses project basename, appends slug if multiple sessions for same project - */ - private generateAgentName( - session: ClaudeSession, - existingAgents: AgentInfo[], - ): string { - const projectName = path.basename(session.projectPath) || 'claude'; - - const sameProjectAgents = existingAgents.filter( - (a) => a.projectPath === session.projectPath, - ); - - if (sameProjectAgents.length === 0) { - return projectName; - } - - if (session.slug) { - const slugPart = session.slug.includes('-') - ? session.slug.split('-')[0] - : session.slug.slice(0, 8); - return `${projectName} (${slugPart})`; - } - - 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; - } - - return this.normalizePath(a) === this.normalizePath(b); - } - - private isChildPath(child?: string, parent?: string): boolean { - if (!child || !parent) { - return false; - } - - const normalizedChild = this.normalizePath(child); - const normalizedParent = this.normalizePath(parent); - return normalizedChild.startsWith(`${normalizedParent}${path.sep}`); - } - /** * Extract meaningful text from a user message content. * Handles string and array formats, skill command expansion, and noise filtering. @@ -758,11 +517,4 @@ export class ClaudeCodeAdapter implements AgentAdapter { 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; - } } diff --git a/packages/agent-manager/src/adapters/CodexAdapter.ts b/packages/agent-manager/src/adapters/CodexAdapter.ts index eda1bba3..44c3696d 100644 --- a/packages/agent-manager/src/adapters/CodexAdapter.ts +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -1,29 +1,23 @@ /** * Codex Adapter * - * Detects running Codex agents by combining: - * 1. Running `codex` processes - * 2. Session metadata under ~/.codex/sessions + * Detects running Codex agents by: + * 1. Finding running codex processes via shared listAgentProcesses() + * 2. Enriching with CWD and start times via shared enrichProcesses() + * 3. Discovering session files from ~/.codex/sessions/YYYY/MM/DD/ via shared batchGetSessionFileBirthtimes() + * 4. Setting resolvedCwd from session_meta first line + * 5. Matching sessions to processes via shared matchProcessesToSessions() + * 6. Extracting summary from last event entry in session JSONL */ import * as fs from 'fs'; import * as path from 'path'; -import { execSync } from 'child_process'; import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter'; import { AgentStatus } from './AgentAdapter'; -import { listProcesses } from '../utils/process'; -import { readJsonLines } from '../utils/file'; - -interface CodexSessionMetaPayload { - id?: string; - timestamp?: string; - cwd?: string; -} - -interface CodexSessionMetaEntry { - type?: string; - payload?: CodexSessionMetaPayload; -} +import { listAgentProcesses, enrichProcesses } from '../utils/process'; +import { batchGetSessionFileBirthtimes } from '../utils/session'; +import type { SessionFile } from '../utils/session'; +import { matchProcessesToSessions, generateAgentName } from '../utils/matching'; interface CodexEventEntry { timestamp?: string; @@ -31,6 +25,9 @@ interface CodexEventEntry { payload?: { type?: string; message?: string; + id?: string; + cwd?: string; + timestamp?: string; }; } @@ -43,21 +40,12 @@ interface CodexSession { lastPayloadType?: string; } -type SessionMatchMode = 'cwd' | 'missing-cwd'; - export class CodexAdapter implements AgentAdapter { readonly type = 'codex' as const; - /** Keep status thresholds aligned across adapters. */ private static readonly IDLE_THRESHOLD_MINUTES = 5; - /** Limit session parsing per run to keep list latency bounded. */ - private static readonly MIN_SESSION_SCAN = 12; - private static readonly MAX_SESSION_SCAN = 40; - private static readonly SESSION_SCAN_MULTIPLIER = 4; - /** Also include session files around process start day to recover long-lived processes. */ + /** Include session files around process start day to recover long-lived processes. */ private static readonly PROCESS_START_DAY_WINDOW_DAYS = 1; - /** Matching tolerance between process start time and session start time. */ - private static readonly PROCESS_SESSION_TIME_TOLERANCE_MS = 2 * 60 * 1000; private codexSessionsDir: string; @@ -70,264 +58,113 @@ export class CodexAdapter implements AgentAdapter { return this.isCodexExecutable(processInfo.command); } + /** + * Detect running Codex agents + */ async detectAgents(): Promise { - const codexProcesses = this.listCodexProcesses(); - - if (codexProcesses.length === 0) { - return []; - } + const processes = enrichProcesses(listAgentProcesses('codex')); + if (processes.length === 0) return []; - const processStartByPid = this.getProcessStartTimes(codexProcesses.map((processInfo) => processInfo.pid)); - - const sessionScanLimit = this.calculateSessionScanLimit(codexProcesses.length); - const sessions = this.readSessions(sessionScanLimit, processStartByPid); + const { sessions, contentCache } = this.discoverSessions(processes); if (sessions.length === 0) { - return codexProcesses.map((processInfo) => - this.mapProcessOnlyAgent(processInfo, [], 'No Codex session metadata found'), - ); + return processes.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 matches = matchProcessesToSessions(processes, sessions); + const matchedPids = new Set(matches.map((m) => m.process.pid)); const agents: AgentInfo[] = []; - // Match exact cwd first, then missing-cwd sessions. - this.assignSessionsForMode( - 'cwd', - codexProcesses, - sortedSessions, - usedSessionIds, - assignedPids, - processStartByPid, - agents, - ); - this.assignSessionsForMode( - 'missing-cwd', - codexProcesses, - sortedSessions, - usedSessionIds, - assignedPids, - processStartByPid, - agents, - ); - - // Every running codex process should still be listed. - for (const processInfo of codexProcesses) { - if (assignedPids.has(processInfo.pid)) { - continue; + for (const match of matches) { + const cachedContent = contentCache.get(match.session.filePath); + const sessionData = this.parseSession(cachedContent, match.session.filePath); + if (sessionData) { + agents.push(this.mapSessionToAgent(sessionData, match.process)); + } else { + matchedPids.delete(match.process.pid); } - - this.addProcessOnlyAgent(processInfo, assignedPids, agents); } - return agents; - } - - private listCodexProcesses(): ProcessInfo[] { - return listProcesses({ namePattern: 'codex' }).filter((processInfo) => - this.canHandle(processInfo), - ); - } - - private calculateSessionScanLimit(processCount: number): number { - return Math.min( - Math.max( - processCount * CodexAdapter.SESSION_SCAN_MULTIPLIER, - CodexAdapter.MIN_SESSION_SCAN, - ), - CodexAdapter.MAX_SESSION_SCAN, - ); - } - - private assignSessionsForMode( - mode: SessionMatchMode, - codexProcesses: ProcessInfo[], - sessions: CodexSession[], - usedSessionIds: Set, - assignedPids: Set, - processStartByPid: Map, - agents: AgentInfo[], - ): void { - for (const processInfo of codexProcesses) { - if (assignedPids.has(processInfo.pid)) { - continue; - } - - const session = this.selectBestSession( - processInfo, - sessions, - usedSessionIds, - processStartByPid, - mode, - ); - if (!session) { - continue; + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapProcessOnlyAgent(proc)); } - - this.addMappedSessionAgent(session, processInfo, usedSessionIds, assignedPids, agents); } - } - - private addMappedSessionAgent( - session: CodexSession, - processInfo: ProcessInfo, - usedSessionIds: Set, - assignedPids: Set, - agents: AgentInfo[], - ): void { - usedSessionIds.add(session.sessionId); - assignedPids.add(processInfo.pid); - agents.push(this.mapSessionToAgent(session, processInfo, agents)); - } - - private addProcessOnlyAgent( - processInfo: ProcessInfo, - assignedPids: Set, - agents: AgentInfo[], - ): void { - assignedPids.add(processInfo.pid); - agents.push(this.mapProcessOnlyAgent(processInfo, agents)); - } - - private mapSessionToAgent( - session: CodexSession, - processInfo: ProcessInfo, - existingAgents: AgentInfo[], - ): AgentInfo { - return { - name: this.generateAgentName(session, existingAgents), - type: this.type, - status: this.determineStatus(session), - summary: session.summary || 'Codex session active', - pid: processInfo.pid, - projectPath: session.projectPath || processInfo.cwd || '', - sessionId: session.sessionId, - lastActive: session.lastActive, - }; - } - - private mapProcessOnlyAgent( - processInfo: ProcessInfo, - existingAgents: AgentInfo[], - summary: string = 'Codex process running', - ): AgentInfo { - const syntheticSession: CodexSession = { - sessionId: `pid-${processInfo.pid}`, - projectPath: processInfo.cwd || '', - summary, - sessionStart: new Date(), - lastActive: new Date(), - lastPayloadType: 'process_only', - }; - return { - name: this.generateAgentName(syntheticSession, existingAgents), - type: this.type, - status: AgentStatus.RUNNING, - summary, - pid: processInfo.pid, - projectPath: processInfo.cwd || '', - sessionId: syntheticSession.sessionId, - lastActive: syntheticSession.lastActive, - }; + return agents; } - private readSessions(limit: number, processStartByPid: Map): CodexSession[] { - const sessionFiles = this.findSessionFiles(limit, processStartByPid); - const sessions: CodexSession[] = []; - - for (const sessionFile of sessionFiles) { + /** + * Discover session files for the given processes. + * + * Uses process start times to determine which YYYY/MM/DD date directories + * to scan (±1 day window), then batches stat calls across all directories. + * Reads each file once and caches content for later parsing by parseSession(). + * Sets resolvedCwd from session_meta first line. + */ + private discoverSessions(processes: ProcessInfo[]): { + sessions: SessionFile[]; + contentCache: Map; + } { + const empty = { sessions: [], contentCache: new Map() }; + if (!fs.existsSync(this.codexSessionsDir)) return empty; + + const dateDirs = this.getDateDirs(processes); + if (dateDirs.length === 0) return empty; + + const files = batchGetSessionFileBirthtimes(dateDirs); + const contentCache = new Map(); + + // Read each file once: extract CWD for matching, cache content for later parsing + for (const file of files) { try { - const session = this.readSession(sessionFile); - if (session) { - sessions.push(session); - } - } catch (error) { - console.error(`Failed to parse Codex session ${sessionFile}:`, error); - } - } - - return sessions; - } - - private findSessionFiles(limit: number, processStartByPid: Map): string[] { - if (!fs.existsSync(this.codexSessionsDir)) { - return []; - } - - const files: Array<{ path: string; mtimeMs: number }> = []; - const stack: string[] = [this.codexSessionsDir]; - - while (stack.length > 0) { - const currentDir = stack.pop(); - if (!currentDir || !fs.existsSync(currentDir)) { - continue; - } - - for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - stack.push(fullPath); - continue; - } - - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - try { - files.push({ - path: fullPath, - mtimeMs: fs.statSync(fullPath).mtimeMs, - }); - } catch { - continue; + const content = fs.readFileSync(file.filePath, 'utf-8'); + contentCache.set(file.filePath, content); + + const firstLine = content.split('\n')[0]?.trim(); + if (firstLine) { + const parsed = JSON.parse(firstLine); + if (parsed.type === 'session_meta') { + file.resolvedCwd = parsed.payload?.cwd || ''; } } + } catch { + // Skip unreadable files } } - const recentFiles = files - .sort((a, b) => b.mtimeMs - a.mtimeMs) - .slice(0, limit) - .map((file) => file.path); - const processDayFiles = this.findProcessDaySessionFiles(processStartByPid); - - const selectedPaths = new Set(recentFiles); - for (const processDayFile of processDayFiles) { - selectedPaths.add(processDayFile); - } - - return Array.from(selectedPaths); + return { sessions: files, contentCache }; } - private findProcessDaySessionFiles(processStartByPid: Map): string[] { - const files: string[] = []; + /** + * Determine which date directories to scan based on process start times. + * Returns only directories that actually exist. + */ + private getDateDirs(processes: ProcessInfo[]): string[] { const dayKeys = new Set(); - const dayWindow = CodexAdapter.PROCESS_START_DAY_WINDOW_DAYS; + const window = CodexAdapter.PROCESS_START_DAY_WINDOW_DAYS; - for (const processStart of processStartByPid.values()) { - for (let offset = -dayWindow; offset <= dayWindow; offset++) { - const day = new Date(processStart.getTime()); + for (const proc of processes) { + const startTime = proc.startTime || new Date(); + for (let offset = -window; offset <= window; offset++) { + const day = new Date(startTime.getTime()); day.setDate(day.getDate() + offset); dayKeys.add(this.toSessionDayKey(day)); } } + const dirs: string[] = []; for (const dayKey of dayKeys) { const dayDir = path.join(this.codexSessionsDir, dayKey); - if (!fs.existsSync(dayDir)) { - continue; - } - - for (const entry of fs.readdirSync(dayDir, { withFileTypes: true })) { - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(path.join(dayDir, entry.name)); + try { + if (fs.statSync(dayDir).isDirectory()) { + dirs.push(dayDir); } + } catch { + continue; } } - return files; + return dirs; } private toSessionDayKey(date: Date): string { @@ -337,18 +174,45 @@ export class CodexAdapter implements AgentAdapter { return path.join(yyyy, mm, dd); } - private readSession(filePath: string): CodexSession | null { - const firstLine = this.readFirstLine(filePath); - if (!firstLine) { + /** + * Parse session file content into CodexSession. + * Uses cached content if available, otherwise reads from disk. + */ + private parseSession(cachedContent: string | undefined, filePath: string): CodexSession | null { + let content: string; + if (cachedContent !== undefined) { + content = cachedContent; + } else { + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + } + + const allLines = content.trim().split('\n'); + if (!allLines[0]) return null; + + let metaEntry: CodexEventEntry; + try { + metaEntry = JSON.parse(allLines[0]); + } catch { return null; } - const metaEntry = this.parseSessionMeta(firstLine); - if (!metaEntry?.payload?.id) { + if (metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) { return null; } - const entries = readJsonLines(filePath, 300); + const entries: CodexEventEntry[] = []; + for (const line of allLines) { + try { + entries.push(JSON.parse(line)); + } catch { + continue; + } + } + const lastEntry = this.findLastEventEntry(entries); const lastPayloadType = lastEntry?.payload?.type; @@ -370,21 +234,30 @@ export class CodexAdapter implements AgentAdapter { }; } - private readFirstLine(filePath: string): string { - const content = fs.readFileSync(filePath, 'utf-8'); - return content.split('\n')[0]?.trim() || ''; + private mapSessionToAgent(session: CodexSession, processInfo: ProcessInfo): AgentInfo { + return { + name: generateAgentName(session.projectPath || processInfo.cwd || '', processInfo.pid), + type: this.type, + status: this.determineStatus(session), + summary: session.summary || 'Codex session active', + pid: processInfo.pid, + projectPath: session.projectPath || processInfo.cwd || '', + sessionId: session.sessionId, + lastActive: session.lastActive, + }; } - private parseSessionMeta(line: string): CodexSessionMetaEntry | null { - try { - const parsed = JSON.parse(line) as CodexSessionMetaEntry; - if (parsed.type !== 'session_meta') { - return null; - } - return parsed; - } catch { - return null; - } + private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { + return { + name: generateAgentName(processInfo.cwd || '', processInfo.pid), + type: this.type, + status: AgentStatus.RUNNING, + summary: 'Codex process running', + pid: processInfo.pid, + projectPath: processInfo.cwd || '', + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), + }; } private findLastEventEntry(entries: CodexEventEntry[]): CodexEventEntry | undefined { @@ -398,122 +271,28 @@ export class CodexAdapter implements AgentAdapter { } private parseTimestamp(value?: string): Date | null { - if (!value) { - return null; - } - + if (!value) return null; const timestamp = new Date(value); return Number.isNaN(timestamp.getTime()) ? null : timestamp; } - private selectBestSession( - processInfo: ProcessInfo, - sessions: CodexSession[], - usedSessionIds: Set, - processStartByPid: Map, - mode: SessionMatchMode, - ): CodexSession | undefined { - const candidates = this.filterCandidateSessions(processInfo, sessions, usedSessionIds, mode); - - if (candidates.length === 0) { - return undefined; - } - - const processStart = processStartByPid.get(processInfo.pid); - if (!processStart) { - return candidates.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime())[0]; - } - - return this.rankCandidatesByStartTime(candidates, processStart)[0]; - } - - private filterCandidateSessions( - processInfo: ProcessInfo, - sessions: CodexSession[], - usedSessionIds: Set, - mode: SessionMatchMode, - ): CodexSession[] { - return sessions.filter((session) => { - if (usedSessionIds.has(session.sessionId)) { - return false; - } - - if (mode === 'cwd') { - return session.projectPath === processInfo.cwd; - } - - if (mode === 'missing-cwd') { - return !session.projectPath; - } - }); - } - - private rankCandidatesByStartTime(candidates: CodexSession[], processStart: Date): CodexSession[] { - const toleranceMs = CodexAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS; - - return candidates - .map((session) => { - const diffMs = Math.abs(session.sessionStart.getTime() - processStart.getTime()); - const outsideTolerance = diffMs > toleranceMs ? 1 : 0; - return { - session, - rank: outsideTolerance, - diffMs, - recency: session.lastActive.getTime(), - }; - }) - .sort((a, b) => { - if (a.rank !== b.rank) return a.rank - b.rank; - if (a.diffMs !== b.diffMs) return a.diffMs - b.diffMs; - return b.recency - a.recency; - }) - .map((ranked) => ranked.session); - } - - private getProcessStartTimes(pids: number[]): Map { - if (pids.length === 0 || process.env.JEST_WORKER_ID) { - return new Map(); - } - - 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)); - } + private determineStatus(session: CodexSession): AgentStatus { + const diffMs = Date.now() - session.lastActive.getTime(); + const diffMinutes = diffMs / 60000; - return startTimes; - } catch { - return new Map(); + if (diffMinutes > CodexAdapter.IDLE_THRESHOLD_MINUTES) { + return AgentStatus.IDLE; } - } - private parseElapsedSeconds(etime: string): number | null { - const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/); - if (!match) { - return null; + if ( + session.lastPayloadType === 'agent_message' || + session.lastPayloadType === 'task_complete' || + session.lastPayloadType === 'turn_aborted' + ) { + return AgentStatus.WAITING; } - 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; + return AgentStatus.RUNNING; } private extractSummary(entries: CodexEventEntry[]): string { @@ -528,9 +307,7 @@ export class CodexAdapter implements AgentAdapter { } private truncate(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } + if (value.length <= maxLength) return value; return `${value.slice(0, maxLength - 3)}...`; } @@ -539,35 +316,4 @@ export class CodexAdapter implements AgentAdapter { const base = path.basename(executable).toLowerCase(); return base === 'codex' || base === 'codex.exe'; } - - private determineStatus(session: CodexSession): AgentStatus { - const diffMs = Date.now() - session.lastActive.getTime(); - const diffMinutes = diffMs / 60000; - - if (diffMinutes > CodexAdapter.IDLE_THRESHOLD_MINUTES) { - return AgentStatus.IDLE; - } - - if ( - session.lastPayloadType === 'agent_message' || - session.lastPayloadType === 'task_complete' || - session.lastPayloadType === 'turn_aborted' - ) { - return AgentStatus.WAITING; - } - - return AgentStatus.RUNNING; - } - - private generateAgentName(session: CodexSession, existingAgents: AgentInfo[]): string { - const fallback = `codex-${session.sessionId.slice(0, 8)}`; - const baseName = session.projectPath ? path.basename(path.normalize(session.projectPath)) : fallback; - - const conflict = existingAgents.some((agent) => agent.name === baseName); - if (!conflict) { - return baseName || fallback; - } - - return `${baseName || fallback} (${session.sessionId.slice(0, 8)})`; - } } diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index f6a3c4d6..c70021fa 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -9,6 +9,4 @@ export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusMana export type { TerminalLocation } from './terminal/TerminalFocusManager'; export { TtyWriter } from './terminal/TtyWriter'; -export { listProcesses, getProcessCwd, getProcessTty, isProcessRunning, getProcessInfo } from './utils/process'; -export type { ListProcessesOptions } from './utils/process'; -export { readLastLines, readJsonLines, fileExists, readJson } from './utils/file'; +export { getProcessTty } from './utils/process'; diff --git a/packages/agent-manager/src/utils/file.ts b/packages/agent-manager/src/utils/file.ts deleted file mode 100644 index e9dff366..00000000 --- a/packages/agent-manager/src/utils/file.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * File Utilities - * - * Helper functions for reading files efficiently - */ - -import * as fs from 'fs'; - -/** - * Read last N lines from a file efficiently - * - * @param filePath Path to the file - * @param lineCount Number of lines to read from the end (default: 100) - * @returns Array of lines - * - * @example - * ```typescript - * const lastLines = readLastLines('/path/to/log.txt', 50); - * ``` - */ -export function readLastLines(filePath: string, lineCount: number = 100): string[] { - if (!fs.existsSync(filePath)) { - return []; - } - - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const allLines = content.trim().split('\n'); - - // Return last N lines (or all if file has fewer lines) - return allLines.slice(-lineCount); - } catch (error) { - console.error(`Failed to read ${filePath}:`, error); - return []; - } -} - -/** - * Read a JSONL (JSON Lines) file and parse each line - * - * @param filePath Path to the JSONL file - * @param maxLines Maximum number of lines to read from end (default: 1000) - * @returns Array of parsed objects - * - * @example - * ```typescript - * const entries = readJsonLines('/path/to/data.jsonl'); - * const recent = readJsonLines('/path/to/data.jsonl', 100); - * ``` - */ -export function readJsonLines(filePath: string, maxLines: number = 1000): T[] { - const lines = readLastLines(filePath, maxLines); - - return lines.map(line => { - try { - return JSON.parse(line) as T; - } catch { - return null; - } - }).filter((entry): entry is T => entry !== null); -} - -/** - * Check if a file exists - * - * @param filePath Path to check - * @returns True if file exists - */ -export function fileExists(filePath: string): boolean { - try { - return fs.existsSync(filePath); - } catch { - return false; - } -} - -/** - * Read a JSON file safely - * - * @param filePath Path to JSON file - * @returns Parsed JSON object or null if error - * - * @example - * ```typescript - * const config = readJson('/path/to/config.json'); - * ``` - */ -export function readJson(filePath: string): T | null { - if (!fs.existsSync(filePath)) { - return null; - } - - try { - const content = fs.readFileSync(filePath, 'utf-8'); - return JSON.parse(content) as T; - } catch (error) { - console.error(`Failed to parse JSON from ${filePath}:`, error); - return null; - } -} diff --git a/packages/agent-manager/src/utils/index.ts b/packages/agent-manager/src/utils/index.ts index bf6832c0..e07607a2 100644 --- a/packages/agent-manager/src/utils/index.ts +++ b/packages/agent-manager/src/utils/index.ts @@ -1,3 +1,6 @@ -export { listProcesses, getProcessCwd, getProcessTty, isProcessRunning, getProcessInfo } from './process'; -export type { ListProcessesOptions } from './process'; -export { readLastLines, readJsonLines, fileExists, readJson } from './file'; +export { listAgentProcesses, batchGetProcessCwds, batchGetProcessStartTimes, enrichProcesses } from './process'; +export { getProcessTty } from './process'; +export { batchGetSessionFileBirthtimes } from './session'; +export type { SessionFile } from './session'; +export { matchProcessesToSessions, generateAgentName } from './matching'; +export type { MatchResult } from './matching'; diff --git a/packages/agent-manager/src/utils/matching.ts b/packages/agent-manager/src/utils/matching.ts new file mode 100644 index 00000000..7cfcbbfb --- /dev/null +++ b/packages/agent-manager/src/utils/matching.ts @@ -0,0 +1,92 @@ +/** + * Session Matching Utilities + * + * Shared 1:1 greedy matching algorithm that pairs running processes with session files + * based on CWD and birth-time proximity to process start time. + */ + +import * as path from 'path'; +import type { ProcessInfo } from '../adapters/AgentAdapter'; +import type { SessionFile } from './session'; + +/** Maximum allowed delta between process start time and session file birth time. */ +const TOLERANCE_MS = 3 * 60 * 1000; // 3 minutes + +/** + * Result of matching a process to a session file. + */ +export interface MatchResult { + /** The matched process */ + process: ProcessInfo; + + /** The matched session file */ + session: SessionFile; + + /** Absolute time delta in ms between process start and session birth time */ + deltaMs: number; +} + +/** + * Match processes to session files using 1:1 greedy assignment. + * + * Algorithm: + * 1. Exclude processes without startTime (they become process-only fallback). + * 2. Build candidate pairs where process.cwd === session.resolvedCwd + * and |process.startTime - session.birthtimeMs| <= 3 minutes. + * 3. Sort candidates by deltaMs ascending (best matches first). + * 4. Greedily assign: once a process or session is matched, skip it. + * + * Adapters must set session.resolvedCwd before calling this function. + */ +export function matchProcessesToSessions( + processes: ProcessInfo[], + sessions: SessionFile[], +): MatchResult[] { + // Build all candidate pairs + const candidates: Array<{ process: ProcessInfo; session: SessionFile; deltaMs: number }> = []; + + for (const proc of processes) { + if (!proc.startTime || !proc.cwd) continue; + + const processStartMs = proc.startTime.getTime(); + + for (const session of sessions) { + if (!session.resolvedCwd) continue; + if (proc.cwd !== session.resolvedCwd) continue; + + const deltaMs = Math.abs(processStartMs - session.birthtimeMs); + if (deltaMs > TOLERANCE_MS) continue; + + candidates.push({ process: proc, session, deltaMs }); + } + } + + // Sort by smallest delta first + candidates.sort((a, b) => a.deltaMs - b.deltaMs); + + // Greedy 1:1 assignment + const matchedPids = new Set(); + const matchedSessionIds = new Set(); + const results: MatchResult[] = []; + + for (const candidate of candidates) { + if (matchedPids.has(candidate.process.pid)) continue; + if (matchedSessionIds.has(candidate.session.sessionId)) continue; + + matchedPids.add(candidate.process.pid); + matchedSessionIds.add(candidate.session.sessionId); + results.push(candidate); + } + + return results; +} + +/** + * Generate a deterministic agent name from CWD and PID. + * + * Format: "folderName (pid)" + */ +export function generateAgentName(cwd: string, pid: number): string { + const folderName = path.basename(cwd) || 'unknown'; + return `${folderName} (${pid})`; +} diff --git a/packages/agent-manager/src/utils/process.ts b/packages/agent-manager/src/utils/process.ts index fe38b5dd..b661046b 100644 --- a/packages/agent-manager/src/utils/process.ts +++ b/packages/agent-manager/src/utils/process.ts @@ -1,145 +1,187 @@ /** * Process Detection Utilities - * - * Utilities for detecting and inspecting running processes on the system. - * Primarily focused on macOS/Unix-like systems using the `ps` command. + * + * Shared shell command wrappers for detecting and inspecting running processes. + * All execSync calls for process data live here — adapters must not call execSync directly. */ +import * as path from 'path'; import { execSync } from 'child_process'; import type { ProcessInfo } from '../adapters/AgentAdapter'; /** - * Options for listing processes + * List running processes matching an agent executable name. + * + * Uses `ps aux | grep ` at shell level for performance, then post-filters + * by checking that the executable basename matches exactly (avoids matching + * `claude-helper`, `vscode-claude-extension`, or the grep process itself). + * + * Returned ProcessInfo has pid, command, tty populated. + * cwd and startTime are NOT populated — call enrichProcesses() to fill them. */ -export interface ListProcessesOptions { - /** Filter processes by name pattern (case-insensitive) */ - namePattern?: string; - - /** Include only processes matching these PIDs */ - pids?: number[]; -} +export function listAgentProcesses(namePattern: string): ProcessInfo[] { + // Validate pattern contains only safe characters (alphanumeric, dash, underscore) + if (!namePattern || !/^[a-zA-Z0-9_-]+$/.test(namePattern)) { + return []; + } -/** - * List running processes on the system - * - * @param options Filtering options - * @returns Array of process information - * - * @example - * ```typescript - * // List all Claude Code processes - * const processes = listProcesses({ namePattern: 'claude' }); - * - * // Get specific process info - * const process = listProcesses({ pids: [12345] }); - * ``` - */ -export function listProcesses(options: ListProcessesOptions = {}): ProcessInfo[] { try { - // Get all processes with full details - // Format: user pid command - const psOutput = execSync('ps aux', { encoding: 'utf-8' }); + // Use [c]laude trick to avoid matching the grep process itself + const escapedPattern = `[${namePattern[0]}]${namePattern.slice(1)}`; - const lines = psOutput.trim().split('\n'); - // Skip header line - const processLines = lines.slice(1); + const output = execSync( + `ps aux | grep -i '${escapedPattern}'`, + { encoding: 'utf-8' }, + ); const processes: ProcessInfo[] = []; - for (const line of processLines) { - // Parse ps aux output - // Format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND - const parts = line.trim().split(/\s+/); + for (const line of output.trim().split('\n')) { + if (!line.trim()) continue; + const parts = line.trim().split(/\s+/); if (parts.length < 11) continue; const pid = parseInt(parts[1], 10); - if (isNaN(pid)) continue; + if (Number.isNaN(pid)) continue; const tty = parts[6]; const command = parts.slice(10).join(' '); - // Apply PID filter - if (options.pids && !options.pids.includes(pid)) { + // Post-filter: check that the executable basename matches exactly + const executable = command.trim().split(/\s+/)[0] || ''; + const base = path.basename(executable).toLowerCase(); + if (base !== namePattern.toLowerCase() && base !== `${namePattern.toLowerCase()}.exe`) { continue; } - // Apply name pattern filter (case-insensitive) - if (options.namePattern) { - const pattern = options.namePattern.toLowerCase(); - const commandLower = command.toLowerCase(); - if (!commandLower.includes(pattern)) { - continue; - } - } - - // Get working directory for this process - const cwd = getProcessCwd(pid); - - // Get TTY in short format (remove /dev/ prefix if present) const ttyShort = tty.startsWith('/dev/') ? tty.slice(5) : tty; processes.push({ pid, command, - cwd, + cwd: '', tty: ttyShort, }); } return processes; - } catch (error) { - // If ps command fails, return empty array - console.error('Failed to list processes:', error); + } catch { return []; } } /** - * Get the current working directory for a specific process - * - * @param pid Process ID - * @returns Working directory path, or empty string if unavailable + * Batch-get current working directories for multiple PIDs. + * + * Single `lsof -a -d cwd -Fn -p PID1,PID2,...` call. + * Returns partial results — if lsof fails for one PID, others still return. */ -export function getProcessCwd(pid: number): string { - try { - // Use lsof to get the current working directory - // -a: AND the selections, -d cwd: get cwd only, -Fn: output format (file names only) - const output = execSync(`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null`, { - encoding: 'utf-8', - }); +export function batchGetProcessCwds(pids: number[]): Map { + const result = new Map(); + if (pids.length === 0) return result; - // Parse lsof output - // Format: p{PID}\nn{path} - const lines = output.trim().split('\n'); - for (const line of lines) { - if (line.startsWith('n')) { - return line.slice(1); // Remove 'n' prefix + try { + const output = execSync( + `lsof -a -d cwd -Fn -p ${pids.join(',')} 2>/dev/null`, + { encoding: 'utf-8' }, + ); + + // lsof output format: p{PID}\nn{path}\np{PID}\nn{path}... + let currentPid: number | null = null; + for (const line of output.trim().split('\n')) { + if (line.startsWith('p')) { + currentPid = parseInt(line.slice(1), 10); + } else if (line.startsWith('n') && currentPid !== null) { + result.set(currentPid, line.slice(1)); + currentPid = null; + } + } + } catch { + // Try per-PID fallback with pwdx (Linux) + for (const pid of pids) { + try { + const output = execSync(`pwdx ${pid} 2>/dev/null`, { encoding: 'utf-8' }); + const match = output.match(/^\d+:\s*(.+)$/); + if (match) { + result.set(pid, match[1].trim()); + } + } catch { + // Skip this PID } } + } - return ''; - } catch (error) { - // If lsof fails, try alternative method using pwdx (Linux) - try { - const output = execSync(`pwdx ${pid} 2>/dev/null`, { - encoding: 'utf-8', - }); - // Format: {PID}: {path} - const match = output.match(/^\d+:\s*(.+)$/); - return match ? match[1].trim() : ''; - } catch { - // Both methods failed - return ''; + return result; +} + +/** + * Batch-get process start times for multiple PIDs. + * + * Single `ps -o pid=,lstart= -p PID1,PID2,...` call. + * Uses lstart format which gives full timestamp (e.g., "Thu Feb 5 16:00:57 2026"). + * Returns partial results. + */ +export function batchGetProcessStartTimes(pids: number[]): Map { + const result = new Map(); + if (pids.length === 0) return result; + + try { + const output = execSync( + `ps -o pid=,lstart= -p ${pids.join(',')}`, + { encoding: 'utf-8' }, + ); + + for (const rawLine of output.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + // Format: " PID DAY MON DD HH:MM:SS YYYY" + // e.g., " 78070 Wed Mar 18 23:18:01 2026" + const match = line.match(/^\s*(\d+)\s+(.+)$/); + if (!match) continue; + + const pid = parseInt(match[1], 10); + const dateStr = match[2].trim(); + + if (!Number.isFinite(pid)) continue; + + const date = new Date(dateStr); + if (!Number.isNaN(date.getTime())) { + result.set(pid, date); + } } + } catch { + // Return whatever we have + } + + return result; +} + +/** + * Enrich ProcessInfo array with cwd and startTime. + * + * Calls batchGetProcessCwds and batchGetProcessStartTimes in batched shell calls, + * then populates each ProcessInfo in-place. Returns partial results — + * if a PID fails, that process keeps empty cwd / undefined startTime. + */ +export function enrichProcesses(processes: ProcessInfo[]): ProcessInfo[] { + if (processes.length === 0) return processes; + + const pids = processes.map(p => p.pid); + const cwdMap = batchGetProcessCwds(pids); + const startTimeMap = batchGetProcessStartTimes(pids); + + for (const proc of processes) { + proc.cwd = cwdMap.get(proc.pid) || ''; + proc.startTime = startTimeMap.get(proc.pid); } + + return processes; } /** * Get the TTY device for a specific process - * - * @param pid Process ID - * @returns TTY device name (e.g., "ttys030"), or "?" if unavailable */ export function getProcessTty(pid: number): string { try { @@ -148,37 +190,9 @@ export function getProcessTty(pid: number): string { }); const tty = output.trim(); - // Remove /dev/ prefix if present return tty.startsWith('/dev/') ? tty.slice(5) : tty; - } catch (error) { - return '?'; - } -} - -/** - * Check if a process with the given PID is running - * - * @param pid Process ID - * @returns True if process is running - */ -export function isProcessRunning(pid: number): boolean { - try { - // Send signal 0 to check if process exists - // This doesn't actually send a signal, just checks if we can - execSync(`kill -0 ${pid} 2>/dev/null`); - return true; } catch { - return false; + return '?'; } } -/** - * Get detailed information for a specific process - * - * @param pid Process ID - * @returns Process information, or null if process not found - */ -export function getProcessInfo(pid: number): ProcessInfo | null { - const processes = listProcesses({ pids: [pid] }); - return processes.length > 0 ? processes[0] : null; -} diff --git a/packages/agent-manager/src/utils/session.ts b/packages/agent-manager/src/utils/session.ts new file mode 100644 index 00000000..c749e832 --- /dev/null +++ b/packages/agent-manager/src/utils/session.ts @@ -0,0 +1,92 @@ +/** + * Session File Utilities + * + * Shell command wrappers for discovering session files and their birth times. + * Uses `stat` to get exact epoch-second birth timestamps without reading file contents. + */ + +import * as path from 'path'; +import { execSync } from 'child_process'; + +/** + * Represents a session file with its birth time metadata. + */ +export interface SessionFile { + /** Session identifier (filename without .jsonl extension) */ + sessionId: string; + + /** Full path to the session file */ + filePath: string; + + /** Parent directory of the session file */ + projectDir: string; + + /** File creation time in milliseconds since epoch */ + birthtimeMs: number; + + /** CWD this session maps to — set by the adapter after calling batchGetSessionFileBirthtimes() */ + resolvedCwd: string; +} + +/** + * Get birth times for .jsonl session files across multiple directories in a single shell call. + * + * Combines all directory globs into one `stat` command to avoid per-directory exec overhead. + * Returns empty array if no directories have .jsonl files or command fails. + * resolvedCwd is left empty — the adapter must set it. + */ +export function batchGetSessionFileBirthtimes(dirs: string[]): SessionFile[] { + if (dirs.length === 0) return []; + + try { + const isMacOS = process.platform === 'darwin'; + const globs = dirs.map((d) => `"${d}"/*.jsonl`).join(' '); + // || true prevents non-zero exit when some globs have no .jsonl matches + const command = isMacOS + ? `stat -f '%B %N' ${globs} 2>/dev/null || true` + : `stat --format='%W %n' ${globs} 2>/dev/null || true`; + + const output = execSync(command, { encoding: 'utf-8' }); + + return parseStatOutput(output); + } catch { + return []; + } +} + +/** + * Parse stat output lines into SessionFile entries. + */ +function parseStatOutput(output: string): SessionFile[] { + const results: SessionFile[] = []; + + for (const rawLine of output.trim().split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + // Format: " " + const spaceIdx = line.indexOf(' '); + if (spaceIdx === -1) continue; + + const epochStr = line.slice(0, spaceIdx); + const filePath = line.slice(spaceIdx + 1).trim(); + + const epochSeconds = parseInt(epochStr, 10); + if (!Number.isFinite(epochSeconds) || epochSeconds <= 0) continue; + + const fileName = path.basename(filePath); + if (!fileName.endsWith('.jsonl')) continue; + + const sessionId = fileName.replace(/\.jsonl$/, ''); + + results.push({ + sessionId, + filePath, + projectDir: path.dirname(filePath), + birthtimeMs: epochSeconds * 1000, + resolvedCwd: '', + }); + } + + return results; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 1b61d9b9..3d75e081 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.18.0", + "version": "0.19.0", "description": "A CLI toolkit for AI-assisted software development with phase templates and environment setup", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -27,7 +27,7 @@ "author": "", "license": "MIT", "dependencies": { - "@ai-devkit/agent-manager": "0.4.0", + "@ai-devkit/agent-manager": "0.5.0", "@ai-devkit/memory": "0.7.0", "chalk": "^4.1.2", "commander": "^11.1.0", diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index b0829d81..8bbb2cb9 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -127,11 +127,11 @@ describe('agent command', () => { expect(ui.table).toHaveBeenCalled(); const tableArg: any = (ui.table as any).mock.calls[0][0]; - expect(tableArg.headers).toEqual(['Agent', 'Type', 'Status', 'Working On', 'Active']); - expect(tableArg.rows[0][1]).toBe('Claude Code'); - expect(tableArg.rows[1][1]).toBe('Codex'); - expect(tableArg.rows[0][2]).toContain('wait'); - expect(tableArg.rows[0][4]).toBe('just now'); + expect(tableArg.headers).toEqual(['Agent', 'CWD', 'Type', 'Status', 'Working On', 'Active']); + expect(tableArg.rows[0][2]).toBe('Claude Code'); + expect(tableArg.rows[1][2]).toBe('Codex'); + expect(tableArg.rows[0][3]).toContain('wait'); + expect(tableArg.rows[0][5]).toBe('just now'); expect(ui.warning).toHaveBeenCalledWith('1 agent(s) waiting for input.'); }); @@ -149,10 +149,10 @@ describe('agent command', () => { await program.parseAsync(['node', 'test', 'agent', 'list']); const tableArg: any = (ui.table as any).mock.calls[0][0]; - expect(tableArg.rows[0][1]).toBe('Claude Code'); - expect(tableArg.rows[1][1]).toBe('Codex'); - expect(tableArg.rows[2][1]).toBe('Gemini CLI'); - expect(tableArg.rows[3][1]).toBe('Other'); + expect(tableArg.rows[0][2]).toBe('Claude Code'); + expect(tableArg.rows[1][2]).toBe('Codex'); + expect(tableArg.rows[2][2]).toBe('Gemini CLI'); + expect(tableArg.rows[3][2]).toBe('Other'); }); it('truncates working-on text to first line', async () => { @@ -174,7 +174,7 @@ Waiting on user input`, await program.parseAsync(['node', 'test', 'agent', 'list']); const tableArg: any = (ui.table as any).mock.calls[0][0]; - expect(tableArg.rows[0][3]).toBe('Investigating parser bug'); + expect(tableArg.rows[0][4]).toBe('Investigating parser bug'); }); it('shows available agents when open target is not found', async () => { diff --git a/packages/cli/src/__tests__/util/file.test.ts b/packages/cli/src/__tests__/util/file.test.ts deleted file mode 100644 index 11f6d8d8..00000000 --- a/packages/cli/src/__tests__/util/file.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Tests for file utilities - */ - -import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { - readLastLines, - readJsonLines, - fileExists, - readJson, -} from '../../util/file'; - -describe('file utilities', () => { - let testDir: string; - let testFile: string; - let testJsonFile: string; - let testJsonlFile: string; - - beforeAll(() => { - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-util-test-')); - testFile = path.join(testDir, 'test.txt'); - testJsonFile = path.join(testDir, 'test.json'); - testJsonlFile = path.join(testDir, 'test.jsonl'); - }); - - afterAll(() => { - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true }); - } - }); - - describe('readLastLines', () => { - it('should read last N lines from a file', () => { - const content = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`).join('\n'); - fs.writeFileSync(testFile, content); - - const last10 = readLastLines(testFile, 10); - - expect(last10).toHaveLength(10); - expect(last10[0]).toBe('Line 91'); - expect(last10[9]).toBe('Line 100'); - }); - - it('should return all lines if file has fewer than requested', () => { - fs.writeFileSync(testFile, 'Line 1\nLine 2\nLine 3'); - - const lines = readLastLines(testFile, 100); - - expect(lines).toHaveLength(3); - expect(lines).toEqual(['Line 1', 'Line 2', 'Line 3']); - }); - - it('should return empty array for non-existent file', () => { - const lines = readLastLines('/non/existent/file.txt', 10); - expect(lines).toEqual([]); - }); - - it('should handle empty file', () => { - fs.writeFileSync(testFile, ''); - const lines = readLastLines(testFile, 10); - expect(lines).toEqual(['']); - }); - }); - - describe('readJsonLines', () => { - it('should parse JSONL file', () => { - const data = [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - { id: 3, name: 'Charlie' }, - ]; - const content = data.map(obj => JSON.stringify(obj)).join('\n'); - fs.writeFileSync(testJsonlFile, content); - - const result = readJsonLines<{ id: number; name: string }>(testJsonlFile); - - expect(result).toHaveLength(3); - expect(result[0]).toEqual({ id: 1, name: 'Alice' }); - expect(result[2]).toEqual({ id: 3, name: 'Charlie' }); - }); - - it('should limit to maxLines', () => { - const data = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 })); - const content = data.map(obj => JSON.stringify(obj)).join('\n'); - fs.writeFileSync(testJsonlFile, content); - - const result = readJsonLines(testJsonlFile, 10); - - expect(result).toHaveLength(10); - expect(result[0].id).toBe(91); - expect(result[9].id).toBe(100); - }); - - it('should skip malformed JSON lines', () => { - const content = '{"valid": 1}\n{invalid json}\n{"valid": 2}'; - fs.writeFileSync(testJsonlFile, content); - - const result = readJsonLines(testJsonlFile); - - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ valid: 1 }); - expect(result[1]).toEqual({ valid: 2 }); - }); - - it('should return empty array for non-existent file', () => { - const result = readJsonLines('/non/existent/file.jsonl'); - expect(result).toEqual([]); - }); - }); - - describe('fileExists', () => { - it('should return true for existing file', () => { - fs.writeFileSync(testFile, 'test'); - expect(fileExists(testFile)).toBe(true); - }); - - it('should return false for non-existent file', () => { - expect(fileExists('/non/existent/file.txt')).toBe(false); - }); - - it('should return true for existing directory', () => { - expect(fileExists(testDir)).toBe(true); - }); - }); - - describe('readJson', () => { - it('should read and parse JSON file', () => { - const data = { name: 'test', value: 123 }; - fs.writeFileSync(testJsonFile, JSON.stringify(data)); - - const result = readJson<{ name: string; value: number }>(testJsonFile); - - expect(result).toEqual(data); - }); - - it('should return null for non-existent file', () => { - const result = readJson('/non/existent/file.json'); - expect(result).toBeNull(); - }); - - it('should return null for invalid JSON', () => { - fs.writeFileSync(testJsonFile, '{invalid json}'); - const result = readJson(testJsonFile); - expect(result).toBeNull(); - }); - - it('should handle nested objects', () => { - const data = { - user: { - name: 'Alice', - age: 30, - settings: { - theme: 'dark', - }, - }, - }; - fs.writeFileSync(testJsonFile, JSON.stringify(data)); - - const result = readJson(testJsonFile); - - expect(result).toEqual(data); - expect(result?.user.settings.theme).toBe('dark'); - }); - }); -}); diff --git a/packages/cli/src/__tests__/util/process.test.ts b/packages/cli/src/__tests__/util/process.test.ts deleted file mode 100644 index 3ec4bc09..00000000 --- a/packages/cli/src/__tests__/util/process.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Tests for process detection utilities - */ - -import { describe, it, expect, beforeEach, jest } from '@jest/globals'; -import { execSync } from 'child_process'; -import { - listProcesses, - getProcessCwd, - getProcessTty, - isProcessRunning, - getProcessInfo, -} from '../../util/process'; - -jest.mock('child_process', () => ({ - execSync: jest.fn(), -})); - -const mockExecSync = execSync as unknown as jest.Mock; - -function defaultExec(command: string): string { - if (command === 'ps aux') { - return [ - 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND', - 'dev 101 0.0 0.1 1000 100 ttys001 S 10:00 0:00 node server.js', - 'dev 202 0.0 0.1 1000 100 ttys002 S 10:01 0:00 claude --debug', - ].join('\n'); - } - - const lsofMatch = command.match(/^lsof -a -p (\d+) -d cwd -Fn 2>\/dev\/null$/); - if (lsofMatch) { - const pid = lsofMatch[1]; - return `p${pid}\nn/tmp/project-${pid}\n`; - } - - const pwdxMatch = command.match(/^pwdx (\d+) 2>\/dev\/null$/); - if (pwdxMatch) { - return `${pwdxMatch[1]}: /tmp/pwdx-${pwdxMatch[1]}\n`; - } - - const ttyMatch = command.match(/^ps -p (\d+) -o tty=$/); - if (ttyMatch) { - return '/dev/ttys009\n'; - } - - const killMatch = command.match(/^kill -0 (\d+) 2>\/dev\/null$/); - if (killMatch) { - if (killMatch[1] === '999999') { - throw new Error('No such process'); - } - return ''; - } - - throw new Error(`Unexpected command: ${command}`); -} - -function setupDefaultExecMocks(): void { - mockExecSync.mockImplementation((cmd: unknown) => { - const command = String(cmd); - return defaultExec(command); - }); -} - -describe('process utilities', () => { - beforeEach(() => { - mockExecSync.mockReset(); - setupDefaultExecMocks(); - }); - - describe('listProcesses', () => { - it('should return parsed process list', () => { - const processes = listProcesses(); - - expect(processes).toHaveLength(2); - expect(processes[0]).toEqual({ - pid: 101, - command: 'node server.js', - cwd: '/tmp/project-101', - tty: 'ttys001', - }); - }); - - it('should filter by name pattern case-insensitively', () => { - const upper = listProcesses({ namePattern: 'NODE' }); - const lower = listProcesses({ namePattern: 'node' }); - - expect(upper).toHaveLength(1); - expect(lower).toHaveLength(1); - expect(upper[0].command.toLowerCase()).toContain('node'); - }); - - it('should filter by pid', () => { - const processes = listProcesses({ pids: [202] }); - expect(processes).toHaveLength(1); - expect(processes[0].pid).toBe(202); - }); - - it('should return empty array when ps fails', () => { - mockExecSync.mockImplementationOnce(() => { - throw new Error('ps failed'); - }); - - expect(listProcesses()).toEqual([]); - }); - }); - - describe('getProcessCwd', () => { - it('should return cwd from lsof', () => { - expect(getProcessCwd(101)).toBe('/tmp/project-101'); - }); - - it('should return empty string when both lsof and pwdx fail', () => { - mockExecSync.mockImplementation((cmd: unknown) => { - const command = String(cmd); - if (command.startsWith('lsof -a -p 404') || command.startsWith('pwdx 404')) { - throw new Error('failed'); - } - throw new Error(`Unexpected command: ${command}`); - }); - - expect(getProcessCwd(404)).toBe(''); - }); - }); - - describe('getProcessTty', () => { - it('should return tty without /dev prefix', () => { - expect(getProcessTty(101)).toBe('ttys009'); - }); - - it('should return ? when lookup fails', () => { - mockExecSync.mockImplementation((cmd: unknown) => { - const command = String(cmd); - if (command === 'ps -p 505 -o tty=') { - throw new Error('ps failed'); - } - return defaultExec(command); - }); - - expect(getProcessTty(505)).toBe('?'); - }); - }); - - describe('isProcessRunning', () => { - it('should return true when kill -0 succeeds', () => { - expect(isProcessRunning(101)).toBe(true); - }); - - it('should return false when kill -0 fails', () => { - expect(isProcessRunning(999999)).toBe(false); - }); - }); - - describe('getProcessInfo', () => { - it('should return process details when found', () => { - const info = getProcessInfo(101); - - expect(info).not.toBeNull(); - expect(info?.pid).toBe(101); - expect(info?.command).toContain('node'); - }); - - it('should return null when process does not exist', () => { - expect(getProcessInfo(999999)).toBeNull(); - }); - }); -}); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index cd821655..dc40c878 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -1,3 +1,4 @@ +import os from 'os'; import { Command } from 'commander'; import chalk from 'chalk'; import inquirer from 'inquirer'; @@ -50,6 +51,15 @@ function formatType(type: AgentType): string { return TYPE_LABELS[type] ?? type; } +function formatCwd(projectPath?: string): string { + if (!projectPath) return ''; + const home = os.homedir(); + if (projectPath.startsWith(home)) { + return '~' + projectPath.slice(home.length); + } + return projectPath; +} + function formatWorkOn(summary?: string): string { const firstLine = (summary ?? '').split(/\r?\n/, 1)[0] || ''; return firstLine || 'No active task'; @@ -89,6 +99,7 @@ export function registerAgentCommand(program: Command): void { const rows = agents.map(agent => [ agent.name, + formatCwd(agent.projectPath), formatType(agent.type), formatStatus(agent.status), formatWorkOn(agent.summary), @@ -96,11 +107,12 @@ export function registerAgentCommand(program: Command): void { ]); ui.table({ - headers: ['Agent', 'Type', 'Status', 'Working On', 'Active'], + headers: ['Agent', 'CWD', 'Type', 'Status', 'Working On', 'Active'], rows: rows, columnStyles: [ (text) => chalk.cyan(text), (text) => chalk.dim(text), + (text) => chalk.dim(text), (text) => { if (text.includes(STATUS_DISPLAY[AgentStatus.RUNNING].label)) return chalk.green(text); if (text.includes(STATUS_DISPLAY[AgentStatus.WAITING].label)) return chalk.yellow(text); diff --git a/packages/cli/src/util/file.ts b/packages/cli/src/util/file.ts deleted file mode 100644 index 070e4189..00000000 --- a/packages/cli/src/util/file.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * File Utilities - * - * Helper functions for reading files efficiently - */ - -import * as fs from 'fs'; - -/** - * Read last N lines from a file efficiently - * - * @param filePath Path to the file - * @param lineCount Number of lines to read from the end (default: 100) - * @returns Array of lines - * - * @example - * ```typescript - * const lastLines = readLastLines('/path/to/log.txt', 50); - * ``` - */ -export function readLastLines(filePath: string, lineCount: number = 100): string[] { - if (!fs.existsSync(filePath)) { - return []; - } - - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const allLines = content.trim().split('\n'); - - // Return last N lines (or all if file has fewer lines) - return allLines.slice(-lineCount); - } catch (error) { - console.error(`Failed to read ${filePath}:`, error); - return []; - } -} - -/** - * Read a JSONL (JSON Lines) file and parse each line - * - * @param filePath Path to the JSONL file - * @param maxLines Maximum number of lines to read from end (default: 1000) - * @returns Array of parsed objects - * - * @example - * ```typescript - * const entries = readJsonLines('/path/to/data.jsonl'); - * const recent = readJsonLines('/path/to/data.jsonl', 100); - * ``` - */ -export function readJsonLines(filePath: string, maxLines: number = 1000): T[] { - const lines = readLastLines(filePath, maxLines); - - return lines.map(line => { - try { - return JSON.parse(line) as T; - } catch { - return null; - } - }).filter((entry): entry is T => entry !== null); -} - -/** - * Check if a file exists - * - * @param filePath Path to check - * @returns True if file exists - */ -export function fileExists(filePath: string): boolean { - try { - return fs.existsSync(filePath); - } catch { - return false; - } -} - -/** - * Read a JSON file safely - * - * @param filePath Path to JSON file - * @returns Parsed JSON object or null if error - * - * @example - * ```typescript - * const config = readJson('/path/to/config.json'); - * ``` - */ -export function readJson(filePath: string): T | null { - if (!fs.existsSync(filePath)) { - return null; - } - - try { - const content = fs.readFileSync(filePath, 'utf-8'); - return JSON.parse(content) as T; - } catch (error) { - console.error(`Failed to parse JSON from ${filePath}:`, error); - return null; - } -} \ No newline at end of file diff --git a/packages/cli/src/util/process.ts b/packages/cli/src/util/process.ts deleted file mode 100644 index 7dcc6358..00000000 --- a/packages/cli/src/util/process.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Process Detection Utilities - * - * Utilities for detecting and inspecting running processes on the system. - * Primarily focused on macOS/Unix-like systems using the `ps` command. - */ - -import { execSync } from 'child_process'; -import type { ProcessInfo } from '@ai-devkit/agent-manager'; - -/** - * Options for listing processes - */ -export interface ListProcessesOptions { - /** Filter processes by name pattern (case-insensitive) */ - namePattern?: string; - - /** Include only processes matching these PIDs */ - pids?: number[]; -} - -/** - * List running processes on the system - * - * @param options Filtering options - * @returns Array of process information - * - * @example - * ```typescript - * // List all Claude Code processes - * const processes = listProcesses({ namePattern: 'claude' }); - * - * // Get specific process info - * const process = listProcesses({ pids: [12345] }); - * ``` - */ -export function listProcesses(options: ListProcessesOptions = {}): ProcessInfo[] { - try { - // Get all processes with full details - // Format: user pid command - const psOutput = execSync('ps aux', { encoding: 'utf-8' }); - - const lines = psOutput.trim().split('\n'); - // Skip header line - const processLines = lines.slice(1); - - const processes: ProcessInfo[] = []; - - for (const line of processLines) { - // Parse ps aux output - // Format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND - const parts = line.trim().split(/\s+/); - - if (parts.length < 11) continue; - - const pid = parseInt(parts[1], 10); - if (isNaN(pid)) continue; - - const tty = parts[6]; - const command = parts.slice(10).join(' '); - - // Apply PID filter - if (options.pids && !options.pids.includes(pid)) { - continue; - } - - // Apply name pattern filter (case-insensitive) - if (options.namePattern) { - const pattern = options.namePattern.toLowerCase(); - const commandLower = command.toLowerCase(); - if (!commandLower.includes(pattern)) { - continue; - } - } - - // Get working directory for this process - const cwd = getProcessCwd(pid); - - // Get TTY in short format (remove /dev/ prefix if present) - const ttyShort = tty.startsWith('/dev/') ? tty.slice(5) : tty; - - processes.push({ - pid, - command, - cwd, - tty: ttyShort, - }); - } - - return processes; - } catch (error) { - // If ps command fails, return empty array - console.error('Failed to list processes:', error); - return []; - } -} - -/** - * Get the current working directory for a specific process - * - * @param pid Process ID - * @returns Working directory path, or empty string if unavailable - */ -export function getProcessCwd(pid: number): string { - try { - // Use lsof to get the current working directory - // -a: AND the selections, -d cwd: get cwd only, -Fn: output format (file names only) - const output = execSync(`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null`, { - encoding: 'utf-8', - }); - - // Parse lsof output - // Format: p{PID}\nn{path} - const lines = output.trim().split('\n'); - for (const line of lines) { - if (line.startsWith('n')) { - return line.slice(1); // Remove 'n' prefix - } - } - - return ''; - } catch (error) { - // If lsof fails, try alternative method using pwdx (Linux) - try { - const output = execSync(`pwdx ${pid} 2>/dev/null`, { - encoding: 'utf-8', - }); - // Format: {PID}: {path} - const match = output.match(/^\d+:\s*(.+)$/); - return match ? match[1].trim() : ''; - } catch { - // Both methods failed - return ''; - } - } -} - -/** - * Get the TTY device for a specific process - * - * @param pid Process ID - * @returns TTY device name (e.g., "ttys030"), or "?" if unavailable - */ -export function getProcessTty(pid: number): string { - try { - const output = execSync(`ps -p ${pid} -o tty=`, { - encoding: 'utf-8', - }); - - const tty = output.trim(); - // Remove /dev/ prefix if present - return tty.startsWith('/dev/') ? tty.slice(5) : tty; - } catch (error) { - return '?'; - } -} - -/** - * Check if a process with the given PID is running - * - * @param pid Process ID - * @returns True if process is running - */ -export function isProcessRunning(pid: number): boolean { - try { - // Send signal 0 to check if process exists - // This doesn't actually send a signal, just checks if we can - execSync(`kill -0 ${pid} 2>/dev/null`); - return true; - } catch { - return false; - } -} - -/** - * Get detailed information for a specific process - * - * @param pid Process ID - * @returns Process information, or null if process not found - */ -export function getProcessInfo(pid: number): ProcessInfo | null { - const processes = listProcesses({ pids: [pid] }); - return processes.length > 0 ? processes[0] : null; -} diff --git a/packages/cli/templates/commands/review-design.md b/packages/cli/templates/commands/review-design.md index db8f3111..f71216d6 100644 --- a/packages/cli/templates/commands/review-design.md +++ b/packages/cli/templates/commands/review-design.md @@ -13,6 +13,9 @@ Review the design documentation in `{{docsDir}}/design/feature-{name}.md` (and t - API/interface contracts (inputs, outputs, auth) - Major design decisions and trade-offs - Non-functional requirements that must be preserved -3. Highlight inconsistencies, missing sections, or diagrams that need updates. +3. **Clarify and explore (loop until converged)**: + - **Ask clarification questions** for every gap, inconsistency, or misalignment between requirements and design. Do not just list issues — actively ask specific questions to resolve them. + - **Brainstorm and explore options** — For key architecture decisions, trade-offs, or areas with multiple viable approaches, proactively brainstorm alternatives. Present options with pros/cons and trade-offs. Challenge assumptions and surface creative alternatives. + - **Repeat** — Continue looping until the user is satisfied with the chosen approach and no open questions remain. 4. **Store Reusable Knowledge** — Persist approved design patterns/constraints with `npx ai-devkit@latest memory store ...` when they will help future work. 5. **Next Command Guidance** — If requirements gaps are found, return to `/review-requirements`; if design is sound, continue to `/execute-plan`. diff --git a/packages/cli/templates/commands/review-requirements.md b/packages/cli/templates/commands/review-requirements.md index ed3bde1a..6dba3d88 100644 --- a/packages/cli/templates/commands/review-requirements.md +++ b/packages/cli/templates/commands/review-requirements.md @@ -11,6 +11,9 @@ Review `{{docsDir}}/requirements/feature-{name}.md` and the project-level templa - Primary user stories & critical flows - Constraints, assumptions, open questions - Any missing sections or deviations from the template -3. Identify gaps or contradictions and suggest clarifications. +3. **Clarify and explore (loop until converged)**: + - **Ask clarification questions** for every gap, contradiction, or ambiguity. Do not just list issues — actively ask specific questions to resolve them. + - **Brainstorm and explore options** — For key decisions, trade-offs, or areas with multiple viable approaches, proactively brainstorm alternatives. Present options with pros/cons and trade-offs. Challenge assumptions and surface creative alternatives. + - **Repeat** — Continue looping until the user is satisfied with the chosen approach and no open questions remain. 4. **Store Reusable Knowledge** — If new reusable requirement conventions are agreed, store them with `npx ai-devkit@latest memory store ...`. 5. **Next Command Guidance** — If fundamentals are missing, go back to `/new-requirement`; otherwise continue to `/review-design`. diff --git a/skills/dev-lifecycle/references/review-design.md b/skills/dev-lifecycle/references/review-design.md index 3323c8e9..ae5dd0b8 100644 --- a/skills/dev-lifecycle/references/review-design.md +++ b/skills/dev-lifecycle/references/review-design.md @@ -5,8 +5,11 @@ Review `docs/ai/design/feature-{name}.md` for completeness and fit against requi 1. **Search memory** for relevant architecture patterns or past decisions. 2. **Cross-check against requirements** — read `docs/ai/requirements/feature-{name}.md` and verify every goal, user story, and constraint has corresponding design coverage. Flag uncovered requirements. 3. **Review completeness** — architecture (mermaid diagram), components, technology choices, data models, API contracts, design trade-offs, non-functional requirements. -4. **Ask clarification questions** for every gap or misalignment between requirements and design. Do not just list issues — actively ask specific questions. Example: "Requirements mention offline support but design has no caching — should we add one?" -5. **Update** the design doc with clarified decisions. +4. **Clarify and explore (loop until converged)**: + - **Ask clarification questions** for every gap or misalignment between requirements and design. Do not just list issues — actively ask specific questions. Example: "Requirements mention offline support but design has no caching — should we add one?" + - **Brainstorm and explore options** — For key architecture decisions, trade-offs, or areas with multiple viable approaches, proactively brainstorm alternatives. Present options with pros/cons and trade-offs. Don't just accept the first approach — challenge assumptions and surface creative alternatives. + - **Repeat** — Clarifying answers may reveal new trade-offs worth exploring, and brainstorming may surface new questions. Continue looping until the user is satisfied with the chosen approach and no open questions remain. +5. **Update** the design doc with clarified decisions and chosen options. 6. **Store** clarified architecture decisions in memory. 7. **Summarize** requirements coverage, completeness assessment, updates made, remaining gaps. diff --git a/skills/dev-lifecycle/references/review-requirements.md b/skills/dev-lifecycle/references/review-requirements.md index 2fd54595..249bf43f 100644 --- a/skills/dev-lifecycle/references/review-requirements.md +++ b/skills/dev-lifecycle/references/review-requirements.md @@ -4,8 +4,11 @@ Review `docs/ai/requirements/feature-{name}.md` against the `README.md` template 1. **Search memory** for relevant conventions or past patterns. 2. **Review** each section: problem statement, goals/non-goals, success criteria, user stories, constraints, open questions, template compliance. -3. **Ask clarification questions** for every gap, contradiction, or ambiguity. Do not just list issues — actively ask specific questions to resolve them. Example: "Success criteria say 'fast' — what's the target latency?" -4. **Update** the requirements doc with clarified answers. +3. **Clarify and explore (loop until converged)**: + - **Ask clarification questions** for every gap, contradiction, or ambiguity. Do not just list issues — actively ask specific questions to resolve them. Example: "Success criteria say 'fast' — what's the target latency?" + - **Brainstorm and explore options** — For key decisions, trade-offs, or areas with multiple viable approaches, proactively brainstorm alternatives. Present options with pros/cons and trade-offs. Don't just accept the first approach — challenge assumptions and surface creative alternatives. + - **Repeat** — Clarifying answers may reveal new trade-offs worth exploring, and brainstorming may surface new questions. Continue looping until the user is satisfied with the chosen approach and no open questions remain. +4. **Update** the requirements doc with clarified answers and chosen options. 5. **Store** clarifications in memory. 6. **Summarize** what was validated, what was updated, remaining open items. diff --git a/skills/index.json b/skills/index.json index 423172e3..097418e8 100644 --- a/skills/index.json +++ b/skills/index.json @@ -1,76 +1,76 @@ { "meta": { "version": 1, - "createdAt": 1773533425938, - "updatedAt": 1773533425938, + "createdAt": 1774138085970, + "updatedAt": 1774138085970, "registryHeads": { "anthropics/skills": "b0cbd3df1533b396d281a6886d5132f623393a9c", - "vercel-labs/agent-skills": "5847a7c7e79bab3e400cf47800b83449d7aea2d4", - "remotion-dev/skills": "937ffb32b148fdceef4b4184f975a708aab8d560", + "vercel-labs/agent-skills": "9aec8ee6aaf702e49312a071e672f1c1b7b7e948", + "remotion-dev/skills": "d5d395582c6227249cec74f53ab79aca77a4ff16", "supabase/agent-skills": "760460c221d30d0db904ff28e8fa52af85672255", - "obra/superpowers": "363923f74aa9cd7b470c0aaa73dee629a8bfdc90", + "obra/superpowers": "8ea39819eed74fe2a0338e71789f06b30e953041", "softaworks/agent-toolkit": "3027f20f3181758385a1bb8c022d4041dfb4de84", - "codeaholicguy/ai-devkit": "a20fdad6177991007a8374d098951731a427fabd", - "antfu/skills": "a42879a5e99272b16296ea7d7592aaccb00ee464", - "browser-use/browser-use": "6e2add1c8139b17b01a549eea944687486724fda", - "microsoft/agent-skills": "34dddbd53c2226379c6da1dac1e05223554a821e", - "vercel-labs/skills": "c9fb03ec0c7b4f864163b6e3abdfe961cb15928f", - "vercel-labs/agent-browser": "5f8e993602e8eee48b69c1f33f9cfb69fa98ed2e", + "codeaholicguy/ai-devkit": "c84735c359d54376a2cd68531c1492ec006cb399", + "antfu/skills": "c35a5588a5158b5b404a14fb10469b2b6dc1952b", + "browser-use/browser-use": "987a990623b4e683a684cb2911014dfa512bf97d", + "microsoft/agent-skills": "949a48d50f0c3289f72a61c653bf8cb0cd148e07", + "vercel-labs/skills": "fc3b8b8d68bd640028d2ceedaa5fe2fdf129d05a", + "vercel-labs/agent-browser": "84c6e6fd301a1b33059d05b6086655fadc2f3112", "coreyhaines31/marketingskills": "9d4d29a795113c492b22e01c9b48a8396e140b8d", - "callstackincubator/agent-skills": "c186e3e2cfd18bb950114be7469146a1ca78fa92", + "callstackincubator/agent-skills": "06ae130045c7e70288ea6de0b8e073c8b13f3f21", "hyf0/vue-skills": "f3dd1bf4d3ac78331bdc903e4519d561c538ca6a", "napoleond/clawdirect": "b645ffdd610571af6dd0dd4911cb905c57b1091a", - "vercel/ai": "8b1e7ad43c03a75e5d4b81ef5caef8acba342580", + "vercel/ai": "50c29b0dc2d23dff959bde8eea21594ba61c46c6", "subsy/ralph-tui": "fcea670a2e811f5ccf2e3987fd710fb6b692c9a2", - "atxp-dev/cli": "cc9c69861fb3cbbe21d459243273adeb37a53fc1", - "giuseppe-trisciuoglio/developer-kit": "810297c13b57c9d3fa31b76ce5518dc567b67b7b", - "vercel/turborepo": "7a200000aafdc560a52b74a7db8b0a2aff9e2556", - "jimliu/baoyu-skills": "4d2b95d1d1da32912a5173ba82dc30d7073db166", + "atxp-dev/cli": "ab6c9253ad105d863f559e915123be6a4d11fd15", + "giuseppe-trisciuoglio/developer-kit": "af3f71c98292fe14e3f5b36041a44f4bdd81c8ee", + "vercel/turborepo": "b9ef2126cc3d54474ddd3068b92f474324b82d50", + "jimliu/baoyu-skills": "603cabaef497f76ffb8f73c96827bac49d5c424b", "google-labs-code/stitch-skills": "ad0b5cc5d5c3569e12a3105b0dee7409c3227e1b", - "jezweb/claude-skills": "cfad81649a24e8cee5157e57e361b9c0b397295d", - "firecrawl/cli": "5c2ba51efd04c26310502491920566030f64a64c", + "jezweb/claude-skills": "c4d32f78fbffede63dfe9bd7e9b803fcddcca3d5", + "firecrawl/cli": "5abd341e3007a8bf05e12780edee106cbb024cda", "vercel-labs/next-skills": "038954e07bfc313e97fa5f6ff7caf87226e4a782", - "inference-sh/skills": "8eb8c8a06ded80c7092bfe8c1101264e549043f5", + "inference-sh/skills": "855aeea275dfc438cd1528c808eb652abaded2ed", "intellectronica/agent-skills": "9f7f750cdb158316fb57fcce755f7530fe1118e2", - "resend/react-email": "ded81d583c45c7a4d803c4388ab279a3e92347b6", - "onmax/nuxt-skills": "696d12b7f4b169c951a9f8fc4ca078568648e0fc", + "resend/react-email": "032972736f658eb0bc9ef61a4f0491a55ef1e5a6", + "onmax/nuxt-skills": "7915ad5692c551fa81adbaaef32dae2e51419761", "forrestchang/andrej-karpathy-skills": "aa4467f0b33e1e80d11c7c043d4b27e7c79a73a3", "vuejs-ai/skills": "f3dd1bf4d3ac78331bdc903e4519d561c538ca6a", "cloudai-x/threejs-skills": "b1c623076c661fc9b03dac19292e825a5d106823", "boristane/agent-skills": "8aa14dd16a1340a6049e6d7cd58e2ed52333a550", "kepano/obsidian-skills": "bb9ec95e1b59c3471bd6fd77a78a4042430bfac3", - "sickn33/antigravity-awesome-skills": "27cb7c68309b0aa7d0525eaf9df30cd62caedef9", - "zackkorman/skills": "c00eabef4599eba085fe832dbfb534038bc31279", + "sickn33/antigravity-awesome-skills": "9c177eb003f8bf2dfe03f118f04653e7afb5370f", + "zackkorman/skills": "7d77bd2f6305ddc150571930a8486c9d8078c845", "jeffallan/claude-skills": "3bf9a24b76a7c122f1fc05e83929fbc84e1c207a", "ibelick/ui-skills": "66eb2f5bc6c5dbdd13e601e23fb55ea64692807c", "brianlovin/claude-config": "009b50c90c4a106e0c94565c4a5afd93343218c9", "waynesutton/convexskills": "8ef49c96675f760dd5569c0588c1abb04cd989dd", - "stripe/ai": "538dae7cc6bd38d28cd98291d637445ea2a4779d", + "stripe/ai": "91f8471adfe5189145aaab61879bfa13452c51eb", "cloudflare/skills": "d3113039790f64e052ff80f351190da65e9b7510", - "resciencelab/opc-skills": "b6f7a03f7fddee7f2fb01510f1ca28719246acb3", + "resciencelab/opc-skills": "c6793db7eb838fe772d221484ddde6b9abfa13ee", "adithya-s-k/manim_skill": "cef045011722d285692e3381d12d4d637da56e18", "analogjs/angular-skills": "b0434c274f168bd4f4be77018befa4264a507bb4", - "github/awesome-copilot": "fcdf1a87ad66f2ab69e296e7fe6149be18fe85df", + "github/awesome-copilot": "c6a75d7e0923ec0a754e5554b1c52ef76f0d75f8", "simonwong/agent-skills": "667ea2ae7643de1e965b135ef38e2b502c39e462", "superdesigndev/superdesign-skill": "12a637f7782194256818d3e47c2aff2be41b2634", "figma/mcp-server-guide": "f046182e32c8160b1254ad12ec8318f9a9aa50b5", "addyosmani/web-quality-skills": "fed9617111260e19f4f54b72a2874a3f3de8ff94", "vueuse/skills": "075b0d6d558cc5ca7d5ffe72a56b5fd92bbef2d1", "pluginagentmarketplace/custom-plugin-java": "51aa571c25436e667618e27b8ac8c1d500d62aa2", - "othmanadi/planning-with-files": "57d13d7bd4de012089859098751e64f2f7fab9a3", - "SawyerHood/dev-browser": "66682fb0513aec308e68d71740e2b0394a29d884", + "othmanadi/planning-with-files": "4206e0e5e83bfc8ebaef70d0d6ffed50a88f5c24", + "SawyerHood/dev-browser": "285b1cdad84bf47a94b14dfde63a370b5db6e8c5", "dgreenheck/webgpu-claude-skill": "4adcfe8ef2317eae0801dbf4a396844954f8e996", - "affaan-m/everything-claude-code": "fcaf78e4495dadd63b584ed9225b9c6d341b20d6", + "affaan-m/everything-claude-code": "0d2828cc00c63a9be12b6a01ce0df23e37ee226b", "CloudAI-X/claude-workflow-v2": "b6952dd3cd31b548d57ea88352c84692030e5480", - "muratcankoylan/Agent-Skills-for-Context-Engineering": "87529d9be4488d1d0ad4d02caf9695451e0d3732", + "muratcankoylan/Agent-Skills-for-Context-Engineering": "a60bf86998296f1facd5e287a51065008a733c2d", "itsmostafa/aws-agent-skills": "5df6da7060ce411e959312f07aa3cc1fad2eedd7", - "apify/agent-skills": "df6b198b8285a1b56347a15185e0daf5c69bef3a", - "WordPress/agent-skills": "cdc950d588af9bd5975698e4d6db3bff738c4ad0", + "apify/agent-skills": "28c1b0717439808a2f3e061fae9b29e564ac8bfd", + "WordPress/agent-skills": "01aa2c04cdfd6fb35033e90925aced1d77ff17af", "lackeyjb/playwright-skill": "bb7e920d376022958214e349ef25498a2644e189", - "dbt-labs/dbt-agent-skills": "faadc2578935e0a2c3b345ce807b4ee3a9359a99", + "dbt-labs/dbt-agent-skills": "f5d6f625ba46238b6671821beed8ec26a8111ce3", "google-gemini/gemini-skills": "6c84b961890eb63fce2ed1162bfef8f66047f5f4", "HeyVincent-ai/agent-skills": "76ae07b11eca1b70f2565f55341a73dafca85d26", - "huggingface/skills": "9a685c9f0de100d26967e0d768f70aca78acd5fe", + "huggingface/skills": "73246ad6dc1a2e7a7f428cefd0e11296d0fff7e3", "mcollina/skills": "c605269f85f6e449c1f76b7c9e8c73381fccfc68" } }, @@ -80,14896 +80,15596 @@ "registry": "anthropics/skills", "path": "skills/algorithmic-art", "description": "Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.", - "lastIndexed": 1773533409869 + "lastIndexed": 1774138078256 }, { "name": "brand-guidelines", "registry": "anthropics/skills", "path": "skills/brand-guidelines", "description": "Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.", - "lastIndexed": 1773533409878 + "lastIndexed": 1774138078249 }, { "name": "canvas-design", "registry": "anthropics/skills", "path": "skills/canvas-design", "description": "Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.", - "lastIndexed": 1773533409877 + "lastIndexed": 1774138078256 }, { "name": "claude-api", "registry": "anthropics/skills", "path": "skills/claude-api", "description": "Build apps with the Claude API or Anthropic SDK. TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`/`claude_agent_sdk`, or user asks to use Claude API, Anthropic SDKs, or Agent SDK. DO NOT TRIGGER when: code imports `openai`/other AI SDK, general programming, or ML/data-science tasks.", - "lastIndexed": 1773533409877 + "lastIndexed": 1774138078258 }, { "name": "doc-coauthoring", "registry": "anthropics/skills", "path": "skills/doc-coauthoring", "description": "Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.", - "lastIndexed": 1773533409867 + "lastIndexed": 1774138078257 }, { "name": "docx", "registry": "anthropics/skills", "path": "skills/docx", "description": "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation.", - "lastIndexed": 1773533409884 + "lastIndexed": 1774138078258 }, { "name": "frontend-design", "registry": "anthropics/skills", "path": "skills/frontend-design", "description": "Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.", - "lastIndexed": 1773533409860 + "lastIndexed": 1774138078277 }, { "name": "internal-comms", "registry": "anthropics/skills", "path": "skills/internal-comms", "description": "A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).", - "lastIndexed": 1773533409885 + "lastIndexed": 1774138078287 }, { "name": "mcp-builder", "registry": "anthropics/skills", "path": "skills/mcp-builder", "description": "Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).", - "lastIndexed": 1773533409878 + "lastIndexed": 1774138078254 }, { "name": "pdf", "registry": "anthropics/skills", "path": "skills/pdf", "description": "Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.", - "lastIndexed": 1773533409877 + "lastIndexed": 1774138078254 }, { "name": "pptx", "registry": "anthropics/skills", "path": "skills/pptx", "description": "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill.", - "lastIndexed": 1773533409882 + "lastIndexed": 1774138078255 }, { "name": "skill-creator", "registry": "anthropics/skills", "path": "skills/skill-creator", "description": "Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.", - "lastIndexed": 1773533409864 + "lastIndexed": 1774138078292 }, { "name": "slack-gif-creator", "registry": "anthropics/skills", "path": "skills/slack-gif-creator", "description": "Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like \"make me a GIF of X doing Y for Slack.\"", - "lastIndexed": 1773533409881 + "lastIndexed": 1774138078251 }, { "name": "theme-factory", "registry": "anthropics/skills", "path": "skills/theme-factory", "description": "Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.", - "lastIndexed": 1773533409888 + "lastIndexed": 1774138078252 }, { "name": "web-artifacts-builder", "registry": "anthropics/skills", "path": "skills/web-artifacts-builder", "description": "Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.", - "lastIndexed": 1773533409861 + "lastIndexed": 1774138078252 }, { "name": "webapp-testing", "registry": "anthropics/skills", "path": "skills/webapp-testing", "description": "Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.", - "lastIndexed": 1773533409874 + "lastIndexed": 1774138078249 }, { "name": "xlsx", "registry": "anthropics/skills", "path": "skills/xlsx", "description": "Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path — even casually (like \"the xlsx in my downloads\") — and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved.", - "lastIndexed": 1773533409892 + "lastIndexed": 1774138078257 }, { "name": "composition-patterns", "registry": "vercel-labs/agent-skills", "path": "skills/composition-patterns", "description": "React composition patterns that scale. Use when refactoring components with boolean prop proliferation, building flexible component libraries, or designing reusable APIs. Triggers on tasks involving compound components, render props, context providers, or component architecture. Includes React 19 API changes.", - "lastIndexed": 1773533408675 + "lastIndexed": 1774138078224 }, { "name": "deploy-to-vercel", "registry": "vercel-labs/agent-skills", "path": "skills/deploy-to-vercel", "description": "Deploy applications and websites to Vercel. Use when the user requests deployment actions like \"deploy my app\", \"deploy and give me the link\", \"push this live\", or \"create a preview deployment\".", - "lastIndexed": 1773533408667 + "lastIndexed": 1774138078255 }, { "name": "react-best-practices", "registry": "vercel-labs/agent-skills", "path": "skills/react-best-practices", "description": "React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.", - "lastIndexed": 1773533408665 + "lastIndexed": 1774138078237 }, { "name": "react-native-skills", "registry": "vercel-labs/agent-skills", "path": "skills/react-native-skills", "description": "React Native and Expo best practices for building performant mobile apps. Use when building React Native components, optimizing list performance, implementing animations, or working with native modules. Triggers on tasks involving React Native, Expo, mobile performance, or native platform APIs.", - "lastIndexed": 1773533408623 + "lastIndexed": 1774138078251 + }, + { + "name": "vercel-cli-with-tokens", + "registry": "vercel-labs/agent-skills", + "path": "skills/vercel-cli-with-tokens", + "description": "Deploy and manage projects on Vercel using token-based authentication. Use when working with Vercel CLI using access tokens rather than interactive login — e.g. \"deploy to vercel\", \"set up vercel\", \"add environment variables to vercel\".", + "lastIndexed": 1774138078253 }, { "name": "web-design-guidelines", "registry": "vercel-labs/agent-skills", "path": "skills/web-design-guidelines", "description": "Review UI code for Web Interface Guidelines compliance. Use when asked to \"review my UI\", \"check accessibility\", \"audit design\", \"review UX\", or \"check my site against best practices\".", - "lastIndexed": 1773533408664 + "lastIndexed": 1774138078247 }, { "name": "remotion", "registry": "remotion-dev/skills", "path": "skills/remotion", "description": "Best practices for Remotion - Video creation in React", - "lastIndexed": 1773533408654 + "lastIndexed": 1774138078282 }, { "name": "supabase-postgres-best-practices", "registry": "supabase/agent-skills", "path": "skills/supabase-postgres-best-practices", "description": "Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.", - "lastIndexed": 1773533408666 + "lastIndexed": 1774138078293 }, { "name": "brainstorming", "registry": "obra/superpowers", "path": "skills/brainstorming", "description": "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.", - "lastIndexed": 1773533408624 + "lastIndexed": 1774138078287 }, { "name": "dispatching-parallel-agents", "registry": "obra/superpowers", "path": "skills/dispatching-parallel-agents", "description": "Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies", - "lastIndexed": 1773533408664 + "lastIndexed": 1774138078292 }, { "name": "executing-plans", "registry": "obra/superpowers", "path": "skills/executing-plans", "description": "Use when you have a written implementation plan to execute in a separate session with review checkpoints", - "lastIndexed": 1773533408649 + "lastIndexed": 1774138078275 }, { "name": "finishing-a-development-branch", "registry": "obra/superpowers", "path": "skills/finishing-a-development-branch", "description": "Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup", - "lastIndexed": 1773533408650 + "lastIndexed": 1774138078288 }, { "name": "receiving-code-review", "registry": "obra/superpowers", "path": "skills/receiving-code-review", "description": "Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation", - "lastIndexed": 1773533408650 + "lastIndexed": 1774138078288 }, { "name": "requesting-code-review", "registry": "obra/superpowers", "path": "skills/requesting-code-review", "description": "Use when completing tasks, implementing major features, or before merging to verify work meets requirements", - "lastIndexed": 1773533408662 + "lastIndexed": 1774138078285 }, { "name": "subagent-driven-development", "registry": "obra/superpowers", "path": "skills/subagent-driven-development", "description": "Use when executing implementation plans with independent tasks in the current session", - "lastIndexed": 1773533408653 + "lastIndexed": 1774138078289 }, { "name": "systematic-debugging", "registry": "obra/superpowers", "path": "skills/systematic-debugging", "description": "Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes", - "lastIndexed": 1773533408661 + "lastIndexed": 1774138078292 }, { "name": "test-driven-development", "registry": "obra/superpowers", "path": "skills/test-driven-development", "description": "Use when implementing any feature or bugfix, before writing implementation code", - "lastIndexed": 1773533408644 + "lastIndexed": 1774138078291 }, { "name": "using-git-worktrees", "registry": "obra/superpowers", "path": "skills/using-git-worktrees", "description": "Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification", - "lastIndexed": 1773533408656 + "lastIndexed": 1774138078283 }, { "name": "using-superpowers", "registry": "obra/superpowers", "path": "skills/using-superpowers", "description": "Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions", - "lastIndexed": 1773533408653 + "lastIndexed": 1774138078291 }, { "name": "verification-before-completion", "registry": "obra/superpowers", "path": "skills/verification-before-completion", "description": "Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always", - "lastIndexed": 1773533408656 + "lastIndexed": 1774138078285 }, { "name": "writing-plans", "registry": "obra/superpowers", "path": "skills/writing-plans", "description": "Use when you have a spec or requirements for a multi-step task, before touching code", - "lastIndexed": 1773533408649 + "lastIndexed": 1774138078290 }, { "name": "writing-skills", "registry": "obra/superpowers", "path": "skills/writing-skills", "description": "Use when creating new skills, editing existing skills, or verifying skills work before deployment", - "lastIndexed": 1773533408747 + "lastIndexed": 1774138078364 }, { "name": "agent-md-refactor", "registry": "softaworks/agent-toolkit", "path": "skills/agent-md-refactor", "description": "Refactor bloated AGENTS.md, CLAUDE.md, or similar agent instruction files to follow progressive disclosure principles. Splits monolithic files into organized, linked documentation.", - "lastIndexed": 1773533410508 + "lastIndexed": 1774138078618 }, { "name": "backend-to-frontend-handoff-docs", "registry": "softaworks/agent-toolkit", "path": "skills/backend-to-frontend-handoff-docs", "description": "Create API handoff documentation for frontend developers. Use when backend work is complete and needs to be documented for frontend integration, or user says 'create handoff', 'document API', 'frontend handoff', or 'API documentation'.", - "lastIndexed": 1773533410166 + "lastIndexed": 1774138078616 }, { "name": "c4-architecture", "registry": "softaworks/agent-toolkit", "path": "skills/c4-architecture", "description": "Generate architecture documentation using C4 model Mermaid diagrams. Use when asked to create architecture diagrams, document system architecture, visualize software structure, create C4 diagrams, or generate context/container/component/deployment diagrams. Triggers include \"architecture diagram\", \"C4 diagram\", \"system context\", \"container diagram\", \"component diagram\", \"deployment diagram\", \"document architecture\", \"visualize architecture\".", - "lastIndexed": 1773533410152 + "lastIndexed": 1774138078615 }, { "name": "codex", "registry": "softaworks/agent-toolkit", "path": "skills/codex", "description": "Use when the user asks to run Codex CLI (codex exec, codex resume) or references OpenAI Codex for code analysis, refactoring, or automated editing. Uses GPT-5.2 by default for state-of-the-art software engineering.", - "lastIndexed": 1773533410164 + "lastIndexed": 1774138078614 }, { "name": "command-creator", "registry": "softaworks/agent-toolkit", "path": "skills/command-creator", "description": "This skill should be used when creating a Claude Code slash command. Use when users ask to \"create a command\", \"make a slash command\", \"add a command\", or want to document a workflow as a reusable command. Essential for creating optimized, agent-executable slash commands with proper structure and best practices.", - "lastIndexed": 1773533410495 + "lastIndexed": 1774138078617 }, { "name": "commit-work", "registry": "softaworks/agent-toolkit", "path": "skills/commit-work", "description": "Create high-quality git commits: review/stage intended changes, split into logical commits, and write clear commit messages (including Conventional Commits). Use when the user asks to commit, craft a commit message, stage changes, or split work into multiple commits.", - "lastIndexed": 1773533410520 + "lastIndexed": 1774138078615 }, { "name": "crafting-effective-readmes", "registry": "softaworks/agent-toolkit", "path": "skills/crafting-effective-readmes", "description": "Use when writing or improving README files. Not all READMEs are the same — provides templates and guidance matched to your audience and project type.", - "lastIndexed": 1773533410508 + "lastIndexed": 1774138078613 }, { "name": "daily-meeting-update", "registry": "softaworks/agent-toolkit", "path": "skills/daily-meeting-update", "description": "Interactive daily standup/meeting update generator. Use when user says 'daily', 'standup', 'scrum update', 'status update', 'what did I do yesterday', 'prepare for meeting', 'morning update', or 'team sync'. Pulls activity from GitHub, Jira, and Claude Code session history. Conducts 4-question interview (yesterday, today, blockers, discussion topics) and generates formatted Markdown update.", - "lastIndexed": 1773533410164 + "lastIndexed": 1774138078617 }, { "name": "database-schema-designer", "registry": "softaworks/agent-toolkit", "path": "skills/database-schema-designer", "description": "Design robust, scalable database schemas for SQL and NoSQL databases. Provides normalization guidelines, indexing strategies, migration patterns, constraint design, and performance optimization. Ensures data integrity, query performance, and maintainable data models.", - "lastIndexed": 1773533410175 + "lastIndexed": 1774138078619 }, { "name": "datadog-cli", "registry": "softaworks/agent-toolkit", "path": "skills/datadog-cli", "description": "Datadog CLI for searching logs, querying metrics, tracing requests, and managing dashboards. Use this when debugging production issues or working with Datadog observability.", - "lastIndexed": 1773533410509 + "lastIndexed": 1774138078613 }, { "name": "dependency-updater", "registry": "softaworks/agent-toolkit", "path": "skills/dependency-updater", "description": "Smart dependency management for any language. Auto-detects project type, applies safe updates automatically, prompts for major versions, diagnoses and fixes dependency issues.", - "lastIndexed": 1773533410523 + "lastIndexed": 1774138078616 }, { "name": "design-system-starter", "registry": "softaworks/agent-toolkit", "path": "skills/design-system-starter", "description": "Create and evolve design systems with design tokens, component architecture, accessibility guidelines, and documentation templates. Ensures consistent, scalable, and accessible UI across products.", - "lastIndexed": 1773533410509 + "lastIndexed": 1774138078617 }, { "name": "difficult-workplace-conversations", "registry": "softaworks/agent-toolkit", "path": "skills/difficult-workplace-conversations", "description": "Structured approach to workplace conflicts, performance discussions, and challenging feedback using preparation-delivery-followup framework. Use when preparing for tough conversations, addressing conflicts, giving critical feedback, or navigating sensitive workplace discussions.", - "lastIndexed": 1773533410506 + "lastIndexed": 1774138078618 }, { "name": "domain-name-brainstormer", "registry": "softaworks/agent-toolkit", "path": "skills/domain-name-brainstormer", "description": "Generates creative domain name ideas for your project and checks availability across multiple TLDs (.com, .io, .dev, .ai, etc.). Saves hours of brainstorming and manual checking.", - "lastIndexed": 1773533410507 + "lastIndexed": 1774138078688 }, { "name": "draw-io", "registry": "softaworks/agent-toolkit", "path": "skills/draw-io", "description": "draw.io diagram creation, editing, and review. Use for .drawio XML editing, PNG conversion, layout adjustment, and AWS icon usage.", - "lastIndexed": 1773533410161 + "lastIndexed": 1774138078707 }, { "name": "excalidraw", "registry": "softaworks/agent-toolkit", "path": "skills/excalidraw", "description": "Use when working with *.excalidraw or *.excalidraw.json files, user mentions diagrams/flowcharts, or requests architecture visualization - delegates all Excalidraw operations to subagents to prevent context exhaustion from verbose JSON (single files: 4k-22k tokens, can exceed read limits)", - "lastIndexed": 1773533410161 + "lastIndexed": 1774138078642 }, { "name": "feedback-mastery", "registry": "softaworks/agent-toolkit", "path": "skills/feedback-mastery", "description": "Navigate difficult conversations and deliver constructive feedback using structured frameworks. Covers the Preparation-Delivery-Follow-up model and Situation-Behavior-Impact (SBI) feedback technique. Use when preparing for difficult conversations, giving feedback, or managing conflicts.", - "lastIndexed": 1773533410165 + "lastIndexed": 1774138078657 }, { "name": "frontend-to-backend-requirements", "registry": "softaworks/agent-toolkit", "path": "skills/frontend-to-backend-requirements", "description": "Document frontend data needs for backend developers. Use when frontend needs to communicate API requirements to backend, or user says 'backend requirements', 'what data do I need', 'API requirements', or is describing data needs for a UI.", - "lastIndexed": 1773533410154 + "lastIndexed": 1774138078649 }, { "name": "game-changing-features", "registry": "softaworks/agent-toolkit", "path": "skills/game-changing-features", "description": "Find 10x product opportunities and high-leverage improvements. Use when user wants strategic product thinking, mentions '10x', wants to find high-impact features, or says 'what would make this 10x better', 'product strategy', or 'what should we build next'.", - "lastIndexed": 1773533410510 + "lastIndexed": 1774138078642 }, { "name": "gemini", "registry": "softaworks/agent-toolkit", "path": "skills/gemini", "description": "Use when the user asks to run Gemini CLI for code review, plan review, or big context (>200k) processing. Ideal for comprehensive analysis requiring large context windows. Uses Gemini 3 Pro by default for state-of-the-art reasoning and coding.", - "lastIndexed": 1773533410509 + "lastIndexed": 1774138078654 }, { "name": "gepetto", "registry": "softaworks/agent-toolkit", "path": "skills/gepetto", "description": "Creates detailed, sectionized implementation plans through research, stakeholder interviews, and multi-LLM review. Use when planning features that need thorough pre-implementation analysis.", - "lastIndexed": 1773533410167 + "lastIndexed": 1774138078646 }, { "name": "humanizer", "registry": "softaworks/agent-toolkit", "path": "skills/humanizer", "description": "Remove signs of AI-generated writing from text. Use when editing or reviewing\ntext to make it sound more natural and human-written. Based on Wikipedia's\ncomprehensive \"Signs of AI writing\" guide. Detects and fixes patterns including:\ninflated symbolism, promotional language, superficial -ing analyses, vague\nattributions, em dash overuse, rule of three, AI vocabulary words, negative\nparallelisms, and excessive conjunctive phrases.\n\nCredits: Original skill by @blader - https://github.com/blader/humanizer", - "lastIndexed": 1773533410169 + "lastIndexed": 1774138078724 }, { "name": "jira", "registry": "softaworks/agent-toolkit", "path": "skills/jira", "description": "Use when the user mentions Jira issues (e.g., \"PROJ-123\"), asks about tickets, wants to create/view/update issues, check sprint status, or manage their Jira workflow. Triggers on keywords like \"jira\", \"issue\", \"ticket\", \"sprint\", \"backlog\", or issue key patterns.", - "lastIndexed": 1773533410501 + "lastIndexed": 1774138078650 }, { "name": "lesson-learned", "registry": "softaworks/agent-toolkit", "path": "skills/lesson-learned", "description": "Analyze recent code changes via git history and extract software engineering lessons. Use when the user asks 'what is the lesson here?', 'what can I learn from this?', 'engineering takeaway', 'what did I just learn?', 'reflect on this code', or wants to extract principles from recent work.", - "lastIndexed": 1773533410239 + "lastIndexed": 1774138078651 }, { "name": "marp-slide", "registry": "softaworks/agent-toolkit", "path": "skills/marp-slide", "description": "Create professional Marp presentation slides with 7 beautiful themes (default, minimal, colorful, dark, gradient, tech, business). Use when users request slide creation, presentations, or Marp documents. Supports custom themes, image layouts, and \"make it look good\" requests with automatic quality improvements.", - "lastIndexed": 1773533410509 + "lastIndexed": 1774138078646 }, { "name": "meme-factory", "registry": "softaworks/agent-toolkit", "path": "skills/meme-factory", "description": "Generate memes using the memegen.link API. Use when users request memes, want to add humor to content, or need visual aids for social media. Supports 100+ popular templates with custom text and styling.", - "lastIndexed": 1773533410508 + "lastIndexed": 1774138078665 }, { "name": "mermaid-diagrams", "registry": "softaworks/agent-toolkit", "path": "skills/mermaid-diagrams", "description": "Comprehensive guide for creating software diagrams using Mermaid syntax. Use when users need to create, visualize, or document software through diagrams including class diagrams (domain modeling, object-oriented design), sequence diagrams (application flows, API interactions, code execution), flowcharts (processes, algorithms, user journeys), entity relationship diagrams (database schemas), C4 architecture diagrams (system context, containers, components), state diagrams, git graphs, pie charts, gantt charts, or any other diagram type. Triggers include requests to \"diagram\", \"visualize\", \"model\", \"map out\", \"show the flow\", or when explaining system architecture, database design, code structure, or user/application flows.", - "lastIndexed": 1773533410508 + "lastIndexed": 1774138078671 }, { "name": "mui", "registry": "softaworks/agent-toolkit", "path": "skills/mui", "description": "Material-UI v7 component library patterns including sx prop styling, theme integration, responsive design, and MUI-specific hooks. Use when working with MUI components, styling with sx prop, theme customization, or MUI utilities.", - "lastIndexed": 1773533410173 + "lastIndexed": 1774138078662 }, { "name": "naming-analyzer", "registry": "softaworks/agent-toolkit", "path": "skills/naming-analyzer", "description": "Suggest better variable, function, and class names based on context and conventions.", - "lastIndexed": 1773533410494 + "lastIndexed": 1774138078672 }, { "name": "openapi-to-typescript", "registry": "softaworks/agent-toolkit", "path": "skills/openapi-to-typescript", "description": "Converts OpenAPI 3.0 JSON/YAML to TypeScript interfaces and type guards. This skill should be used when the user asks to generate types from OpenAPI, convert schema to TS, create API interfaces, or generate TypeScript types from an API specification.", - "lastIndexed": 1773533410507 + "lastIndexed": 1774138078671 }, { "name": "perplexity", "registry": "softaworks/agent-toolkit", "path": "skills/perplexity", "description": "Web search and research using Perplexity AI. Use when user says \"search\", \"find\", \"look up\", \"ask\", \"research\", or \"what's the latest\" for generic queries. NOT for library/framework docs (use Context7) or workspace questions.", - "lastIndexed": 1773533410166 + "lastIndexed": 1774138078645 }, { "name": "plugin-forge", "registry": "softaworks/agent-toolkit", "path": "skills/plugin-forge", "description": "Create and manage Claude Code plugins with proper structure, manifests, and marketplace integration. Use when creating plugins for a marketplace, adding plugin components (commands, agents, hooks), bumping plugin versions, or working with plugin.json/marketplace.json manifests.", - "lastIndexed": 1773533410510 + "lastIndexed": 1774138078668 }, { "name": "professional-communication", "registry": "softaworks/agent-toolkit", "path": "skills/professional-communication", "description": "Guide technical communication for software developers. Covers email structure, team messaging etiquette, meeting agendas, and adapting messages for technical vs non-technical audiences. Use when drafting professional messages, preparing meeting communications, or improving written communication.", - "lastIndexed": 1773533410506 + "lastIndexed": 1774138078688 }, { "name": "qa-test-planner", "registry": "softaworks/agent-toolkit", "path": "skills/qa-test-planner", "description": "Generate comprehensive test plans, manual test cases, regression test suites, and bug reports for QA engineers. Includes Figma MCP integration for design validation.", - "lastIndexed": 1773533410170 + "lastIndexed": 1774138078674 }, { "name": "react-dev", "registry": "softaworks/agent-toolkit", "path": "skills/react-dev", "description": "This skill should be used when building React components with TypeScript, typing hooks, handling events, or when React TypeScript, React 19, Server Components are mentioned. Covers type-safe patterns for React 18-19 including generic components, proper event typing, and routing integration (TanStack Router, React Router).", - "lastIndexed": 1773533410510 + "lastIndexed": 1774138078654 }, { "name": "react-useeffect", "registry": "softaworks/agent-toolkit", "path": "skills/react-useeffect", "description": "React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives.", - "lastIndexed": 1773533410166 + "lastIndexed": 1774138078674 }, { "name": "reducing-entropy", "registry": "softaworks/agent-toolkit", "path": "skills/reducing-entropy", "description": "Manual-only skill for minimizing total codebase size. Only activate when explicitly requested by user. Measures success by final code amount, not effort. Bias toward deletion.", - "lastIndexed": 1773533410169 + "lastIndexed": 1774138078655 }, { "name": "requirements-clarity", "registry": "softaworks/agent-toolkit", "path": "skills/requirements-clarity", "description": "Clarify ambiguous requirements through focused dialogue before implementation. Use when requirements are unclear, features are complex (>2 days), or involve cross-team coordination. Ask two core questions - Why? (YAGNI check) and Simpler? (KISS check) - to ensure clarity before coding.", - "lastIndexed": 1773533410171 + "lastIndexed": 1774138078663 }, { "name": "session-handoff", "registry": "softaworks/agent-toolkit", "path": "skills/session-handoff", "description": "Creates comprehensive handoff documents for seamless AI agent session transfers. Triggered when: (1) user requests handoff/memory/context save, (2) context window approaches capacity, (3) major task milestone completed, (4) work session ending, (5) user says 'save state', 'create handoff', 'I need to pause', 'context is getting full', (6) resuming work with 'load handoff', 'resume from', 'continue where we left off'. Proactively suggests handoffs after substantial work (multiple file edits, complex debugging, architecture decisions). Solves long-running agent context exhaustion by enabling fresh agents to continue with zero ambiguity.", - "lastIndexed": 1773533410166 + "lastIndexed": 1774138078655 }, { "name": "ship-learn-next", "registry": "softaworks/agent-toolkit", "path": "skills/ship-learn-next", "description": "Transform learning content (like YouTube transcripts, articles, tutorials) into actionable implementation plans using the Ship-Learn-Next framework. Use when user wants to turn advice, lessons, or educational content into concrete action steps, reps, or a learning quest.", - "lastIndexed": 1773533410501 + "lastIndexed": 1774138078661 }, { "name": "skill-judge", "registry": "softaworks/agent-toolkit", "path": "skills/skill-judge", "description": "Evaluate Agent Skill design quality against official specifications and best practices. Use when reviewing, auditing, or improving SKILL.md files and skill packages. Provides multi-dimensional scoring and actionable improvement suggestions.", - "lastIndexed": 1773533410189 + "lastIndexed": 1774138078663 }, { "name": "web-to-markdown", "registry": "softaworks/agent-toolkit", "path": "skills/web-to-markdown", "description": "Use ONLY when the user explicitly says: 'use the skill web-to-markdown ...' (or 'use a skill web-to-markdown ...'). Converts webpage URLs to clean Markdown by calling the local web2md CLI (Puppeteer + Readability), suitable for JS-rendered pages.", - "lastIndexed": 1773533410507 + "lastIndexed": 1774138078661 }, { "name": "writing-clearly-and-concisely", "registry": "softaworks/agent-toolkit", "path": "skills/writing-clearly-and-concisely", "description": "Use when writing prose humans will read—documentation, commit messages, error messages, explanations, reports, or UI text. Applies Strunk's timeless rules for clearer, stronger, more professional writing.", - "lastIndexed": 1773533410504 + "lastIndexed": 1774138078674 }, { "name": "capture-knowledge", "registry": "codeaholicguy/ai-devkit", "path": "skills/capture-knowledge", "description": "Capture structured knowledge about a code entry point and save it to the knowledge docs. Use when users ask to document, understand, or map code for a module, file, folder, function, or API.", - "lastIndexed": 1773533410116 + "lastIndexed": 1774138078585 }, { "name": "debug", "registry": "codeaholicguy/ai-devkit", "path": "skills/debug", "description": "Guide structured debugging before code changes by clarifying expected behavior, reproducing issues, identifying likely root causes, and agreeing on a fix plan with validation steps. Use when users ask to debug bugs, investigate regressions, triage incidents, diagnose failing behavior, handle failing tests, analyze production incidents, investigate error spikes, or run root cause analysis (RCA).", - "lastIndexed": 1773533410116 + "lastIndexed": 1774138078607 }, { "name": "dev-lifecycle", "registry": "codeaholicguy/ai-devkit", "path": "skills/dev-lifecycle", "description": "Structured SDLC workflow with 8 phases — requirements, design review, planning, implementation, testing, and code review. Use when the user wants to build a feature end-to-end, or run any individual phase (new requirement, review requirements, review design, execute plan, update planning, check implementation, write tests, code review).", - "lastIndexed": 1773533410117 + "lastIndexed": 1774138078614 }, { "name": "memory", "registry": "codeaholicguy/ai-devkit", "path": "skills/memory", "description": "Use AI DevKit's memory service to store and retrieve knowledge via CLI commands instead of MCP.", - "lastIndexed": 1773533410116 + "lastIndexed": 1774138078596 }, { "name": "simplify-implementation", "registry": "codeaholicguy/ai-devkit", "path": "skills/simplify-implementation", "description": "Analyze and simplify existing implementations to reduce complexity, improve maintainability, and enhance scalability. Use when users ask to simplify code, reduce complexity, refactor for readability, clean up implementations, improve maintainability, reduce technical debt, or make code easier to understand.", - "lastIndexed": 1773533410150 + "lastIndexed": 1774138078585 }, { "name": "technical-writer", "registry": "codeaholicguy/ai-devkit", "path": "skills/technical-writer", "description": "Review and improve documentation for novice users. Use when users ask to review docs, improve documentation, audit README files, evaluate API docs, review guides, or improve technical writing.", - "lastIndexed": 1773533410148 + "lastIndexed": 1774138078585 }, { "name": "antfu", "registry": "antfu/skills", "path": "skills/antfu", "description": "Anthony Fu's opinionated tooling and conventions for JavaScript/TypeScript projects. Use when setting up new projects, configuring ESLint/Prettier alternatives, monorepos, library publishing, or when the user mentions Anthony Fu's preferences.", - "lastIndexed": 1773533410116 + "lastIndexed": 1774138078612 }, { "name": "nuxt", "registry": "antfu/skills", "path": "skills/nuxt", "description": "Nuxt full-stack Vue framework with SSR, auto-imports, and file-based routing. Use when working with Nuxt apps, server routes, useFetch, middleware, or hybrid rendering.", - "lastIndexed": 1773533410098 + "lastIndexed": 1774138078607 }, { "name": "pinia", "registry": "antfu/skills", "path": "skills/pinia", "description": "Pinia official Vue state management library, type-safe and extensible. Use when defining stores, working with state/getters/actions, or implementing store patterns in Vue apps.", - "lastIndexed": 1773533410109 + "lastIndexed": 1774138078596 }, { "name": "pnpm", "registry": "antfu/skills", "path": "skills/pnpm", "description": "Node.js package manager with strict dependency resolution. Use when running pnpm specific commands, configuring workspaces, or managing dependencies with catalogs, patches, or overrides.", - "lastIndexed": 1773533410098 + "lastIndexed": 1774138078612 }, { "name": "slidev", "registry": "antfu/skills", "path": "skills/slidev", "description": "Create and present web-based slidedecks for developers using Slidev with Markdown, Vue components, code highlighting, animations, and interactive features. Use when building technical presentations, conference talks, code walkthroughs, teaching materials, or developer decks.", - "lastIndexed": 1773533410114 + "lastIndexed": 1774138078675 }, { "name": "tsdown", "registry": "antfu/skills", "path": "skills/tsdown", "description": "Bundle TypeScript and JavaScript libraries with blazing-fast speed powered by Rolldown. Use when building libraries, generating type declarations, bundling for multiple formats, or migrating from tsup.", - "lastIndexed": 1773533410115 + "lastIndexed": 1774138078611 }, { "name": "turborepo", "registry": "antfu/skills", "path": "skills/turborepo", "description": "Turborepo monorepo build system guidance. Triggers on: turbo.json, task pipelines,\ndependsOn, caching, remote cache, the \"turbo\" CLI, --filter, --affected, CI optimization, environment\nvariables, internal packages, monorepo structure/best practices, and boundaries.\n\nUse when user: configures tasks/workflows/pipelines, creates packages, sets up\nmonorepo, shares code between apps, runs changed/affected packages, debugs cache,\nor has apps/packages directories.", - "lastIndexed": 1773533410118 + "lastIndexed": 1774138078619 }, { "name": "unocss", "registry": "antfu/skills", "path": "skills/unocss", "description": "UnoCSS instant atomic CSS engine, superset of Tailwind CSS. Use when configuring UnoCSS, writing utility rules, shortcuts, or working with presets like Wind, Icons, Attributify.", - "lastIndexed": 1773533410109 + "lastIndexed": 1774138078611 }, { "name": "vite", "registry": "antfu/skills", "path": "skills/vite", "description": "Vite build tool configuration, plugin API, SSR, and Vite 8 Rolldown migration. Use when working with Vite projects, vite.config.ts, Vite plugins, or building libraries/SSR apps with Vite.", - "lastIndexed": 1773533410109 + "lastIndexed": 1774138078607 }, { "name": "vitepress", "registry": "antfu/skills", "path": "skills/vitepress", "description": "VitePress static site generator powered by Vite and Vue. Use when building documentation sites, configuring themes, or writing Markdown with Vue components.", - "lastIndexed": 1773533410099 + "lastIndexed": 1774138078613 }, { "name": "vitest", "registry": "antfu/skills", "path": "skills/vitest", "description": "Vitest fast unit testing framework powered by Vite with Jest-compatible API. Use when writing tests, mocking, configuring coverage, or working with test filtering and fixtures.", - "lastIndexed": 1773533410115 + "lastIndexed": 1774138078612 }, { "name": "vue-best-practices", "registry": "antfu/skills", "path": "skills/vue-best-practices", "description": "MUST be used for Vue.js tasks. Strongly recommends Composition API with `