diff --git a/docs/ai/design/feature-list-sessions.md b/docs/ai/design/feature-list-sessions.md new file mode 100644 index 0000000..70ab649 --- /dev/null +++ b/docs/ai/design/feature-list-sessions.md @@ -0,0 +1,257 @@ +--- +phase: design +title: System Design & Architecture +description: Design for `agent sessions` — list historical Claude/Codex/Gemini sessions +--- + +# System Design & Architecture + +## Architecture Overview + +The feature adds a new `agent sessions` subcommand to `packages/cli` that delegates to the existing `AgentManager` and the per-tool adapters in `packages/agent-manager`. Each adapter learns a new responsibility — enumerating on-disk sessions for its tool — alongside its existing "detect running agents" job. + +**Layering rule**: the CLI is the source of truth for filter *defaults and semantics* (e.g. `cwd` defaults to `process.cwd()`, `--all` clears it, `--type` selects one tool). The CLI computes the values and passes them through `ListSessionsOptions`. The manager and adapters apply the filters they receive — they do not invent defaults or reinterpret user intent. + +### Flow + +1. **CLI parses flags** and computes: + - cwd filter: `--all` → `undefined`; `--cwd ` → that path; neither → `process.cwd()`. + - type filter: from `--type ` (or `undefined`). + - limit: from `--limit `, default `50`, `0` = unlimited. Applied post-merge in the CLI; not part of `ListSessionsOptions`. + - output mode: `--json` vs table. +2. **CLI calls `AgentManager.listSessions({ cwd, type })`** with the computed options. +3. **`AgentManager`** fans out via `Promise.all` to every registered adapter, but skips adapters whose `type` doesn't match `opts.type` when set. Per-adapter exceptions are caught and logged to stderr; the listing continues. Results are merged and sorted by `lastActive` descending. Returned unfiltered beyond `cwd`/`type`. +4. **Each adapter applies `opts.cwd`** at the disk layer: + - `ClaudeCodeAdapter` — always walks every `~/.claude/projects/*` subdir; parses each `*.jsonl` and filters by `session.lastCwd === opts.cwd` when set. (We can't shortcut by encoding `opts.cwd`: Claude Code stores files under the *launch* directory's encoded name, not the recorded `cwd` — they diverge in worktrees.) + - `CodexAdapter` — walk every `~/.codex/sessions/YYYY/MM/DD/` dir, parse `session_meta` first line for cwd, keep matches if filter set. + - `GeminiCliAdapter` — walk every `~/.gemini/tmp//chats/session-*.json`, use `directories[0]` as cwd, keep matches if filter set. + - Each returns `SessionSummary[]`. +5. **CLI applies `--limit`** to the merged result (slice). +6. **Output**: + - `--json` → `JSON.stringify(rows, null, 2)` with ISO date strings. `firstUserMessage` is the raw string (empty when there's no user message yet); machine consumers do their own placeholder if needed. + - Default → `ui.table` with columns `Type | Session ID | CWD | First Message | Last Active`. The renderer truncates the first message to 80 chars and substitutes `"(no message yet)"` when `firstUserMessage` is empty. +7. **Empty-state**: if rows empty and neither `--all` nor `--cwd` was passed, print a one-line hint suggesting `--all`. + +```mermaid +graph TD + CLI[cli: agent sessions] + CLI -->|computes opts| Opts[ListSessionsOptions cwd?, type?] + Opts --> Manager[AgentManager.listSessions] + Manager -->|filter adapters by opts.type| Dispatch{{matching adapters}} + Dispatch --> Claude[ClaudeCodeAdapter.listSessions] + Dispatch --> Codex[CodexAdapter.listSessions] + Dispatch --> Gemini[GeminiCliAdapter.listSessions] + Claude --> ClaudeFiles[(~/.claude/projects/<encoded-cwd>/*.jsonl)] + Codex --> CodexFiles[(~/.codex/sessions/YYYY/MM/DD/*.jsonl)] + Gemini --> GeminiFiles[(~/.gemini/tmp/<shortId>/chats/session-*.json)] + Claude -->|SessionSummary[]| Manager + Codex -->|SessionSummary[]| Manager + Gemini -->|SessionSummary[]| Manager + Manager -->|merged + sorted| CLI + CLI -->|apply --limit, render placeholder| Out{{Table or JSON}} +``` + +### Key components and responsibilities + +- **`packages/cli/src/commands/agent.ts`** — registers the `agent sessions` subcommand; handles flag parsing, table rendering, and JSON output. +- **`packages/agent-manager/src/AgentManager.ts`** — gains a `listSessions(opts)` method that fans out to every registered adapter and merges results. +- **`packages/agent-manager/src/adapters/AgentAdapter.ts`** — adds a `listSessions(opts)` method to the interface. +- **`packages/agent-manager/src/adapters/{ClaudeCodeAdapter,CodexAdapter,GeminiCliAdapter}.ts`** — each implements `listSessions` by walking its tool's session directory layout and reusing its existing parser to extract metadata. +- **`packages/agent-manager/src/utils/ClaudeSessionParser.ts`** — extended to also capture `firstUserMessage` during its existing single-pass iteration (cheap addition). + +### Technology stack + +- Node 20+, TypeScript, `commander`, `chalk`, `inquirer` — all already in the repo. +- Reuses `ui.table` from `packages/cli/src/util/terminal-ui` and `withErrorHandler`. +- No new runtime dependencies. + +## Data Models + +### `SessionSummary` (new, exported from `agent-manager`) + +```ts +export interface SessionSummary { + /** Tool that produced this session */ + type: AgentType; // 'claude' | 'codex' | 'gemini_cli' + /** + * ID accepted by the tool's resume command. Adapters MUST pass this + * through verbatim — no normalization, no encoding/decoding — so it + * round-trips into `claude --resume ` (and equivalents) unmodified. + */ + sessionId: string; + /** Working directory the session was started in (best-known value) */ + cwd: string; + /** + * Trimmed first user message; empty string if none. Adapters reuse the + * same noise-filter their existing parsers already apply (skip + * tool_result blocks, request-interruption notices, system-injected + * skill content). The CLI table renderer substitutes a placeholder for + * empty values; JSON output keeps the empty string raw. + */ + firstUserMessage: string; + /** Last activity timestamp (from session content; falls back to file mtime) */ + lastActive: Date; + /** Session start time (from file content; falls back to file birthtime/mtime) */ + startedAt: Date; + /** Absolute path to the session file on disk (debug/diagnostics) */ + sessionFilePath: string; +} +``` + +### `ListSessionsOptions` + +```ts +export interface ListSessionsOptions { + /** + * Filter to sessions whose recorded cwd matches this path using strict + * equality (no prefix/ancestor matching in v1). Undefined = no cwd filter. + */ + cwd?: string; + /** + * Filter to a single tool. Enforced by `AgentManager.listSessions`, which + * skips adapters whose `type` doesn't match. Adapters MAY ignore this + * field — by the time their `listSessions` runs, the type filter is + * already satisfied. Undefined = include every registered adapter. + */ + type?: AgentType; +} +``` + +`--limit` is intentionally not in `ListSessionsOptions`. The CLI applies it post-merge to preserve global top-K correctness; pushing a per-adapter cap into the options would still require the same CLI-side re-cap, so it adds contract complexity (mtime-sort assumption) without removing the post-merge slice. + +### Per-tool extensions + +- `ClaudeSession` (in `ClaudeSessionParser.ts`) gains `firstUserMessage?: string`. Captured during the same line iteration that already walks the JSONL, reusing the existing `extractUserMessageText` noise filter (skips tool_result blocks, `[Request interrupted]` notices, expanded skill markers, etc.). +- `CodexAdapter.listSessions` parses inline (does not reuse `parseSession`, which extracts the *last* message for live status). It walks events in order and grabs the first `payload.type === 'user_message'` with non-empty `payload.message`. +- `GeminiCliAdapter.listSessions` reuses the adapter's existing `messageText` helper (already used by `getConversation`) to extract content, and walks the messages array forward to grab the first `type === 'user'` entry. + +### Shared file-system helpers + +`packages/agent-manager/src/utils/session.ts` exports three small fs wrappers used by the new `listSessions` paths in all three adapters: + +- `isDirectory(p)` — `fs.statSync(p).isDirectory()` with try/catch. +- `safeReaddir(dir)` — `fs.readdirSync(dir)` with try/catch returning `[]`. +- `listJsonl(dir)` — `safeReaddir` filtered to `*.jsonl`. + +Factored out after the initial adapter implementations had drifted into duplicated copies of the same try/catch boilerplate. + +## API Design + +### Internal: `AgentAdapter` + +```ts +export interface AgentAdapter { + // ...existing members + listSessions(opts?: ListSessionsOptions): Promise; +} +``` + +### Internal: `AgentManager` + +```ts +class AgentManager { + // ...existing members + async listSessions(opts?: ListSessionsOptions): Promise; +} +``` + +`AgentManager.listSessions` runs every registered adapter via `Promise.all`, but skips adapters whose `type` doesn't match `opts.type` when set. Concatenates results, drops adapters that throw (with a stderr warning), and sorts by `lastActive` descending. + +### CLI surface + +``` +ai-devkit agent sessions [options] + +Options: + --all Include sessions from every cwd (default: only current cwd) + --cwd Override the cwd filter (implies non-default scope) + --type Filter to one of: claude, codex, gemini_cli + --limit Max rows to print (default: 50; 0 = no limit) + -j, --json Emit JSON array instead of a table +``` + +Default table columns: + +| Type | Session ID | CWD | First Message | Last Active | + +`--json` returns an array of `SessionSummary` with ISO date strings. + +## Component Breakdown + +### Per-tool listing strategy (v1: reuse existing parsers) + +**Claude Code (`ClaudeCodeAdapter.listSessions`)** + +- Always walk every subdir of `~/.claude/projects/`. We can't take an encoded-dir shortcut for the cwd-scoped path because Claude Code indexes session files by the *launch* directory's encoded name, while the recorded `cwd` field inside the session can change (e.g. when the user `cd`'s into a worktree). The two diverge in real-world setups. +- For each `*.jsonl` file, call `ClaudeSessionParser.readSession(filePath, decodedDirAsFallback)` (extended to also return `firstUserMessage`). The decoded dir name is best-effort (`-` → `/`, lossy for paths containing `-`); session content's `lastCwd` overrides it when present. +- Drop sessions whose JSONL had no parseable conversation entries (guards against garbage files). +- If `opts.cwd` is set, drop sessions where the resolved cwd doesn't match (strict equality). +- Map to `SessionSummary`. + +**Codex (`CodexAdapter.listSessions`)** + +- Walk every `YYYY/MM/DD` date dir under `~/.codex/sessions/`. +- For each file, parse the first line (`session_meta`) to read `payload.cwd`. If `opts.cwd` is set, drop non-matches without further parsing. +- For each kept file, run the existing `parseSession` flow, augmented to also record the first `user_message` payload. +- Map to `SessionSummary`. + +**Gemini CLI (`GeminiCliAdapter.listSessions`)** + +- Walk every `~/.gemini/tmp//chats/session-*.json`. +- For each file, `JSON.parse` it and take `directories[0]` as cwd. If `opts.cwd` is set, drop non-matches. +- Augment the existing `parseSession` to also capture the first `user`-typed message text. +- Map to `SessionSummary`. + +### CLI flow + +See the **Flow** subsection in *Architecture Overview* above. In short: CLI computes `cwd` and `type` from flags, calls `AgentManager.listSessions({ cwd, type })`, applies `--limit` to the returned list, then renders as table or JSON. + +## Design Decisions + +### Why extend the adapter interface (vs. a standalone scanner) + +Each adapter already encodes the on-disk path layout and parser quirks for its tool. Putting `listSessions` on the same interface keeps tool-specific knowledge in one place and makes adding a new CLI (e.g. Cursor, Aider) a one-stop change. + +### Why reuse the existing parsers in v1 + +The user explicitly chose "reuse whatever's in agent-manager first". The existing parsers do a single full read per file, which is good enough for typical session counts. We avoid premature optimization (streaming JSON, on-disk index) and leave a clear extension point. + +### Why current-cwd default + +It's the dominant use case ("resume something I was working on here") and matches the answer to the clarifying question. `--all` and `--cwd ` cover the alternatives without overloading the default. + +### Why a separate subcommand instead of `agent list --history` + +`agent list` is built around live processes (PID, terminal focus, send-message). Mixing historical sessions into it would either silently drop those columns for history rows or pollute the live view. A new `sessions` subcommand keeps each command focused. + +### Alternatives considered + +- **Persistent on-disk index** — fastest on repeated runs, but adds cache invalidation, schema migration, and disk state. Deferred until measured perf demands it. +- **Streaming line reads** — modest speedup on huge files, more code. Deferred; revisit if profiling shows full-file reads dominating. +- **Standalone `SessionLister` class** — reasonable, but duplicates path-encoding logic that already lives in adapters. + +## Non-Functional Requirements + +### Performance + +- Target: <2s for ~200 sessions in default-cwd scope on a developer laptop. Treated as a guideline; concrete budget set after a measured baseline. +- Adapter scans run in parallel via `Promise.all`. +- File parsing within an adapter is sequential in v1 (synchronous fs calls match existing code style); a `Promise.all` over file reads is a low-effort follow-up if needed. +- ClaudeCodeAdapter always reads every JSONL in `~/.claude/projects/**` (even with `opts.cwd` set), because the worktree case requires reading session content to authoritatively resolve cwd. If this grows expensive, the next step is to cap by mtime first (fast `stat`), then read full content only for the top-N. +- CodexAdapter walks every `YYYY/MM/DD` dir under `~/.codex/sessions/`. No date-window pre-filter in v1; revisit if measured. + +### Security + +- Read-only on user-owned files in `$HOME`. No network calls. +- `--cwd ` is used as a filter value only, never executed or interpolated into shell. +- JSON output of session content is bounded to the first user message, so we don't dump full conversations to a pipe by accident. + +### Reliability + +- Per-file failures (malformed JSON, permission denied, partial writes) are skipped with a one-line stderr note; they never fail the whole listing. +- Adapter-level failures are caught in `AgentManager.listSessions` so one broken tool doesn't hide the others. + +### Compatibility + +- Existing `agent list / open / send / detail` commands are unchanged. New code paths are additive. +- `AgentAdapter.listSessions` is a new required method; all three in-tree adapters implement it. The interface change is internal to the repo. diff --git a/docs/ai/implementation/feature-list-sessions.md b/docs/ai/implementation/feature-list-sessions.md new file mode 100644 index 0000000..0c5fa4c --- /dev/null +++ b/docs/ai/implementation/feature-list-sessions.md @@ -0,0 +1,83 @@ +--- +phase: implementation +title: Implementation Guide +description: Where the code lives, key design choices that survived (and didn't) into the actual code +--- + +# Implementation Guide + +## Code Map + +| Concern | File | Notes | +|---|---|---| +| Public types | `packages/agent-manager/src/adapters/AgentAdapter.ts` | New: `SessionSummary`, `ListSessionsOptions`. Existing `AgentAdapter` interface gains `listSessions(opts?)`. | +| Module re-exports | `packages/agent-manager/src/index.ts` | `SessionSummary`, `ListSessionsOptions` added to the type-only export list. | +| Manager fan-out | `packages/agent-manager/src/AgentManager.ts` | New `listSessions(opts?)`: skip-by-type, `Promise.all` over remaining adapters, catch + stderr warn, sort by `lastActive` desc. | +| Claude listing | `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` | New `listSessions` walks every `~/.claude/projects/*` subdir, parses each `*.jsonl` via `ClaudeSessionParser`, filters by `session.lastCwd === opts.cwd` when set. | +| Codex listing | `packages/agent-manager/src/adapters/CodexAdapter.ts` | New `listSessions` walks `~/.codex/sessions/YYYY/MM/DD/*.jsonl`, parses inline (does not reuse existing `parseSession` because that one finds *last* user message; this one wants *first*). | +| Gemini listing | `packages/agent-manager/src/adapters/GeminiCliAdapter.ts` | New `listSessions` walks every `~/.gemini/tmp//chats/session-*.json`, uses `directories[0]` as cwd, reuses `messageText` to extract first user message. | +| Parser extension | `packages/agent-manager/src/utils/ClaudeSessionParser.ts` | `ClaudeSession.firstUserMessage?: string` added; captured during the existing single-pass loop next to `lastUserMessage`. | +| Shared fs helpers | `packages/agent-manager/src/utils/session.ts` | New exports `isDirectory`, `safeReaddir`, `listJsonl` — generic fs wrappers used by all three adapters' `listSessions` implementations (factored out after the initial impl had duplicated copies). | +| CLI command | `packages/cli/src/commands/agent.ts` | New `agent sessions` subcommand. Imports its helpers from `util/sessions.ts`. | +| CLI session helpers | `packages/cli/src/util/sessions.ts` (new) | `resolveListSessionsOptions`, `parseLimit`, `formatFirstMessage`, `toJsonSession`. `formatFirstMessage` reuses `truncate(text, maxLength, replaceText)` from `util/text.ts`. Tested at 100% coverage in `__tests__/util/sessions.test.ts`. | + +## Layering Rule (as built) + +CLI computes filter values from flags (defaults, validation, error messages) and passes them to `AgentManager.listSessions({ cwd, type })`. The manager skips adapters whose `type` doesn't match `opts.type`; otherwise it forwards the same `opts` to each adapter. Adapters apply `opts.cwd` as a strict-equality filter against the session's recorded cwd. `--limit` stays CLI-only, applied after the manager returns, because pushing it down would still require a CLI-side re-cap and adds a per-adapter ordering contract that v1 doesn't need. This matches the design doc's flow exactly. + +## Key Decisions That Changed During Implementation + +### Dropped the Claude encoded-dir shortcut + +**Original design**: when `opts.cwd` is set, derive `~/.claude/projects//` and read just that one directory (perf optimization). + +**What happened**: when running the smoke test from a worktree (`/Users/hoangnguyen/Codeaholicguy/Code/ai-devkit/.worktrees/feature-list-sessions`), the shortcut returned zero matches even though the current Claude session's recorded cwd was that exact path. Cause: Claude Code stores session files under the *launch* directory's encoded name (`-Users-hoangnguyen-Codeaholicguy-Code-ai-devkit`), not under the encoding of the recorded `cwd` field. These diverge any time the user `cd`'s into a worktree (or another subdir) after launching Claude. + +**Fix**: `ClaudeCodeAdapter.listSessions` now always walks every project dir and filters by `session.lastCwd` after parsing. A regression test in `ClaudeCodeAdapter.test.ts` covers this case ("finds sessions whose recorded cwd lives in a different encoded dir (worktree case)"). + +The perf cost is reading every `*.jsonl` in `~/.claude/projects/**`, which is acceptable for v1 (matches the "reuse existing parsers" stance). If it becomes a problem, add an mtime pre-filter as a follow-up. + +## Edge Cases Handled + +- Tool's session dir doesn't exist → returns `[]`. +- Malformed JSON line(s) in a session → individual lines are skipped via the existing parser's per-line `try/catch`. +- Session JSONL with *no* parseable conversation entries (e.g. all-garbage file) → the Claude adapter explicitly drops these (`if (!session.lastEntryType) continue;`) so we don't surface shell records. +- Gemini session JSON missing `directories` → `cwd` field is empty string. With `opts.cwd` set, the row is filtered out; with `--all` it appears with empty cwd. +- Adapter throws → caught in `AgentManager.listSessions`, logs one stderr line, continues with the other adapters' results. +- `--type wrong` → CLI raises `Error: Invalid --type "wrong". Expected one of: claude, codex, gemini_cli.` via `withErrorHandler`. + +## Conventions Followed + +- TypeScript strict mode, jest tests in `__tests__/` folders mirroring `src/`. +- Adapter tests use a real tmp dir + `(adapter as any). = ...` override (matches existing pattern in `ClaudeCodeAdapter.test.ts`). +- Manager tests use a hand-rolled `MockAdapter` (had to grow `listSessions`, `setSessions`, `setFailListSessions` plumbing). +- CLI command file follows the same shape as the existing `list/open/send/detail` subcommands. + +## Test Coverage + +Per package, test file → suites added/updated: + +- `packages/agent-manager`: `AgentManager.test.ts` (+ 6 listSessions tests, MockAdapter extended), `ClaudeCodeAdapter.test.ts` (+ 8 listSessions tests including worktree regression), `CodexAdapter.test.ts` (+ 6 listSessions tests), `GeminiCliAdapter.test.ts` (+ 7 listSessions tests). +- `packages/cli`: `agent.test.ts` (+ 9 sessions subcommand tests). + +Full suite: 459 passing across all 4 projects. + +## Manual Verification + +Smoke commands run from worktree, all exit 0: + +- `agent sessions --help` — flags listed correctly. +- `agent sessions --all --limit 5 --json` — returned 5 Claude sessions sorted by lastActive desc, IDs are UUIDs in Claude's resume format. +- `agent sessions --all --type codex --limit 3 --json` — returned 3 Codex sessions, IDs are Codex UUIDs. +- `agent sessions --all --type gemini_cli --limit 2 --json` — returned 2 Gemini sessions, IDs are Gemini UUIDs. +- `agent sessions --limit 5` (default cwd) — table output, current session shown with truncated first message. +- `agent sessions --all --type wrong` — exit 1 with the expected validation error. +- Default-cwd lookup with no matches — empty-state hint pointing at `--all`. + +## Known Limitations / Follow-ups + +- Gemini sessions without a recorded `directories` array show `cwd: ''`. Worth documenting in user-facing docs once we have any. +- No persistent index/cache. Adequate for hundreds of sessions; revisit if performance complaints surface. +- Performance not formally measured against the design doc's soft "<2s for ~200 sessions" guideline. The smoke run on a developer machine with a real history was perceptibly fast. +- Windows path handling is untested in this repo (matches existing adapters). +- A future `agent resume ` that exec's the right CLI is intentionally not in v1. diff --git a/docs/ai/planning/feature-list-sessions.md b/docs/ai/planning/feature-list-sessions.md new file mode 100644 index 0000000..e218a5d --- /dev/null +++ b/docs/ai/planning/feature-list-sessions.md @@ -0,0 +1,97 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Tasks, order, and risks for the `agent sessions` command +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] **M1: Types & adapter scaffolding** — `SessionSummary`, `ListSessionsOptions`, `AgentAdapter.listSessions` signature, no-op implementations. +- [x] **M2: Per-tool listing** — Claude, Codex, Gemini adapters each implement `listSessions` and capture `firstUserMessage`. +- [x] **M3: AgentManager + CLI command** — `AgentManager.listSessions`, `agent sessions` subcommand wired with table/JSON output. +- [x] **M4: Tests + manual smoke** — unit tests per adapter, integration test for the CLI command, manual run on real session dirs. + +## Task Breakdown + +### Phase 1: Foundation (M1) + +- [x] **T1.1** Add `SessionSummary` and `ListSessionsOptions` to `packages/agent-manager/src/adapters/AgentAdapter.ts`; export from `packages/agent-manager/src/index.ts`. +- [x] **T1.2** Add `listSessions(opts?: ListSessionsOptions): Promise` to the `AgentAdapter` interface. +- [x] **T1.3** Add a stub `listSessions` returning `[]` to all three adapters so the build stays green during the rollout. + +### Phase 2: Per-tool listing (M2) + +- [x] **T2.1** Extend `ClaudeSessionParser.readSession` to also return `firstUserMessage` (captured in the same line iteration that already finds `lastUserMessage`). +- [x] **T2.2** Implement `ClaudeCodeAdapter.listSessions`. **Scope change**: dropped the encoded-dir shortcut for cwd-scoped lookups. Claude Code stores sessions under the *launch* directory's encoding, not the recorded `cwd` field — these diverge in worktrees, so we now always walk all project dirs and filter by `session.lastCwd`. Regression test added. +- [x] **T2.3** Implement `CodexAdapter.listSessions`. Walks `YYYY/MM/DD` dirs, parses `session_meta`, captures first `user_message`. +- [x] **T2.4** Implement `GeminiCliAdapter.listSessions`. Walks `~/.gemini/tmp//chats/session-*.json`, uses `directories[0]` as cwd, captures first user-typed message via existing `messageText` helper. + +### Phase 3: Manager + CLI (M3) + +- [x] **T3.1** Add `AgentManager.listSessions`. Skips adapters whose `type` doesn't match `opts.type`, runs the rest in `Promise.all`, catches per-adapter failures (one-line stderr warn), sorts merged results by `lastActive` descending. +- [x] **T3.2** Register `agent sessions` subcommand in `packages/cli/src/commands/agent.ts` with flags `--all`, `--cwd `, `--type `, `--limit `, `-j/--json`. Helpers (`resolveListSessionsOptions`, `parseLimit`, `formatFirstMessage`, `toJsonSession`) exported for unit testing. +- [x] **T3.3** `npx nx run-many -t build` succeeds; `ai-devkit agent sessions` runs end-to-end against real Claude/Codex/Gemini session dirs. + +### Phase 4: Tests + smoke (M4) + +- [x] **T4.1** Unit tests added to each adapter's test file: empty dir, malformed-file-skipped, cwd-filter applied, plus a worktree regression for Claude. Sort-by-lastActive lives at the manager level (where the sort happens) — covered by T4.2. +- [x] **T4.2** `AgentManager.listSessions` covered by 6 new tests (merge across adapters, sort-by-lastActive desc, type-filter short-circuits adapters, throwing adapter tolerated, opts pass-through). +- [x] **T4.3** CLI tests added for `agent sessions`: default-cwd → `process.cwd()`, `--all` clears cwd, `--type` forwarded, JSON ISO dates, table column order, empty-state hint, placeholder substitution rule, raw empty firstUserMessage in JSON, `--limit` slice. 21 tests total in agent.test.ts pass. +- [x] **T4.4** Manual smoke against real session dirs returned valid SessionSummary entries for Claude/Codex/Gemini. Session IDs are UUIDs in each tool's native format and round-trip into the respective `--resume` commands without modification. + +## Dependencies + +- T1.1 / T1.2 must land first (introduce types). T1.3 (stubs) keeps the tree green. +- T2.1 must complete before T2.2 (Claude impl uses the new field). +- T3.1 depends on Phase 2 results being merge-able. +- T3.2 depends on T3.1 (CLI calls the manager). +- T4.* depend on the corresponding T2/T3 task. + +External dependencies: none (no new npm packages, no API calls). + +## Timeline & Estimates + +Rough effort (for an engineer familiar with the repo): + +- Phase 1 (T1.1–T1.3): ~30 min +- Phase 2 (T2.1–T2.4): ~2–3 hr +- Phase 3 (T3.1–T3.3): ~1–2 hr +- Phase 4 (T4.1–T4.4): ~1–2 hr + +End-to-end: roughly half a focused day. + +## Risks & Mitigation + +- **Session-format drift across CLI versions** — Claude/Codex/Gemini may change their JSON schemas. Mitigation: tolerate missing fields; keep parsers defensive (already the existing pattern); add fixture tests so a regression is loud. +- **Performance on `--all` with many sessions** — full-file reads of every JSONL could dwarf the <2s target on heavy users. Mitigation: ship v1 with reuse-existing-parsers; profile once; if needed, follow up with mtime pre-filtering and/or streaming reads. +- **cwd-mismatch surprises** — a session may have moved (e.g., the user renamed a directory after starting it). The recorded cwd is what we filter on, so the user might miss it; `--all` is the documented escape hatch. +- **Windows path handling** — existing adapters are not Windows-tested in this repo. Treat as out of scope for v1; document in the requirements doc. +- **Interface change to `AgentAdapter`** — internal-only, but every adapter must implement it. Mitigation: T1.3 stubs land alongside the interface change in one commit. + +## Resources Needed + +- One engineer with TypeScript familiarity and access to the repo. +- A machine with at least one of each tool's session directory populated for manual smoke (T4.4). +- No infrastructure or third-party services. + +## Status Summary (post-Phase 4) + +All 14 planning tasks (T1.1 → T4.4) are complete. Full test suite is green: 459 tests across `agent-manager` and `cli` packages pass with the new code. End-to-end smoke run against real Claude/Codex/Gemini session directories produced valid `SessionSummary` records with copy-pasteable session IDs. + +**Scope changes during execution** + +- Dropped the encoded-dir shortcut in `ClaudeCodeAdapter.listSessions`. Claude Code stores session files under the *launch* directory's encoded name, but the recorded `cwd` field inside the session can change (worktree case). Always walking all project dirs and filtering by `session.lastCwd` is correct; the perf cost is acceptable for v1 and matches the "reuse existing parsers" stance. +- Required updating `MockAdapter` in `AgentManager.test.ts` to implement the new interface method. Trivial test plumbing change, not a planning miss. + +**Open follow-ups** + +- Gemini's `directories` field is often missing in real sessions, so `cwd` shows as empty for those rows. Filtering by `--cwd` will skip them. This is a real-world quirk to document, not a v1 blocker. +- Performance: not measured against the soft <2s guideline. Defer until users complain. + +**Suggested next steps** + +- Phase 6 (Check Implementation): verify code matches the design doc end-to-end. +- Phase 7 (Write Tests): the bulk of test work landed inline during TDD; Phase 7 will mostly be a coverage audit. +- Phase 8 (Code Review): pre-push polish. diff --git a/docs/ai/requirements/feature-list-sessions.md b/docs/ai/requirements/feature-list-sessions.md new file mode 100644 index 0000000..4826fc0 --- /dev/null +++ b/docs/ai/requirements/feature-list-sessions.md @@ -0,0 +1,98 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: List historical Claude Code, Codex, and Gemini CLI sessions so a user can find a session ID to resume +--- + +# Requirements & Problem Understanding + +## Problem Statement + +Today the `ai-devkit agent list` command only shows agents whose host process is **currently running**. When a user wants to resume an earlier conversation with `claude --resume `, `codex resume `, or `gemini --session `, they must manually: + +- Remember the tool used +- Locate the right on-disk session file (`~/.claude/projects//*.jsonl`, `~/.codex/sessions/YYYY/MM/DD/*.jsonl`, or `~/.gemini/tmp//chats/session-*.json`) +- Open the file to read the first user message and confirm it is the right session + +This is slow, error-prone, and breaks the "single tool to manage agents" abstraction the rest of `agent` commands provide. + +**Affected user**: any developer using more than one AI CLI tool, or who frequently switches projects and needs to pick up an earlier session. + +## Goals & Objectives + +### Primary goals + +- Provide a single command that lists **all** historical sessions across Claude Code, Codex, and Gemini CLI. +- For each session, surface enough context to identify it: session ID, tool type, cwd, the first user message, and last activity timestamp. +- Default scope is "sessions for the current cwd" so the most common use case (resume a session here) is one short command. +- Emit a `--json` form for scripting (pipe to `fzf`, `jq`, etc.). + +### Secondary goals + +- Reuse the existing `AgentAdapter` abstraction in `packages/agent-manager` so adding future CLIs is straightforward. +- Keep performance acceptable for a typical developer (hundreds of sessions, ~tens of MB total) with the simple "reuse existing parsers" path; leave room for streaming/index optimizations later if needed. + +### Non-goals + +- Resuming sessions automatically (user copies the ID and runs the tool's native resume command). +- Editing, deleting, or migrating session files. +- Cross-host or cloud-stored session sources. +- A persistent on-disk index/cache (deferred until perf demands it). +- Replacing or breaking `agent list` (it stays focused on running agents). + +## User Stories & Use Cases + +- **As a developer**, I want to run `ai-devkit agent sessions` in my project and see every Claude/Codex/Gemini session that started here, so I can pick the right ID to resume. +- **As a developer**, I want `ai-devkit agent sessions --all` to list sessions across every cwd, so I can find a session I started in another project. +- **As a developer who only uses one tool right now**, I want `ai-devkit agent sessions --type claude` (or `codex` / `gemini_cli`) to narrow the list to that tool, so I don't sift through unrelated sessions. +- **As a developer with many sessions**, I want `--limit ` to cap how many rows print (default sensible cap), so the terminal isn't flooded. +- **As a developer**, I want `ai-devkit agent sessions --json | fzf` to work, so I can build my own pickers/aliases. +- **As a developer with many sessions**, I want the listing sorted by last-active descending, so the relevant session is near the top. +- **As a developer**, when I find the session I want, I want the session ID printed verbatim so I can copy it directly into `claude --resume ` (or equivalent). +- **As a developer running the command in an empty project**, I want a hint pointing at `--all` when no sessions match the default scope, so I know how to broaden the search. + +### Edge cases + +- A tool's session directory does not exist yet (user has never run that CLI). Treat as empty, no error. +- A session file is malformed / partially written. Skip with a debug warning, continue. +- A session has zero user messages (created but never sent). Show with "(no message yet)" placeholder and `lastActive` from file mtime. +- Two sessions share the same cwd and very close timestamps. Both shown, sorted by `lastActive`. +- Very large session files. Reuse existing adapters' parsers in v1; revisit if measured runtime is unacceptable. +- An adapter throws while listing (corrupt index, unexpected format). The other adapters' results are still shown; a one-line warning goes to stderr. +- The user is in a subdirectory of where the session was started. Default cwd filter uses strict equality, so the session won't appear; the user is expected to `cd` to the recorded directory or pass `--all`. + +## Success Criteria + +- `ai-devkit agent sessions` from a project root prints a table with columns: Type, Session ID, CWD, First Message, Last Active. +- Sessions in current cwd appear by default using **strict equality** against `process.cwd()`; `--all` shows every cwd. +- `--type ` filters to one tool; rows from other tools are excluded after merging. +- `--limit ` caps printed rows (default 50; `0` disables the cap). +- `--json` returns an array of `SessionSummary` objects matching the schema declared in the design doc (`{ type, sessionId, cwd, firstUserMessage, lastActive, startedAt, sessionFilePath }`, ISO date strings); the schema does not change between runs of the same v1 release. +- "First user message" matches what the existing per-tool parsers consider a meaningful first user prompt (skips tool-result blocks, request-interruption notices, system-injected context). +- Printed session IDs are copy-pasteable into the originating tool's resume command without modification. +- Sort order is `lastActive` descending (most recent first). +- When the result set is empty and no `--all`/`--cwd` was passed, a one-line hint suggests `--all`. +- An adapter failure logs a one-line warning to stderr but does not abort the listing. +- Performance: on a developer machine with ~200 historical sessions, default-cwd run completes within a few seconds. Treated as a soft guideline, not a hard SLO in v1. +- Existing `agent list/open/send/detail` behavior is unchanged. + +## Constraints & Assumptions + +### Technical constraints + +- Must run on Node 20+ (matches repo's `engines.node`). +- Cross-platform paths (macOS, Linux). Windows is best-effort (existing adapters are not Windows-tested in this repo). +- v1 reuses existing parsing paths in `ClaudeCodeAdapter` / `CodexAdapter` / `GeminiCliAdapter`; no streaming parser, no on-disk index. + +### Assumptions + +- Session ID strings stored on disk are exactly the values each tool's `--resume` (or equivalent) flag accepts. +- Session files are append-only during a session and effectively immutable afterward, so file mtime ≈ last activity. +- Users prefer current-cwd default scope (matches the existing `agent` group conventions and the answer in clarifying questions). + +## Questions & Open Items + +- Should "first chat message" fall back to the assistant message if no user message exists? **Decision (v1)**: no — show "(no message yet)" placeholder. +- Should we filter out the currently-running agent's session from the list to dedupe with `agent list`? **Decision (v1)**: no — show everything; running ones can be visually identified by very recent `lastActive`. +- Default cwd filter precision? **Decision (v1)**: strict equality with `process.cwd()`. `--all` is the only escape hatch; prefix-aware matching can be added later if users complain. +- Long-term: should `ai-devkit agent resume ` exec the right CLI for the user? Not in scope for v1; tracked as a follow-up. diff --git a/docs/ai/testing/feature-list-sessions.md b/docs/ai/testing/feature-list-sessions.md new file mode 100644 index 0000000..8502f6f --- /dev/null +++ b/docs/ai/testing/feature-list-sessions.md @@ -0,0 +1,87 @@ +--- +phase: testing +title: Testing Strategy +description: Test coverage and approach for `agent sessions` +--- + +# Testing Strategy + +## Coverage Goals + +- **Target**: 100% statement / branch coverage on new code introduced by this feature. +- **Approach**: tests landed inline during implementation (TDD: failing test → impl → green) with a coverage audit during Phase 7 to fill gaps and consolidate. + +## Coverage Results (Phase 7 audit) + +Coverage is measured per-package against the files this feature touched. + +### `packages/agent-manager` (new code paths) + +| File | Stmts | Branches | Funcs | Lines | Notes | +|---|---|---|---|---|---| +| `AgentManager.ts` | 98.55% | 80% | 95% | 98.38% | One uncovered branch in pre-existing `listAgents` formatting; new `listSessions` fully covered. | +| `adapters/ClaudeCodeAdapter.ts` | 97.34% | 73.58% | 100% | 100% | Uncovered branches are in pre-existing `detectAgents` PID-file matching (lines 62-73, 151, 164, etc.), not new code. | +| `adapters/CodexAdapter.ts` | 93.92% | 70.58% | 100% | 96.84% | Uncovered branches in pre-existing detection helpers. New `listSessions` fully covered. | +| `adapters/GeminiCliAdapter.ts` | 93.8% | 74.31% | 100% | 94.81% | Same pattern — pre-existing `detectAgents`/`discoverSessions` branches. | +| `utils/session.ts` | 96.96% | 100% | 100% | 96.55% | New helpers (`isDirectory`, `safeReaddir`, `listJsonl`) fully covered; one stat-error branch in `batchGetSessionFileBirthtimes` is pre-existing. | +| `utils/ClaudeSessionParser.ts` | 96.35% | 88.39% | 100% | 97.67% | New `firstUserMessage` capture covered. | + +### `packages/cli` (new code paths) + +| File | Stmts | Branches | Funcs | Lines | Notes | +|---|---|---|---|---|---| +| `util/sessions.ts` | **100%** | **100%** | **100%** | **100%** | All new helpers (`resolveListSessionsOptions`, `parseLimit`, `formatFirstMessage`, `toJsonSession`) fully covered by `__tests__/util/sessions.test.ts`. | +| `commands/agent.ts` | 58% | 41% | 53% | 58% | All `agent sessions` paths covered by `agent.test.ts`. Remaining uncovered lines are pre-existing `list/open/send/detail` branches that were never tested in the repo before this feature. | + +## Test Inventory + +Test files added or extended by this feature: + +### `packages/agent-manager/src/__tests__/` + +- **`AgentManager.test.ts`** — 6 new tests in `describe('listSessions')`: empty manager, merge across adapters, sort by `lastActive` desc, type-filter short-circuit, throwing-adapter tolerance, opts pass-through. `MockAdapter` extended with `listSessions`/`setSessions`/`setFailListSessions` plumbing. +- **`adapters/ClaudeCodeAdapter.test.ts`** — 8 new tests in `describe('listSessions')`: empty dir, cwd-scoped success, all-cwd fan-out, cwd strict-equality (rejects subdir/parent), cwd-mismatch with encoded dir (rejects), malformed-file skip, first-message noise filter, empty-firstUserMessage placeholder, plus a regression test "finds sessions whose recorded cwd lives in a different encoded dir (worktree case)". +- **`adapters/CodexAdapter.test.ts`** — 6 new tests: empty dir, walks YYYY/MM/DD, cwd strict-equality, missing-`session_meta` skip, first-`user_message` extraction, empty-firstUserMessage. +- **`adapters/GeminiCliAdapter.test.ts`** — 7 new tests: empty `~/.gemini/tmp`, walks shortId/chats, cwd strict-equality vs `directories[0]`, malformed-JSON skip, missing-`sessionId` skip, first-user message (string + Part[] forms), empty-firstUserMessage. + +### `packages/cli/src/__tests__/` + +- **`commands/agent.test.ts`** — 9 new tests in `describe('sessions')`: default-cwd → `process.cwd()`, `--all` clears cwd, `--type` forwarded, JSON ISO dates, table column order, empty-state hint, placeholder substitution rule, raw empty firstUserMessage in JSON, `--limit` slice. +- **`util/sessions.test.ts`** (new file) — 19 unit tests across `resolveListSessionsOptions` (default cwd, --all, --cwd, --all+--cwd precedence, empty-cwd fallback, valid types, invalid type, empty type), `parseLimit` (default, string, number, 0 = unlimited, non-numeric, negative), `formatFirstMessage` (placeholder, short, truncate, exactly-80), `toJsonSession` (Date → ISO, raw empty fields). + +## Integration / Smoke + +End-to-end smoke runs against real Claude/Codex/Gemini session directories (logged in `implementation/feature-list-sessions.md`): + +- `agent sessions --all --limit 5 --json` → returns 5 Claude sessions sorted by `lastActive` desc with copy-pasteable UUIDs. +- `agent sessions --all --type codex --limit 3 --json` → returns 3 Codex sessions. +- `agent sessions --all --type gemini_cli --limit 2 --json` → returns 2 Gemini sessions. +- `agent sessions --limit 5` (default cwd) → table output with relative-time `Last Active`. +- `agent sessions --all --type wrong` → exit 1 with the expected validation error. +- Default-cwd lookup with no matches → empty-state hint pointing at `--all`. + +## Test Data + +- Adapter tests use real `mkdtempSync` directories under `os.tmpdir()` and override the adapter's path field via `(adapter as any). = ...` (matches the existing pattern in pre-existing adapter tests). +- Manager tests use a hand-rolled `MockAdapter` implementing the full `AgentAdapter` interface. +- CLI tests use the existing `jest.mock('@ai-devkit/agent-manager', ...)` pattern with the manager mock extended for `listSessions`. + +## Test Reporting & Coverage + +- Run all tests: `npx nx run-many -t test` (459 + 20 from the new util tests = 479 total across `cli`, `agent-manager`, `memory`, `channel-connector`). +- Per-file coverage on demand: `cd packages/ && npx jest --coverage --coverageReporters=text --collectCoverageFrom='src/'`. +- New code achieves the 100% coverage target on `util/sessions.ts` (CLI helpers); per-package totals are slightly under because they include pre-existing untested branches in adjacent code we deliberately did not touch. + +## Manual Testing + +Confirmed during smoke (see Integration / Smoke above). Session IDs round-trip into `claude --resume `, `codex resume `, and `gemini --session ` in their native UUID formats. + +## Performance Testing + +Not formally measured in v1 — captured as a follow-up in the planning doc. Soft target was <2s for ~200 sessions in default-cwd scope; smoke runs on a developer machine were perceptibly fast. + +## Known Test Gaps / Follow-ups + +- No tests for `parseLimit` rejecting fractional input (e.g. `"2.5"`); current `parseInt` floors silently. Low priority. +- No regression test for the case where `~/.codex/sessions` exists but contains no valid YYYY/MM/DD layout — covered transitively by the empty-walk path but not as a dedicated case. +- Performance benchmark deferred until measured complaints arise. diff --git a/e2e/cli.e2e.ts b/e2e/cli.e2e.ts index a2d6932..2c0935b 100644 --- a/e2e/cli.e2e.ts +++ b/e2e/cli.e2e.ts @@ -1,4 +1,5 @@ -import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; import { join } from 'path'; import { run, createTempProject, cleanupTempProject, writeConfigFile } from './helpers'; @@ -429,3 +430,214 @@ describe('Node.js compatibility', () => { expect(result.exitCode).toBe(0); }); }); + +describe('agent sessions command', () => { + interface ClaudeJsonlEntry { + type: string; + timestamp?: string; + cwd?: string; + message?: { content?: string }; + } + + interface CodexLine { + type: string; + timestamp?: string; + payload?: { id?: string; cwd?: string; timestamp?: string; type?: string; message?: string }; + } + + function writeClaudeSession(home: string, recordedCwd: string, sessionId: string, firstUserMessage: string): string { + return writeClaudeSessionInLaunchDir(home, recordedCwd, recordedCwd, sessionId, firstUserMessage); + } + + /** + * Write a Claude session under one launch dir's encoded path while the + * session content records a different cwd. Lets us simulate the worktree + * case (user cd'd into a subdir/worktree after launching Claude) by + * passing different launch and recorded cwds. + */ + function writeClaudeSessionInLaunchDir( + home: string, + launchCwd: string, + recordedCwd: string, + sessionId: string, + firstUserMessage: string, + ): string { + const encoded = launchCwd.replace(/\//g, '-'); + const projectDir = join(home, '.claude', 'projects', encoded); + mkdirSync(projectDir, { recursive: true }); + const filePath = join(projectDir, `${sessionId}.jsonl`); + const entries: ClaudeJsonlEntry[] = [ + { + type: 'user', + timestamp: '2025-01-01T00:00:00Z', + cwd: recordedCwd, + message: { content: firstUserMessage }, + }, + ]; + writeFileSync(filePath, entries.map((e) => JSON.stringify(e)).join('\n')); + return filePath; + } + + function writeCodexSession(home: string, cwd: string, sessionId: string, firstUserMessage: string): string { + const dayDir = join(home, '.codex', 'sessions', '2025', '01', '01'); + mkdirSync(dayDir, { recursive: true }); + const filePath = join(dayDir, `${sessionId}.jsonl`); + const lines: CodexLine[] = [ + { type: 'session_meta', payload: { id: sessionId, cwd, timestamp: '2025-01-01T00:00:00Z' } }, + { + type: 'event', + timestamp: '2025-01-01T00:00:01Z', + payload: { type: 'user_message', message: firstUserMessage }, + }, + ]; + writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n')); + return filePath; + } + + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'ai-devkit-sessions-e2e-')); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it('lists the sessions subcommand in agent --help', () => { + const result = run('agent --help', { env: { HOME: home } }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('sessions'); + }); + + it('shows the --all hint when default-cwd lookup is empty', () => { + const projectDir = createTempProject(); + try { + const result = run('agent sessions', { cwd: projectDir, env: { HOME: home } }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('--all'); + } finally { + cleanupTempProject(projectDir); + } + }); + + it('finds a Claude session recorded for the current cwd', () => { + const projectDir = createTempProject(); + // macOS symlinks /var → /private/var; the spawned CLI's process.cwd() + // returns the canonical path. Use realpath here so the recorded cwd in + // the fake session matches what the CLI computes from process.cwd(). + const canonical = realpathSync(projectDir); + try { + writeClaudeSession(home, canonical, 'claude-here', 'hello from project'); + + const result = run('agent sessions --json', { cwd: projectDir, env: { HOME: home } }); + expect(result.exitCode).toBe(0); + + const sessions = JSON.parse(result.stdout) as Array<{ + type: string; + sessionId: string; + cwd: string; + firstUserMessage: string; + }>; + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + type: 'claude', + sessionId: 'claude-here', + cwd: canonical, + firstUserMessage: 'hello from project', + }); + } finally { + cleanupTempProject(projectDir); + } + }); + + it('finds a session whose recorded cwd lives in a different launch dir (worktree case)', () => { + const launchCwd = '/repo'; + const worktreeCwd = '/repo/.worktrees/feature'; + writeClaudeSessionInLaunchDir(home, launchCwd, worktreeCwd, 'wt-session', 'in worktree'); + + const result = run(`agent sessions --cwd "${worktreeCwd}" --json`, { env: { HOME: home } }); + expect(result.exitCode).toBe(0); + + const sessions = JSON.parse(result.stdout) as Array<{ sessionId: string; cwd: string }>; + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ sessionId: 'wt-session', cwd: worktreeCwd }); + }); + + it('lists sessions across every cwd with --all', () => { + writeClaudeSession(home, '/repo-a', 'claude-a', 'a'); + writeClaudeSession(home, '/repo-b', 'claude-b', 'b'); + writeCodexSession(home, '/repo-codex', 'codex-1', 'codex hi'); + + const result = run('agent sessions --all --json', { env: { HOME: home } }); + expect(result.exitCode).toBe(0); + + const sessions = JSON.parse(result.stdout) as Array<{ type: string; sessionId: string }>; + expect(sessions).toHaveLength(3); + expect(sessions.map((s) => s.sessionId).sort()).toEqual(['claude-a', 'claude-b', 'codex-1']); + }); + + it('filters to one tool with --type', () => { + writeClaudeSession(home, '/repo-claude', 'claude-1', 'c'); + writeCodexSession(home, '/repo-codex', 'codex-1', 'cx'); + + const result = run('agent sessions --all --type codex --json', { env: { HOME: home } }); + expect(result.exitCode).toBe(0); + + const sessions = JSON.parse(result.stdout) as Array<{ type: string; sessionId: string }>; + expect(sessions).toHaveLength(1); + expect(sessions[0].type).toBe('codex'); + expect(sessions[0].sessionId).toBe('codex-1'); + }); + + it('rejects an invalid --type with a clear error', () => { + const result = run('agent sessions --all --type wrong', { env: { HOME: home } }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr + result.stdout).toMatch(/Invalid --type "wrong"/); + }); + + it('caps rows with --limit', () => { + writeClaudeSession(home, '/r1', 's1', 'one'); + writeClaudeSession(home, '/r2', 's2', 'two'); + writeClaudeSession(home, '/r3', 's3', 'three'); + + const result = run('agent sessions --all --limit 2 --json', { env: { HOME: home } }); + expect(result.exitCode).toBe(0); + + const sessions = JSON.parse(result.stdout) as Array<{ sessionId: string }>; + expect(sessions).toHaveLength(2); + }); + + it('treats --limit 0 as unlimited', () => { + for (let i = 0; i < 3; i++) { + writeClaudeSession(home, `/r${i}`, `s${i}`, `msg-${i}`); + } + + const result = run('agent sessions --all --limit 0 --json', { env: { HOME: home } }); + expect(result.exitCode).toBe(0); + + const sessions = JSON.parse(result.stdout) as Array; + expect(sessions).toHaveLength(3); + }); + + it('emits a JSON schema with expected fields and ISO date strings', () => { + writeClaudeSession(home, '/repo', 'claude-z', 'hello'); + + const result = run('agent sessions --all --json', { env: { HOME: home } }); + expect(result.exitCode).toBe(0); + + const sessions = JSON.parse(result.stdout); + expect(Array.isArray(sessions)).toBe(true); + expect(sessions[0]).toEqual( + expect.objectContaining({ + type: 'claude', + sessionId: 'claude-z', + cwd: '/repo', + firstUserMessage: 'hello', + sessionFilePath: expect.any(String), + }), + ); + expect(sessions[0].lastActive).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(sessions[0].startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index be80895..22cc4ff 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -5,7 +5,12 @@ * Manages adapter registration and aggregates results from all adapters. */ -import type { AgentAdapter, AgentInfo } from './adapters/AgentAdapter'; +import type { + AgentAdapter, + AgentInfo, + SessionSummary, + ListSessionsOptions, +} from './adapters/AgentAdapter'; import { AgentStatus } from './adapters/AgentAdapter'; /** @@ -141,12 +146,59 @@ export class AgentManager { return this.sortAgentsByStatus(allAgents); } + /** + * List historical sessions across every registered adapter. + * + * When `opts.type` is set, adapters whose `type` doesn't match are + * skipped without being called. The remaining adapters' results are + * merged and sorted by `lastActive` descending. Adapter failures are + * caught (one-line stderr warning) so one broken adapter doesn't hide + * the others. + * + * @param opts Filter options computed by the CLI; the manager passes + * them through to each adapter unchanged. + */ + async listSessions(opts?: ListSessionsOptions): Promise { + const targetAdapters = Array.from(this.adapters.values()).filter( + (adapter) => opts?.type === undefined || adapter.type === opts.type, + ); + + const errors: Array<{ type: string; error: Error }> = []; + + const results = await Promise.all( + targetAdapters.map(async (adapter) => { + try { + return await adapter.listSessions(opts); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + errors.push({ type: adapter.type, error: err }); + return []; + } + }), + ); + + if (errors.length > 0) { + console.error(`Warning: ${errors.length} adapter(s) failed to list sessions:`); + for (const { type, error } of errors) { + console.error(` - ${type}: ${error.message}`); + } + } + + const merged: SessionSummary[] = []; + for (const list of results) { + merged.push(...list); + } + + merged.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()); + return merged; + } + /** * Sort agents by status priority - * + * * Priority order: waiting > running > idle > unknown * This ensures agents that need attention appear first. - * + * * @param agents Array of agents to sort * @returns Sorted array of agents */ diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index a835f5c..eb66de8 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -4,15 +4,25 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; import { AgentManager } from '../AgentManager'; -import type { AgentAdapter, AgentInfo, AgentType, ConversationMessage } from '../adapters/AgentAdapter'; +import type { + AgentAdapter, + AgentInfo, + AgentType, + ConversationMessage, + SessionSummary, +} from '../adapters/AgentAdapter'; import { AgentStatus } from '../adapters/AgentAdapter'; // Mock adapter for testing class MockAdapter implements AgentAdapter { + public lastListSessionsOpts: unknown = undefined; + constructor( public readonly type: AgentType, private mockAgents: AgentInfo[] = [], - private shouldFail: boolean = false + private shouldFail: boolean = false, + private mockSessions: SessionSummary[] = [], + private shouldFailListSessions: boolean = false, ) { } async detectAgents(): Promise { @@ -30,6 +40,14 @@ class MockAdapter implements AgentAdapter { return []; } + async listSessions(opts?: unknown): Promise { + this.lastListSessionsOpts = opts; + if (this.shouldFailListSessions) { + throw new Error(`Mock adapter ${this.type} listSessions failed`); + } + return this.mockSessions; + } + setAgents(agents: AgentInfo[]): void { this.mockAgents = agents; } @@ -37,6 +55,14 @@ class MockAdapter implements AgentAdapter { setFail(shouldFail: boolean): void { this.shouldFail = shouldFail; } + + setSessions(sessions: SessionSummary[]): void { + this.mockSessions = sessions; + } + + setFailListSessions(shouldFail: boolean): void { + this.shouldFailListSessions = shouldFail; + } } // Helper to create mock agent @@ -226,6 +252,112 @@ describe('AgentManager', () => { }); }); + describe('listSessions', () => { + function createMockSession(overrides: Partial = {}): SessionSummary { + return { + type: 'claude', + sessionId: 'session-1', + cwd: '/repo', + firstUserMessage: 'hello', + lastActive: new Date('2025-01-01T00:00:00Z'), + startedAt: new Date('2025-01-01T00:00:00Z'), + sessionFilePath: '/tmp/session-1.jsonl', + ...overrides, + }; + } + + it('returns empty array when no adapters are registered', async () => { + const result = await manager.listSessions(); + expect(result).toEqual([]); + }); + + it('merges sessions from every registered adapter', async () => { + const claudeSession = createMockSession({ type: 'claude', sessionId: 'c1' }); + const codexSession = createMockSession({ type: 'codex', sessionId: 'cx1' }); + manager.registerAdapter(new MockAdapter('claude', [], false, [claudeSession])); + manager.registerAdapter(new MockAdapter('codex', [], false, [codexSession])); + + const result = await manager.listSessions(); + + expect(result).toHaveLength(2); + expect(result.map((s) => s.sessionId).sort()).toEqual(['c1', 'cx1']); + }); + + it('sorts merged sessions by lastActive descending', async () => { + const older = createMockSession({ + sessionId: 'older', + lastActive: new Date('2025-01-01T00:00:00Z'), + }); + const newer = createMockSession({ + type: 'codex', + sessionId: 'newer', + lastActive: new Date('2025-06-01T00:00:00Z'), + }); + manager.registerAdapter(new MockAdapter('claude', [], false, [older])); + manager.registerAdapter(new MockAdapter('codex', [], false, [newer])); + + const result = await manager.listSessions(); + + expect(result.map((s) => s.sessionId)).toEqual(['newer', 'older']); + }); + + it('skips adapters whose type does not match opts.type', async () => { + const claudeAdapter = new MockAdapter( + 'claude', + [], + false, + [createMockSession({ type: 'claude', sessionId: 'c1' })], + ); + const codexAdapter = new MockAdapter( + 'codex', + [], + false, + [createMockSession({ type: 'codex', sessionId: 'cx1' })], + ); + manager.registerAdapter(claudeAdapter); + manager.registerAdapter(codexAdapter); + + const result = await manager.listSessions({ type: 'claude' }); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('c1'); + // Codex adapter must not have been called + expect(codexAdapter.lastListSessionsOpts).toBeUndefined(); + expect(claudeAdapter.lastListSessionsOpts).toEqual({ type: 'claude' }); + }); + + it('tolerates an adapter that throws and still returns the others', async () => { + const goodSession = createMockSession({ sessionId: 'good' }); + manager.registerAdapter(new MockAdapter('claude', [], false, [goodSession])); + manager.registerAdapter( + new MockAdapter('codex', [], false, [], true /* failListSessions */), + ); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + const result = await manager.listSessions(); + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('good'); + expect(consoleErrorSpy).toHaveBeenCalled(); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('passes the same opts to every called adapter', async () => { + const a = new MockAdapter('claude', [], false, []); + const b = new MockAdapter('codex', [], false, []); + manager.registerAdapter(a); + manager.registerAdapter(b); + + await manager.listSessions({ cwd: '/Users/test/proj' }); + + expect(a.lastListSessionsOpts).toEqual({ cwd: '/Users/test/proj' }); + expect(b.lastListSessionsOpts).toEqual({ cwd: '/Users/test/proj' }); + }); + }); + describe('resolveAgent', () => { it('should return null for empty input or empty agents list', () => { const agent = createMockAgent({ name: 'test-agent' }); diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 6e51537..46ff520 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -18,9 +18,13 @@ jest.mock('../../utils/process', () => ({ enrichProcesses: jest.fn(), })); -jest.mock('../../utils/session', () => ({ - batchGetSessionFileBirthtimes: jest.fn(), -})); +jest.mock('../../utils/session', () => { + const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session'); + return { + ...actual, + batchGetSessionFileBirthtimes: jest.fn(), + }; +}); jest.mock('../../utils/matching', () => ({ matchProcessesToSessions: jest.fn(), @@ -1288,4 +1292,156 @@ describe('ClaudeCodeAdapter', () => { expect(messages[0].content).toBe('Real question'); }); }); + + describe('listSessions', () => { + let tmpDir: string; + let projectsDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-list-')); + projectsDir = path.join(tmpDir, 'projects'); + fs.mkdirSync(projectsDir, { recursive: true }); + (adapter as any).projectsDir = projectsDir; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeSession(projectDir: string, sessionId: string, lines: object[]): string { + fs.mkdirSync(projectDir, { recursive: true }); + const filePath = path.join(projectDir, `${sessionId}.jsonl`); + fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n')); + return filePath; + } + + it('returns empty when projects dir does not exist', async () => { + fs.rmSync(projectsDir, { recursive: true, force: true }); + const result = await adapter.listSessions(); + expect(result).toEqual([]); + }); + + it('returns sessions from a single cwd-scoped project dir', async () => { + const cwd = '/Users/test/proj'; + const projDir = path.join(projectsDir, '-Users-test-proj'); + const filePath = writeSession(projDir, 'sess-1', [ + { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'first prompt' } }, + { type: 'assistant', timestamp: '2025-01-01T00:01:00Z' }, + ]); + + const result = await adapter.listSessions({ cwd }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'claude', + sessionId: 'sess-1', + cwd, + firstUserMessage: 'first prompt', + sessionFilePath: filePath, + }); + expect(result[0].lastActive).toBeInstanceOf(Date); + expect(result[0].startedAt).toBeInstanceOf(Date); + }); + + it('lists sessions from all project dirs when no cwd filter', async () => { + const cwdA = '/Users/test/proj-a'; + const cwdB = '/Users/test/proj-b'; + writeSession(path.join(projectsDir, '-Users-test-proj-a'), 'a', [ + { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdA, message: { content: 'msg-a' } }, + ]); + writeSession(path.join(projectsDir, '-Users-test-proj-b'), 'b', [ + { type: 'user', timestamp: '2025-01-02T00:00:00Z', cwd: cwdB, message: { content: 'msg-b' } }, + ]); + + const result = await adapter.listSessions(); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.sessionId).sort()).toEqual(['a', 'b']); + const cwds = result.map((r) => r.cwd).sort(); + expect(cwds).toEqual([cwdA, cwdB]); + }); + + it('drops sessions whose recorded cwd does not match opts.cwd (strict equality)', async () => { + const cwdReal = '/Users/test/foo'; + const cwdRequested = '/Users/test/foo/sub'; + writeSession(path.join(projectsDir, '-Users-test-foo'), 's', [ + { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdReal, message: { content: 'hi' } }, + ]); + + // Encoded dir for the requested cwd doesn't exist → return [] + const result = await adapter.listSessions({ cwd: cwdRequested }); + expect(result).toEqual([]); + }); + + it('drops sessions whose recorded cwd disagrees with the encoded dir', async () => { + // Edge case: encoded dir lookup matches, but session content + // records a different cwd. Strict-equality filter must reject. + const requested = '/Users/test/proj'; + const projDir = path.join(projectsDir, '-Users-test-proj'); + writeSession(projDir, 's', [ + { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: '/different/path', message: { content: 'mismatch' } }, + ]); + + const result = await adapter.listSessions({ cwd: requested }); + expect(result).toEqual([]); + }); + + it('finds sessions whose recorded cwd lives in a different encoded dir (worktree case)', async () => { + // Real-world case: Claude Code is launched in /repo, then chdirs into + // /repo/.worktrees/feature. The session file is stored under the + // ENCODED launch dir, but its content records the worktree path. + // listSessions({ cwd: worktree }) must still find it. + const launchDir = path.join(projectsDir, '-repo'); + const worktreeCwd = '/repo/.worktrees/feature'; + writeSession(launchDir, 'wt', [ + { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: worktreeCwd, message: { content: 'in worktree' } }, + ]); + + const result = await adapter.listSessions({ cwd: worktreeCwd }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + sessionId: 'wt', + cwd: worktreeCwd, + firstUserMessage: 'in worktree', + }); + }); + + it('skips malformed session files', async () => { + const cwd = '/Users/test/p'; + const projDir = path.join(projectsDir, '-Users-test-p'); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync(path.join(projDir, 'bad.jsonl'), 'not valid json'); + writeSession(projDir, 'good', [ + { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'ok' } }, + ]); + + const result = await adapter.listSessions({ cwd }); + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('good'); + }); + + it('captures first user message after filtering noise', async () => { + const cwd = '/Users/test/q'; + writeSession(path.join(projectsDir, '-Users-test-q'), 's', [ + { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'Tool loaded.' } }, + { type: 'user', timestamp: '2025-01-01T00:00:01Z', cwd, message: { content: 'real first prompt' } }, + { type: 'user', timestamp: '2025-01-01T00:00:02Z', cwd, message: { content: 'second prompt' } }, + ]); + + const result = await adapter.listSessions({ cwd }); + expect(result).toHaveLength(1); + expect(result[0].firstUserMessage).toBe('real first prompt'); + }); + + it('returns empty firstUserMessage when no user message exists', async () => { + const cwd = '/Users/test/empty'; + writeSession(path.join(projectsDir, '-Users-test-empty'), 's', [ + { type: 'assistant', timestamp: '2025-01-01T00:00:00Z' }, + ]); + + const result = await adapter.listSessions({ cwd }); + expect(result).toHaveLength(1); + expect(result[0].firstUserMessage).toBe(''); + }); + }); }); diff --git a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts index e8cc46b..25c18d4 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -19,9 +19,13 @@ jest.mock('../../utils/process', () => ({ enrichProcesses: jest.fn(), })); -jest.mock('../../utils/session', () => ({ - batchGetSessionFileBirthtimes: jest.fn(), -})); +jest.mock('../../utils/session', () => { + const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session'); + return { + ...actual, + batchGetSessionFileBirthtimes: jest.fn(), + }; +}); jest.mock('../../utils/matching', () => ({ matchProcessesToSessions: jest.fn(), @@ -630,4 +634,120 @@ describe('CodexAdapter', () => { expect(messages[0].content).toBe('Response'); }); }); + + describe('listSessions', () => { + let tmpDir: string; + let sessionsDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-list-')); + sessionsDir = path.join(tmpDir, 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + (adapter as any).codexSessionsDir = sessionsDir; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeCodexSession(dateDir: string, sessionId: string, lines: object[]): string { + fs.mkdirSync(dateDir, { recursive: true }); + const filePath = path.join(dateDir, `${sessionId}.jsonl`); + fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n')); + return filePath; + } + + it('returns empty when sessions dir does not exist', async () => { + fs.rmSync(sessionsDir, { recursive: true, force: true }); + const result = await adapter.listSessions(); + expect(result).toEqual([]); + }); + + it('walks every YYYY/MM/DD dir and returns all sessions', async () => { + const dayA = path.join(sessionsDir, '2025', '01', '01'); + const dayB = path.join(sessionsDir, '2025', '02', '03'); + writeCodexSession(dayA, 'sess-a', [ + { type: 'session_meta', payload: { id: 'sess-a', cwd: '/repo-a', timestamp: '2025-01-01T00:00:00Z' } }, + { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'msg-a' } }, + ]); + writeCodexSession(dayB, 'sess-b', [ + { type: 'session_meta', payload: { id: 'sess-b', cwd: '/repo-b', timestamp: '2025-02-03T00:00:00Z' } }, + { type: 'event', timestamp: '2025-02-03T00:00:01Z', payload: { type: 'user_message', message: 'msg-b' } }, + ]); + + const result = await adapter.listSessions(); + + expect(result).toHaveLength(2); + const byId = Object.fromEntries(result.map((r) => [r.sessionId, r])); + expect(byId['sess-a']).toMatchObject({ + type: 'codex', + cwd: '/repo-a', + firstUserMessage: 'msg-a', + }); + expect(byId['sess-b']).toMatchObject({ + type: 'codex', + cwd: '/repo-b', + firstUserMessage: 'msg-b', + }); + }); + + it('applies strict-equality cwd filter against session_meta cwd', async () => { + const day = path.join(sessionsDir, '2025', '01', '01'); + writeCodexSession(day, 'keep', [ + { type: 'session_meta', payload: { id: 'keep', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } }, + { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'yes' } }, + ]); + writeCodexSession(day, 'drop', [ + { type: 'session_meta', payload: { id: 'drop', cwd: '/other', timestamp: '2025-01-01T00:01:00Z' } }, + { type: 'event', timestamp: '2025-01-01T00:01:01Z', payload: { type: 'user_message', message: 'no' } }, + ]); + + const result = await adapter.listSessions({ cwd: '/repo' }); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('keep'); + }); + + it('skips files without a session_meta first line', async () => { + const day = path.join(sessionsDir, '2025', '01', '01'); + writeCodexSession(day, 'bad', [ + { type: 'event', timestamp: '2025-01-01T00:00:00Z', payload: { type: 'user_message', message: 'orphan' } }, + ]); + writeCodexSession(day, 'good', [ + { type: 'session_meta', payload: { id: 'good', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } }, + { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'ok' } }, + ]); + + const result = await adapter.listSessions(); + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('good'); + }); + + it('captures the first user_message as firstUserMessage', async () => { + const day = path.join(sessionsDir, '2025', '01', '01'); + writeCodexSession(day, 's', [ + { type: 'session_meta', payload: { id: 's', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } }, + { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'agent_message', message: 'preamble' } }, + { type: 'event', timestamp: '2025-01-01T00:00:02Z', payload: { type: 'user_message', message: 'first user' } }, + { type: 'event', timestamp: '2025-01-01T00:00:03Z', payload: { type: 'user_message', message: 'second user' } }, + ]); + + const result = await adapter.listSessions({ cwd: '/repo' }); + + expect(result).toHaveLength(1); + expect(result[0].firstUserMessage).toBe('first user'); + }); + + it('returns empty firstUserMessage when no user_message exists', async () => { + const day = path.join(sessionsDir, '2025', '01', '01'); + writeCodexSession(day, 's', [ + { type: 'session_meta', payload: { id: 's', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } }, + { type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'agent_message', message: 'agent only' } }, + ]); + + const result = await adapter.listSessions({ cwd: '/repo' }); + expect(result).toHaveLength(1); + expect(result[0].firstUserMessage).toBe(''); + }); + }); }); diff --git a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts index 6521d56..2938af7 100644 --- a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts @@ -760,6 +760,139 @@ describe('GeminiCliAdapter', () => { expect(adapter.getConversation(sessionPath)).toEqual([]); }); }); + + describe('listSessions', () => { + it('returns empty when ~/.gemini/tmp does not exist', async () => { + // tmpHome has no .gemini dir by default + const result = await adapter.listSessions(); + expect(result).toEqual([]); + }); + + it('walks every shortId/chats dir and returns sessions', async () => { + writeSession(tmpHome, 'aaa', 'session-1', { + sessionId: 's-1', + projectHash: hashProjectRoot('/repo-a'), + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:01:00Z', + directories: ['/repo-a'], + messages: [ + { type: 'user', timestamp: '2025-01-01T00:00:00Z', content: [{ text: 'hello a' }] }, + ], + }); + writeSession(tmpHome, 'bbb', 'session-2', { + sessionId: 's-2', + projectHash: hashProjectRoot('/repo-b'), + startTime: '2025-01-02T00:00:00Z', + lastUpdated: '2025-01-02T00:01:00Z', + directories: ['/repo-b'], + messages: [ + { type: 'user', timestamp: '2025-01-02T00:00:00Z', content: [{ text: 'hello b' }] }, + ], + }); + + const result = await adapter.listSessions(); + + expect(result).toHaveLength(2); + const byId = Object.fromEntries(result.map((r) => [r.sessionId, r])); + expect(byId['s-1']).toMatchObject({ + type: 'gemini_cli', + cwd: '/repo-a', + firstUserMessage: 'hello a', + }); + expect(byId['s-2']).toMatchObject({ + type: 'gemini_cli', + cwd: '/repo-b', + firstUserMessage: 'hello b', + }); + }); + + it('applies strict-equality cwd filter against directories[0]', async () => { + writeSession(tmpHome, 'aaa', 'session-keep', { + sessionId: 'keep', + projectHash: hashProjectRoot('/repo'), + startTime: '2025-01-01T00:00:00Z', + directories: ['/repo'], + messages: [{ type: 'user', timestamp: '2025-01-01T00:00:00Z', content: 'yes' }], + }); + writeSession(tmpHome, 'bbb', 'session-drop', { + sessionId: 'drop', + projectHash: hashProjectRoot('/other'), + startTime: '2025-01-01T00:00:00Z', + directories: ['/other'], + messages: [{ type: 'user', timestamp: '2025-01-01T00:00:00Z', content: 'no' }], + }); + + const result = await adapter.listSessions({ cwd: '/repo' }); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('keep'); + }); + + it('skips malformed JSON files', async () => { + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', 'aaa', 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync(path.join(chatsDir, 'session-bad.json'), '{ not json'); + writeSession(tmpHome, 'aaa', 'session-good', { + sessionId: 'good', + projectHash: hashProjectRoot('/repo'), + startTime: '2025-01-01T00:00:00Z', + directories: ['/repo'], + messages: [{ type: 'user', timestamp: '2025-01-01T00:00:00Z', content: 'ok' }], + }); + + const result = await adapter.listSessions(); + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe('good'); + }); + + it('skips files missing sessionId', async () => { + writeSession(tmpHome, 'aaa', 'session-no-id', { + projectHash: hashProjectRoot('/repo'), + startTime: '2025-01-01T00:00:00Z', + directories: ['/repo'], + messages: [], + }); + + const result = await adapter.listSessions(); + expect(result).toEqual([]); + }); + + it('captures the first user-typed message as firstUserMessage', async () => { + writeSession(tmpHome, 'aaa', 'session-x', { + sessionId: 's', + projectHash: hashProjectRoot('/repo'), + startTime: '2025-01-01T00:00:00Z', + directories: ['/repo'], + messages: [ + { type: 'gemini', timestamp: '2025-01-01T00:00:00Z', content: 'preamble' }, + { type: 'user', timestamp: '2025-01-01T00:00:01Z', content: [{ text: 'first user' }] }, + { type: 'user', timestamp: '2025-01-01T00:00:02Z', content: 'second user' }, + ], + }); + + const result = await adapter.listSessions({ cwd: '/repo' }); + + expect(result).toHaveLength(1); + expect(result[0].firstUserMessage).toBe('first user'); + }); + + it('returns empty firstUserMessage when no user message exists', async () => { + writeSession(tmpHome, 'aaa', 'session-x', { + sessionId: 's', + projectHash: hashProjectRoot('/repo'), + startTime: '2025-01-01T00:00:00Z', + directories: ['/repo'], + messages: [ + { type: 'gemini', timestamp: '2025-01-01T00:00:00Z', content: 'agent only' }, + ], + }); + + const result = await adapter.listSessions({ cwd: '/repo' }); + + expect(result).toHaveLength(1); + expect(result[0].firstUserMessage).toBe(''); + }); + }); }); /** diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index f0192ec..f13a5ff 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -81,6 +81,70 @@ export interface ConversationMessage { timestamp?: string; } +/** + * A historical session discovered on disk (running or not). + * + * Used by `listSessions` to surface enough context for a user to identify + * a session and resume it via the originating tool's resume command. + */ +export interface SessionSummary { + /** Tool that produced this session */ + type: AgentType; + + /** + * ID accepted by the tool's resume command. Adapters MUST pass this + * through verbatim — no normalization, no encoding/decoding — so it + * round-trips into `claude --resume ` (and equivalents). + */ + sessionId: string; + + /** Working directory the session was started in (best-known value) */ + cwd: string; + + /** + * Trimmed first user message; empty string if none. Adapters apply + * the same noise-filter their existing parsers use (skip tool_result + * blocks, request-interruption notices, system-injected skill + * content). The CLI table renderer substitutes a placeholder for + * empty values; JSON output keeps the empty string raw. + */ + firstUserMessage: string; + + /** Last activity timestamp (from session content; falls back to file mtime) */ + lastActive: Date; + + /** Session start time (from session content; falls back to file birthtime/mtime) */ + startedAt: Date; + + /** Absolute path to the session file on disk (debug/diagnostics) */ + sessionFilePath: string; +} + +/** + * Filters passed by the CLI to {@link AgentAdapter.listSessions}. + * + * The CLI is the source of truth for filter defaults and semantics + * (e.g. cwd defaults to process.cwd(); --all clears it). Adapters apply + * the values they receive — they don't invent defaults. + */ +export interface ListSessionsOptions { + /** + * Filter to sessions whose recorded cwd matches this path using strict + * equality (no prefix/ancestor matching in v1). Undefined = no cwd + * filter. + */ + cwd?: string; + + /** + * Filter to a single tool. Enforced by `AgentManager.listSessions`, + * which skips adapters whose `type` doesn't match. Adapters MAY + * ignore this field — by the time their `listSessions` runs, the + * type filter is already satisfied. Undefined = include every + * registered adapter. + */ + type?: AgentType; +} + /** * Agent Adapter Interface * @@ -110,4 +174,16 @@ export interface AgentAdapter { * @returns Array of conversation messages */ getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[]; + + /** + * Enumerate historical sessions for this tool from disk. + * + * Applies `opts.cwd` as a strict-equality filter when set. Returns + * {@link SessionSummary} entries unsorted; sorting and global filters + * are handled by `AgentManager` and the CLI. + * + * @param opts Filter options computed by the CLI + * @returns Array of sessions discovered on disk + */ + listSessions(opts?: ListSessionsOptions): Promise; } diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index f1a17f4..7395457 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -1,9 +1,16 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter'; +import type { + AgentAdapter, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, +} from './AgentAdapter'; import { AgentStatus } from './AgentAdapter'; import { listAgentProcesses, enrichProcesses } from '../utils/process'; -import { batchGetSessionFileBirthtimes } from '../utils/session'; +import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session'; import type { SessionFile } from '../utils/session'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching'; import { ClaudeSessionParser } from '../utils/ClaudeSessionParser'; @@ -265,4 +272,71 @@ export class ClaudeCodeAdapter implements AgentAdapter { getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { return this.parser.getConversation(sessionFilePath, options); } + + async listSessions(opts?: ListSessionsOptions): Promise { + const filterCwd = opts?.cwd; + const candidates = this.discoverSessionFiles(); + const summaries: SessionSummary[] = []; + + for (const { filePath, defaultCwd } of candidates) { + const session = this.parser.readSession(filePath, defaultCwd); + if (!session) continue; + + // Drop sessions whose JSONL had no parseable conversation entries. + // readSession is permissive (returns a shell record even when every + // line fails to parse); listSessions needs at least one real entry + // so we don't surface garbage files. + if (!session.lastEntryType) continue; + + const recordedCwd = session.lastCwd || defaultCwd; + if (filterCwd !== undefined && recordedCwd !== filterCwd) continue; + + const stat = safeStat(filePath); + + summaries.push({ + type: 'claude', + sessionId: session.sessionId, + cwd: recordedCwd, + firstUserMessage: session.firstUserMessage || '', + lastActive: session.lastActive ?? stat?.mtime ?? new Date(), + startedAt: session.sessionStart ?? stat?.birthtime ?? stat?.mtime ?? new Date(), + sessionFilePath: filePath, + }); + } + + return summaries; + } + + /** + * Discover candidate session files for {@link listSessions}. + * + * Always walks every subdirectory of `projectsDir`. We can't use the + * encoded-dir shortcut for the cwd-scoped path because Claude Code + * indexes session files by where the *process was launched*, not by + * the recorded `cwd` field inside the session — these diverge in + * worktrees and similar setups. The cwd filter is applied later + * against `session.lastCwd` so callers see exactly the sessions whose + * recorded cwd matches. + */ + private discoverSessionFiles(): Array<{ filePath: string; defaultCwd: string }> { + const out: Array<{ filePath: string; defaultCwd: string }> = []; + + if (!isDirectory(this.projectsDir)) return out; + + for (const dirName of safeReaddir(this.projectsDir)) { + const projectDir = path.join(this.projectsDir, dirName); + if (!isDirectory(projectDir)) continue; + + // Best-effort decode for the rare case session content has no + // recorded cwd: '-Users-foo-bar' → '/Users/foo/bar'. Lossy for + // paths containing '-'; session content's lastCwd overrides + // this when available. + const decoded = dirName.replace(/-/g, '/'); + for (const name of listJsonl(projectDir)) { + out.push({ filePath: path.join(projectDir, name), defaultCwd: decoded }); + } + } + + return out; + } } diff --git a/packages/agent-manager/src/adapters/CodexAdapter.ts b/packages/agent-manager/src/adapters/CodexAdapter.ts index 7a85fbd..a3ec5e0 100644 --- a/packages/agent-manager/src/adapters/CodexAdapter.ts +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -12,10 +12,17 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter'; +import type { + AgentAdapter, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, +} from './AgentAdapter'; import { AgentStatus } from './AgentAdapter'; import { listAgentProcesses, enrichProcesses } from '../utils/process'; -import { batchGetSessionFileBirthtimes } from '../utils/session'; +import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session'; import type { SessionFile } from '../utils/session'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching'; @@ -326,12 +333,8 @@ export class CodexAdapter implements AgentAdapter { getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { const verbose = options?.verbose ?? false; - let content: string; - try { - content = fs.readFileSync(sessionFilePath, 'utf-8'); - } catch { - return []; - } + const content = safeReadFile(sessionFilePath); + if (content === undefined) return []; const lines = content.trim().split('\n'); const messages: ConversationMessage[] = []; @@ -372,4 +375,119 @@ export class CodexAdapter implements AgentAdapter { return messages; } + + async listSessions(opts?: ListSessionsOptions): Promise { + if (!isDirectory(this.codexSessionsDir)) return []; + + const files = this.collectAllSessionFiles(); + const summaries: SessionSummary[] = []; + + for (const filePath of files) { + const summary = this.fileToSessionSummary(filePath); + if (!summary) continue; + if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue; + summaries.push(summary); + } + + return summaries; + } + + /** + * Walk every YYYY/MM/DD directory under `codexSessionsDir` and return + * absolute paths of `.jsonl` files. Tolerates malformed layouts + * (skips entries that aren't directories at the expected depth). + */ + private collectAllSessionFiles(): string[] { + const out: string[] = []; + + for (const yearEntry of safeReaddir(this.codexSessionsDir)) { + const yearDir = path.join(this.codexSessionsDir, yearEntry); + if (!isDirectory(yearDir)) continue; + + for (const monthEntry of safeReaddir(yearDir)) { + const monthDir = path.join(yearDir, monthEntry); + if (!isDirectory(monthDir)) continue; + + for (const dayEntry of safeReaddir(monthDir)) { + const dayDir = path.join(monthDir, dayEntry); + if (!isDirectory(dayDir)) continue; + + for (const fileEntry of safeReaddir(dayDir)) { + if (!fileEntry.endsWith('.jsonl')) continue; + out.push(path.join(dayDir, fileEntry)); + } + } + } + } + + return out; + } + + /** + * Read a Codex session JSONL file and produce a {@link SessionSummary}. + * Returns null when the file is unreadable, has no `session_meta`, or + * lacks a session id. + */ + private fileToSessionSummary(filePath: string): SessionSummary | null { + const content = safeReadFile(filePath); + if (content === undefined) 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; + } + + if (metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) { + return null; + } + + let firstUserMessage = ''; + let lastTimestamp: Date | null = null; + + for (let i = 1; i < allLines.length; i++) { + let entry: CodexEventEntry; + try { + entry = JSON.parse(allLines[i]); + } catch { + continue; + } + + const ts = this.parseTimestamp(entry.timestamp); + if (ts) lastTimestamp = ts; + + if ( + !firstUserMessage && + entry.payload?.type === 'user_message' && + typeof entry.payload.message === 'string' && + entry.payload.message.trim().length > 0 + ) { + firstUserMessage = entry.payload.message.trim(); + } + } + + const stat = safeStat(filePath); + + const startedAt = + this.parseTimestamp(metaEntry.payload.timestamp) || + lastTimestamp || + stat?.birthtime || + stat?.mtime || + new Date(); + const lastActive = lastTimestamp || startedAt; + + return { + type: 'codex', + sessionId: metaEntry.payload.id, + cwd: metaEntry.payload.cwd || '', + firstUserMessage, + lastActive, + startedAt, + sessionFilePath: filePath, + }; + } } diff --git a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts index c37d4cc..6b1a28f 100644 --- a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts +++ b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts @@ -13,9 +13,17 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter'; +import type { + AgentAdapter, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, +} from './AgentAdapter'; import { AgentStatus } from './AgentAdapter'; import { listAgentProcesses, enrichProcesses } from '../utils/process'; +import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session'; import type { SessionFile } from '../utils/session'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching'; @@ -441,12 +449,8 @@ export class GeminiCliAdapter implements AgentAdapter { getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { const verbose = options?.verbose ?? false; - let content: string; - try { - content = fs.readFileSync(sessionFilePath, 'utf-8'); - } catch { - return []; - } + const content = safeReadFile(sessionFilePath); + if (content === undefined) return []; let parsed: GeminiSessionFile; try { @@ -485,4 +489,95 @@ export class GeminiCliAdapter implements AgentAdapter { return messages; } + + async listSessions(opts?: ListSessionsOptions): Promise { + if (!isDirectory(this.geminiTmpDir)) return []; + + const summaries: SessionSummary[] = []; + + for (const shortId of safeReaddir(this.geminiTmpDir)) { + const chatsDir = path.join( + this.geminiTmpDir, + shortId, + GeminiCliAdapter.CHATS_DIR_NAME, + ); + if (!isDirectory(chatsDir)) continue; + + for (const fileName of safeReaddir(chatsDir)) { + if ( + !fileName.startsWith(GeminiCliAdapter.SESSION_FILE_PREFIX) || + !fileName.endsWith('.json') + ) { + continue; + } + + const filePath = path.join(chatsDir, fileName); + const summary = this.fileToSessionSummary(filePath); + if (!summary) continue; + if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue; + summaries.push(summary); + } + } + + return summaries; + } + + /** + * Read a Gemini session JSON file and produce a {@link SessionSummary}. + * Returns null when the file is unreadable, the JSON doesn't parse, + * or the body lacks a sessionId. + */ + private fileToSessionSummary(filePath: string): SessionSummary | null { + const content = safeReadFile(filePath); + if (content === undefined) return null; + + let parsed: GeminiSessionFile; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + + if (!parsed.sessionId) return null; + + const messages = Array.isArray(parsed.messages) ? parsed.messages : []; + const firstUserMessage = this.extractFirstUserMessage(messages); + + const cwd = + Array.isArray(parsed.directories) && parsed.directories.length > 0 + ? parsed.directories[0] + : ''; + + const stat = safeStat(filePath); + + const lastEntryTimestamp = this.parseTimestamp( + messages.length > 0 ? messages[messages.length - 1]?.timestamp : undefined, + ); + const lastActive = + this.parseTimestamp(parsed.lastUpdated) || + lastEntryTimestamp || + stat?.mtime || + new Date(); + const startedAt = + this.parseTimestamp(parsed.startTime) || stat?.birthtime || stat?.mtime || lastActive; + + return { + type: 'gemini_cli', + sessionId: parsed.sessionId, + cwd, + firstUserMessage, + lastActive, + startedAt, + sessionFilePath: filePath, + }; + } + + private extractFirstUserMessage(messages: GeminiMessageEntry[]): string { + for (const entry of messages) { + if (entry?.type !== 'user') continue; + const text = this.messageText(entry).trim(); + if (text) return text; + } + return ''; + } } diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index b10624a..0817a01 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -4,7 +4,15 @@ export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; export { CodexAdapter } from './adapters/CodexAdapter'; export { GeminiCliAdapter } from './adapters/GeminiCliAdapter'; export { AgentStatus } from './adapters/AgentAdapter'; -export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, ConversationMessage } from './adapters/AgentAdapter'; +export type { + AgentAdapter, + AgentType, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, +} from './adapters/AgentAdapter'; export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager'; export type { TerminalLocation } from './terminal/TerminalFocusManager'; diff --git a/packages/agent-manager/src/utils/ClaudeSessionParser.ts b/packages/agent-manager/src/utils/ClaudeSessionParser.ts index b9950b0..13cd696 100644 --- a/packages/agent-manager/src/utils/ClaudeSessionParser.ts +++ b/packages/agent-manager/src/utils/ClaudeSessionParser.ts @@ -47,6 +47,8 @@ export interface ClaudeSession { lastEntryType?: string; isInterrupted: boolean; lastUserMessage?: string; + /** First meaningful user prompt in the session (post noise filter) */ + firstUserMessage?: string; } /** Entry types that are metadata, not conversation state. */ @@ -91,6 +93,7 @@ export class ClaudeSessionParser { let lastCwd: string | undefined; let isInterrupted = false; let lastUserMessage: string | undefined; + let firstUserMessage: string | undefined; for (const line of allLines) { try { @@ -125,6 +128,9 @@ export class ClaudeSessionParser { const text = this.extractUserMessageText(msgContent); if (text) { lastUserMessage = text; + if (!firstUserMessage) { + firstUserMessage = text; + } } } else { isInterrupted = false; @@ -144,6 +150,7 @@ export class ClaudeSessionParser { lastEntryType, isInterrupted, lastUserMessage, + firstUserMessage, }; } diff --git a/packages/agent-manager/src/utils/session.ts b/packages/agent-manager/src/utils/session.ts index 2a58fe3..5060c8c 100644 --- a/packages/agent-manager/src/utils/session.ts +++ b/packages/agent-manager/src/utils/session.ts @@ -28,6 +28,60 @@ export interface SessionFile { resolvedCwd: string; } +/** + * Check whether a path exists and is a directory. + * Returns false on any error (missing path, permission denied, broken symlink, etc.). + */ +export function isDirectory(p: string): boolean { + return safeStat(p)?.isDirectory() ?? false; +} + +/** + * `fs.statSync` that swallows errors and returns `undefined` on failure. + * Callers can pull whichever fields they need (mtime, birthtime, ...). + */ +export function safeStat(filePath: string): fs.Stats | undefined { + try { + return fs.statSync(filePath); + } catch { + return undefined; + } +} + +/** + * `fs.readFileSync` (utf-8) that swallows errors and returns `undefined` + * on failure. Use when an unreadable file should be skipped rather than + * raised. + */ +export function safeReadFile(filePath: string): string | undefined { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return undefined; + } +} + +/** + * `fs.readdirSync` that swallows errors and returns `[]` on failure. + * Useful when walking optional/transient directories where missing or + * unreadable entries should be skipped silently. + */ +export function safeReaddir(dir: string): string[] { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +} + +/** + * List entries in a directory that end with `.jsonl`. Returns `[]` on + * read errors. The result preserves directory order (no sorting). + */ +export function listJsonl(dir: string): string[] { + return safeReaddir(dir).filter((name) => name.endsWith('.jsonl')); +} + /** * Get birth times for .jsonl session files across multiple directories. * diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 01736b6..8197c55 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -7,6 +7,7 @@ import { ui } from '../../util/terminal-ui'; const mockManager: any = { registerAdapter: jest.fn(), listAgents: jest.fn(), + listSessions: jest.fn(), resolveAgent: jest.fn(), }; @@ -317,4 +318,150 @@ Waiting on user input`, expect(ui.error).toHaveBeenCalledWith('Cannot find terminal for agent "repo-a" (PID: 10).'); expect(mockTtyWriterSend).not.toHaveBeenCalled(); }); + + describe('sessions', () => { + function makeSession(overrides: Record = {}) { + return { + type: 'claude', + sessionId: 'sess-1', + cwd: '/repo', + firstUserMessage: 'hello', + lastActive: new Date('2025-01-01T00:00:00Z'), + startedAt: new Date('2025-01-01T00:00:00Z'), + sessionFilePath: '/tmp/sess-1.jsonl', + ...overrides, + }; + } + + it('passes process.cwd() as the default cwd filter', async () => { + mockManager.listSessions.mockResolvedValue([]); + const cwd = process.cwd(); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions']); + + expect(mockManager.listSessions).toHaveBeenCalledWith({ cwd, type: undefined }); + }); + + it('clears the cwd filter when --all is set', async () => { + mockManager.listSessions.mockResolvedValue([]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions', '--all']); + + expect(mockManager.listSessions).toHaveBeenCalledWith({ cwd: undefined, type: undefined }); + }); + + it('forwards --type to the manager', async () => { + mockManager.listSessions.mockResolvedValue([]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions', '--all', '--type', 'codex']); + + expect(mockManager.listSessions).toHaveBeenCalledWith({ cwd: undefined, type: 'codex' }); + }); + + it('emits JSON with ISO date strings for --json', async () => { + const session = makeSession(); + mockManager.listSessions.mockResolvedValue([session]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions', '--all', '--json']); + + const printed = (logSpy.mock.calls[0]?.[0] ?? '') as string; + const parsed = JSON.parse(printed); + expect(parsed).toEqual([ + { + type: 'claude', + sessionId: 'sess-1', + cwd: '/repo', + firstUserMessage: 'hello', + lastActive: '2025-01-01T00:00:00.000Z', + startedAt: '2025-01-01T00:00:00.000Z', + sessionFilePath: '/tmp/sess-1.jsonl', + }, + ]); + }); + + it('renders the table with the documented column order', async () => { + const session = makeSession({ firstUserMessage: 'real first prompt' }); + mockManager.listSessions.mockResolvedValue([session]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions', '--all']); + + expect(ui.table).toHaveBeenCalledTimes(1); + const tableArg = (ui.table as jest.Mock).mock.calls[0][0] as { + headers: string[]; + rows: string[][]; + }; + expect(tableArg.headers).toEqual([ + 'Type', + 'Session ID', + 'CWD', + 'First Message', + 'Last Active', + ]); + expect(tableArg.rows).toHaveLength(1); + const [, idCell, , firstMsgCell] = tableArg.rows[0]; + expect(idCell).toBe('sess-1'); + expect(firstMsgCell).toBe('real first prompt'); + }); + + it('shows the --all hint when default-cwd lookup is empty', async () => { + mockManager.listSessions.mockResolvedValue([]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions']); + + const infoCalls = (ui.info as jest.Mock).mock.calls.map((c: unknown[]) => c[0]); + expect(infoCalls.some((m: unknown) => typeof m === 'string' && m.includes('--all'))).toBe(true); + }); + + it('substitutes "(no message yet)" placeholder in the table for empty firstUserMessage', async () => { + mockManager.listSessions.mockResolvedValue([makeSession({ firstUserMessage: '' })]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions', '--all']); + + const tableArg = (ui.table as jest.Mock).mock.calls[0][0] as { rows: string[][] }; + expect(tableArg.rows[0][3]).toBe('(no message yet)'); + }); + + it('keeps empty firstUserMessage raw in --json output', async () => { + mockManager.listSessions.mockResolvedValue([makeSession({ firstUserMessage: '' })]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions', '--all', '--json']); + + const parsed = JSON.parse((logSpy.mock.calls[0]?.[0] ?? '') as string) as Array<{ + firstUserMessage: string; + }>; + expect(parsed[0].firstUserMessage).toBe(''); + }); + + it('applies --limit by slicing after merge', async () => { + const sessions = [ + makeSession({ sessionId: 's1' }), + makeSession({ sessionId: 's2' }), + makeSession({ sessionId: 's3' }), + ]; + mockManager.listSessions.mockResolvedValue(sessions); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'sessions', '--all', '--limit', '2', '--json']); + + const parsed = JSON.parse((logSpy.mock.calls[0]?.[0] ?? '') as string) as Array<{ sessionId: string }>; + expect(parsed.map((s) => s.sessionId)).toEqual(['s1', 's2']); + }); + }); }); diff --git a/packages/cli/src/__tests__/util/sessions.test.ts b/packages/cli/src/__tests__/util/sessions.test.ts new file mode 100644 index 0000000..c0baa63 --- /dev/null +++ b/packages/cli/src/__tests__/util/sessions.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, afterEach } from '@jest/globals'; +import { + formatFirstMessage, + parseLimit, + resolveListSessionsOptions, + toJsonSession, +} from '../../util/sessions'; + +describe('sessions util', () => { + describe('resolveListSessionsOptions', () => { + const realCwd = process.cwd(); + + afterEach(() => { + // process.cwd() is real; nothing to restore. + }); + + it('falls back to process.cwd() and flags usedDefaultCwd=true', () => { + const result = resolveListSessionsOptions({}); + expect(result.adapterOptions.cwd).toBe(realCwd); + expect(result.usedDefaultCwd).toBe(true); + expect(result.adapterOptions.type).toBeUndefined(); + }); + + it('clears the cwd filter when --all is set', () => { + const result = resolveListSessionsOptions({ all: true }); + expect(result.adapterOptions.cwd).toBeUndefined(); + expect(result.usedDefaultCwd).toBe(false); + }); + + it('uses --cwd verbatim when provided without --all', () => { + const result = resolveListSessionsOptions({ cwd: '/Users/test/proj' }); + expect(result.adapterOptions.cwd).toBe('/Users/test/proj'); + expect(result.usedDefaultCwd).toBe(false); + }); + + it('--all wins over --cwd', () => { + const result = resolveListSessionsOptions({ all: true, cwd: '/ignored' }); + expect(result.adapterOptions.cwd).toBeUndefined(); + }); + + it('empty --cwd string falls back to process.cwd()', () => { + const result = resolveListSessionsOptions({ cwd: '' }); + expect(result.adapterOptions.cwd).toBe(realCwd); + expect(result.usedDefaultCwd).toBe(true); + }); + + it('forwards a valid --type', () => { + for (const type of ['claude', 'codex', 'gemini_cli'] as const) { + const result = resolveListSessionsOptions({ all: true, type }); + expect(result.adapterOptions.type).toBe(type); + } + }); + + it('throws on an invalid --type', () => { + expect(() => resolveListSessionsOptions({ all: true, type: 'wrong' })).toThrow( + 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli.', + ); + }); + + it('treats an empty --type as undefined (no filter)', () => { + const result = resolveListSessionsOptions({ all: true, type: '' }); + expect(result.adapterOptions.type).toBeUndefined(); + }); + }); + + describe('parseLimit', () => { + it('returns 50 by default when the flag is omitted', () => { + expect(parseLimit(undefined)).toBe(50); + }); + + it('parses a string integer', () => { + expect(parseLimit('25')).toBe(25); + }); + + it('passes a number through directly', () => { + expect(parseLimit(7)).toBe(7); + }); + + it('returns undefined (no cap) when the value is 0', () => { + expect(parseLimit('0')).toBeUndefined(); + expect(parseLimit(0)).toBeUndefined(); + }); + + it('throws on a non-numeric string', () => { + expect(() => parseLimit('abc')).toThrow(/non-negative integer/); + }); + + it('throws on a negative number', () => { + expect(() => parseLimit('-3')).toThrow(/non-negative integer/); + expect(() => parseLimit(-1)).toThrow(/non-negative integer/); + }); + }); + + describe('formatFirstMessage', () => { + it('substitutes the placeholder for an empty string', () => { + expect(formatFirstMessage('')).toBe('(no message yet)'); + }); + + it('passes short text through unchanged', () => { + expect(formatFirstMessage('hello world')).toBe('hello world'); + }); + + it('truncates long text to 80 chars with an ellipsis suffix', () => { + const long = 'x'.repeat(200); + const out = formatFirstMessage(long); + expect(out).toHaveLength(80); + expect(out.endsWith('…')).toBe(true); + }); + + it('passes text exactly at 80 chars unchanged', () => { + const eighty = 'a'.repeat(80); + expect(formatFirstMessage(eighty)).toBe(eighty); + }); + }); + + describe('toJsonSession', () => { + it('serializes Date fields as ISO strings and passes others through', () => { + const out = toJsonSession({ + type: 'claude', + sessionId: 'abc', + cwd: '/repo', + firstUserMessage: 'hi', + lastActive: new Date('2025-06-01T12:00:00Z'), + startedAt: new Date('2025-06-01T11:00:00Z'), + sessionFilePath: '/tmp/abc.jsonl', + }); + + expect(out).toEqual({ + type: 'claude', + sessionId: 'abc', + cwd: '/repo', + firstUserMessage: 'hi', + lastActive: '2025-06-01T12:00:00.000Z', + startedAt: '2025-06-01T11:00:00.000Z', + sessionFilePath: '/tmp/abc.jsonl', + }); + }); + + it('keeps an empty firstUserMessage raw (no placeholder substitution)', () => { + const out = toJsonSession({ + type: 'codex', + sessionId: 'cx', + cwd: '', + firstUserMessage: '', + lastActive: new Date('2025-01-01T00:00:00Z'), + startedAt: new Date('2025-01-01T00:00:00Z'), + sessionFilePath: '/tmp/cx.jsonl', + }); + expect(out.firstUserMessage).toBe(''); + expect(out.cwd).toBe(''); + }); + }); +}); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index f686c75..1744e66 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -15,6 +15,12 @@ import { } from '@ai-devkit/agent-manager'; import { ui } from '../util/terminal-ui'; import { withErrorHandler } from '../util/errors'; +import { + formatFirstMessage, + parseLimit, + resolveListSessionsOptions, + toJsonSession, +} from '../util/sessions'; const STATUS_DISPLAY: Record = { [AgentStatus.RUNNING]: { emoji: '🟢', label: 'run' }, @@ -134,6 +140,56 @@ export function registerAgentCommand(program: Command): void { } })); + agentCommand + .command('sessions') + .description('List historical Claude/Codex/Gemini sessions for resume') + .option('--all', 'Include sessions from every cwd (default: only current cwd)') + .option('--cwd ', 'Override the cwd filter (implies non-default scope)') + .option('--type ', 'Filter to one of: claude, codex, gemini_cli') + .option('--limit ', 'Max rows to print (default: 50; 0 = no limit)', '50') + .option('-j, --json', 'Output as JSON') + .action(withErrorHandler('list sessions', async (options) => { + const opts = resolveListSessionsOptions(options); + const manager = createAgentManager(); + let sessions = await manager.listSessions(opts.adapterOptions); + + const limit = parseLimit(options.limit); + if (limit !== undefined) { + sessions = sessions.slice(0, limit); + } + + if (options.json) { + console.log(JSON.stringify(sessions.map(toJsonSession), null, 2)); + return; + } + + if (sessions.length === 0) { + ui.info(opts.usedDefaultCwd + ? `No sessions found for ${formatCwd(opts.adapterOptions.cwd)}. Use --all to broaden.` + : 'No sessions found.'); + return; + } + + ui.text('Sessions:', { breakline: true }); + ui.table({ + headers: ['Type', 'Session ID', 'CWD', 'First Message', 'Last Active'], + rows: sessions.map((s) => [ + formatType(s.type), + s.sessionId, + formatCwd(s.cwd), + formatFirstMessage(s.firstUserMessage), + formatRelativeTime(s.lastActive), + ]), + columnStyles: [ + (text) => chalk.dim(text), + (text) => chalk.cyan(text), + (text) => chalk.dim(text), + (text) => text, + (text) => chalk.dim(text), + ], + }); + })); + agentCommand .command('open ') .description('Focus a running agent terminal') diff --git a/packages/cli/src/util/sessions.ts b/packages/cli/src/util/sessions.ts new file mode 100644 index 0000000..61100a7 --- /dev/null +++ b/packages/cli/src/util/sessions.ts @@ -0,0 +1,94 @@ +import type { + AgentType, + ListSessionsOptions, + SessionSummary, +} from '@ai-devkit/agent-manager'; +import { truncate } from './text'; + +const FIRST_MESSAGE_MAX_WIDTH = 80; +const FIRST_MESSAGE_PLACEHOLDER = '(no message yet)'; +const VALID_AGENT_TYPES: AgentType[] = ['claude', 'codex', 'gemini_cli']; + +export interface ResolvedListSessionsOptions { + adapterOptions: ListSessionsOptions; + /** True when the cwd filter fell back to process.cwd() (no --all/--cwd given). */ + usedDefaultCwd: boolean; +} + +/** + * Translate the raw `agent sessions` flags into the options object passed + * to `AgentManager.listSessions`. CLI is the source of truth for filter + * defaults — adapters apply what they receive. + */ +export function resolveListSessionsOptions(options: { + all?: boolean; + cwd?: string; + type?: string; +}): ResolvedListSessionsOptions { + let cwd: string | undefined; + let usedDefaultCwd = false; + if (options.all) { + cwd = undefined; + } else if (typeof options.cwd === 'string' && options.cwd.length > 0) { + cwd = options.cwd; + } else { + cwd = process.cwd(); + usedDefaultCwd = true; + } + + let type: AgentType | undefined; + if (typeof options.type === 'string' && options.type.length > 0) { + if (!VALID_AGENT_TYPES.includes(options.type as AgentType)) { + throw new Error( + `Invalid --type "${options.type}". Expected one of: ${VALID_AGENT_TYPES.join(', ')}.`, + ); + } + type = options.type as AgentType; + } + + return { adapterOptions: { cwd, type }, usedDefaultCwd }; +} + +/** + * Parse the `--limit ` flag. Returns: + * - `50` when the flag is omitted (default). + * - `undefined` when the flag is `0` (meaning "no cap"). + * - The parsed integer otherwise. + * Throws on negative or non-numeric input. + */ +export function parseLimit(raw: string | number | undefined): number | undefined { + if (raw === undefined) return 50; + const n = typeof raw === 'number' ? raw : parseInt(raw, 10); + if (Number.isNaN(n) || n < 0) { + throw new Error(`--limit must be a non-negative integer (got "${raw}")`); + } + return n === 0 ? undefined : n; +} + +/** + * Render the `firstUserMessage` field for the table column: + * - Empty string → "(no message yet)" placeholder. + * - Long strings truncated to 80 chars with an ellipsis. + * JSON output keeps the raw string; this is render-only. + */ +export function formatFirstMessage(text: string): string { + const display = text.length > 0 ? text : FIRST_MESSAGE_PLACEHOLDER; + return truncate(display, FIRST_MESSAGE_MAX_WIDTH, '…'); +} + +/** + * Convert a {@link SessionSummary} into the JSON-serializable shape + * exposed by `agent sessions --json`. Dates become ISO strings; every + * other field is passed through unchanged. + */ +export function toJsonSession(session: SessionSummary): Record { + return { + type: session.type, + sessionId: session.sessionId, + cwd: session.cwd, + firstUserMessage: session.firstUserMessage, + lastActive: session.lastActive.toISOString(), + startedAt: session.startedAt.toISOString(), + sessionFilePath: session.sessionFilePath, + }; +}