|
| 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/<encoded-cwd>/*.jsonl)] |
| 45 | + Codex --> CodexFiles[(~/.codex/sessions/YYYY/MM/DD/*.jsonl)] |
| 46 | + Gemini --> GeminiFiles[(~/.gemini/tmp/<shortId>/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