Spec: Carry context on Claude account switch#
Problem#
Switching a live Claude session to another account (PATCH /api/v1/sessions/{id}/claude-account) starts a fresh conversation —
the new account's CLI has no memory of what the session was doing. This
is intentional (PR #331): claude --resume <uuid> validates the UUID
against the target account's own session registry, so a UUID minted
under account A fails with "No conversation found" under account B and
the CLI exits immediately. #331 cleared ClaudeSessionID on switch to
keep the session alive at the cost of conversation continuity.
We cannot resume the same conversation across accounts — that's a Claude Code constraint, not ours. But we can carry the meaning forward: read the prior transcript and inject it as context into the fresh session's system prompt, the same channel skills / memory / project-docs already use.
Goal#
An opt-in "carry context" mode on account switch that seeds the new conversation with the prior one's content, so the operator doesn't have to re-explain what they were working on.
Non-goal: byte-exact resume (impossible across accounts). Non-goal: changing the default — fresh-session stays the default; carry-over is explicit.
Constraint recap (why injection, not resume)#
claude --resumeis account-scoped; cross-account resume is rejected.- The old transcript-migration hard-link (removed in #331) didn't help:
Claude needs the session in the account's registry, not just a
.jsonlon disk. - The only portable channel is
--append-system-prompt, already used for: skills index, memory guidance, ambient memory, project docs (internal/catalog/adapter.go,case "claude"→out.Args = append(out.Args, "--append-system-prompt", text)).
Existing infrastructure we reuse#
| Piece | Location | Role in this feature |
|---|---|---|
Manager.SwitchClaudeAccount |
internal/session/manager.go:952-1027 |
where we read the old transcript before clearing ClaudeSessionID (line 994) |
claudeTranscript() / findClaudeProjectDir() / findLatestClaudeJSONL() |
internal/session/claude_jsonl.go |
locate + read the old account's .jsonl for the old UUID |
Context threading into Prepare() |
internal/session/provider.go (WithCwd, WithSessionID, WithResumeClaudeSessionID) |
add a one-shot WithCarryoverContext key |
--append-system-prompt injection |
internal/catalog/adapter.go Resolve() (case "claude") |
the sink that puts the carried text into the spawn |
| Switch request body | internal/session/handler.go:111-116 (SwitchAccountRequest) |
add carry_context bool |
Design#
1. Capture (before the switch tears down the old binding)#
In SwitchClaudeAccount, before line 994 clears ClaudeSessionID,
while we still hold current.ClaudeAccountID (old) and
current.ClaudeSessionID (old):
buildCarryover reuses claude_jsonl.go to:
- Resolve the old account's projects root +
findClaudeProjectDir(cwd). - Read
<dir>/<oldUUID>.jsonl(fail-closed on missing — same M22 defense the existing reader uses; do NOT fall back to latest-mtime, that risks pulling an unrelated session). - Parse the JSONL turns, keep
user+assistanttext content, drop tool-call/tool-result noise. - Take the tail up to a byte/token cap (see Budget).
- Wrap in a labeled block (see Prompt format).
The transcript file persists on disk after Stop(), so capture can
happen either side of the stop; doing it first means a read failure can
abort cleanly before we touch the running process.
2. Inject (one-shot, into the respawn only)#
Thread the captured text into the respawn via a new transient context key — NOT a persisted Session field:
[object Promise]In SwitchClaudeAccount, when calling spawn:
(spawn currently builds its own prepareCtx; thread the carryover
through it.)
In adapter.go Resolve(), after the existing skill/memory injections,
add:
One-shot is correct and important. The context key is set only by
SwitchClaudeAccount, so the carryover is injected only into the first
spawn under the new account. The new account mints its own fresh UUID;
later restarts --resume that UUID, whose transcript already contains
the seeded context — so continuity persists naturally without
re-injecting (and without re-injecting on every daemon-restart resume).
3. Prompt format#
[object Promise]4. Budget + truncation#
- Cap the injected block at ~6 000 tokens (≈ 24 KB) — large enough for meaningful continuity, small enough to not dominate the new context window or spike cost. Configurable.
- Tail-truncate (keep the most recent turns); if the first kept turn is
mid-thread, prepend an explicit
…[earlier turns omitted]…marker. - Strip tool_use / tool_result blocks; keep their text outcomes only if cheap. v1 can drop tool content entirely.
5. Failure handling — degrade to fresh, never fail the switch#
The switch must keep working even if carryover can't be built:
| Failure | Behavior |
|---|---|
| transcript file missing | log debug, inject nothing, fresh session |
| parse error / malformed jsonl | log warn, inject nothing |
| over budget | truncate to cap |
carry_context=false (default) |
skip entirely — current behavior |
A carryover failure logs and proceeds; it never returns an error from
SwitchClaudeAccount.
API change#
SwitchAccountRequest (internal/session/handler.go):
Default false preserves #331 behavior. No new route, no path change.
UI change (web + mobile)#
The account switcher gains a checkbox: "Carry over conversation context" (default off), with a one-line helper:
Seeds the new account's session with your recent conversation. The prior conversation content is sent to Anthropic under the new account.
That second sentence is the consent surface — see Privacy.
Privacy / data-boundary note (important)#
Carrying context means conversation content created under account A
is fed into a prompt processed under account B — i.e. sent to
Anthropic billed/governed by account B's terms. For most operators
(personal multi-account pools) this is fine, but it's a real
cross-boundary data flow and must be explicit, opt-in, and labeled at
the point of action. Never default it on. The UI helper text above is
the consent surface; the API default false is the backstop.
Phasing#
- Phase 1 (this spec): raw transcript-tail injection, opt-in via
carry_context, byte/token-capped, tool-noise stripped, fail-open. No extra LLM call — deterministic and cheap. UI checkbox + consent helper. Ships the 80% value. - Phase 2 (optional, separate PR): summarized carryover — generate
a compact recap instead of raw tail. Open question: which credential
summarizes? Cleanest is a short headless
claude -punder the old account before Stop(), so the summary is produced under the account that owns the data. Adds latency + token cost; gate behind a config flagcarry_context_mode: raw|summary. - Phase 3 (optional): global default in config + per-account policy (e.g. "never carry across these two accounts").
Test plan#
- Switch with
carry_context=true→ new session's first turn shows it has prior context (ask "what were we doing?" → coherent answer). - Switch with
carry_context=false(default) → fresh session, no injection (byte-for-byte the current #331 behavior). - Old transcript missing (e.g. brand-new session, no
.jsonlyet) → switch still succeeds, fresh session, debug log only. - Oversized transcript → injected block capped at budget, truncation marker present.
- Later restart of the switched session → resumes the new UUID, does
NOT re-inject carryover (verify only one
--append-system-promptcarryover block ever appears). - Catalog unit test locking: carryover context key present →
--append-system-promptarg emitted; absent → not emitted.
Files touched (implementer checklist)#
internal/session/handler.go—SwitchAccountRequest.CarryContextinternal/session/manager.go—SwitchClaudeAccountcapture + thread carryover into respawn;spawnaccepts the carryover ctxinternal/session/provider.go—WithCarryoverContext/CarryoverContextinternal/session/claude_jsonl.go—buildCarryoverhelper (reuses existing locate/read/parse)internal/catalog/adapter.go— inject--append-system-promptwhen carryover present (claude case)internal/catalog/*_test.go— lock the inject contractapp/web/src/components/sessions/AccountSwitcher.tsx+ mobile equivalent — checkbox + consent helperapp/shared/src/lib/*— passcarry_contextin the switch call- i18n: en/es/zh strings for the checkbox + helper