Skip to content

Commit b5ddbc9

Browse files
Add agent sessions command to list historical sessions (#79)
* feat: add session list feature planning * feat: update implementation and testing plan * feat(docs): update session listing command implementation plan and testing plan * feat(agent-manager): implement new session listing * feat(cli): implement session listing command * feat: add e2e test for agent session command
1 parent 24f9ad9 commit b5ddbc9

22 files changed

Lines changed: 2339 additions & 30 deletions
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
---
2+
phase: design
3+
title: System Design & Architecture
4+
description: Design for `agent sessions` — list historical Claude/Codex/Gemini sessions
5+
---
6+
7+
# System Design & Architecture
8+
9+
## Architecture Overview
10+
11+
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.
12+
13+
**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.
14+
15+
### Flow
16+
17+
1. **CLI parses flags** and computes:
18+
- cwd filter: `--all``undefined`; `--cwd <path>` → that path; neither → `process.cwd()`.
19+
- type filter: from `--type <name>` (or `undefined`).
20+
- limit: from `--limit <n>`, default `50`, `0` = unlimited. Applied post-merge in the CLI; not part of `ListSessionsOptions`.
21+
- output mode: `--json` vs table.
22+
2. **CLI calls `AgentManager.listSessions({ cwd, type })`** with the computed options.
23+
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`.
24+
4. **Each adapter applies `opts.cwd`** at the disk layer:
25+
- `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.)
26+
- `CodexAdapter` — walk every `~/.codex/sessions/YYYY/MM/DD/` dir, parse `session_meta` first line for cwd, keep matches if filter set.
27+
- `GeminiCliAdapter` — walk every `~/.gemini/tmp/<shortId>/chats/session-*.json`, use `directories[0]` as cwd, keep matches if filter set.
28+
- Each returns `SessionSummary[]`.
29+
5. **CLI applies `--limit`** to the merged result (slice).
30+
6. **Output**:
31+
- `--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.
32+
- 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.
33+
7. **Empty-state**: if rows empty and neither `--all` nor `--cwd` was passed, print a one-line hint suggesting `--all`.
34+
35+
```mermaid
36+
graph TD
37+
CLI[cli: agent sessions]
38+
CLI -->|computes opts| Opts[ListSessionsOptions cwd?, type?]
39+
Opts --> Manager[AgentManager.listSessions]
40+
Manager -->|filter adapters by opts.type| Dispatch{{matching adapters}}
41+
Dispatch --> Claude[ClaudeCodeAdapter.listSessions]
42+
Dispatch --> Codex[CodexAdapter.listSessions]
43+
Dispatch --> Gemini[GeminiCliAdapter.listSessions]
44+
Claude --> ClaudeFiles[(~/.claude/projects/&lt;encoded-cwd&gt;/*.jsonl)]
45+
Codex --> CodexFiles[(~/.codex/sessions/YYYY/MM/DD/*.jsonl)]
46+
Gemini --> GeminiFiles[(~/.gemini/tmp/&lt;shortId&gt;/chats/session-*.json)]
47+
Claude -->|SessionSummary[]| Manager
48+
Codex -->|SessionSummary[]| Manager
49+
Gemini -->|SessionSummary[]| Manager
50+
Manager -->|merged + sorted| CLI
51+
CLI -->|apply --limit, render placeholder| Out{{Table or JSON}}
52+
```
53+
54+
### Key components and responsibilities
55+
56+
- **`packages/cli/src/commands/agent.ts`** — registers the `agent sessions` subcommand; handles flag parsing, table rendering, and JSON output.
57+
- **`packages/agent-manager/src/AgentManager.ts`** — gains a `listSessions(opts)` method that fans out to every registered adapter and merges results.
58+
- **`packages/agent-manager/src/adapters/AgentAdapter.ts`** — adds a `listSessions(opts)` method to the interface.
59+
- **`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.
60+
- **`packages/agent-manager/src/utils/ClaudeSessionParser.ts`** — extended to also capture `firstUserMessage` during its existing single-pass iteration (cheap addition).
61+
62+
### Technology stack
63+
64+
- Node 20+, TypeScript, `commander`, `chalk`, `inquirer` — all already in the repo.
65+
- Reuses `ui.table` from `packages/cli/src/util/terminal-ui` and `withErrorHandler`.
66+
- No new runtime dependencies.
67+
68+
## Data Models
69+
70+
### `SessionSummary` (new, exported from `agent-manager`)
71+
72+
```ts
73+
export interface SessionSummary {
74+
/** Tool that produced this session */
75+
type: AgentType; // 'claude' | 'codex' | 'gemini_cli'
76+
/**
77+
* ID accepted by the tool's resume command. Adapters MUST pass this
78+
* through verbatim — no normalization, no encoding/decoding — so it
79+
* round-trips into `claude --resume <id>` (and equivalents) unmodified.
80+
*/
81+
sessionId: string;
82+
/** Working directory the session was started in (best-known value) */
83+
cwd: string;
84+
/**
85+
* Trimmed first user message; empty string if none. Adapters reuse the
86+
* same noise-filter their existing parsers already apply (skip
87+
* tool_result blocks, request-interruption notices, system-injected
88+
* skill content). The CLI table renderer substitutes a placeholder for
89+
* empty values; JSON output keeps the empty string raw.
90+
*/
91+
firstUserMessage: string;
92+
/** Last activity timestamp (from session content; falls back to file mtime) */
93+
lastActive: Date;
94+
/** Session start time (from file content; falls back to file birthtime/mtime) */
95+
startedAt: Date;
96+
/** Absolute path to the session file on disk (debug/diagnostics) */
97+
sessionFilePath: string;
98+
}
99+
```
100+
101+
### `ListSessionsOptions`
102+
103+
```ts
104+
export interface ListSessionsOptions {
105+
/**
106+
* Filter to sessions whose recorded cwd matches this path using strict
107+
* equality (no prefix/ancestor matching in v1). Undefined = no cwd filter.
108+
*/
109+
cwd?: string;
110+
/**
111+
* Filter to a single tool. Enforced by `AgentManager.listSessions`, which
112+
* skips adapters whose `type` doesn't match. Adapters MAY ignore this
113+
* field — by the time their `listSessions` runs, the type filter is
114+
* already satisfied. Undefined = include every registered adapter.
115+
*/
116+
type?: AgentType;
117+
}
118+
```
119+
120+
`--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.
121+
122+
### Per-tool extensions
123+
124+
- `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.).
125+
- `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`.
126+
- `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.
127+
128+
### Shared file-system helpers
129+
130+
`packages/agent-manager/src/utils/session.ts` exports three small fs wrappers used by the new `listSessions` paths in all three adapters:
131+
132+
- `isDirectory(p)``fs.statSync(p).isDirectory()` with try/catch.
133+
- `safeReaddir(dir)``fs.readdirSync(dir)` with try/catch returning `[]`.
134+
- `listJsonl(dir)``safeReaddir` filtered to `*.jsonl`.
135+
136+
Factored out after the initial adapter implementations had drifted into duplicated copies of the same try/catch boilerplate.
137+
138+
## API Design
139+
140+
### Internal: `AgentAdapter`
141+
142+
```ts
143+
export interface AgentAdapter {
144+
// ...existing members
145+
listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]>;
146+
}
147+
```
148+
149+
### Internal: `AgentManager`
150+
151+
```ts
152+
class AgentManager {
153+
// ...existing members
154+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]>;
155+
}
156+
```
157+
158+
`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.
159+
160+
### CLI surface
161+
162+
```
163+
ai-devkit agent sessions [options]
164+
165+
Options:
166+
--all Include sessions from every cwd (default: only current cwd)
167+
--cwd <path> Override the cwd filter (implies non-default scope)
168+
--type <type> Filter to one of: claude, codex, gemini_cli
169+
--limit <n> Max rows to print (default: 50; 0 = no limit)
170+
-j, --json Emit JSON array instead of a table
171+
```
172+
173+
Default table columns:
174+
175+
| Type | Session ID | CWD | First Message | Last Active |
176+
177+
`--json` returns an array of `SessionSummary` with ISO date strings.
178+
179+
## Component Breakdown
180+
181+
### Per-tool listing strategy (v1: reuse existing parsers)
182+
183+
**Claude Code (`ClaudeCodeAdapter.listSessions`)**
184+
185+
- 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.
186+
- 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.
187+
- Drop sessions whose JSONL had no parseable conversation entries (guards against garbage files).
188+
- If `opts.cwd` is set, drop sessions where the resolved cwd doesn't match (strict equality).
189+
- Map to `SessionSummary`.
190+
191+
**Codex (`CodexAdapter.listSessions`)**
192+
193+
- Walk every `YYYY/MM/DD` date dir under `~/.codex/sessions/`.
194+
- For each file, parse the first line (`session_meta`) to read `payload.cwd`. If `opts.cwd` is set, drop non-matches without further parsing.
195+
- For each kept file, run the existing `parseSession` flow, augmented to also record the first `user_message` payload.
196+
- Map to `SessionSummary`.
197+
198+
**Gemini CLI (`GeminiCliAdapter.listSessions`)**
199+
200+
- Walk every `~/.gemini/tmp/<shortId>/chats/session-*.json`.
201+
- For each file, `JSON.parse` it and take `directories[0]` as cwd. If `opts.cwd` is set, drop non-matches.
202+
- Augment the existing `parseSession` to also capture the first `user`-typed message text.
203+
- Map to `SessionSummary`.
204+
205+
### CLI flow
206+
207+
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.
208+
209+
## Design Decisions
210+
211+
### Why extend the adapter interface (vs. a standalone scanner)
212+
213+
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.
214+
215+
### Why reuse the existing parsers in v1
216+
217+
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.
218+
219+
### Why current-cwd default
220+
221+
It's the dominant use case ("resume something I was working on here") and matches the answer to the clarifying question. `--all` and `--cwd <path>` cover the alternatives without overloading the default.
222+
223+
### Why a separate subcommand instead of `agent list --history`
224+
225+
`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.
226+
227+
### Alternatives considered
228+
229+
- **Persistent on-disk index** — fastest on repeated runs, but adds cache invalidation, schema migration, and disk state. Deferred until measured perf demands it.
230+
- **Streaming line reads** — modest speedup on huge files, more code. Deferred; revisit if profiling shows full-file reads dominating.
231+
- **Standalone `SessionLister` class** — reasonable, but duplicates path-encoding logic that already lives in adapters.
232+
233+
## Non-Functional Requirements
234+
235+
### Performance
236+
237+
- Target: <2s for ~200 sessions in default-cwd scope on a developer laptop. Treated as a guideline; concrete budget set after a measured baseline.
238+
- Adapter scans run in parallel via `Promise.all`.
239+
- 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.
240+
- 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.
241+
- CodexAdapter walks every `YYYY/MM/DD` dir under `~/.codex/sessions/`. No date-window pre-filter in v1; revisit if measured.
242+
243+
### Security
244+
245+
- Read-only on user-owned files in `$HOME`. No network calls.
246+
- `--cwd <path>` is used as a filter value only, never executed or interpolated into shell.
247+
- JSON output of session content is bounded to the first user message, so we don't dump full conversations to a pipe by accident.
248+
249+
### Reliability
250+
251+
- Per-file failures (malformed JSON, permission denied, partial writes) are skipped with a one-line stderr note; they never fail the whole listing.
252+
- Adapter-level failures are caught in `AgentManager.listSessions` so one broken tool doesn't hide the others.
253+
254+
### Compatibility
255+
256+
- Existing `agent list / open / send / detail` commands are unchanged. New code paths are additive.
257+
- `AgentAdapter.listSessions` is a new required method; all three in-tree adapters implement it. The interface change is internal to the repo.

0 commit comments

Comments
 (0)