From ac8f71d3a3046c4c260fe8701a9c5da76bc2a25c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:37:46 -0700 Subject: [PATCH 01/12] docs: add implementation plan for resume-session-button --- docs/plans/2026-07-29-resume-button-spec.md | 133 ++ .../plans/2026-07-29-resume-session-button.md | 2073 +++++++++++++++++ 2 files changed, 2206 insertions(+) create mode 100644 docs/plans/2026-07-29-resume-button-spec.md create mode 100644 docs/plans/2026-07-29-resume-session-button.md diff --git a/docs/plans/2026-07-29-resume-button-spec.md b/docs/plans/2026-07-29-resume-button-spec.md new file mode 100644 index 000000000..aaed5d165 --- /dev/null +++ b/docs/plans/2026-07-29-resume-button-spec.md @@ -0,0 +1,133 @@ +# Resume Session Button — freshell left rail + +## Summary + +Add a **Resume** button to the left rail (Sidebar) that is **always on screen**: it is +rendered in a pinned footer region of the rail, BELOW the infinite-scroll session list's +scroll viewport — NOT as an item inside the scrollable list. It must be visible at every +scroll position (top/middle/bottom) and in every rail presentation where the list is +visible, including the `fullWidth` mobile mode. + +Implementation note (verified): the Sidebar root is `h-full flex flex-col` and the list +wrapper is the `flex flex-1 min-h-0` region — the footer is a sibling div AFTER that +wrapper. Give it a `data-testid` for tests. + +Clicking it opens a Resume dialog: an agent picker (advisory — see resolution engine) and +a paste field for a resume string. freshell resolves the string to a concrete +(agent, full session id, cwd, sessionType) tuple and resumes that session in a tab. + +## Supported agents + +The CLI providers freshell already supports: `claude`, `codex`, `opencode`, `amplifier` +(`shared/coding-cli-defaults.ts` DEFAULT_ENABLED_CLI_PROVIDERS). The picker lists these; +detection may resolve to any of them regardless of picker state. + +## Target backend + +The resolve capability must live in the server that actually serves the app in the +default `dev`/`start` path — per current `package.json` that is the **Node server** +(`server/index.ts`), which has its own session indexer +(`server/coding-cli/session-indexer.ts`). The Rust server's `IndexExistenceProbe` +(`crates/freshell-server/src/existence.rs`) is exact-match-only, WS/reconcile-internal, +and is NOT this feature's API. The planner must confirm which server serves the sidebar +in supported deployments and implement there; parity in the other server is out of scope +for this feature. + +**New work, stated explicitly:** a resolve endpoint (e.g. `POST /api/sessions/resolve` +or equivalent) that scans the session index across ALL providers at once for exact and +prefix matches and returns per-candidate metadata. Nothing existing does prefix search; +the existing exact-id fallbacks (claude transcript locator, opencode by-id DB query) may +be reused for exact-id misses. Prefix matching may be limited to indexed sessions — +if extending prefix search to fallback stores is non-trivial, document +"prefix only matches indexed sessions" as an accepted limitation rather than building +new store scanners. + +## Extremely permissive input parsing + +Accept arbitrary pasted text and extract candidate session ids. Token shapes: + +- Full UUIDs (any version, any case, `8-4-4-4-12`). +- Opencode ids: `ses_` + 26 base62 chars (e.g. `ses_root0000000000000000000000`) — + first-class token shape; also accept other known `xxx_`-prefixed id families. +- Short hex tokens as id **prefixes** (amplifier surfaces short ids like `417e8345`): + require **≥8 hex chars containing at least one digit** (avoids matching English words + like "decade"/"facade"), up to 32 chars. +- Strip surrounding noise: full command lines (`codex resume `, + `claude --resume `, `claude -r `, `amplifier --resume `, + `opencode --session `), quotes, backticks, whitespace/newlines, shell prompts + (`$`, `>`), flags, and ids embedded in longer strings or paths. +- If multiple candidate tokens exist: prefer prefixed ids (`ses_…`) and full UUIDs, + then the longest hex token; try candidates in that order until one resolves. + +The parser is a pure, shared function (usable by client for live feedback and by tests). + +## Getting the agent RIGHT (hard requirement) + +A resume attempted with the wrong agent fails — the resolved agent must be correct. +Strategy: **evidence decides; hints only assist the UI.** + +1. **Evidence (decisive):** one resolve call scans the session index snapshot across all + four providers simultaneously (exact match AND prefix match). No per-agent probe + ordering — a single scan answers all agents at once. On an exact-id miss against the + index, reuse the existing exact-id fallbacks (claude transcript locator, opencode + by-id) before concluding absence. +2. **Hints (advisory only, labeled unverified):** explicit agent words in the pasted + text, CLI command shapes, and id-format heuristics (codex ids are *typically* UUIDv7, + claude's v4, short-hex *suggests* amplifier — but none of this is guaranteed; codex + ids can be non-UUID, and amplifier ids are full UUIDs whose short form is a prefix). + Hints are used ONLY to pre-fill the agent picker and as the default agent for the + "resume anyway" escape hatch. The user's picker choice is itself a hint — store + evidence overrides it. +3. **Resolve responses carry full resume metadata** for each match: provider, full + session id, `cwd`, `sessionType`, title/first-message snippet, last-modified — the + existing tab-resume path requires sessionId, provider, sessionType, and cwd, not just + (agent, id). +4. **Outcomes:** + - Exactly one match → resume with that provider (even if the picker disagreed); show + a small non-blocking note ("found in codex"). + - Multiple matches (across providers, or a prefix matching several sessions) → show a + disambiguation list (capped, e.g. 20, most-recent first) with the metadata above; + one click resumes. + - Zero matches with the index **ready** → clear inline error, input preserved. + Offer a "resume anyway" escape hatch: attempts a verbatim resume with the + picker-selected (or hint-default) agent, with cwd defaulting to the user's home + directory and shown/editable before launch. + - Index **warming / provider unavailable / unknown** → this is NOT "not found": + show a loading/retry state and re-resolve when the index is ready. +5. A tab is only created once a concrete (provider, full id, cwd, sessionType) tuple is + in hand (except the explicit "resume anyway" path). + +## Resume in a tab + +Reuse the existing tab-resume mechanics (the same path the sidebar uses when opening a +session: `openSessionTab`/`buildResumeContent` and `session-type-utils`). Follow the +sidebar's existing dedup convention: if the session is already open in a pane +(`findPaneForSession`), focus that pane instead of spawning a duplicate; otherwise open +a **new tab**, focused, running the correct agent with the FULL session id. + +## Acceptance examples (must be tests) + +| Pasted input | Expected | +|---|---| +| `417e8345` | prefix-match in amplifier store → resume amplifier session | +| `codex resume 019fac27-69d7-78a0-b972-b339d551042e` | codex (hint + store evidence agree) | +| `ed2afda6-a340-443e-ba60-024a1b3554b4` | no hint; resolve finds it under claude → resume claude | +| `opencode --session ses_root0000000000000000000000` | opencode (prefixed id shape) | +| bare `ses_…` id with picker set to claude | evidence wins → opencode, with a note | +| ` "claude --resume ed2afda6-…" ` with picker set to codex | evidence wins → claude, with a note | +| prefix matching multiple sessions | disambiguation list, capped, most-recent first | +| valid id, index still warming | loading/retry state, NOT "not found" | +| garbage with no id-like token | inline error, no tab created | +| session already open in a pane | focuses existing pane, no duplicate tab | + +## Non-functional requirements + +- Parser: pure function, table-driven unit tests including the cases above plus + adversarial noise (multi-line paste, ANSI codes, trailing punctuation, `decade`-style + hex-looking words must NOT match). +- Resolve endpoint: tested against fixture session stores/indexes for all four agents + (exact, prefix, ambiguous, missing, warming). +- UI: pinned placement verified at top/middle/bottom scroll and in `fullWidth` mobile + mode; keyboard accessible; paste-then-Enter fast path (auto-resolve on paste). +- Follow repo conventions (AGENTS.md / CLAUDE.md), match existing Sidebar patterns, + TDD where the repo requires it. diff --git a/docs/plans/2026-07-29-resume-session-button.md b/docs/plans/2026-07-29-resume-session-button.md new file mode 100644 index 000000000..ecbf21551 --- /dev/null +++ b/docs/plans/2026-07-29-resume-session-button.md @@ -0,0 +1,2073 @@ +# Resume Session Button Implementation Plan + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Add an always-visible **Resume** button pinned below the Sidebar's session +list that opens a dialog where the user pastes any session-id-bearing text; freshell +resolves it to a concrete (provider, full session id, cwd, sessionType) tuple across +all four CLI providers and resumes that session in a tab. + +**Architecture:** A pure shared parser (`shared/resume-input-parser.ts`) extracts +candidate tokens + an advisory provider hint from arbitrary pasted text. A new +`POST /api/sessions/resolve` endpoint on the **Node server** scans the session-indexer +snapshot across all providers at once (exact + prefix), with exact-id fallbacks +(a new Node claude transcript locator; the existing opencode by-id sqlite query). +The client dialog calls resolve, then reuses the existing tab-resume path +(`findPaneForSession` dedup → `openSessionTab`). + +**Tech Stack:** TypeScript strict, Express 4 + zod v4 (server), React + Redux Toolkit + +Tailwind (client), Vitest 3 (+ RTL/jsdom for client, supertest/node for server), +Playwright for browser e2e. + +## Global Constraints + +- Providers are exactly `DEFAULT_ENABLED_CLI_PROVIDERS = ['claude', 'codex', 'opencode', 'amplifier']` from `shared/coding-cli-defaults.ts`. +- Target backend is the **Node server** (`server/index.ts`) — confirmed: `npm run dev` runs `tsx watch server/index.ts` and `npm start` runs `dist/server/index.js`. Rust-server parity is **out of scope**. +- **Accepted limitation (per spec):** prefix matching only matches **indexed** sessions. Exact-id misses additionally consult the claude transcript locator and the opencode by-id query. "Provider unavailable" is approximated by the single global index-readiness flag (`startup-state.ts` task `codingCliIndexer`) — the Node server has no per-provider readiness. +- Hex-prefix token rule: **≥8 hex chars containing at least one digit, ≤32 chars** (rejects `decade`, `facade`, `deadbeef`). +- Disambiguation list is capped at **20**, sorted most-recent first by `lastActivityAt` desc. +- Evidence (store scan) decides the provider; hints (agent words, command shapes, id-shape heuristics) are advisory only — they pre-fill the picker and set the "resume anyway" default. +- A tab is only created once a concrete (provider, full id, cwd, sessionType) tuple is in hand — except the explicit "resume anyway" path. +- Conventional Commits with scope (`feat(shared):`, `test(server):`…). Red-Green TDD per step. +- Server/shared code is NodeNext ESM: **relative imports need `.js` suffix**. Client code uses `@/`, `@shared/`, `@test/` aliases. +- Never run raw `npx vitest`; use `npm run test:vitest -- --config --run`. Before any broad run, check the coordinator: `npm run test:status`. +- New markdown docs: none beyond this plan (working/agent doc). Update `docs/index.html` for this user-facing UI change (AGENTS.md rule) — folded into Task 6. +- Data-testids are kebab-case; dialogs are hand-rolled `createPortal` modals following `src/components/ui/confirm-modal.tsx` (role="dialog", aria-modal, Escape closes, `OVERLAY_Z.modal` from `src/components/ui/overlay.ts`). + +## Scope Check + +Single plan: one feature with three thin layers (shared parser, one endpoint, one +dialog + one footer) that only make sense together and ship as one working unit. +Each task below is still independently testable. + +## File Structure + +| File | Responsibility | +|---|---| +| `shared/resume-input-parser.ts` (create) | Pure token extraction + advisory hint from pasted text | +| `shared/resume-resolve-contract.ts` (create) | zod request/response schemas + TS types for the resolve API | +| `server/coding-cli/claude-transcript-locator.ts` (create) | Exact-id claude `.jsonl` locator (fallback for index misses) | +| `server/coding-cli/resolve-session.ts` (create) | Resolve engine: index scan (exact+prefix) + fallbacks; pure w.r.t. injected deps | +| `server/sessions-router.ts` (modify) | Add `POST /sessions/resolve` route + widen `SessionsRouterDeps` | +| `server/index.ts` (modify) | Wire readiness + fallback deps into `createSessionsRouter` (~line 748) | +| `src/lib/resume-session.ts` (create) | Client resume helper: `findPaneForSession` dedup → focus, else `openSessionTab` | +| `src/components/ResumeSessionDialog.tsx` (create) | The Resume dialog (paste field, picker, all outcome states) | +| `src/components/Sidebar.tsx` (modify) | Pinned footer with the Resume button (sibling AFTER the list wrapper) | +| `test/unit/shared/resume-input-parser.test.ts` (create) | Table-driven parser tests | +| `test/integration/server/claude-transcript-locator.test.ts` (create) | Locator tests against tmpdir fixtures | +| `test/integration/server/sessions-resolve-router.test.ts` (create) | supertest endpoint tests (exact/prefix/ambiguous/missing/warming/fallbacks) | +| `test/unit/client/lib/resume-session.test.ts` (create) | Dedup-vs-open helper tests | +| `test/unit/client/components/ResumeSessionDialog.test.tsx` (create) | Dialog flow tests (RTL) | +| `test/unit/client/components/Sidebar.resume-footer.test.tsx` (create) | Pinned placement + fullWidth tests | +| `test/e2e-browser/specs/resume-button.spec.ts` (create) | Playwright: pinned at all scroll positions; paste→Enter resumes | +| `docs/index.html` (modify) | Mention the Resume button (user-facing feature doc) | + +## Spec → test traceability (acceptance examples) + +| Pasted input | Covered by | +|---|---| +| `417e8345` → amplifier prefix match | Task 1 parser test; Task 3 router "prefix match" test | +| `codex resume 019fac27-…` → codex | Task 1 hint test; Task 3 exact test; Task 4 dialog test 1 | +| bare v4 UUID → resolve finds claude | Task 3 "exact uuid, no hint"; Task 4 dialog test 1 | +| `opencode --session ses_…` → opencode | Task 1 parser test; Task 3 exact test | +| bare `ses_…`, picker=claude → opencode + note | Task 4 dialog test 2 (evidence wins, note shown) | +| `"claude --resume ed2afda6-…"`, picker=codex → claude + note | Task 1 noise-stripping test; Task 4 dialog test 2 | +| prefix matching multiple → capped list, recent first | Task 3 ambiguous + cap tests; Task 4 dialog test 3 | +| valid id, index warming → loading/retry, NOT "not found" | Task 3 warming test; Task 4 dialog test 5 | +| garbage → inline error, no tab | Task 1 parser test; Task 4 dialog test 6 | +| session already open in pane → focus, no duplicate | Task 5 helper test (Task 4's `resume-session.test.ts`) | + +--- + +### Task 1: Shared resume-input parser + +**Files:** +- Create: `shared/resume-input-parser.ts` +- Test: `test/unit/shared/resume-input-parser.test.ts` + +**Interfaces:** +- Consumes: nothing (pure, dependency-free). +- Produces (used by Tasks 3 and 4): + - `parseResumeInput(text: string): ResumeInputParse` + - `interface ResumeInputParse { candidates: ResumeCandidate[]; hint: ResumeHint | null }` + - `interface ResumeCandidate { token: string; kind: 'prefixed-id' | 'uuid' | 'hex-prefix' }` + - `interface ResumeHint { provider: 'claude' | 'codex' | 'opencode' | 'amplifier'; source: 'command' | 'word' | 'id-shape' }` + - `candidates` are in resolution-priority order: prefixed ids, then full UUIDs (each in order of appearance), then hex prefixes longest-first, deduped. + +- [ ] **Step 1: Write the failing test** + +Mirror the import style of `test/unit/shared/path-basename.test.ts` (this suite runs +under the client vitest config, which has the `@shared/` alias). Create +`test/unit/shared/resume-input-parser.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { parseResumeInput } from '@shared/resume-input-parser' + +const V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' +const V7 = '019fac27-69d7-78a0-b972-b339d551042e' +const SES = 'ses_root0000000000000000000000' + +describe('parseResumeInput — candidate extraction', () => { + it.each([ + ['bare short hex', '417e8345', [{ token: '417e8345', kind: 'hex-prefix' }]], + ['bare v4 uuid', V4, [{ token: V4, kind: 'uuid' }]], + ['bare opencode id', SES, [{ token: SES, kind: 'prefixed-id' }]], + ['codex resume command', `codex resume ${V7}`, [{ token: V7, kind: 'uuid' }]], + ['claude --resume command', `claude --resume ${V4}`, [{ token: V4, kind: 'uuid' }]], + ['claude -r command', `$ claude -r ${V4}`, [{ token: V4, kind: 'uuid' }]], + ['opencode --session command', `opencode --session ${SES}`, [{ token: SES, kind: 'prefixed-id' }]], + ['amplifier --resume short id', 'amplifier --resume 417e8345', [{ token: '417e8345', kind: 'hex-prefix' }]], + ['quoted + padded', ` "claude --resume ${V4}" `, [{ token: V4, kind: 'uuid' }]], + ['backticks', '`417e8345`', [{ token: '417e8345', kind: 'hex-prefix' }]], + ['id embedded in a path', `/home/x/.claude/projects/foo/${V4}.jsonl`, [{ token: V4, kind: 'uuid' }]], + ['trailing punctuation', 'session 417e8345.', [{ token: '417e8345', kind: 'hex-prefix' }]], + ['ansi codes', `\u001b[32m417e8345\u001b[0m`, [{ token: '417e8345', kind: 'hex-prefix' }]], + [ + 'multi-line noise', + `To continue:\n$ codex resume ${V7}\nor open the app`, + [{ token: V7, kind: 'uuid' }], + ], + ] as const)('%s', (_label, input, expected) => { + expect(parseResumeInput(input).candidates).toEqual(expected) + }) + + it.each([ + ['english hex-looking word', 'decade'], + ['facade sentence', 'I spent a decade behind a facade'], + ['hex without digits', 'deadbeef'], + ['garbage', 'hello world!! no ids here'], + ['empty', ''], + ] as const)('extracts nothing from %s', (_label, input) => { + expect(parseResumeInput(input).candidates).toEqual([]) + }) + + it('orders prefixed ids, then uuids, then hex prefixes longest-first', () => { + const { candidates } = parseResumeInput(`417e8345 ${V4} ${SES} 417e8345abcd`) + expect(candidates.map((c) => c.token)).toEqual([SES, V4, '417e8345abcd', '417e8345']) + }) + + it('dedupes repeated tokens case-insensitively', () => { + const { candidates } = parseResumeInput(`${V4} ${V4.toUpperCase()}`) + expect(candidates).toHaveLength(1) + }) + + it('does not extract hex segments out of a uuid', () => { + const { candidates } = parseResumeInput(V4) + expect(candidates).toEqual([{ token: V4, kind: 'uuid' }]) + }) + + it('caps hex tokens at 32 chars (git shas do not match)', () => { + expect(parseResumeInput('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2').candidates).toEqual([]) + }) +}) + +describe('parseResumeInput — advisory hint', () => { + it.each([ + ['codex command', `codex resume ${V7}`, { provider: 'codex', source: 'command' }], + ['claude --resume', `claude --resume ${V4}`, { provider: 'claude', source: 'command' }], + ['claude -r', `claude -r ${V4}`, { provider: 'claude', source: 'command' }], + ['opencode --session', `opencode --session ${SES}`, { provider: 'opencode', source: 'command' }], + ['amplifier --resume', 'amplifier --resume 417e8345', { provider: 'amplifier', source: 'command' }], + ['agent word only', `the claude session ${V4}`, { provider: 'claude', source: 'word' }], + ['ses_ id shape', SES, { provider: 'opencode', source: 'id-shape' }], + ['uuid v7 shape', V7, { provider: 'codex', source: 'id-shape' }], + ['uuid v4 shape', V4, { provider: 'claude', source: 'id-shape' }], + ['short hex shape', '417e8345', { provider: 'amplifier', source: 'id-shape' }], + ] as const)('%s', (_label, input, expected) => { + expect(parseResumeInput(input).hint).toEqual(expected) + }) + + it('returns null hint for garbage', () => { + expect(parseResumeInput('nothing to see').hint).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts --run` +Expected: FAIL — cannot resolve `@shared/resume-input-parser`. + +- [ ] **Step 3: Write the implementation** + +Create `shared/resume-input-parser.ts`: + +```ts +// Pure, dependency-free parser: extracts candidate session ids and an +// advisory provider hint from arbitrary pasted text. Hints only assist +// the UI — session-store evidence decides the provider. + +export type ResumeHintProvider = 'claude' | 'codex' | 'opencode' | 'amplifier' + +export type ResumeCandidateKind = 'prefixed-id' | 'uuid' | 'hex-prefix' + +export interface ResumeCandidate { + token: string + kind: ResumeCandidateKind +} + +export interface ResumeHint { + provider: ResumeHintProvider + source: 'command' | 'word' | 'id-shape' +} + +export interface ResumeInputParse { + /** Candidate tokens in resolution-priority order. */ + candidates: ResumeCandidate[] + hint: ResumeHint | null +} + +const ANSI_ESCAPE_RE = /\u001b\[[0-9;?]*[0-9A-Za-z]/g +const UUID_RE = + /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g +// ses_ + 26 base62 is the first-class shape; the generic form also accepts +// other known xxx_-prefixed id families. +const PREFIXED_ID_RE = /\b[a-z]{2,10}_[0-9A-Za-z]{8,40}\b/g +// >=8 hex chars, <=32; must contain a digit (filters decade/facade/deadbeef). +const HEX_PREFIX_RE = /\b[0-9a-fA-F]{8,32}\b/g + +const COMMAND_HINTS: ReadonlyArray = [ + [/\bclaude\s+(?:--resume|-r)\b/i, 'claude'], + [/\bcodex\s+resume\b/i, 'codex'], + [/\bopencode\s+--session\b/i, 'opencode'], + [/\bamplifier\s+(?:--resume|resume)\b/i, 'amplifier'], +] + +const WORD_HINTS: ReadonlyArray = [ + [/\bclaude\b/i, 'claude'], + [/\bcodex\b/i, 'codex'], + [/\bopencode\b/i, 'opencode'], + [/\bamplifier\b/i, 'amplifier'], +] + +function extractAndMask(text: string, re: RegExp, out: string[]): string { + return text.replace(re, (match) => { + out.push(match) + return ' '.repeat(match.length) + }) +} + +function earliestHint( + text: string, + table: ReadonlyArray, +): ResumeHintProvider | null { + let best: ResumeHintProvider | null = null + let bestIndex = Number.POSITIVE_INFINITY + for (const [re, provider] of table) { + const match = re.exec(text) + if (match && match.index < bestIndex) { + bestIndex = match.index + best = provider + } + } + return best +} + +function deriveHint(text: string, candidates: ResumeCandidate[]): ResumeHint | null { + const byCommand = earliestHint(text, COMMAND_HINTS) + if (byCommand) return { provider: byCommand, source: 'command' } + const byWord = earliestHint(text, WORD_HINTS) + if (byWord) return { provider: byWord, source: 'word' } + const top = candidates[0] + if (!top) return null + if (top.kind === 'prefixed-id' && top.token.startsWith('ses_')) { + return { provider: 'opencode', source: 'id-shape' } + } + if (top.kind === 'uuid') { + const version = top.token.charAt(14) + if (version === '7') return { provider: 'codex', source: 'id-shape' } + if (version === '4') return { provider: 'claude', source: 'id-shape' } + return null + } + if (top.kind === 'hex-prefix') return { provider: 'amplifier', source: 'id-shape' } + return null +} + +export function parseResumeInput(text: string): ResumeInputParse { + const sanitized = text.replace(ANSI_ESCAPE_RE, ' ') + + const uuids: string[] = [] + const prefixed: string[] = [] + const rawHex: string[] = [] + + // Mask each class as it is extracted so uuid segments never re-match as hex. + let masked = extractAndMask(sanitized, UUID_RE, uuids) + masked = extractAndMask(masked, PREFIXED_ID_RE, prefixed) + extractAndMask(masked, HEX_PREFIX_RE, rawHex) + + const hexTokens = rawHex.filter((token) => /[0-9]/.test(token)) + hexTokens.sort((a, b) => b.length - a.length) + + const seen = new Set() + const candidates: ResumeCandidate[] = [] + const push = (token: string, kind: ResumeCandidateKind) => { + const key = kind === 'prefixed-id' ? token : token.toLowerCase() + if (seen.has(key)) return + seen.add(key) + candidates.push({ token, kind }) + } + for (const token of prefixed) push(token, 'prefixed-id') + for (const token of uuids) push(token, 'uuid') + for (const token of hexTokens) push(token, 'hex-prefix') + + return { candidates, hint: deriveHint(sanitized, candidates) } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts --run` +Expected: PASS (all tables green). + +- [ ] **Step 5: Commit** + +```bash +git add shared/resume-input-parser.ts test/unit/shared/resume-input-parser.test.ts +git commit -m "feat(shared): resume-input parser extracting session-id candidates and provider hints" +``` + +--- + +### Task 2: Node claude transcript exact-id locator + +**Files:** +- Create: `server/coding-cli/claude-transcript-locator.ts` +- Test: `test/integration/server/claude-transcript-locator.test.ts` + +**Interfaces:** +- Consumes: `node:fs/promises`, `node:path` only. +- Produces (used by Task 3): + - `locateClaudeTranscript(sessionId: string, projectsDir: string): Promise` + - `interface ClaudeTranscriptHit { sessionId: string; sourceFile: string; cwd?: string }` + - Returns `null` for non-UUID input, missing dir, or no `/*/.jsonl` file. `cwd` comes from the first JSONL line containing a string `cwd` field. + +Rationale: the spec's "claude transcript locator" exact-id fallback exists only in the +Rust server; the Node server needs its own minimal equivalent for index misses +(cold-start skips cwd-less files, so the index alone yields false negatives). + +- [ ] **Step 1: Write the failing test** + +Create `test/integration/server/claude-transcript-locator.test.ts`: + +```ts +// @vitest-environment node +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { locateClaudeTranscript } from '../../../server/coding-cli/claude-transcript-locator.js' + +const SESSION_ID = 'ed2afda6-a340-443e-ba60-024a1b3554b4' + +describe('locateClaudeTranscript', () => { + let projectsDir: string + + beforeEach(async () => { + projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'claude-projects-')) + }) + + afterEach(async () => { + await fsp.rm(projectsDir, { recursive: true, force: true }) + }) + + async function writeTranscript(dirName: string, id: string, lines: string[]) { + const dir = path.join(projectsDir, dirName) + await fsp.mkdir(dir, { recursive: true }) + const file = path.join(dir, `${id}.jsonl`) + await fsp.writeFile(file, lines.join('\n'), 'utf8') + return file + } + + it('finds a transcript by exact id and reads cwd from the first entry', async () => { + const file = await writeTranscript('-repo-alpha', SESSION_ID, [ + JSON.stringify({ type: 'summary', summary: 'hello' }), + JSON.stringify({ type: 'user', cwd: '/repo/alpha', message: 'hi' }), + ]) + await expect(locateClaudeTranscript(SESSION_ID, projectsDir)).resolves.toEqual({ + sessionId: SESSION_ID, + sourceFile: file, + cwd: '/repo/alpha', + }) + }) + + it('matches case-insensitively and returns the normalized id', async () => { + await writeTranscript('-repo-alpha', SESSION_ID, [JSON.stringify({ cwd: '/repo/alpha' })]) + const hit = await locateClaudeTranscript(SESSION_ID.toUpperCase(), projectsDir) + expect(hit?.sessionId).toBe(SESSION_ID) + }) + + it('returns undefined cwd when no entry carries one', async () => { + await writeTranscript('-repo-beta', SESSION_ID, [JSON.stringify({ type: 'summary' })]) + const hit = await locateClaudeTranscript(SESSION_ID, projectsDir) + expect(hit).not.toBeNull() + expect(hit?.cwd).toBeUndefined() + }) + + it('returns null for an unknown id', async () => { + await expect( + locateClaudeTranscript('019fac27-69d7-78a0-b972-b339d551042e', projectsDir), + ).resolves.toBeNull() + }) + + it('returns null for non-uuid input without touching the fs', async () => { + await expect(locateClaudeTranscript('417e8345', projectsDir)).resolves.toBeNull() + }) + + it('returns null when the projects dir does not exist', async () => { + await expect( + locateClaudeTranscript(SESSION_ID, path.join(projectsDir, 'missing')), + ).resolves.toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/integration/server/claude-transcript-locator.test.ts --run` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +Create `server/coding-cli/claude-transcript-locator.ts`: + +```ts +import fsp from 'node:fs/promises' +import path from 'node:path' + +export interface ClaudeTranscriptHit { + sessionId: string + sourceFile: string + cwd?: string +} + +const UUID_ONLY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +/** + * Exact-id fallback for claude sessions the index cannot see (e.g. cold-start + * skipped cwd-less transcripts). Scans //.jsonl. + */ +export async function locateClaudeTranscript( + sessionId: string, + projectsDir: string, +): Promise { + const normalized = sessionId.toLowerCase() + if (!UUID_ONLY_RE.test(normalized)) return null + + let entries: string[] + try { + entries = await fsp.readdir(projectsDir) + } catch { + return null + } + + for (const entry of entries) { + const candidate = path.join(projectsDir, entry, `${normalized}.jsonl`) + try { + const stat = await fsp.stat(candidate) + if (!stat.isFile()) continue + } catch { + continue + } + return { + sessionId: normalized, + sourceFile: candidate, + cwd: await readCwdFromTranscript(candidate), + } + } + return null +} + +async function readCwdFromTranscript(filePath: string): Promise { + let head: string + try { + const handle = await fsp.open(filePath, 'r') + try { + const buffer = Buffer.alloc(64 * 1024) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + head = buffer.subarray(0, bytesRead).toString('utf8') + } finally { + await handle.close() + } + } catch { + return undefined + } + for (const line of head.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) continue + try { + const parsed = JSON.parse(trimmed) as { cwd?: unknown } + if (typeof parsed.cwd === 'string' && parsed.cwd.length > 0) return parsed.cwd + } catch { + continue // truncated tail line etc. + } + } + return undefined +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/integration/server/claude-transcript-locator.test.ts --run` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/coding-cli/claude-transcript-locator.ts test/integration/server/claude-transcript-locator.test.ts +git commit -m "feat(server): claude transcript exact-id locator for index misses" +``` + +--- + +### Task 3: `POST /api/sessions/resolve` endpoint + +**Files:** +- Create: `shared/resume-resolve-contract.ts` +- Create: `server/coding-cli/resolve-session.ts` +- Modify: `server/sessions-router.ts` (deps interface ~lines 39–58; new route after the existing `/sessions/*` routes ~line 223) +- Modify: `server/index.ts` (the `createSessionsRouter({...})` call at ~line 748; startupState lives at ~line 188, the provider array near ~line 236) +- Test: `test/integration/server/sessions-resolve-router.test.ts` + +**Interfaces:** +- Consumes: `parseResumeInput` (Task 1), `locateClaudeTranscript` (Task 2), + `CodingCliSession`/`ProjectGroup` from `server/coding-cli/types.ts` (fields: + `sessionId`, `provider`, `projectPath`, `cwd?`, `title?`, `sessionType?`, + `firstUserMessage?`, `lastActivityAt: number`), + `OpencodeProvider.resolveOpencodeSessionRoots(sessionIds: readonly string[]): Promise<{ rootsBySessionId: Map; unresolvedSessionIds: Set }>` + (`server/coding-cli/providers/opencode.ts:199`), + `startupState.snapshot(): { ready: boolean; tasks: Record }` + (`server/startup-state.ts`), `getClaudeProjectsDir()` (`server/claude-home.ts`). +- Produces (used by Task 4): + - HTTP `POST /api/sessions/resolve` with body `{ input: string }`. + - `ResumeResolveRequestSchema`, `ResumeResolveResponseSchema`, `ResumeResolveMatchSchema` and inferred types `ResumeResolveRequest`, `ResumeResolveResponse`, `ResumeResolveMatch` from `shared/resume-resolve-contract.ts`. + - Response: `{ status: 'ready' | 'warming', matches: ResumeResolveMatch[], hint: { provider, source } | null }` where a match is `{ provider, sessionId, cwd?, sessionType?, title?, firstUserMessage?, lastActivityAt?, matchKind: 'exact' | 'prefix' }`, capped at 20, sorted `lastActivityAt` desc. + - `resolveResumeInput(input, deps)` and `RESOLVE_MATCH_CAP = 20` from `server/coding-cli/resolve-session.ts`. + +- [ ] **Step 1: Write the shared contract** + +Create `shared/resume-resolve-contract.ts` (zod v4 is already a dependency; see +`shared/read-models.ts` for precedent): + +```ts +import { z } from 'zod' + +export const ResumeResolveRequestSchema = z + .object({ + input: z.string().min(1).max(20000), + }) + .strict() + +export const ResumeResolveMatchSchema = z.object({ + provider: z.string().min(1), + sessionId: z.string().min(1), + cwd: z.string().optional(), + sessionType: z.string().optional(), + title: z.string().optional(), + firstUserMessage: z.string().optional(), + lastActivityAt: z.number().int().nonnegative().optional(), + matchKind: z.enum(['exact', 'prefix']), +}) + +export const ResumeResolveHintSchema = z.object({ + provider: z.string().min(1), + source: z.enum(['command', 'word', 'id-shape']), +}) + +export const ResumeResolveResponseSchema = z.object({ + status: z.enum(['ready', 'warming']), + matches: z.array(ResumeResolveMatchSchema), + hint: ResumeResolveHintSchema.nullable(), +}) + +export type ResumeResolveRequest = z.infer +export type ResumeResolveMatch = z.infer +export type ResumeResolveResponse = z.infer +``` + +- [ ] **Step 2: Write the failing endpoint test** + +Create `test/integration/server/sessions-resolve-router.test.ts`, modeled on +`test/integration/server/session-directory-router.test.ts` (real router + supertest + +literal `ProjectGroup[]` fake indexer — the house fixture pattern): + +```ts +// @vitest-environment node +import { describe, it, expect, beforeEach, vi } from 'vitest' +import express, { type Express } from 'express' +import request from 'supertest' +import { createSessionsRouter } from '../../../server/sessions-router.js' +import type { ProjectGroup } from '../../../server/coding-cli/types.js' + +const CLAUDE_ID = 'ed2afda6-a340-443e-ba60-024a1b3554b4' +const CODEX_ID = '019fac27-69d7-78a0-b972-b339d551042e' +const OPENCODE_ID = 'ses_root0000000000000000000000' +const AMP_ID_NEW = '417e8345-aaaa-4bbb-8ccc-000000000001' +const AMP_ID_OLD = '417e8345-bbbb-4ccc-8ddd-000000000002' + +function fixtureProjects(): ProjectGroup[] { + return [ + { + projectPath: '/repo/alpha', + sessions: [ + { + provider: 'claude', + sessionId: CLAUDE_ID, + projectPath: '/repo/alpha', + cwd: '/repo/alpha', + title: 'Fix the parser', + firstUserMessage: 'fix the parser', + lastActivityAt: 400, + }, + { + provider: 'codex', + sessionId: CODEX_ID, + projectPath: '/repo/alpha', + cwd: '/repo/alpha', + sessionType: 'codex', + lastActivityAt: 300, + }, + ], + }, + { + projectPath: '/repo/beta', + sessions: [ + { + provider: 'opencode', + sessionId: OPENCODE_ID, + projectPath: '/repo/beta', + cwd: '/repo/beta', + lastActivityAt: 200, + }, + { + provider: 'amplifier', + sessionId: AMP_ID_NEW, + projectPath: '/repo/beta', + cwd: '/repo/beta', + lastActivityAt: 900, + }, + { + provider: 'amplifier', + sessionId: AMP_ID_OLD, + projectPath: '/repo/beta', + cwd: '/repo/beta', + lastActivityAt: 100, + }, + ], + }, + ] +} + +interface HarnessOptions { + projects?: ProjectGroup[] + ready?: boolean + resolveOpencodeSessionIds?: ( + ids: readonly string[], + ) => Promise<{ rootsBySessionId: Map; unresolvedSessionIds: Set }> + locateClaudeTranscript?: ( + id: string, + ) => Promise<{ sessionId: string; sourceFile: string; cwd?: string } | null> +} + +function buildApp(options: HarnessOptions = {}): Express { + const app = express() + app.use(express.json()) + app.use( + '/api', + createSessionsRouter({ + configStore: { + getSettings: vi.fn().mockResolvedValue({}), + patchSessionOverride: vi.fn(), + deleteSession: vi.fn(), + }, + codingCliIndexer: { + getProjects: () => options.projects ?? fixtureProjects(), + refresh: vi.fn().mockResolvedValue(undefined), + }, + codingCliProviders: [], + perfConfig: { slowSessionRefreshMs: 500 }, + terminalMetadata: { list: () => [] }, + getIndexReadiness: () => options.ready ?? true, + resolveOpencodeSessionIds: options.resolveOpencodeSessionIds, + locateClaudeTranscript: options.locateClaudeTranscript, + }), + ) + return app +} + +const post = (app: Express, body: unknown) => + request(app).post('/api/sessions/resolve').send(body as object) + +describe('POST /api/sessions/resolve', () => { + let app: Express + beforeEach(() => { + app = buildApp() + }) + + it.each([ + ['claude exact uuid', CLAUDE_ID, 'claude', CLAUDE_ID], + ['codex exact via command line', `codex resume ${CODEX_ID}`, 'codex', CODEX_ID], + ['opencode exact via command line', `opencode --session ${OPENCODE_ID}`, 'opencode', OPENCODE_ID], + ] as const)('%s resolves to a single exact match', async (_label, input, provider, id) => { + const res = await post(app, { input }) + expect(res.status).toBe(200) + expect(res.body.status).toBe('ready') + expect(res.body.matches).toHaveLength(1) + expect(res.body.matches[0]).toMatchObject({ provider, sessionId: id, matchKind: 'exact' }) + }) + + it('returns full resume metadata on matches', async () => { + const res = await post(app, { input: CLAUDE_ID }) + expect(res.body.matches[0]).toMatchObject({ + provider: 'claude', + sessionId: CLAUDE_ID, + cwd: '/repo/alpha', + title: 'Fix the parser', + firstUserMessage: 'fix the parser', + lastActivityAt: 400, + }) + }) + + it('prefix-matches short hex across providers, most-recent first', async () => { + const res = await post(app, { input: '417e8345' }) + expect(res.body.status).toBe('ready') + expect(res.body.matches.map((m: { sessionId: string }) => m.sessionId)).toEqual([ + AMP_ID_NEW, + AMP_ID_OLD, + ]) + expect(res.body.matches[0].matchKind).toBe('prefix') + expect(res.body.matches[0].provider).toBe('amplifier') + }) + + it('caps ambiguous prefix matches at 20', async () => { + const many: ProjectGroup[] = [ + { + projectPath: '/repo/many', + sessions: Array.from({ length: 25 }, (_, i) => ({ + provider: 'amplifier', + sessionId: `417e8345-0000-4000-8000-${String(i).padStart(12, '0')}`, + projectPath: '/repo/many', + lastActivityAt: i, + })), + }, + ] + const res = await post(buildApp({ projects: many }), { input: '417e8345' }) + expect(res.body.matches).toHaveLength(20) + expect(res.body.matches[0].lastActivityAt).toBe(24) // most recent first + }) + + it('reports hint alongside evidence', async () => { + const res = await post(app, { input: `codex resume ${CODEX_ID}` }) + expect(res.body.hint).toEqual({ provider: 'codex', source: 'command' }) + }) + + it('returns ready + empty matches for an unknown id', async () => { + const res = await post(app, { input: '019fffff-ffff-7fff-bfff-ffffffffffff' }) + expect(res.body).toMatchObject({ status: 'ready', matches: [] }) + }) + + it('returns warming (not "not found") while the index is not ready', async () => { + const res = await post(buildApp({ ready: false }), { input: CLAUDE_ID }) + expect(res.body).toMatchObject({ status: 'warming', matches: [] }) + }) + + it('falls back to the opencode by-id query on exact-id index miss', async () => { + const unknown = 'ses_child000000000000000000000' + const res = await post( + buildApp({ + resolveOpencodeSessionIds: vi.fn().mockResolvedValue({ + rootsBySessionId: new Map([[unknown, OPENCODE_ID]]), + unresolvedSessionIds: new Set(), + }), + }), + { input: unknown }, + ) + expect(res.body.matches).toEqual([ + { provider: 'opencode', sessionId: unknown, sessionType: 'opencode', matchKind: 'exact' }, + ]) + }) + + it('falls back to the claude transcript locator on exact-id index miss', async () => { + const unknown = 'aaaaaaaa-1111-4222-8333-444444444444' + const res = await post( + buildApp({ + locateClaudeTranscript: vi.fn().mockResolvedValue({ + sessionId: unknown, + sourceFile: `/home/u/.claude/projects/x/${unknown}.jsonl`, + cwd: '/repo/gamma', + }), + }), + { input: unknown }, + ) + expect(res.body.matches).toEqual([ + { + provider: 'claude', + sessionId: unknown, + cwd: '/repo/gamma', + sessionType: 'claude', + matchKind: 'exact', + }, + ]) + }) + + it('returns ready + empty matches for garbage input with no id-like token', async () => { + const res = await post(app, { input: 'hello decade facade!!' }) + expect(res.body).toMatchObject({ status: 'ready', matches: [], hint: null }) + }) + + it('rejects an invalid body with 400', async () => { + const res = await post(app, { nope: true }) + expect(res.status).toBe(400) + expect(res.body.error).toBeDefined() + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/integration/server/sessions-resolve-router.test.ts --run` +Expected: FAIL — 404 on `/api/sessions/resolve` and unknown deps (TS error on +`getIndexReadiness` etc.). + +- [ ] **Step 4: Implement the resolve engine** + +Create `server/coding-cli/resolve-session.ts`: + +```ts +import { parseResumeInput } from '../../shared/resume-input-parser.js' +import type { + ResumeResolveMatch, + ResumeResolveResponse, +} from '../../shared/resume-resolve-contract.js' +import type { CodingCliSession, ProjectGroup } from './types.js' +import type { ClaudeTranscriptHit } from './claude-transcript-locator.js' + +export const RESOLVE_MATCH_CAP = 20 + +export interface ResolveResumeDeps { + getProjects: () => ProjectGroup[] + isIndexReady: () => boolean + resolveOpencodeSessionIds?: ( + ids: readonly string[], + ) => Promise<{ rootsBySessionId: Map; unresolvedSessionIds: Set }> + locateClaudeTranscript?: (sessionId: string) => Promise +} + +export async function resolveResumeInput( + input: string, + deps: ResolveResumeDeps, +): Promise { + const { candidates, hint } = parseResumeInput(input) + + if (!deps.isIndexReady()) { + return { status: 'warming', matches: [], hint } + } + if (candidates.length === 0) { + return { status: 'ready', matches: [], hint } + } + + const sessions = deps.getProjects().flatMap((group) => group.sessions) + + // Evidence pass: one scan answers all providers at once. Candidates are + // tried in priority order until one resolves. + for (const candidate of candidates) { + const needle = candidate.token.toLowerCase() + const exact: ResumeResolveMatch[] = [] + const prefix: ResumeResolveMatch[] = [] + for (const session of sessions) { + const id = session.sessionId.toLowerCase() + if (id === needle) exact.push(toMatch(session, 'exact')) + else if (id.startsWith(needle)) prefix.push(toMatch(session, 'prefix')) + } + const matches = exact.length > 0 ? exact : prefix + if (matches.length > 0) { + matches.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)) + return { status: 'ready', matches: dedupe(matches).slice(0, RESOLVE_MATCH_CAP), hint } + } + } + + // Exact-id fallbacks for sessions the index cannot see (opencode child + // sessions; cwd-less claude transcripts skipped on cold start). + for (const candidate of candidates) { + if ( + candidate.kind === 'prefixed-id' && + candidate.token.startsWith('ses_') && + deps.resolveOpencodeSessionIds + ) { + const resolution = await deps.resolveOpencodeSessionIds([candidate.token]) + if (!resolution.unresolvedSessionIds.has(candidate.token)) { + return { + status: 'ready', + matches: [ + { + provider: 'opencode', + sessionId: candidate.token, + sessionType: 'opencode', + matchKind: 'exact', + }, + ], + hint, + } + } + } + if (candidate.kind === 'uuid' && deps.locateClaudeTranscript) { + const hit = await deps.locateClaudeTranscript(candidate.token) + if (hit) { + return { + status: 'ready', + matches: [ + { + provider: 'claude', + sessionId: hit.sessionId, + cwd: hit.cwd, + sessionType: 'claude', + matchKind: 'exact', + }, + ], + hint, + } + } + } + } + + return { status: 'ready', matches: [], hint } +} + +function toMatch(session: CodingCliSession, matchKind: 'exact' | 'prefix'): ResumeResolveMatch { + return { + provider: session.provider, + sessionId: session.sessionId, + cwd: session.cwd ?? session.projectPath, + sessionType: session.sessionType, + title: session.title, + firstUserMessage: session.firstUserMessage, + lastActivityAt: session.lastActivityAt, + matchKind, + } +} + +function dedupe(matches: ResumeResolveMatch[]): ResumeResolveMatch[] { + const seen = new Set() + return matches.filter((match) => { + const key = `${match.provider}:${match.sessionId}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} +``` + +- [ ] **Step 5: Register the route and widen deps** + +In `server/sessions-router.ts`: + +1. Add to the imports (NodeNext — keep `.js` suffixes): + +```ts +import { ResumeResolveRequestSchema } from '../shared/resume-resolve-contract.js' +import { resolveResumeInput } from './coding-cli/resolve-session.js' +import type { ClaudeTranscriptHit } from './coding-cli/claude-transcript-locator.js' +``` + +2. Add to `SessionsRouterDeps` (~line 39–58), after the existing optional members: + +```ts + /** Global index readiness (startup-state codingCliIndexer task). Defaults to ready. */ + getIndexReadiness?: () => boolean + /** Opencode by-id sqlite fallback (OpencodeProvider.resolveOpencodeSessionRoots). */ + resolveOpencodeSessionIds?: ( + ids: readonly string[], + ) => Promise<{ rootsBySessionId: Map; unresolvedSessionIds: Set }> + /** Claude transcript exact-id fallback. */ + locateClaudeTranscript?: (sessionId: string) => Promise +``` + +3. Register the route inside `createSessionsRouter`, after the existing +`/sessions/:sessionId` routes (~line 223), following the house zod pattern +(`safeParse(req.body ?? {})` → `400 { error, details }`): + +```ts + router.post('/sessions/resolve', async (req, res) => { + const parsed = ResumeResolveRequestSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return res + .status(400) + .json({ error: 'Invalid resolve request', details: parsed.error.issues }) + } + const response = await resolveResumeInput(parsed.data.input, { + getProjects: () => deps.codingCliIndexer.getProjects(), + isIndexReady: deps.getIndexReadiness ?? (() => true), + resolveOpencodeSessionIds: deps.resolveOpencodeSessionIds, + locateClaudeTranscript: deps.locateClaudeTranscript, + }) + res.json(response) + }) +``` + +**Route-order caution:** Express matches in registration order and +`router.post('/sessions/resolve', …)` cannot collide with the existing routes (the +only sibling POST is `/sessions/:sessionId/generate-title`, a deeper path) — but if +any `router.all('/sessions/:sessionId', …)` style catch-all is ever present, register +`/sessions/resolve` BEFORE parameterized `/sessions/:sessionId` routes. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/integration/server/sessions-resolve-router.test.ts --run` +Expected: PASS (all cases). + +- [ ] **Step 7: Wire production deps in `server/index.ts`** + +In the `createSessionsRouter({ ... })` options object (~line 748), add three +entries. Use the actual local variable names in that file: `startupState` +(declared ~line 188) and the coding-CLI provider array constructed near line 236 +(follow how `codingCliProviders` is passed to the router today). Add imports: + +```ts +import { locateClaudeTranscript } from './coding-cli/claude-transcript-locator.js' +import { OpencodeProvider } from './coding-cli/providers/opencode.js' +import { getClaudeProjectsDir } from './claude-home.js' +``` + +(If `getClaudeProjectsDir` is exported under a slightly different name, use the +export that `server/claude-home.ts` actually provides for the projects dir.) + +And in the options: + +```ts + getIndexReadiness: () => startupState.snapshot().tasks.codingCliIndexer === true, + resolveOpencodeSessionIds: (ids) => { + const opencode = codingCliProviders.find( + (provider): provider is OpencodeProvider => provider instanceof OpencodeProvider, + ) + if (!opencode) { + return Promise.resolve({ + rootsBySessionId: new Map(), + unresolvedSessionIds: new Set(ids), + }) + } + return opencode.resolveOpencodeSessionRoots(ids) + }, + locateClaudeTranscript: (sessionId) => + locateClaudeTranscript(sessionId, getClaudeProjectsDir()), +``` + +- [ ] **Step 8: Type-check and re-run server tests** + +Run: `npx tsc --noEmit -p tsconfig.json` (or the repo's `npm run check` if the +coordinator is free — check `npm run test:status` first). +Expected: no new type errors. + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/integration/server/sessions-resolve-router.test.ts test/integration/server/session-directory-router.test.ts --run` +Expected: PASS — new route works and the existing sessions-router suite still passes. + +- [ ] **Step 9: Commit** + +```bash +git add shared/resume-resolve-contract.ts server/coding-cli/resolve-session.ts server/sessions-router.ts server/index.ts test/integration/server/sessions-resolve-router.test.ts +git commit -m "feat(server): POST /api/sessions/resolve scans all CLI providers with exact-id fallbacks" +``` + +--- + +### Task 4: Client resume helper + Resume dialog + +**Files:** +- Create: `src/lib/resume-session.ts` +- Create: `src/components/ResumeSessionDialog.tsx` +- Test: `test/unit/client/lib/resume-session.test.ts` +- Test: `test/unit/client/components/ResumeSessionDialog.test.tsx` + +**Interfaces:** +- Consumes: + - `findPaneForSession(state, { provider, sessionId }, localServerInstanceId?)` → `{ tabId, paneId? } | undefined` from `src/lib/session-utils.ts` (~line 376). + - `openSessionTab({ sessionId, provider?, sessionType?, cwd?, title?, firstUserMessage? })` thunk and `setActiveTab(tabId)` from `src/store/tabsSlice.ts`; `setActivePane({ tabId, paneId })` from `src/store/panesSlice.ts`. (`openSessionTab` defaults `provider→'claude'`, `sessionType→provider` — always pass `sessionType` explicitly.) + - `api.post(path, body)` from `src/lib/api.ts` (full path form, e.g. `api.post('/api/network/disable-remote-access', {})`). + - `parseResumeInput` (Task 1); `ResumeResolveResponseSchema`, `ResumeResolveMatch` (Task 3); `DEFAULT_ENABLED_CLI_PROVIDERS` from `@shared/coding-cli-defaults`. + - `OVERLAY_Z` from `src/components/ui/overlay.ts`; modal structure copied from `src/components/ui/confirm-modal.tsx`; `RootState`/`AppDispatch` from the store types used by `src/store/hooks.ts`. +- Produces (used by Task 5): + - `resumeSessionInTab(state: RootState, dispatch: AppDispatch, target: ResumeTarget, onNavigate?: (view: 'terminal') => void): { deduped: boolean }` + - `interface ResumeTarget { provider: string; sessionId: string; cwd?: string; sessionType?: string; title?: string; firstUserMessage?: string }` + - `` with props `{ open: boolean; onClose: () => void; onNavigate?: (view: 'terminal') => void }`. + - Dialog data-testids: `resume-dialog`, `resume-input`, `resume-agent-picker`, `resume-resolve-button`, `resume-warming`, `resume-error`, `resume-note`, `resume-match-list`, `resume-match`, `resume-anyway-cwd`, `resume-anyway-button`. + +- [ ] **Step 1: Write the failing helper test** + +Create `test/unit/client/lib/resume-session.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const findPaneForSession = vi.fn() +vi.mock('@/lib/session-utils', async (importOriginal) => ({ + ...(await importOriginal()), + findPaneForSession: (...args: unknown[]) => findPaneForSession(...args), +})) + +const openSessionTabAction = { type: 'test/openSessionTab' } +const openSessionTab = vi.fn(() => openSessionTabAction) +vi.mock('@/store/tabsSlice', async (importOriginal) => ({ + ...(await importOriginal()), + openSessionTab: (...args: unknown[]) => openSessionTab(...args), +})) + +import { setActiveTab } from '@/store/tabsSlice' +import { setActivePane } from '@/store/panesSlice' +import { resumeSessionInTab } from '@/lib/resume-session' +import type { RootState } from '@/store/store' + +const state = { connection: { serverInstanceId: 'srv-1' } } as unknown as RootState + +describe('resumeSessionInTab', () => { + const dispatch = vi.fn() + const onNavigate = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('focuses the existing pane instead of opening a duplicate', () => { + findPaneForSession.mockReturnValue({ tabId: 'tab-1', paneId: 'pane-1' }) + const result = resumeSessionInTab( + state, + dispatch, + { provider: 'codex', sessionId: 'abc', sessionType: 'codex' }, + onNavigate, + ) + expect(result).toEqual({ deduped: true }) + expect(findPaneForSession).toHaveBeenCalledWith( + state, + { provider: 'codex', sessionId: 'abc' }, + 'srv-1', + ) + expect(dispatch).toHaveBeenCalledWith(setActiveTab('tab-1')) + expect(dispatch).toHaveBeenCalledWith(setActivePane({ tabId: 'tab-1', paneId: 'pane-1' })) + expect(openSessionTab).not.toHaveBeenCalled() + expect(onNavigate).toHaveBeenCalledWith('terminal') + }) + + it('opens a new tab with the full tuple when no pane holds the session', () => { + findPaneForSession.mockReturnValue(undefined) + const result = resumeSessionInTab( + state, + dispatch, + { provider: 'opencode', sessionId: 'ses_x00000000000000000000000000', cwd: '/repo/beta' }, + onNavigate, + ) + expect(result).toEqual({ deduped: false }) + expect(openSessionTab).toHaveBeenCalledWith({ + sessionId: 'ses_x00000000000000000000000000', + provider: 'opencode', + sessionType: 'opencode', // defaults to provider when unset + cwd: '/repo/beta', + title: undefined, + firstUserMessage: undefined, + }) + expect(dispatch).toHaveBeenCalledWith(openSessionTabAction) + expect(onNavigate).toHaveBeenCalledWith('terminal') + }) +}) +``` + +- [ ] **Step 2: Run helper test to verify it fails** + +Run: `npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/client/lib/resume-session.test.ts --run` +Expected: FAIL — `@/lib/resume-session` not found. + +- [ ] **Step 3: Implement the helper** + +Create `src/lib/resume-session.ts`: + +```ts +import { findPaneForSession } from '@/lib/session-utils' +import { openSessionTab, setActiveTab } from '@/store/tabsSlice' +import { setActivePane } from '@/store/panesSlice' +import type { AppDispatch, RootState } from '@/store/store' + +export interface ResumeTarget { + provider: string + sessionId: string + cwd?: string + sessionType?: string + title?: string + firstUserMessage?: string +} + +/** + * Resume a session in a tab following the sidebar's dedup convention: + * if a pane already holds the session, focus it; otherwise open a new + * focused tab running the correct agent with the FULL session id. + */ +export function resumeSessionInTab( + state: RootState, + dispatch: AppDispatch, + target: ResumeTarget, + onNavigate?: (view: 'terminal') => void, +): { deduped: boolean } { + const existing = findPaneForSession( + state, + { provider: target.provider, sessionId: target.sessionId }, + state.connection.serverInstanceId, + ) + if (existing) { + dispatch(setActiveTab(existing.tabId)) + if (existing.paneId) { + dispatch(setActivePane({ tabId: existing.tabId, paneId: existing.paneId })) + } + onNavigate?.('terminal') + return { deduped: true } + } + dispatch( + openSessionTab({ + sessionId: target.sessionId, + provider: target.provider, + sessionType: target.sessionType ?? target.provider, + cwd: target.cwd, + title: target.title, + firstUserMessage: target.firstUserMessage, + }), + ) + onNavigate?.('terminal') + return { deduped: false } +} +``` + +If `src/store/store.ts` does not export `RootState`/`AppDispatch` under those exact +names, use the type exports that `src/store/hooks.ts` imports (same types, canonical +names for this repo). + +- [ ] **Step 4: Run helper test to verify it passes** + +Run: `npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/client/lib/resume-session.test.ts --run` +Expected: PASS. + +- [ ] **Step 5: Commit the helper** + +```bash +git add src/lib/resume-session.ts test/unit/client/lib/resume-session.test.ts +git commit -m "feat(client): resumeSessionInTab helper with pane-dedup focus" +``` + +- [ ] **Step 6: Write the failing dialog test** + +Create `test/unit/client/components/ResumeSessionDialog.test.tsx`: + +```tsx +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' + +const apiPost = vi.fn() +vi.mock('@/lib/api', () => ({ + api: { post: (...args: unknown[]) => apiPost(...args) }, +})) + +const resumeSessionInTab = vi.fn(() => ({ deduped: false })) +vi.mock('@/lib/resume-session', () => ({ + resumeSessionInTab: (...args: unknown[]) => resumeSessionInTab(...args), +})) + +import { ResumeSessionDialog } from '@/components/ResumeSessionDialog' + +const V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' +const V7 = '019fac27-69d7-78a0-b972-b339d551042e' +const SES = 'ses_root0000000000000000000000' + +const match = (overrides: Record = {}) => ({ + provider: 'codex', + sessionId: V7, + cwd: '/repo/alpha', + sessionType: 'codex', + matchKind: 'exact', + ...overrides, +}) + +const ok = (matches: unknown[], hint: unknown = null) => + Promise.resolve({ status: 'ready', matches, hint }) + +function renderDialog() { + const store = configureStore({ + reducer: { connection: () => ({ serverInstanceId: 'srv-1' }) }, + }) + const onClose = vi.fn() + const onNavigate = vi.fn() + render( + + + , + ) + return { onClose, onNavigate } +} + +const typeAndResolve = (text: string) => { + const input = screen.getByTestId('resume-input') + fireEvent.change(input, { target: { value: text } }) + fireEvent.keyDown(input, { key: 'Enter' }) +} + +describe('ResumeSessionDialog', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('resolves on Enter and resumes a single match with a note', async () => { + apiPost.mockReturnValue(ok([match()])) + renderDialog() + typeAndResolve(`codex resume ${V7}`) + await waitFor(() => + expect(apiPost).toHaveBeenCalledWith('/api/sessions/resolve', { + input: `codex resume ${V7}`, + }), + ) + await waitFor(() => expect(resumeSessionInTab).toHaveBeenCalled()) + expect(resumeSessionInTab.mock.calls[0][2]).toMatchObject({ + provider: 'codex', + sessionId: V7, + cwd: '/repo/alpha', + sessionType: 'codex', + }) + expect(screen.getByTestId('resume-note').textContent).toContain('codex') + }) + + it('evidence wins over the picker, with a note', async () => { + apiPost.mockReturnValue(ok([match({ provider: 'opencode', sessionId: SES, sessionType: undefined })])) + renderDialog() + fireEvent.change(screen.getByTestId('resume-agent-picker'), { target: { value: 'claude' } }) + typeAndResolve(SES) + await waitFor(() => expect(resumeSessionInTab).toHaveBeenCalled()) + expect(resumeSessionInTab.mock.calls[0][2]).toMatchObject({ provider: 'opencode' }) + expect(screen.getByTestId('resume-note').textContent).toContain('opencode') + }) + + it('shows a disambiguation list and resumes the clicked match', async () => { + apiPost.mockReturnValue( + ok([ + match({ sessionId: '417e8345-aaaa-4bbb-8ccc-000000000001', provider: 'amplifier', matchKind: 'prefix', lastActivityAt: 900 }), + match({ sessionId: '417e8345-bbbb-4ccc-8ddd-000000000002', provider: 'amplifier', matchKind: 'prefix', lastActivityAt: 100 }), + ]), + ) + renderDialog() + typeAndResolve('417e8345') + const rows = await screen.findAllByTestId('resume-match') + expect(rows).toHaveLength(2) + fireEvent.click(rows[1]) + expect(resumeSessionInTab).toHaveBeenCalledTimes(1) + expect(resumeSessionInTab.mock.calls[0][2]).toMatchObject({ + sessionId: '417e8345-bbbb-4ccc-8ddd-000000000002', + }) + }) + + it('zero matches: inline error, input preserved, resume-anyway uses picker agent', async () => { + apiPost.mockReturnValue(ok([])) + renderDialog() + typeAndResolve(V4) + await screen.findByTestId('resume-error') + expect((screen.getByTestId('resume-input') as HTMLTextAreaElement).value).toBe(V4) + // hint pre-filled the picker to claude (v4 shape); user switches to amplifier + fireEvent.change(screen.getByTestId('resume-agent-picker'), { target: { value: 'amplifier' } }) + expect((screen.getByTestId('resume-anyway-cwd') as HTMLInputElement).value).toBe('~') + fireEvent.click(screen.getByTestId('resume-anyway-button')) + expect(resumeSessionInTab).toHaveBeenCalledTimes(1) + expect(resumeSessionInTab.mock.calls[0][2]).toMatchObject({ + provider: 'amplifier', + sessionId: V4, + sessionType: 'amplifier', + cwd: undefined, // '~' means server default (home directory) + }) + }) + + it('warming is not "not found": shows retry state and re-resolves', async () => { + apiPost + .mockReturnValueOnce(Promise.resolve({ status: 'warming', matches: [], hint: null })) + .mockReturnValueOnce(ok([match()])) + renderDialog() + typeAndResolve(V7) + await screen.findByTestId('resume-warming') + expect(screen.queryByTestId('resume-error')).toBeNull() + await vi.advanceTimersByTimeAsync(2100) + await waitFor(() => expect(resumeSessionInTab).toHaveBeenCalled()) + }) + + it('garbage input: inline error, no server call, no tab', async () => { + renderDialog() + typeAndResolve('hello decade facade!!') + await screen.findByTestId('resume-error') + expect(apiPost).not.toHaveBeenCalled() + expect(resumeSessionInTab).not.toHaveBeenCalled() + }) + + it('pre-fills the agent picker from the hint', async () => { + renderDialog() + fireEvent.change(screen.getByTestId('resume-input'), { + target: { value: `codex resume ${V7}` }, + }) + expect((screen.getByTestId('resume-agent-picker') as HTMLSelectElement).value).toBe('codex') + }) + + it('closes on Escape', () => { + const { onClose } = renderDialog() + fireEvent.keyDown(screen.getByTestId('resume-dialog'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 7: Run dialog test to verify it fails** + +Run: `npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/client/components/ResumeSessionDialog.test.tsx --run` +Expected: FAIL — component not found. + +- [ ] **Step 8: Implement the dialog** + +Create `src/components/ResumeSessionDialog.tsx`. Modal shell (portal, backdrop, +z-index, focus handling) follows `src/components/ui/confirm-modal.tsx`; the select +styling matches the sidebar's raw `