Summary
When an interactive terminal session started with happier is taken over from the web/mobile app (local → remote handoff), the CLI can pass a Claude session id to resume that has no on-disk transcript. Claude Code then exits with No conversation found with session ID: <uuid> and the remote runner retries the same dead id indefinitely, so the session never becomes usable.
Environment
- CLI:
0.2.1 (self-hosted / preview channel), also reproducible against dev logic (see Root cause)
- Self-hosted relay (
relay-server:preview), SQLite light flavor
- macOS, Claude Code 2.1.x
Steps to reproduce
- In a terminal, run
happier in a project directory (starts a local session).
- From the web or mobile app, open that session and send a message (triggers local → remote handoff).
- The remote runner starts Claude with
resume: <trackedSessionId>, but that id has no transcript on disk.
Expected: the handoff starts (or resumes) a working session.
Actual: Error: Claude Code process exited with code 1, subprocess log shows [ERROR] No conversation found with session ID: <uuid>, and the runner loops "Starting new Claude session… / exited unexpectedly. Waiting for the next message to retry…".
Confirmed the id is a ghost: find ~/.claude/projects -name "<uuid>.jsonl" returns nothing, i.e. Claude never materialized a transcript for it. Standalone claude -p --output-format stream-json --input-format stream-json works fine in the same project, so this is not a Claude-side / stream-json issue.
Root cause
apps/cli/src/backends/claude/remote/sessionStartPlan.ts, resolveClaudeRemoteSessionStartPlan.
On dev, the hasMaterializedSessionTranscript guard nulls startFrom only when the transcript file is absent/empty. The very next branch, when the transcript exists but checkSession fails validation, intentionally "attempts resume anyway":
} else if (!effectiveDeps.checkSession(opts.sessionId, opts.path, opts.transcriptPath)) {
effectiveDeps.logDebug(
`[${effectiveDeps.logPrefix}] Session ${opts.sessionId} did not pass transcript validation yet; attempting resume anyway`,
);
}
That branch forwards an unresumable id to the SDK resume, which is exactly what surfaces No conversation found. (On the preview / 0.2.1 code there is no materialized guard at all — the single if (!checkSession) block always attempts resume — so it is even more exposed.)
The call site comment deliberately avoids "failing closed" to preserve context during fast local↔remote switching, but the current behavior trades a recoverable context loss for a hard crash + infinite retry.
Proposed fix
When checkSession fails, start fresh instead of resuming a session the SDK cannot find (one line — set startFrom = null in that branch). This turns an unrecoverable crash loop into a clean fresh start (at worst, loss of prior context for that one handoff), consistent with the existing hasMaterializedSessionTranscript fallback right above it. Happy to open a PR with the change plus a sessionStartPlan.test.ts case asserting startFrom === null when checkSession returns false.
Summary
When an interactive terminal session started with
happieris taken over from the web/mobile app (local → remote handoff), the CLI can pass a Claude session id toresumethat has no on-disk transcript. Claude Code then exits withNo conversation found with session ID: <uuid>and the remote runner retries the same dead id indefinitely, so the session never becomes usable.Environment
0.2.1(self-hosted / preview channel), also reproducible againstdevlogic (see Root cause)relay-server:preview), SQLite light flavorSteps to reproduce
happierin a project directory (starts a local session).resume: <trackedSessionId>, but that id has no transcript on disk.Expected: the handoff starts (or resumes) a working session.
Actual:
Error: Claude Code process exited with code 1, subprocess log shows[ERROR] No conversation found with session ID: <uuid>, and the runner loops "Starting new Claude session… / exited unexpectedly. Waiting for the next message to retry…".Confirmed the id is a ghost:
find ~/.claude/projects -name "<uuid>.jsonl"returns nothing, i.e. Claude never materialized a transcript for it. Standaloneclaude -p --output-format stream-json --input-format stream-jsonworks fine in the same project, so this is not a Claude-side / stream-json issue.Root cause
apps/cli/src/backends/claude/remote/sessionStartPlan.ts,resolveClaudeRemoteSessionStartPlan.On
dev, thehasMaterializedSessionTranscriptguard nullsstartFromonly when the transcript file is absent/empty. The very next branch, when the transcript exists butcheckSessionfails validation, intentionally "attempts resume anyway":That branch forwards an unresumable id to the SDK
resume, which is exactly what surfacesNo conversation found. (On thepreview/0.2.1code there is no materialized guard at all — the singleif (!checkSession)block always attempts resume — so it is even more exposed.)The call site comment deliberately avoids "failing closed" to preserve context during fast local↔remote switching, but the current behavior trades a recoverable context loss for a hard crash + infinite retry.
Proposed fix
When
checkSessionfails, start fresh instead of resuming a session the SDK cannot find (one line — setstartFrom = nullin that branch). This turns an unrecoverable crash loop into a clean fresh start (at worst, loss of prior context for that one handoff), consistent with the existinghasMaterializedSessionTranscriptfallback right above it. Happy to open a PR with the change plus asessionStartPlan.test.tscase assertingstartFrom === nullwhencheckSessionreturns false.