From eb6ee6c5465a8e2bc3625ad68327cb282ae40250 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:04:06 -0700 Subject: [PATCH] fix(server): Claude threads no longer stay stuck in plan mode Claude entered itself Claude can call EnterPlanMode on its own. The V2 adapter set the permission mode only when opening the CLI process and reused that process for later turns, so after ExitPlanMode was captured (and denied, as in V1) the process stayed in plan mode until it was replaced. V1 reset the mode on every turn. Track the mode the CLI reports on init/status frames and, when a turn reuses the live process and the mode has drifted, set it back to the mode the process was opened with before sending the prompt. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 36 ++++++ .../Adapters/ClaudeAdapterV2.testkit.ts | 13 +++ .../Adapters/ClaudeAdapterV2.ts | 107 ++++++++++++++---- 3 files changed, 131 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e5839c824219..b837575c1faf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -947,6 +947,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1097,6 +1098,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1187,6 +1189,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1406,6 +1409,7 @@ describe("ClaudeAdapterV2 attachments", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }), @@ -1544,6 +1548,7 @@ describe("ClaudeAdapterV2 attachments", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1632,6 +1637,7 @@ describe("ClaudeAdapterV2 native fork", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1803,6 +1809,7 @@ describe("ClaudeAdapterV2 native session identity", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -2058,6 +2065,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Deferred.await(processed); }); const offeredMessages: Array = []; + const permissionModeChanges: Array = []; const continuationRequests: Array = []; const terminalReceipts = yield* Queue.unbounded>(); @@ -2106,6 +2114,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: (mode) => + Effect.sync(() => { + permissionModeChanges.push(mode); + }), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, }; @@ -2158,6 +2170,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { sdkMessages, offerAndWait, offeredMessages, + permissionModeChanges, continuationRequests, events, terminalReceipts, @@ -2985,6 +2998,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), ); + // Claude entered plan mode on its own (EnterPlanMode). + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + uuid: "00000000-0000-4000-8000-000000000508", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -3095,6 +3120,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); assert.equal(proposedPlan?.status, "active"); + // The second prompt reuses the live process, which is still in the + // plan mode Claude entered, so it is put back in the thread's mode. + assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); @@ -3615,6 +3643,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends // it, so Stop stays parked waiting for it to exit. @@ -3864,6 +3893,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(queue), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), }; @@ -6655,6 +6685,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. close: Queue.shutdown(sdkMessages), @@ -6837,6 +6868,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7071,6 +7103,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7257,6 +7290,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7433,6 +7467,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7564,6 +7599,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ca133173a9eb..39b0d44ba9ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -184,6 +184,11 @@ interface ClaudeQuerySetModelFrame { readonly model: string; } +interface ClaudeQuerySetPermissionModeFrame { + readonly type: "query.set_permission_mode"; + readonly mode: string; +} + interface ClaudeQueryInterruptFrame { readonly type: "query.interrupt"; } @@ -239,6 +244,7 @@ type ClaudeOutboundFrame = | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame + | ClaudeQuerySetPermissionModeFrame | ClaudeQueryInterruptFrame | ClaudePermissionResponseFrame | ClaudeSessionForkFrame @@ -811,6 +817,13 @@ function makeReplayQueryRunner( model, }); }), + setPermissionMode: (mode) => + replayEffect(() => { + assertNextOutboundFrame({ + type: "query.set_permission_mode", + mode, + }); + }), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d437ac2a2ef5..0aca36abe77c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -327,6 +327,9 @@ export interface ClaudeAgentSdkQuerySession { readonly messages: Stream.Stream; readonly offer: (message: SDKUserMessage) => Effect.Effect; readonly setModel: (model: string) => Effect.Effect; + readonly setPermissionMode: ( + mode: PermissionMode, + ) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -466,6 +469,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly model: string; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.set_permission_mode"; + readonly mode: PermissionMode; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -670,6 +681,22 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer< }), ), ), + setPermissionMode: (mode) => + Effect.tryPromise({ + try: () => queryRuntime.setPermissionMode(mode), + catch: (cause) => queryRunnerError(cause, "setPermissionMode"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.set_permission_mode", + mode, + }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -2688,6 +2715,11 @@ interface ClaudeLiveQueryContext { // uuid before any echo, so it echoes, but a resume's own turns can still // run ahead of that prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; + // The mode this process was opened in, and the mode the CLI last reported + // (init and status frames). Claude changes the latter itself through + // EnterPlanMode. + readonly openedPermissionMode: PermissionMode; + permissionMode: PermissionMode; // Stop, rollback or fork is closing this process; its work is ending. stopping: boolean; // Registry entries still running when this process opened. Their process @@ -6843,10 +6875,23 @@ export function makeClaudeAdapterV2( if ( existing !== null && existing.nativeThreadId === nativeThreadId && - (isClaudeProviderContinuationTurn(turnInput) || - (existing.queryPolicyKey === queryPolicyKey && - existing.selectionKey === compiledSelection.queryIdentity)) + isClaudeProviderContinuationTurn(turnInput) + ) { + return existing; + } + if ( + existing !== null && + existing.nativeThreadId === nativeThreadId && + existing.queryPolicyKey === queryPolicyKey && + existing.selectionKey === compiledSelection.queryIdentity ) { + // Claude can switch its own mode mid-session (EnterPlanMode), and + // a denied ExitPlanMode leaves it there. Put the live process back + // in the thread's mode before the next prompt. + if (existing.permissionMode !== existing.openedPermissionMode) { + yield* existing.query.setPermissionMode(existing.openedPermissionMode); + existing.permissionMode = existing.openedPermissionMode; + } return existing; } @@ -6889,31 +6934,32 @@ export function makeClaudeAdapterV2( const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + const queryOptions = makeClaudeQueryOptions({ + modelSelection: turnInput.modelSelection, + nativeThreadId, + resume: shouldResume, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), + cwd: turnInput.runtimePolicy.cwd, + attachmentsDir, + settings: adapterOptions.settings, + environment: adapterOptions.environment, + tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, + ...mcpOverrides, + permissionMode: queryPolicy.permissionMode, + ...(queryPolicy.allowDangerouslySkipPermissions === undefined + ? {} + : { + allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, + }), + canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], + }); const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, providerSessionId: input.providerSessionId, - options: makeClaudeQueryOptions({ - modelSelection: turnInput.modelSelection, - nativeThreadId, - resume: shouldResume, - ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), - cwd: turnInput.runtimePolicy.cwd, - attachmentsDir, - settings: adapterOptions.settings, - environment: adapterOptions.environment, - tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...mcpOverrides, - permissionMode: queryPolicy.permissionMode, - ...(queryPolicy.allowDangerouslySkipPermissions === undefined - ? {} - : { - allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, - }), - canUseTool, - onUserDialog, - supportedDialogKinds: ["resume_return"], - }), + options: queryOptions, }) .pipe( Effect.tapError(() => @@ -6960,6 +7006,8 @@ export function makeClaudeAdapterV2( selectionKey: compiledSelection.queryIdentity, closed, promptEchoMode: "unknown", + openedPermissionMode: queryOptions.permissionMode, + permissionMode: queryOptions.permissionMode, stopping: false, subagentsFromEarlierProcesses: new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( @@ -6969,7 +7017,16 @@ export function makeClaudeAdapterV2( }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( - Stream.runForEach((message) => handleSdkMessage({ query: querySession, message })), + Stream.runForEach((message) => { + if ( + message.type === "system" && + (message.subtype === "init" || message.subtype === "status") && + message.permissionMode !== undefined + ) { + context.permissionMode = message.permissionMode; + } + return handleSdkMessage({ query: querySession, message }); + }), Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) {