From aad732901e4b7d485574eaef5a9c1fb388c4291a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 08:55:10 -0700 Subject: [PATCH] fix(server): Claude threads no longer stay stuck in plan mode Claude entered itself (#15224) --- .../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 e5839c8242199abc3857c75fac430dd8ffa30ebd..b837575c1faf7a10e86d8cb756947de1b3fa9065 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 ca133173a9eba07ae9db3e6b95633c7012b219f8..39b0d44ba9ede860db8dec2190d5b25659a9e309 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 d437ac2a2ef596fdd4e7ae1fae753ee50e21da91..0aca36abe77c6b4011ee23ad5a9140fca94971f7 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,12 +6875,25 @@ 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; + } // Background agents and shells run inside the CLI process, so a // new selection would kill them and lose their results. Refuse until @@ -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) { -- 2.54.0