From fb18bcd0559f18ba27a658e0adb7e0af1273e784 Mon Sep 17 00:00:00 2001 From: yinjialu <2481044901@qq.com> Date: Fri, 29 May 2026 17:05:46 +0800 Subject: [PATCH 1/5] docs(spec): propose Claude CLI session resume Add a change proposal for an opt-in, Claude-first, best-effort session resume path that passes --resume and sends only the latest turn on qualifying follow-ups, falling back to the full-transcript spawn whenever a guard fails. Borrows multica's resume + runtime-match / force-fresh / poisoned-session guards and adds an OD-specific history epoch guard for editable history. Spec only; implementation lands in follow-up PRs per the plan. --- .../20260529-claude-session-resume/spec.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 specs/change/20260529-claude-session-resume/spec.md diff --git a/specs/change/20260529-claude-session-resume/spec.md b/specs/change/20260529-claude-session-resume/spec.md new file mode 100644 index 000000000..ed394792b --- /dev/null +++ b/specs/change/20260529-claude-session-resume/spec.md @@ -0,0 +1,315 @@ +--- +id: 20260529-claude-session-resume +name: Claude CLI Session Resume +status: proposed +created: '2026-05-29' +--- + +## Overview + +Every chat turn in a conversation spawns a fresh, context-free `claude -p` +process and re-sends the entire conversation as a recomposed transcript. The +process never resumes the prior turn's Claude Code session, so each turn pays +to re-read project files, re-process the full history, and forfeits any +cross-turn prompt-cache hits. On long conversations the per-turn payload grows +O(N) in turns. (Reported externally: "every message starts a new Claude Code +session, re-sends prompt/history, re-reads project files → token waste.") + +This is a deliberate trade-off, not a defect — the recompose-transcript model +buys uniform behavior across ~15 heterogeneous adapters, full daemon control of +per-turn prompt composition, and a single daemon-owned source of truth. But the +project's cited architectural inspiration, [multica](https://github.com/multica-ai/multica), +runs the *same* `claude -p --output-format stream-json` base invocation and yet +resumes the CLI's own session via `--resume ` with a small set of +guards. multica demonstrates that session resume is compatible with this daemon +architecture when the invalidation edges are handled explicitly. + +Goal: add an **opt-in, Claude-first, best-effort** session-resume path that, on +a qualifying follow-up turn, passes `--resume ` and sends only the +latest user message instead of the full transcript — while never removing the +transcript path that remains the correct behavior for every other adapter and +for any turn that fails the resume guards. + +Constraints: +- Do not regress any existing behavior. Resume is best-effort: when a guard + fails, the capability is absent, or `--resume` is rejected at runtime, the + daemon falls back to today's full-transcript spawn for that turn. +- Do not break the interactive `stream-json` / `AskUserQuestion` machinery + (`pendingHostAnswers`, `POST /api/runs/:id/tool-result`). +- Keep the daemon the source of truth: the stored session pointer is a cache + keyed on daemon-owned conversation state, never the authoritative history. +- Claude-only in v1. Other adapters keep `resumesSessionViaCli` unset and the + transcript path unchanged. +- Honor the UI/CLI dual-track rule: a force-fresh control must exist on both + the web composer and the `od` run path. + +Open questions: +- Should resume default on for Claude, or ship behind a per-project opt-in for + one release while we gather token-savings telemetry? (Leaning: default on, + with a force-fresh escape hatch — see Design Decisions.) +- Should a resumed turn show a small "continuing session" affordance in the + chat UI, or stay invisible? (Leaning: telemetry only in v1, no UI badge.) +- Do we extend the same machinery to other resumable CLIs (codex, cursor-agent) + in a follow-up, gated per-adapter? (Out of scope here.) + +## Research + +### Existing System + +- Claude's `buildArgs` emits no session flag; every spawn is a cold start. + Source: `apps/daemon/src/runtimes/defs/claude.ts:45-74` +- The daemon already parses `session_id` from Claude's `system/init` line and + emits it as a `status` event, but it is only streamed to the client. + Source: `apps/daemon/src/claude-stream.ts:91` +- `session_id` is never persisted: the persisted-event mapper drops it, and the + `messages` table has no session column. + Source: `apps/daemon/src/db.ts:86-103` (schema), the status-event branch of + `daemonAgentPayloadToPersistedAgentEvent` in `apps/daemon/src/server.ts` +- Per-turn context is recomposed client-side into a markdown transcript and + sent as `message`; `currentPrompt` carries only the latest user turn. + Source: `apps/web/src/providers/daemon.ts:171-185` (`buildDaemonTranscript`), + `:326-330` (call site) +- The daemon already has the seam to send only the latest turn: when + `def.resumesSessionViaCli === true`, `composeChatUserRequestForAgent` skips + the transcript and sends `currentPrompt`. + Source: `apps/daemon/src/server.ts:2542-2571`, `:10985-10989` +- `RuntimeContext.hasPriorAssistantTurn` is already computed per run and passed + into `buildArgs` — the hook a resume flag would read. + Source: `apps/daemon/src/runtimes/types.ts:19-29`, `apps/daemon/src/server.ts:11322,11376` +- The transcript is already scoped to the active agent and sanitized for prior + `` markup — work that only applies to the daemon-owned + transcript, not to a CLI-held session. + Source: `apps/web/src/providers/daemon.ts:127-136` (`scopeHistoryToAgent`), + `:150-169` (`sanitizePriorAssistantTurnForTranscript`) +- The capability-probe pattern (probe `claude -p --help`, set a capability flag, + gate the arg) already exists for `--include-partial-messages` / `--add-dir`. + Source: `apps/daemon/src/runtimes/defs/claude.ts:16-24,58-71` + +### Reference Implementation (multica) + +multica runs the same base invocation and resumes via `--resume`, persisting +and re-injecting a per-(agent, issue) session id with guards that map directly +onto OD's edges: + +- Same base argv, plus conditional `--resume`. + Source: `multica/server/pkg/agent/claude.go:483-519` (`--resume` at `:513-514`) +- Resume only when not a forced rerun, and only when the prior session ran on + the **same runtime** as the claiming task; excludes poisoned sessions; falls + back to the last task that recorded a session id. + Source: `multica/server/internal/handler/daemon.go:1306-1360` +- session id captured from the `system` message and pinned mid-flight to the + task row. + Source: `multica/server/internal/daemon/client.go:248` (`PinTaskSession`) +- A "Focus on THIS comment" prompt guard defends against the resumed session + inheriting the prior turn's completion marker. + Source: `multica/server/internal/daemon/prompt.go` + +### Why OD diverged (product characteristics that shape this design) + +OD is a synchronous, interactive design-chat, not an async issue/task runner. +These traits are why resume must be guarded rather than unconditional, and why +it stays Claude-first and opt-out-able: + +1. **Interactive mid-turn tools.** OD keeps `stream-json` stdin open to answer + `AskUserQuestion` with a real `tool_result`. multica disables it + (`--disallowedTools AskUserQuestion`). Resume must not disturb this path. +2. **Per-turn prompt rewriting.** OD recomposes the system prompt + skills + + memory + design system into the `# Instructions` block of the stdin user + message every turn. Because the instructions ride the stdin message (not a + one-time `--append-system-prompt`), they still reach the model on a resumed + turn — so OD keeps prompt control *and* gets resume. This is the key reason + OD can adopt resume where the antigravity `-c` path could not: `agy -c` + activated an internal agentic loop OD could not steer + (`apps/daemon/src/runtimes/defs/antigravity.ts:189-204`); `claude --resume` + still consumes the fresh stdin turn. +3. **Mid-conversation agent switching.** OD lets a user switch active agent per + conversation; a Claude session id is meaningless to Codex. Mirror multica's + runtime-match guard. +4. **User-editable history.** OD supports retry-from-message and editing prior + turns. Claude's session is an immutable replay; once OD's history diverges + from what produced the session, the session is stale. multica has no direct + analog, so OD needs an extra epoch guard. +5. **Heterogeneous fleet.** Most adapters have no compatible resume. Resume is a + per-adapter opt-in; the transcript path stays the default everywhere else. + +### Constraints & Dependencies + +- Contracts-first: any new request/response field lands in + `packages/contracts` before web/daemon wiring (`AGENTS.md` contract rule). +- SQLite migration discipline: additive, backward-compatible schema only. +- Capability probe must gate `--resume` so forks lacking it (e.g. openclaude) + never receive it (`fallbackBins` includes `openclaude`). + +### Key References + +- `apps/daemon/src/runtimes/defs/claude.ts` +- `apps/daemon/src/claude-stream.ts:91` +- `apps/daemon/src/server.ts:2542-2571,10985-10989,11322,11376` +- `apps/daemon/src/runtimes/types.ts:19-29,45-54,149-155` +- `apps/web/src/providers/daemon.ts:127-185,326-330` +- `apps/daemon/src/db.ts:86-103,888-908,1031-1050` +- multica: `server/pkg/agent/claude.go`, `server/internal/handler/daemon.go:1306-1360` + +## Design + +### Architecture Overview + +Three additions, all behind a Claude-only capability gate: + +1. **Capture + persist** the Claude `session_id` (already parsed) keyed on + `(conversationId, agentId)`, plus a `historyEpoch` snapshot and the run's + `workDir`. Update on run success. +2. **Decide** at run start whether to resume: a pure `resolveResumeDecision()` + helper that returns a `sessionId` only when every guard passes. +3. **Apply**: when resuming, `buildArgs` appends `--resume ` and the prompt + composer sends only `currentPrompt` (existing `skipTranscript` seam) plus an + anti-echo guard line. On runtime rejection, fall back to a transcript spawn. + +``` +turn N: claude system/init → session_id ──pin──▶ conversation_agent_session + (conv, agent, sid, epoch, wd) +turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) + │ pass → buildArgs(+--resume sid) + skipTranscript + anti-echo guard + │ fail → today's full-transcript cold spawn + claude --resume rejects sid? → one retry: transcript cold spawn +``` + +### Change Scope + +- `packages/contracts`: add `forceFreshSession?: boolean` to the chat/run + request DTO; add `sessionResumed?: boolean` to run status/telemetry shape. +- `apps/daemon/src/db.ts`: new `conversation_agent_session` table + queries; + stop stripping `session_id` so it reaches the pin path. +- `apps/daemon/src/runtimes/types.ts`: extend `RuntimeContext` with + `resumeSessionId?: string`; add `supportsSessionResume` capability key. +- `apps/daemon/src/runtimes/defs/claude.ts`: probe `--resume`; emit it when + `runtimeContext.resumeSessionId` is set. +- `apps/daemon/src/server.ts`: `resolveResumeDecision()`, pin-on-success, + resume-aware `skipTranscript`, anti-echo guard line, runtime-rejection + fallback. +- `apps/web/src/`: force-fresh control in the composer; bump epoch on history + edit/retry. +- `apps/daemon/src/cli.ts`: `--fresh-session` flag on the run path (dual-track). + +### Design Decisions + +1. **Claude-only, capability-gated, opt-in per adapter.** Reuse the existing + `resumesSessionViaCli` semantics so the transcript-skip seam and the resume + flag travel together; other adapters are untouched. +2. **Store a pointer keyed on `(conversationId, agentId)`** — the OD analog of + multica's per-(agent, issue) session. A dedicated small table avoids bloating + `conversations` and makes the agent-switch guard a natural key miss. +3. **`historyEpoch` guard.** The conversation carries a monotonic epoch bumped + whenever history is edited, truncated, or a turn is retried. The pinned + session records the epoch it was produced under; a mismatch invalidates + resume (Claude's immutable session would diverge from OD's edited history). + This is OD's addition over multica. +4. **Best-effort with guaranteed fallback.** If `claude --resume` exits early + reporting an unknown/invalid session, retry the same turn once without + `--resume` and with the full transcript. Worst case equals today. +5. **Anti-echo guard, not transcript sanitization.** On a resumed turn the prior + `` lives in Claude's session, beyond reach of + `sanitizePriorAssistantTurnForTranscript`. Replace that defense with a short + instruction-block guard (multica's "Focus on THIS turn" analog) telling the + model the prior form was already answered. +6. **Force-fresh is first-class and dual-surface.** Retry-from-message, history + edits, an explicit composer toggle, and `od --fresh-session` all set + `forceFreshSession`, mirroring multica's `ForceFreshSession`. + +### Why this design + +It captures multica's token win on the dominant path (Claude, linear +conversation) while preserving the four OD properties the transcript model was +protecting: interactive tools, per-turn prompt control, agent switching, and +editable history. The fallback and capability gate make it strictly +non-regressing, so it can ship default-on without a flag day. + +### Test Strategy + +Red-spec first at the cheapest layer that sees the symptom (daemon HTTP e2e), +per `AGENTS.md`: + +- **Resume happy path (red on main).** Two turns, same conversation, same agent: + assert turn 2 spawns with `--resume ` and that turn 2's stdin + prompt does **not** contain the full transcript (only `currentPrompt`). +- **Agent-switch guard.** Turn 1 Claude, turn 2 after switching agent → no + `--resume`; transcript path used. +- **Force-fresh.** Retry-from-message and `--fresh-session` → no `--resume`. +- **History-edit epoch guard.** Editing a prior turn bumps epoch → no `--resume`. +- **Runtime-rejection fallback.** Stub a `claude` that rejects `--resume` → the + daemon retries with transcript and the run still succeeds. +- **Capability probe.** A `claude --help` without `--resume` → flag never sent. +- **Interactive intact.** An `AskUserQuestion` turn still resolves via + `POST /api/runs/:id/tool-result` under resume. + +Human verification (per `AGENTS.md`, two namespaced runtimes: `main` vs branch): +drive a multi-turn chat through production HTTP only; confirm continuity holds +and compare per-turn input-token counts (resume turn should drop sharply). + +### Pseudocode + +```ts +// server.ts — at run start, before buildArgs +function resolveResumeDecision(ctx): string | null { + if (!agentDef.capabilities.supportsSessionResume) return null; + if (chatBody.forceFreshSession) return null; + if (!hasPriorAssistantTurn) return null; + const row = getConversationAgentSession(db, conversationId, agentId); + if (!row || row.agentId !== agentId) return null; // agent-switch guard + if (row.historyEpoch !== currentHistoryEpoch) return null; // edit guard + return row.sessionId; +} + +// claude.ts buildArgs +if (runtimeContext.resumeSessionId && caps.sessionResume) { + args.push('--resume', runtimeContext.resumeSessionId); +} + +// prompt composition +const skipTranscript = def.resumesSessionViaCli === true || resumeSessionId != null; + +// on run success (mirrors multica PinTaskSession) +if (finalSessionId) upsertConversationAgentSession(db, { + conversationId, agentId, sessionId: finalSessionId, + historyEpoch: currentHistoryEpoch, workDir, +}); + +// runtime rejection +if (exitedWith('unknown session') && usedResume) { + return respawnWithoutResumeAndFullTranscript(); +} +``` + +### File Structure + +- `specs/change/20260529-claude-session-resume/spec.md` (this file) +- (implementation, follow-up PR) contracts → db → runtime types → claude def → + server orchestration → web composer → cli flag + +## Plan + +1. Land this spec (this PR). +2. Contracts: `forceFreshSession`, `sessionResumed`. +3. DB: `conversation_agent_session` table + queries; stop dropping `session_id`. +4. Runtime: `RuntimeContext.resumeSessionId`, `supportsSessionResume` probe, + `--resume` in `claude.ts`. +5. Server: `resolveResumeDecision`, pin-on-success, resume-aware skipTranscript, + anti-echo guard, rejection fallback. +6. Web + CLI: force-fresh control on both surfaces; epoch bump on history edit. +7. Tests per Test Strategy, then human verification. + +## Notes + +### Implementation + +- Steps 2–6 ship as separate PRs after this spec is accepted; each carries its + own red spec. +- Keep `resolveResumeDecision` a pure function for unit-testing the guard matrix + without spawning a process. + +### Verification + +- `pnpm guard`, `pnpm typecheck`, `pnpm --filter @open-design/daemon test` per + changed surface. +- Token-savings telemetry (`sessionResumed`, input-token delta) confirms the + win that motivates the change. From 278e7ebbcf80173e7af0ee7623723e693b3648ba Mon Sep 17 00:00:00 2001 From: yinjialu <2481044901@qq.com> Date: Fri, 29 May 2026 17:53:59 +0800 Subject: [PATCH 2/5] docs(spec): couple transcript-skip to per-run resume decision Address review: keying skipTranscript on the static resumesSessionViaCli flag would skip the transcript on guard-fail turns (force-fresh, history edit, no --resume capability) even though --resume is omitted, producing a cold spawn with neither resume nor history and breaking the non-regression guarantee. Derive both --resume and skipTranscript from the single per-run resumeSessionId; add supportsSessionResume capability distinct from the per-turn decision; and assert full-transcript presence in every guard-fail test. --- .../20260529-claude-session-resume/spec.md | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/specs/change/20260529-claude-session-resume/spec.md b/specs/change/20260529-claude-session-resume/spec.md index ed394792b..3ee55c51f 100644 --- a/specs/change/20260529-claude-session-resume/spec.md +++ b/specs/change/20260529-claude-session-resume/spec.md @@ -193,9 +193,22 @@ turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) ### Design Decisions -1. **Claude-only, capability-gated, opt-in per adapter.** Reuse the existing - `resumesSessionViaCli` semantics so the transcript-skip seam and the resume - flag travel together; other adapters are untouched. +1. **Claude-only, capability-gated, opt-in per adapter.** Other adapters are + untouched. Note the existing `resumesSessionViaCli` flag is a *static, + per-adapter* skip-transcript switch (agy resumes on every follow-up or never). + Claude resumes *conditionally per turn*, so it must NOT reuse that static flag + to gate transcript-skipping — see Design Decision #1a. + 1a. **Transcript-skip is keyed to the per-run resume decision, never a static + flag.** Both `--resume` and skip-transcript derive from the single per-run + `resumeSessionId` returned by `resolveResumeDecision()`: present → emit + `--resume` AND skip the transcript; null → omit `--resume` AND send the full + transcript. They are coupled so a guard-fail / no-capability / force-fresh + turn can never produce the broken state "cold spawn + no `--resume` + no + transcript" (which would silently drop all prior context and break the + non-regression guarantee in Design Decision #4). For Claude, leave the static + `resumesSessionViaCli` flag unset; introduce `supportsSessionResume` as a + *capability* (can this CLI resume at all?) distinct from the per-turn + *decision* (should THIS turn resume?). 2. **Store a pointer keyed on `(conversationId, agentId)`** — the OD analog of multica's per-(agent, issue) session. A dedicated small table avoids bloating `conversations` and makes the agent-switch guard a natural key miss. @@ -232,13 +245,22 @@ per `AGENTS.md`: - **Resume happy path (red on main).** Two turns, same conversation, same agent: assert turn 2 spawns with `--resume ` and that turn 2's stdin prompt does **not** contain the full transcript (only `currentPrompt`). + Every guard-fail case below asserts BOTH halves of the coupling (Design + Decision #1a): no `--resume` AND the full transcript is present in the stdin + prompt — this is the explicit non-regression proof that a fallback turn never + loses prior context. - **Agent-switch guard.** Turn 1 Claude, turn 2 after switching agent → no - `--resume`; transcript path used. -- **Force-fresh.** Retry-from-message and `--fresh-session` → no `--resume`. -- **History-edit epoch guard.** Editing a prior turn bumps epoch → no `--resume`. + `--resume` AND turn 2's prompt contains the full transcript. +- **Force-fresh.** Retry-from-message and `--fresh-session` → no `--resume` AND + full transcript present. +- **History-edit epoch guard.** Editing a prior turn bumps epoch → no `--resume` + AND full transcript present. - **Runtime-rejection fallback.** Stub a `claude` that rejects `--resume` → the - daemon retries with transcript and the run still succeeds. -- **Capability probe.** A `claude --help` without `--resume` → flag never sent. + daemon retries WITHOUT `--resume` AND WITH the full transcript, and the run + still succeeds. +- **Capability probe.** A `claude --help` without `--resume` → flag never sent + AND every turn carries the full transcript (skip-transcript stays off because + `resolveResumeDecision` returns null without the capability). - **Interactive intact.** An `AskUserQuestion` turn still resolves via `POST /api/runs/:id/tool-result` under resume. @@ -265,8 +287,10 @@ if (runtimeContext.resumeSessionId && caps.sessionResume) { args.push('--resume', runtimeContext.resumeSessionId); } -// prompt composition -const skipTranscript = def.resumesSessionViaCli === true || resumeSessionId != null; +// prompt composition — keyed ONLY to the per-run decision, NOT a static flag. +// `--resume` (above) and skipTranscript share the same source of truth, so a +// guard-fail turn (resumeSessionId == null) always sends the full transcript. +const skipTranscript = resumeSessionId != null; // on run success (mirrors multica PinTaskSession) if (finalSessionId) upsertConversationAgentSession(db, { From 911fa8fbb82e495c1957b805d3f33fcba100e321 Mon Sep 17 00:00:00 2001 From: yinjialu <2481044901@qq.com> Date: Sun, 31 May 2026 15:48:00 +0800 Subject: [PATCH 3/5] docs(spec): define live history-epoch store and work-dir resume guard Specify conversations.history_epoch as the live epoch source read at run start, distinct from the pinned conversation_agent_session.historyEpoch, so resolveResumeDecision has a defined currentHistoryEpoch. Make workDir an explicit cwd-scope resume guard (pseudocode check, Architecture Overview, and a Test Strategy case) rather than write-only state. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) --- .../20260529-claude-session-resume/spec.md | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/specs/change/20260529-claude-session-resume/spec.md b/specs/change/20260529-claude-session-resume/spec.md index 3ee55c51f..ced25e93f 100644 --- a/specs/change/20260529-claude-session-resume/spec.md +++ b/specs/change/20260529-claude-session-resume/spec.md @@ -158,7 +158,12 @@ Three additions, all behind a Claude-only capability gate: 1. **Capture + persist** the Claude `session_id` (already parsed) keyed on `(conversationId, agentId)`, plus a `historyEpoch` snapshot and the run's - `workDir`. Update on run success. + `workDir`. Update on run success. `workDir` is a **resume guard**, not just + the spawn cwd: Claude Code sessions live under a per-cwd path, so + `claude --resume ` is only meaningful from the same working directory that + produced the session — `resolveResumeDecision()` rejects a pinned row whose + `workDir` differs from the current run's. This is the OD analog of multica's + "same runtime" guard. 2. **Decide** at run start whether to resume: a pure `resolveResumeDecision()` helper that returns a `sessionId` only when every guard passes. 3. **Apply**: when resuming, `buildArgs` appends `--resume ` and the prompt @@ -178,8 +183,12 @@ turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) - `packages/contracts`: add `forceFreshSession?: boolean` to the chat/run request DTO; add `sessionResumed?: boolean` to run status/telemetry shape. -- `apps/daemon/src/db.ts`: new `conversation_agent_session` table + queries; - stop stripping `session_id` so it reaches the pin path. +- `apps/daemon/src/db.ts`: add `history_epoch INTEGER NOT NULL DEFAULT 0` to + `conversations` (the live conversation epoch, bumped on history edit / truncate + / retry); new `conversation_agent_session` table + queries (the table's + `historyEpoch` column records the epoch a session was *pinned under*, distinct + from the live `conversations.history_epoch`); stop stripping `session_id` so it + reaches the pin path. - `apps/daemon/src/runtimes/types.ts`: extend `RuntimeContext` with `resumeSessionId?: string`; add `supportsSessionResume` capability key. - `apps/daemon/src/runtimes/defs/claude.ts`: probe `--resume`; emit it when @@ -213,10 +222,14 @@ turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) multica's per-(agent, issue) session. A dedicated small table avoids bloating `conversations` and makes the agent-switch guard a natural key miss. 3. **`historyEpoch` guard.** The conversation carries a monotonic epoch bumped - whenever history is edited, truncated, or a turn is retried. The pinned - session records the epoch it was produced under; a mismatch invalidates - resume (Claude's immutable session would diverge from OD's edited history). - This is OD's addition over multica. + whenever history is edited, truncated, or a turn is retried. The live epoch is + the new `conversations.history_epoch` column (the web edit/retry path issues + the bump); the pinned `conversation_agent_session.historyEpoch` records the + epoch a session was produced under. At run start the daemon reads + `conversations.history_epoch` for the active conversation to form + `currentHistoryEpoch`, then `resolveResumeDecision()` compares it against the + pinned row; a mismatch invalidates resume (Claude's immutable session would + diverge from OD's edited history). This is OD's addition over multica. 4. **Best-effort with guaranteed fallback.** If `claude --resume` exits early reporting an unknown/invalid session, retry the same turn once without `--resume` and with the full transcript. Worst case equals today. @@ -255,6 +268,9 @@ per `AGENTS.md`: full transcript present. - **History-edit epoch guard.** Editing a prior turn bumps epoch → no `--resume` AND full transcript present. +- **Work-dir guard.** A pinned session whose `workDir` differs from the current + run's (e.g. the conversation's project cwd moved) → no `--resume` AND full + transcript present. - **Runtime-rejection fallback.** Stub a `claude` that rejects `--resume` → the daemon retries WITHOUT `--resume` AND WITH the full transcript, and the run still succeeds. @@ -278,7 +294,9 @@ function resolveResumeDecision(ctx): string | null { if (!hasPriorAssistantTurn) return null; const row = getConversationAgentSession(db, conversationId, agentId); if (!row || row.agentId !== agentId) return null; // agent-switch guard + // currentHistoryEpoch is read from conversations.history_epoch at run start. if (row.historyEpoch !== currentHistoryEpoch) return null; // edit guard + if (row.workDir !== currentWorkDir) return null; // cwd-scope guard return row.sessionId; } From 35d7701d0c143ea02c2e8745875b065963e91eed Mon Sep 17 00:00:00 2001 From: yinjialu <2481044901@qq.com> Date: Sun, 31 May 2026 16:18:37 +0800 Subject: [PATCH 4/5] docs(spec): scope resume epoch per-agent, add same-runtime guard Address review on the Claude session-resume spec: - Make the live history epoch agent-scoped via a new conversation_agent_epoch table keyed on (conversationId, agentId) instead of a conversation-wide conversations.history_epoch column, so editing a Codex-only turn no longer invalidates Claude's pinned session. Update Design Decision #3, pseudocode, change scope, web bump bullet, and the epoch-guard test case. - Persist a runtimeId runtime-identity fingerprint alongside the session pointer and add a same-runtime guard to resolveResumeDecision(), the true OD analog of multica's "same runtime" guard, so a mid-conversation bin/fork swap (e.g. openclaude) falls back to a transcript spawn instead of resuming a foreign session. Add the matching test case. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) --- .../20260529-claude-session-resume/spec.md | 97 +++++++++++++------ 1 file changed, 67 insertions(+), 30 deletions(-) diff --git a/specs/change/20260529-claude-session-resume/spec.md b/specs/change/20260529-claude-session-resume/spec.md index ced25e93f..163a8322f 100644 --- a/specs/change/20260529-claude-session-resume/spec.md +++ b/specs/change/20260529-claude-session-resume/spec.md @@ -157,13 +157,24 @@ it stays Claude-first and opt-out-able: Three additions, all behind a Claude-only capability gate: 1. **Capture + persist** the Claude `session_id` (already parsed) keyed on - `(conversationId, agentId)`, plus a `historyEpoch` snapshot and the run's - `workDir`. Update on run success. `workDir` is a **resume guard**, not just - the spawn cwd: Claude Code sessions live under a per-cwd path, so - `claude --resume ` is only meaningful from the same working directory that - produced the session — `resolveResumeDecision()` rejects a pinned row whose - `workDir` differs from the current run's. This is the OD analog of multica's - "same runtime" guard. + `(conversationId, agentId)`, plus a `historyEpoch` snapshot, the run's + `workDir`, and a `runtimeId` runtime-identity fingerprint. Update on run + success. Two distinct guards ride these fields: + - `workDir` is a **cwd-scope guard**, not just the spawn cwd: Claude Code + sessions live under a per-cwd path, so `claude --resume ` is only + meaningful from the same working directory that produced the session — + `resolveResumeDecision()` rejects a pinned row whose `workDir` differs from + the current run's. + - `runtimeId` is the OD analog of multica's **"same runtime" guard** (multica's + guard is about the *producing runtime*, not just the cwd). The Claude adapter + can resolve through different binaries/forks (`fallbackBins` includes + `openclaude`, Research lines 140-141), and a session created by one + executable is not guaranteed resumable by another. `runtimeId` is a small, + stable fingerprint of the resolved runtime (e.g. the resolved bin path plus + its `--resume` capability probe result); `resolveResumeDecision()` rejects a + pinned row whose `runtimeId` differs from the current run's resolved runtime, + so a mid-conversation binary/fork swap falls back to a transcript spawn + rather than attempting `--resume` against a foreign session. 2. **Decide** at run start whether to resume: a pure `resolveResumeDecision()` helper that returns a `sessionId` only when every guard passes. 3. **Apply**: when resuming, `buildArgs` appends `--resume ` and the prompt @@ -172,7 +183,7 @@ Three additions, all behind a Claude-only capability gate: ``` turn N: claude system/init → session_id ──pin──▶ conversation_agent_session - (conv, agent, sid, epoch, wd) + (conv, agent, sid, epoch, wd, rt) turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) │ pass → buildArgs(+--resume sid) + skipTranscript + anti-echo guard │ fail → today's full-transcript cold spawn @@ -183,12 +194,15 @@ turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) - `packages/contracts`: add `forceFreshSession?: boolean` to the chat/run request DTO; add `sessionResumed?: boolean` to run status/telemetry shape. -- `apps/daemon/src/db.ts`: add `history_epoch INTEGER NOT NULL DEFAULT 0` to - `conversations` (the live conversation epoch, bumped on history edit / truncate - / retry); new `conversation_agent_session` table + queries (the table's - `historyEpoch` column records the epoch a session was *pinned under*, distinct - from the live `conversations.history_epoch`); stop stripping `session_id` so it - reaches the pin path. +- `apps/daemon/src/db.ts`: add a `conversation_agent_epoch` table keyed on + `(conversation_id, agent_id)` with `history_epoch INTEGER NOT NULL DEFAULT 0` + (the live **agent-scoped** epoch, bumped only when a mutation affects *that + agent's* scoped history — edit / truncate / retry of a turn inside + `scopeHistoryToAgent`'s slice for that agent); new `conversation_agent_session` + table + queries (its `historyEpoch` column records the epoch a session was + *pinned under*, distinct from the live `conversation_agent_epoch.history_epoch`; + its `runtimeId` column records the runtime fingerprint a session was *produced + by*); stop stripping `session_id` so it reaches the pin path. - `apps/daemon/src/runtimes/types.ts`: extend `RuntimeContext` with `resumeSessionId?: string`; add `supportsSessionResume` capability key. - `apps/daemon/src/runtimes/defs/claude.ts`: probe `--resume`; emit it when @@ -196,8 +210,10 @@ turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) - `apps/daemon/src/server.ts`: `resolveResumeDecision()`, pin-on-success, resume-aware `skipTranscript`, anti-echo guard line, runtime-rejection fallback. -- `apps/web/src/`: force-fresh control in the composer; bump epoch on history - edit/retry. +- `apps/web/src/`: force-fresh control in the composer; bump the live + agent-scoped epoch on history edit/retry, scoped to the agent(s) whose + `scopeHistoryToAgent` slice the edited/truncated turn belongs to (so editing a + Codex-only turn does not invalidate Claude's pinned session). - `apps/daemon/src/cli.ts`: `--fresh-session` flag on the run path (dual-track). ### Design Decisions @@ -221,15 +237,25 @@ turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) 2. **Store a pointer keyed on `(conversationId, agentId)`** — the OD analog of multica's per-(agent, issue) session. A dedicated small table avoids bloating `conversations` and makes the agent-switch guard a natural key miss. -3. **`historyEpoch` guard.** The conversation carries a monotonic epoch bumped - whenever history is edited, truncated, or a turn is retried. The live epoch is - the new `conversations.history_epoch` column (the web edit/retry path issues - the bump); the pinned `conversation_agent_session.historyEpoch` records the - epoch a session was produced under. At run start the daemon reads - `conversations.history_epoch` for the active conversation to form - `currentHistoryEpoch`, then `resolveResumeDecision()` compares it against the - pinned row; a mismatch invalidates resume (Claude's immutable session would - diverge from OD's edited history). This is OD's addition over multica. +3. **`historyEpoch` guard (agent-scoped).** Each `(conversation, agent)` carries + a monotonic epoch bumped whenever *that agent's scoped history* is edited, + truncated, or retried. The epoch is **agent-scoped, not conversation-wide**, + because the resumable state itself is agent-scoped: OD scopes the prompt + transcript to the active agent (`scopeHistoryToAgent`, Research lines 79-83) + and pins the session pointer by `(conversationId, agentId)` (Design Decision + #2). A conversation-wide epoch would let an edit to a *Codex-only* turn bump + the key and invalidate Claude's pinned session even though Claude's scoped + history — the actual prompt source — never changed, making the invalidation + model stricter than the prompt source. The live epoch therefore lives in the + new `conversation_agent_epoch` table keyed on `(conversationId, agentId)` (the + web edit/retry path issues the bump only for the agent(s) whose scoped slice + the mutation touches); the pinned `conversation_agent_session.historyEpoch` + records the epoch a session was produced under. At run start the daemon reads + `conversation_agent_epoch.history_epoch` for the active `(conversation, agent)` + to form `currentHistoryEpoch`, then `resolveResumeDecision()` compares it + against the pinned row; a mismatch invalidates resume (Claude's immutable + session would diverge from that agent's edited history). This is OD's addition + over multica. 4. **Best-effort with guaranteed fallback.** If `claude --resume` exits early reporting an unknown/invalid session, retry the same turn once without `--resume` and with the full transcript. Worst case equals today. @@ -266,11 +292,18 @@ per `AGENTS.md`: `--resume` AND turn 2's prompt contains the full transcript. - **Force-fresh.** Retry-from-message and `--fresh-session` → no `--resume` AND full transcript present. -- **History-edit epoch guard.** Editing a prior turn bumps epoch → no `--resume` - AND full transcript present. +- **History-edit epoch guard.** Editing a prior turn in the active agent's scoped + history bumps that agent's epoch → no `--resume` AND full transcript present. + Conversely, editing a turn that belongs only to a *different* agent's scoped + history (e.g. a Codex-only turn) must NOT bump Claude's epoch → Claude still + resumes with `--resume`. - **Work-dir guard.** A pinned session whose `workDir` differs from the current run's (e.g. the conversation's project cwd moved) → no `--resume` AND full transcript present. +- **Same-runtime guard.** A pinned session whose `runtimeId` differs from the + current run's resolved runtime (e.g. the Claude bin resolved to `openclaude` on + turn 2 while cwd and agent stayed the same) → no `--resume` AND full transcript + present. - **Runtime-rejection fallback.** Stub a `claude` that rejects `--resume` → the daemon retries WITHOUT `--resume` AND WITH the full transcript, and the run still succeeds. @@ -294,9 +327,12 @@ function resolveResumeDecision(ctx): string | null { if (!hasPriorAssistantTurn) return null; const row = getConversationAgentSession(db, conversationId, agentId); if (!row || row.agentId !== agentId) return null; // agent-switch guard - // currentHistoryEpoch is read from conversations.history_epoch at run start. + // currentHistoryEpoch is read from conversation_agent_epoch for THIS + // (conversationId, agentId) at run start — agent-scoped, so a Codex-only edit + // never invalidates Claude's pinned session. if (row.historyEpoch !== currentHistoryEpoch) return null; // edit guard if (row.workDir !== currentWorkDir) return null; // cwd-scope guard + if (row.runtimeId !== currentRuntimeId) return null; // same-runtime guard return row.sessionId; } @@ -313,7 +349,7 @@ const skipTranscript = resumeSessionId != null; // on run success (mirrors multica PinTaskSession) if (finalSessionId) upsertConversationAgentSession(db, { conversationId, agentId, sessionId: finalSessionId, - historyEpoch: currentHistoryEpoch, workDir, + historyEpoch: currentHistoryEpoch, workDir, runtimeId: currentRuntimeId, }); // runtime rejection @@ -332,7 +368,8 @@ if (exitedWith('unknown session') && usedResume) { 1. Land this spec (this PR). 2. Contracts: `forceFreshSession`, `sessionResumed`. -3. DB: `conversation_agent_session` table + queries; stop dropping `session_id`. +3. DB: `conversation_agent_session` (with `runtimeId`) + `conversation_agent_epoch` + tables + queries; stop dropping `session_id`. 4. Runtime: `RuntimeContext.resumeSessionId`, `supportsSessionResume` probe, `--resume` in `claude.ts`. 5. Server: `resolveResumeDecision`, pin-on-success, resume-aware skipTranscript, From 8b3e5039e17e55036139b6bd16aed9599ab9d14a Mon Sep 17 00:00:00 2001 From: yinjialu <2481044901@qq.com> Date: Sun, 31 May 2026 16:28:03 +0800 Subject: [PATCH 5/5] docs(spec): bump active-agent epoch on foreign-agent boundary retry Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) --- .../20260529-claude-session-resume/spec.md | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/specs/change/20260529-claude-session-resume/spec.md b/specs/change/20260529-claude-session-resume/spec.md index 163a8322f..5529398e5 100644 --- a/specs/change/20260529-claude-session-resume/spec.md +++ b/specs/change/20260529-claude-session-resume/spec.md @@ -210,10 +210,19 @@ turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) - `apps/daemon/src/server.ts`: `resolveResumeDecision()`, pin-on-success, resume-aware `skipTranscript`, anti-echo guard line, runtime-rejection fallback. -- `apps/web/src/`: force-fresh control in the composer; bump the live - agent-scoped epoch on history edit/retry, scoped to the agent(s) whose - `scopeHistoryToAgent` slice the edited/truncated turn belongs to (so editing a - Codex-only turn does not invalidate Claude's pinned session). +- `apps/web/src/`: force-fresh control in the composer; on a history + edit/truncate/retry, bump the live epoch of **every agent whose + `scopeHistoryToAgent` output changes** as a result of the mutation — defined by + diffing each agent's scoped slice before vs after, not by which agent's slice + literally contains the mutated turn. This matters because `scopeHistoryToAgent` + cuts each agent's slice at the most recent assistant turn from a *different* + agent (`apps/web/src/providers/daemon.ts:127-135`), so truncating or retrying + that foreign-agent boundary turn (or any of its descendants) shifts or removes + the active agent's resumed suffix even though the mutated turn is not inside the + active agent's slice — that case must bump the active agent's epoch too. The + diff-the-scoped-output rule still prevents a Codex-only edit that does not change + Claude's scoped slice (e.g. editing the *content* of a Codex turn that stays the + boundary) from invalidating Claude's pinned session. - `apps/daemon/src/cli.ts`: `--fresh-session` flag on the run path (dual-track). ### Design Decisions @@ -248,9 +257,15 @@ turn N+1: resolveResumeDecision(conv, agent, epoch, forceFresh, caps) history — the actual prompt source — never changed, making the invalidation model stricter than the prompt source. The live epoch therefore lives in the new `conversation_agent_epoch` table keyed on `(conversationId, agentId)` (the - web edit/retry path issues the bump only for the agent(s) whose scoped slice - the mutation touches); the pinned `conversation_agent_session.historyEpoch` - records the epoch a session was produced under. At run start the daemon reads + web edit/retry path bumps the epoch of every agent whose `scopeHistoryToAgent` + output changes under the mutation — which includes the active agent when a + truncate/retry lands on the most recent *foreign-agent* boundary turn or its + descendants, since `scopeHistoryToAgent` cuts the slice at that boundary + (`apps/web/src/providers/daemon.ts:127-135`) and dropping/regenerating it shifts + or removes the active agent's resumed suffix even though the mutated turn sits + outside that agent's slice); the pinned + `conversation_agent_session.historyEpoch` records the epoch a session was + produced under. At run start the daemon reads `conversation_agent_epoch.history_epoch` for the active `(conversation, agent)` to form `currentHistoryEpoch`, then `resolveResumeDecision()` compares it against the pinned row; a mismatch invalidates resume (Claude's immutable @@ -294,9 +309,18 @@ per `AGENTS.md`: full transcript present. - **History-edit epoch guard.** Editing a prior turn in the active agent's scoped history bumps that agent's epoch → no `--resume` AND full transcript present. - Conversely, editing a turn that belongs only to a *different* agent's scoped - history (e.g. a Codex-only turn) must NOT bump Claude's epoch → Claude still - resumes with `--resume`. + Conversely, editing the *content* of a turn that belongs only to a *different* + agent's scoped history without changing Claude's scoped slice (e.g. a Codex-only + turn that stays the boundary) must NOT bump Claude's epoch → Claude still resumes + with `--resume`. +- **Cross-agent boundary retry/truncate guard.** History contains a Codex + (foreign-agent) assistant turn followed by later Claude turns, so Claude's + `scopeHistoryToAgent` slice starts just after that Codex boundary turn. A + retry-from-message or truncate **on the Codex boundary turn** (or its + descendants) drops or shifts Claude's resumed suffix, so it MUST bump Claude's + epoch → no `--resume` AND full transcript present — even though the mutated turn + sits outside Claude's own slice. (This is the case a "mutated turn is inside the + slice" rule would miss: Claude could otherwise resume against stale history.) - **Work-dir guard.** A pinned session whose `workDir` differs from the current run's (e.g. the conversation's project cwd moved) → no `--resume` AND full transcript present.