Repository navigation
Conversation
Finish the in-progress ServerListClaudeResumableSessions/resumeExternalSessionId scaffolding: a ClaudeSessionHistory service reads on-disk Claude transcripts to list resumable sessions for a workspace, the server binds a picked session to a new thread before its first turn dispatches, and a new "Resume a Claude session" picker in the new-thread screen drives it end to end. Also adds a per-instance Remote Control toggle for Claude provider settings, launching sessions with --remote-control so they can be attached to from claude.ai or the Claude mobile app.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces significant new functionality (session resume and Remote Control features) with new components, services, and API endpoints. Multiple unresolved review comments identify potential bugs including state management issues that could cause thread creation failures and lost history on page reload. You can customize Macroscope's approvability policy. Learn more. |
Move the resume-session bind logic (instance lookup + ProviderSessionDirectory upsert) into ClaudeSessionHistory itself instead of ws.ts, using the publicly exposed ProviderInstanceRegistry rather than the private ProviderAdapterRegistry facade. Explicitly provide ClaudeSessionHistory with RuntimeCoreDependenciesLive in server.ts, since Layer.provideMerge only threads the *base* layer's remaining requirements forward, not the newly added layer's own — ws.ts's direct yield* on these services was leaking an unmet requirement all the way up through bin.ts's CLI composition and every hand-built test layer stack. Also fixes the test fixtures/mocks this exposed: missing `remoteControl` on ClaudeSettings test literals, and a pipe() argument-count overflow in server.test.ts's mock chain once the new ClaudeSessionHistory mock pushed it past the 20-argument overload limit.
- Force the resumed draft onto the same Claude provider instance the session list came from, instead of inheriting whatever provider was carried/sticky from the previous thread — a non-Claude selection made the resume attempt get rejected once the first message sent. - Resolve the on-disk session store per Claude *instance* (respecting a configured custom homePath) instead of always scanning the default ~/.claude location, by threading providerInstanceId through the list RPC and decoding that instance's actual ClaudeSettings. - Force "local" env mode (no worktree) for resume-initiated drafts — --resume is scoped to the exact cwd the session was created under, and a worktree would run Claude from a different directory. - Stop relying on useNewThreadHandler's reuse-existing-draft heuristics for this flow (they could no-op when the picked project was already the active draft, silently dropping the resume intent). Create the draft directly and set the resume intent against a known threadId, with a toast on failure instead of silently closing the dialog.
Picking a session in the resume dialog was working correctly under the
hood (new draft created, resume intent set) but landed on a completely
blank "new thread" screen with zero indication anything had happened,
which read as "nothing happens" when clicking a session. Add a
dismissible composer banner ("Resuming a previous Claude session" +
the session's label) for the lifetime of the pending intent, using the
existing ComposerBannerStack mechanism. Carry the session's label
through resumeSessionIntentStore (previously just the raw session id)
so the banner has something to display.
Resuming a session correctly continued the model's context server-side, but T3's own thread view starts empty for the brand-new thread (it's a separate event-sourced history from Claude's on-disk transcript), so none of the prior conversation was visible — just the new message and a context-aware reply with no visible "why." Add a new server.getClaudeResumableSessionTranscript RPC that reads a picked session's full on-disk transcript back as flat, text-only messages (ClaudeSessionHistory.getTranscript). The resume dialog fetches it when a session is picked and stores it in a new, deliberately separate resumeSessionHistoryStore (unlike the pending-resume intent, this should keep rendering for the life of the thread, not just until the first message sends). ChatView merges it into the timeline ahead of the thread's own messages as plain, unfolded (turnId: null) rows. Deliberately client-side/display-only: these never become real orchestration events, avoiding the event-sourced store's invariants (the only command that can add a user-role message also triggers a live provider turn) — see the architecture note in resumeSessionHistoryStore.ts.
… bugs Adds a Remote Control toggle chip to the composer for draft Claude threads, wired through a generalized bindSessionLaunchOptions (formerly bindResumeSession) that binds resume and/or remote-control choices together at thread-creation time. Also fixes the Remote Control settings field rendering as a text input instead of a switch, closes a path traversal hole in session id handling, fixes config dir resolution to use the workspace cwd, and fixes a double-click race and stale-draft-loss race in the resume dialog.
| // Best-effort: the resume itself (above) is already fully wired up | ||
| // server-side, so a failure fetching the display-only transcript | ||
| // here should never block navigating into the resumed draft. | ||
| const transcriptResult = await getClaudeResumableSessionTranscript({ |
There was a problem hiding this comment.
🟡 Medium chat/ResumeSessionDialog.tsx:185
The await getClaudeResumableSessionTranscript(...) call at line 185 blocks router.navigate even though the comment above it says the transcript fetch is display-only and should never block navigation. If that request is slow or stalls, the user is stuck in the dialog with isResuming true, unable to enter the already-created draft. Consider firing the transcript fetch without awaiting it (or awaiting it only after navigation), so navigation proceeds immediately and the history populates in the background.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ResumeSessionDialog.tsx around line 185:
The `await getClaudeResumableSessionTranscript(...)` call at line 185 blocks `router.navigate` even though the comment above it says the transcript fetch is display-only and should never block navigation. If that request is slow or stalls, the user is stuck in the dialog with `isResuming` true, unable to enter the already-created draft. Consider firing the transcript fetch without awaiting it (or awaiting it only after navigation), so navigation proceeds immediately and the history populates in the background.
| worktreePath: activeThread.worktreePath, | ||
| createdAt: activeThread.createdAt, | ||
| ...(resumeExternalSessionId ? { resumeExternalSessionId } : {}), | ||
| ...(draftRemoteControlEnabled ? { remoteControl: true } : {}), |
There was a problem hiding this comment.
Remote Control survives provider switch
High Severity
The draftRemoteControlEnabled state isn't reset when the selected provider changes. If Remote Control is enabled for Claude and the user switches to a non-Claude provider, the createThread payload still includes remoteControl: true, which causes thread creation to fail.
Reviewed by Cursor Bugbot for commit 8224188. Configure here.
| const hasUnsentContent = | ||
| (existingComposerState?.prompt.trim().length ?? 0) > 0 || | ||
| (existingComposerState?.images.length ?? 0) > 0 || | ||
| (existingComposerState?.persistedAttachments.length ?? 0) > 0; |
There was a problem hiding this comment.
Resume guard misses draft contexts
Low Severity
The new unsent-draft guard only checks prompt, images, and persistedAttachments. A draft that only has terminal contexts, element contexts, preview annotations, or review comments is treated as empty and replaced when resuming, discarding that composer state.
Reviewed by Cursor Bugbot for commit 8224188. Configure here.
Moves the Remote Control toggle out of the composer banner and into the toolbar next to the Build/Plan switch, and extends it to already-created threads (not just new drafts) via a new setClaudeThreadRemoteControl RPC. Since Remote Control is a claude process launch flag, applying it to a thread with an active session stops that session so it relaunches with the flag on its next turn. Also adds a dismiss control to the "resuming a previous session" banner so it can be hidden without cancelling the resume, and fixes bindSessionLaunchOptions to merge onto an existing resumeCursor instead of overwriting it (so setting remoteControl on a thread that already has a resume cursor doesn't strand its continuity).
| description: "Applies the next time this thread's session starts.", | ||
| }), | ||
| ); | ||
| }, |
There was a problem hiding this comment.
Ignores applied false as success
Medium Severity
The handleToggleRemoteControl function optimistically updates the UI and shows a success toast when the setClaudeThreadRemoteControl RPC call succeeds. It doesn't check the applied flag within the successful response, which can be false if the setting wasn't actually applied server-side. This leaves the UI toggle and success message out of sync with the server's actual state.
Reviewed by Cursor Bugbot for commit a7b4ef0. Configure here.
If you resume a Claude session into a T3 thread and then keep chatting with that same session directly in a terminal (claude --resume <id>), T3 previously had no way to see those messages short of re-resuming from scratch. Adds serverGetClaudeThreadImportedHistory, which resolves a thread's bound session id server-side from its ProviderSessionDirectory binding and re-reads the on-disk transcript. The client calls it whenever a resumed thread is opened/switched to, merging in only messages newer than everything T3 already knows about (its own driven turns plus the original import) so nothing gets duplicated.
| // a live poll. Current timeline/imported state is read fresh via | ||
| // `timelineMessagesRef`/`getState()` when the fetch resolves. | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [activeThread?.id, isLocalDraftThread, activeProject, selectedProvider, environmentId]); |
There was a problem hiding this comment.
Import merge drops reopen history
High Severity
The useEffect fetching Claude thread history incorrectly filters imported messages after a page reload. Since resumeSessionHistoryStore is ephemeral, the client-side merge discards all imported messages older than the live timeline, failing to restore the original imported context.
Reviewed by Cursor Bugbot for commit 2d46275. Configure here.
--remote-control as a bare CLI launch flag is a no-op under the Agent
SDK's query() — it only does something in the interactive CLI's own
terminal-driven code path. Remote Control has to be turned on via a
control request on the running session instead: query()'s returned
object has an undocumented enableRemoteControl(enabled) method (not in
the SDK's public types, but real at runtime) that sends the same
{subtype: "remote_control"} request the interactive CLI's /remote-control
slash command triggers internally.
Calls it once the session's query object exists, whenever remote control
should be on (persistent per-instance setting or a per-thread choice).
Verified live: the previously silent flag now yields a genuine
"bridge_state: ready" system message from the CLI subprocess, confirming
the claude.ai bridge is actually connecting -- this was completely inert
before. Also captures a connect URL from either the enableRemoteControl
response or the bridge_state message if present, surfaced as a runtime
warning in the thread's work log (best-effort; neither has reliably
included one in testing so far).
There was a problem hiding this comment.
Effect Service Conventions
Reviewed the new ClaudeSessionHistory service, its wiring in server.ts / ws.ts, and the touched ClaudeAdapter code paths. The service itself follows most conventions (inline interface in the Context.Service declaration, ClaudeSessionHistory["Service"]["…"] method typings, exported make + layer, dependencies acquired via yield* from the environment, no ManagedRuntime/runPromise in the service, no service-instance parameters). Three findings below.
apps/server/src/provider/Layers/ClaudeSessionHistory.tsis a single combined module (tag +make+layer) but lives in theLayers/half of the legacyServices/+Layers/split, and its tag id encodes that path. Canonical location for a combined module isapps/server/src/provider/ClaudeSessionHistory.ts.- Service boundary imported with a named import instead of the module namespace (
import { ProviderService } from "../Services/ProviderService.ts"), while the sibling boundaries in the same file use namespaces. - New backend behavior ships without focused tests.
summarizeTranscript/parseTranscriptMessages/isValidClaudeSessionId(path-traversal guard) / thebindSessionLaunchOptionsresume-cursor merge, plus the newshouldEnableRemoteControllaunch path inClaudeAdapter.ts, are all untested — the only test change is aLayer.mockadded toserver.test.tsso the existing suite keeps compiling. Per the change-discipline rule ("If backend behavior changes, require focused tests"), please add a focusedClaudeSessionHistory.test.ts(aFileSystemtest layer over a fixture transcript directory is enough forlist/getTranscript, andProviderSessionDirectoryassertions for the merge/validation paths) and extendClaudeAdapter.test.tsfor the remote-control enable path.
Posted via Macroscope — Effect Service Conventions
| export class ClaudeSessionHistory extends Context.Service< | ||
| ClaudeSessionHistory, | ||
| { | ||
| readonly list: ( | ||
| input: ServerListClaudeResumableSessionsInput, | ||
| ) => Effect.Effect<ServerListClaudeResumableSessionsResult>; | ||
| /** | ||
| * Reads a specific resumable session's on-disk transcript back as a flat, | ||
| * text-only message list, for display-only import into T3's own thread | ||
| * view (see `ResumeSessionDialog.tsx`). These never become real | ||
| * orchestration events/messages. | ||
| */ | ||
| readonly getTranscript: ( | ||
| input: ServerGetClaudeResumableSessionTranscriptInput, | ||
| ) => Effect.Effect<ServerGetClaudeResumableSessionTranscriptResult>; | ||
| /** | ||
| * Re-reads a resumed thread's bound on-disk transcript, resolving the | ||
| * bound session id from the thread's `ProviderSessionDirectory` | ||
| * binding server-side (the caller only needs to know the thread, not | ||
| * which session it's bound to). Returns `{messages: []}` — not an | ||
| * error — when the thread has no bound `resume` session (never | ||
| * resumed) or the binding can't be read, matching `getTranscript`'s | ||
| * best-effort behavior. | ||
| */ | ||
| readonly getImportedHistoryForThread: ( | ||
| input: ServerGetClaudeThreadImportedHistoryInput, | ||
| ) => Effect.Effect<ServerGetClaudeThreadImportedHistoryResult>; | ||
| /** | ||
| * Binds thread-creation-time Claude launch choices (resume a picked | ||
| * on-disk session, and/or start with Remote Control enabled) onto a | ||
| * thread's provider session directory entry, so they take effect on the | ||
| * next (first) turn. No-op if neither option is set. Fails if the | ||
| * target provider instance isn't a Claude instance. | ||
| */ | ||
| readonly bindSessionLaunchOptions: ( | ||
| input: ClaudeSessionHistoryBindSessionLaunchOptionsInput, | ||
| ) => Effect.Effect<void, ProviderValidationError | ProviderSessionDirectoryWriteError>; | ||
| /** | ||
| * Turns Remote Control on/off for an already-created thread. Unlike | ||
| * `bindSessionLaunchOptions` (thread-creation time only), this can run | ||
| * against a thread with an active running session — Remote Control is a | ||
| * `claude` process launch flag, not something a running process can be | ||
| * told to flip live, so this stops the thread's active session (if any) | ||
| * after binding the choice, so it relaunches with the flag applied on | ||
| * its next turn. Best-effort/non-fatal: `applied: false` on a bind | ||
| * failure (e.g. non-Claude instance); a stop failure or no active | ||
| * session never fails the call, since the bind itself already took. | ||
| */ | ||
| readonly setThreadRemoteControl: ( | ||
| input: ServerSetClaudeThreadRemoteControlInput, | ||
| ) => Effect.Effect<ServerSetClaudeThreadRemoteControlResult>; | ||
| } | ||
| >()("t3/provider/Layers/ClaudeSessionHistory") {} |
There was a problem hiding this comment.
This module already owns the schemas/helpers, the Context.Service tag, make, and layer in one file, so it is the canonical module rather than a Layers/ implementation paired with a Services/ tag. Consider hoisting it to apps/server/src/provider/ClaudeSessionHistory.ts (and updating the tag id plus the @module tag accordingly) so the combined form matches the canonical layout instead of the legacy Services/ + Layers/ split:
>()("t3/provider/ClaudeSessionHistory") {}Imports in server.ts, ws.ts, and server.test.ts would move to ./provider/ClaudeSessionHistory.ts.
Posted via Macroscope — Effect Service Conventions
| import { ProviderService } from "../Services/ProviderService.ts"; | ||
| import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; | ||
| import type { ProviderSessionDirectoryWriteError } from "../Services/ProviderSessionDirectory.ts"; |
There was a problem hiding this comment.
At a service boundary the local service module should be imported as a namespace and used through its module shape (as done for ProviderInstanceRegistry/ProviderSessionDirectory on the surrounding lines). ProviderSessionDirectoryWriteError can also come off the existing namespace instead of a second import from the same path.
-import { ProviderService } from "../Services/ProviderService.ts";
import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts";
-import type { ProviderSessionDirectoryWriteError } from "../Services/ProviderSessionDirectory.ts";
+import * as ProviderService from "../Services/ProviderService.ts";Then yield* ProviderService.ProviderService (line 311) and ProviderSessionDirectory.ProviderSessionDirectoryWriteError in the bindSessionLaunchOptions signature (line 288).
Posted via Macroscope — Effect Service Conventions
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 6 total unresolved issues (including 4 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2ed6a86. Configure here.
| : `Remote Control ${state ?? "status"}${url ? ` · ${url}` : ""}`; | ||
| yield* emitRuntimeWarning(context, content, url ? { url } : undefined); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Wrong bridge status message subtype
Medium Severity
The new ClaudeAdapter logic for handling Remote Control status messages expects the bridge_state subtype, but the Claude SDK emits bridge_status. This causes Remote Control status to appear as generic "unknown-subtype" warnings in the work log instead of the intended status line.
Reviewed by Cursor Bugbot for commit 2ed6a86. Configure here.
| : undefined; | ||
| if (url) { | ||
| yield* emitRuntimeWarning(context, `Remote Control ready · ${url}`, { url }); | ||
| } |
There was a problem hiding this comment.
Connect URL field name mismatch
Medium Severity
Remote Control connect URLs aren't displayed in the work log. The code expects url or sessionUrl when parsing the enableRemoteControl response and bridge_state messages, but the Claude API returns session_url and bridgeUrl respectively.
Reviewed by Cursor Bugbot for commit 2ed6a86. Configure here.
|
Closing in favor of #2829 (orchestration V2). #2829 deletes the V1 orchestration layer this PR builds on — This is not a judgement on the change itself. Several of these are real gaps we still want fixed; the base just moved out from under them. Once #2829 merges, please rebase onto |


Summary
ServerListClaudeResumableSessions/resumeExternalSessionIdcontract scaffolding: a newClaudeSessionHistoryserver service reads Claude Code's on-disk transcripts (~/.claude/projects/<encoded-cwd>/*.jsonl) to list resumable sessions for a workspace.--resume <uuid>takes over automatically.--remote-controlso they can be attached to from claude.ai or the Claude mobile app.Test plan
pnpm typecheckacrosspackages/contracts,apps/server,packages/client-runtime,apps/web(not run here — sandboxed dev environment couldn't completepnpm installdue to network/memory limits fetching@anthropic-ai/claude-agent-sdkplatform binaries, unrelated to this change)Note
Medium Risk
Changes Claude session launch/resume binding and stops active sessions when toggling Remote Control; filesystem access is guarded by UUID session-id validation but remains user-workspace–scoped.
Overview
Adds end-to-end Claude Code session resume and Remote Control across server, contracts, and web.
A new
ClaudeSessionHistoryservice scans Claude’s on-disk*.jsonltranscripts per workspace (respecting per-instancehomePath), exposes list/transcript/import RPCs, and bindsresume+remoteControlonto a thread’s provider session directory before the first turn. Bootstrap turn start inws.tscallsbindSessionLaunchOptionswhenresumeExternalSessionId/remoteControlare set on thread creation.ClaudeAdapterreadsremoteControlfrom instance settings or the per-thread resume cursor, enables Remote Control via the SDK’s undocumentedenableRemoteControlcontrol request (not the CLI flag underquery()), and surfacesbridge_state/ connect URLs as runtime warnings.The web adds Resume a Claude session (
ResumeSessionDialogfrom the draft hero), ephemeral intent/history stores, a resume banner on drafts, display-only imported transcript prepended to the timeline (with dedupe when re-opening threads), and a Remote Control composer toggle that callssetClaudeThreadRemoteControl(bind + best-effort session stop so the next launch picks up the flag). Claude provider settings gain a default Remote Control switch.Reviewed by Cursor Bugbot for commit 2ed6a86. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add Claude Code session resume and Remote Control support to chat
ClaudeSessionHistoryservice (ClaudeSessionHistory.ts) that reads Claude's.jsonltranscript files to list sessions, fetch transcripts, and bindresumeExternalSessionId/remoteControlflags to threads at launch time.setClaudeThreadRemoteControlon the server, which stops the current session so the next turn starts with the new setting.resumeExternalSessionIdandremoteControlfields; the server binds these viaClaudeSessionHistory.bindSessionLaunchOptionswhen a thread is created.listClaudeResumableSessions,getClaudeResumableSessionTranscript,getClaudeThreadImportedHistory,setClaudeThreadRemoteControl) through the WS layer with auth-scope enforcement.Macroscope summarized 2ed6a86.