authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-03 08:55:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 08:55:10-07:00
logaad732901e4b7d485574eaef5a9c1fb388c4291a
tree9548ed0ea2bd2ddcb4d8a0788d1bf12afd1ad43a
parent0080e80c00a3e333e3dcafd85b2615d56f84e3b8
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude threads no longer stay stuck in plan mode Claude entered itself (#15224)


3 files changed, 131 insertions(+), 25 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+36
......@@ -947,6 +947,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
947947 messages: Stream.never,
948948 offer: () => Effect.void,
949949 setModel: () => Effect.void,
950 setPermissionMode: () => Effect.void,
950951 interrupt: Effect.void,
951952 close: Effect.void,
952953 };
......@@ -1097,6 +1098,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio
10971098 messages: Stream.never,
10981099 offer: () => Effect.void,
10991100 setModel: () => Effect.void,
1101 setPermissionMode: () => Effect.void,
11001102 interrupt: Effect.void,
11011103 close: Effect.void,
11021104 };
......@@ -1187,6 +1189,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {
11871189 messages: Stream.never,
11881190 offer: () => Effect.void,
11891191 setModel: () => Effect.void,
1192 setPermissionMode: () => Effect.void,
11901193 interrupt: Effect.void,
11911194 close: Effect.void,
11921195 };
......@@ -1406,6 +1409,7 @@ describe("ClaudeAdapterV2 attachments", () => {
14061409 offeredMessages.push(message);
14071410 }),
14081411 setModel: () => Effect.void,
1412 setPermissionMode: () => Effect.void,
14091413 interrupt: Effect.void,
14101414 close: Effect.void,
14111415 }),
......@@ -1544,6 +1548,7 @@ describe("ClaudeAdapterV2 attachments", () => {
15441548 messages: Stream.never,
15451549 offer: () => Effect.void,
15461550 setModel: () => Effect.void,
1551 setPermissionMode: () => Effect.void,
15471552 interrupt: Effect.void,
15481553 close: Effect.void,
15491554 };
......@@ -1632,6 +1637,7 @@ describe("ClaudeAdapterV2 native fork", () => {
16321637 messages: Stream.empty,
16331638 offer: () => Effect.void,
16341639 setModel: () => Effect.void,
1640 setPermissionMode: () => Effect.void,
16351641 interrupt: Effect.void,
16361642 close: Effect.void,
16371643 };
......@@ -1803,6 +1809,7 @@ describe("ClaudeAdapterV2 native session identity", () => {
18031809 messages: Stream.empty,
18041810 offer: () => Effect.void,
18051811 setModel: () => Effect.void,
1812 setPermissionMode: () => Effect.void,
18061813 interrupt: Effect.void,
18071814 close: Effect.void,
18081815 };
......@@ -2058,6 +2065,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
20582065 yield* Deferred.await(processed);
20592066 });
20602067 const offeredMessages: Array<SDKUserMessage> = [];
2068 const permissionModeChanges: Array<string> = [];
20612069 const continuationRequests: Array<ProviderContinuationRequest> = [];
20622070 const terminalReceipts =
20632071 yield* Queue.unbounded<Extract<ProviderAdapterV2Event, { type: "turn.terminal" }>>();
......@@ -2106,6 +2114,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
21062114 offeredMessages.push(message);
21072115 }),
21082116 setModel: () => Effect.void,
2117 setPermissionMode: (mode) =>
2118 Effect.sync(() => {
2119 permissionModeChanges.push(mode);
2120 }),
21092121 interrupt: options?.interrupt ?? Effect.void,
21102122 close: options?.close?.(sdkMessages) ?? Effect.void,
21112123 };
......@@ -2158,6 +2170,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
21582170 sdkMessages,
21592171 offerAndWait,
21602172 offeredMessages,
2173 permissionModeChanges,
21612174 continuationRequests,
21622175 events,
21632176 terminalReceipts,
......@@ -2985,6 +2998,18 @@ describe("ClaudeAdapterV2 background wake turns", () => {
29852998 harness.sdkMessages,
29862999 toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]),
29873000 );
3001 // Claude entered plan mode on its own (EnterPlanMode).
3002 yield* Queue.offer(
3003 harness.sdkMessages,
3004 claudeSdkFrame({
3005 type: "system",
3006 subtype: "status",
3007 status: null,
3008 permissionMode: "plan",
3009 uuid: "00000000-0000-4000-8000-000000000508",
3010 session_id: WAKE_NATIVE_SESSION,
3011 }),
3012 );
29883013 yield* Queue.offer(
29893014 harness.sdkMessages,
29903015 makeResultFrame({
......@@ -3095,6 +3120,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {
30953120 );
30963121 const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan");
30973122 assert.equal(proposedPlan?.status, "active");
3123 // The second prompt reuses the live process, which is still in the
3124 // plan mode Claude entered, so it is put back in the thread's mode.
3125 assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]);
30983126 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
30993127 ),
31003128 );
......@@ -3615,6 +3643,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
36153643 messages: Stream.fromQueue(sdkMessages),
36163644 offer: () => Effect.void,
36173645 setModel: () => Effect.void,
3646 setPermissionMode: () => Effect.void,
36183647 interrupt: Effect.void,
36193648 // The first CLI process keeps streaming until the test ends
36203649 // it, so Stop stays parked waiting for it to exit.
......@@ -3864,6 +3893,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
38643893 messages: Stream.fromQueue(queue),
38653894 offer: () => Effect.void,
38663895 setModel: () => Effect.void,
3896 setPermissionMode: () => Effect.void,
38673897 interrupt: Effect.void,
38683898 close: Queue.shutdown(queue),
38693899 };
......@@ -6655,6 +6685,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
66556685 messages: Stream.fromQueue(sdkMessages),
66566686 offer: () => Effect.void,
66576687 setModel: () => Effect.void,
6688 setPermissionMode: () => Effect.void,
66586689 interrupt: Effect.void,
66596690 // End this process stream so openQuery can replace it.
66606691 close: Queue.shutdown(sdkMessages),
......@@ -6837,6 +6868,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
68376868 messages: Stream.fromQueue(sdkMessages),
68386869 offer: () => Effect.void,
68396870 setModel: () => Effect.void,
6871 setPermissionMode: () => Effect.void,
68406872 interrupt: Effect.void,
68416873 close: Queue.shutdown(sdkMessages),
68426874 };
......@@ -7071,6 +7103,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
70717103 messages: Stream.fromQueue(sdkMessages),
70727104 offer: () => Effect.void,
70737105 setModel: () => Effect.void,
7106 setPermissionMode: () => Effect.void,
70747107 interrupt: Effect.void,
70757108 close: Queue.shutdown(sdkMessages),
70767109 };
......@@ -7257,6 +7290,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
72577290 messages: Stream.fromQueue(sdkMessages),
72587291 offer: () => Effect.void,
72597292 setModel: () => Effect.void,
7293 setPermissionMode: () => Effect.void,
72607294 interrupt: Effect.void,
72617295 close: Queue.shutdown(sdkMessages),
72627296 };
......@@ -7433,6 +7467,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
74337467 messages: Stream.fromQueue(sdkMessages),
74347468 offer: () => Effect.void,
74357469 setModel: () => Effect.void,
7470 setPermissionMode: () => Effect.void,
74367471 interrupt: Effect.void,
74377472 close: Queue.shutdown(sdkMessages),
74387473 };
......@@ -7564,6 +7599,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
75647599 messages: Stream.fromQueue(sdkMessages),
75657600 offer: () => Effect.void,
75667601 setModel: () => Effect.void,
7602 setPermissionMode: () => Effect.void,
75677603 interrupt: Effect.void,
75687604 close: Queue.shutdown(sdkMessages),
75697605 };
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+13
......@@ -184,6 +184,11 @@ interface ClaudeQuerySetModelFrame {
184184 readonly model: string;
185185}
186186
187interface ClaudeQuerySetPermissionModeFrame {
188 readonly type: "query.set_permission_mode";
189 readonly mode: string;
190}
191
187192interface ClaudeQueryInterruptFrame {
188193 readonly type: "query.interrupt";
189194}
......@@ -239,6 +244,7 @@ type ClaudeOutboundFrame =
239244 | ClaudeQueryOpenFrame
240245 | ClaudePromptOfferFrame
241246 | ClaudeQuerySetModelFrame
247 | ClaudeQuerySetPermissionModeFrame
242248 | ClaudeQueryInterruptFrame
243249 | ClaudePermissionResponseFrame
244250 | ClaudeSessionForkFrame
......@@ -811,6 +817,13 @@ function makeReplayQueryRunner(
811817 model,
812818 });
813819 }),
820 setPermissionMode: (mode) =>
821 replayEffect(() => {
822 assertNextOutboundFrame({
823 type: "query.set_permission_mode",
824 mode,
825 });
826 }),
814827 interrupt: replayEffect(() => {
815828 assertNextOutboundFrame({ type: "query.interrupt" });
816829 }),
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+82-25
......@@ -327,6 +327,9 @@ export interface ClaudeAgentSdkQuerySession {
327327 readonly messages: Stream.Stream<SDKMessage, ClaudeAgentSdkQueryRunnerError>;
328328 readonly offer: (message: SDKUserMessage) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
329329 readonly setModel: (model: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
330 readonly setPermissionMode: (
331 mode: PermissionMode,
332 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
330333 readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
331334 readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
332335}
......@@ -466,6 +469,14 @@ export type ClaudeAgentSdkProtocolLogEvent =
466469 readonly model: string;
467470 };
468471 }
472 | {
473 readonly direction: "outgoing";
474 readonly stage: "decoded";
475 readonly payload: {
476 readonly type: "query.set_permission_mode";
477 readonly mode: PermissionMode;
478 };
479 }
469480 | {
470481 readonly direction: "outgoing";
471482 readonly stage: "decoded";
......@@ -670,6 +681,22 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer<
670681 }),
671682 ),
672683 ),
684 setPermissionMode: (mode) =>
685 Effect.tryPromise({
686 try: () => queryRuntime.setPermissionMode(mode),
687 catch: (cause) => queryRunnerError(cause, "setPermissionMode"),
688 }).pipe(
689 Effect.tap(() =>
690 logProtocolEvent({
691 direction: "outgoing",
692 stage: "decoded",
693 payload: {
694 type: "query.set_permission_mode",
695 mode,
696 },
697 }),
698 ),
699 ),
673700 interrupt: Effect.tryPromise({
674701 try: () => queryRuntime.interrupt(),
675702 catch: (cause) => queryRunnerError(cause, "interrupt"),
......@@ -2688,6 +2715,11 @@ interface ClaudeLiveQueryContext {
26882715 // uuid before any echo, so it echoes, but a resume's own turns can still
26892716 // run ahead of that prompt.
26902717 promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only";
2718 // The mode this process was opened in, and the mode the CLI last reported
2719 // (init and status frames). Claude changes the latter itself through
2720 // EnterPlanMode.
2721 readonly openedPermissionMode: PermissionMode;
2722 permissionMode: PermissionMode;
26912723 // Stop, rollback or fork is closing this process; its work is ending.
26922724 stopping: boolean;
26932725 // Registry entries still running when this process opened. Their process
......@@ -6843,10 +6875,23 @@ export function makeClaudeAdapterV2(
68436875 if (
68446876 existing !== null &&
68456877 existing.nativeThreadId === nativeThreadId &&
6846 (isClaudeProviderContinuationTurn(turnInput) ||
6847 (existing.queryPolicyKey === queryPolicyKey &&
6848 existing.selectionKey === compiledSelection.queryIdentity))
6878 isClaudeProviderContinuationTurn(turnInput)
6879 ) {
6880 return existing;
6881 }
6882 if (
6883 existing !== null &&
6884 existing.nativeThreadId === nativeThreadId &&
6885 existing.queryPolicyKey === queryPolicyKey &&
6886 existing.selectionKey === compiledSelection.queryIdentity
68496887 ) {
6888 // Claude can switch its own mode mid-session (EnterPlanMode), and
6889 // a denied ExitPlanMode leaves it there. Put the live process back
6890 // in the thread's mode before the next prompt.
6891 if (existing.permissionMode !== existing.openedPermissionMode) {
6892 yield* existing.query.setPermissionMode(existing.openedPermissionMode);
6893 existing.permissionMode = existing.openedPermissionMode;
6894 }
68506895 return existing;
68516896 }
68526897
......@@ -6889,31 +6934,32 @@ export function makeClaudeAdapterV2(
68896934 const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1;
68906935 const shouldResume =
68916936 resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn;
6937 const queryOptions = makeClaudeQueryOptions({
6938 modelSelection: turnInput.modelSelection,
6939 nativeThreadId,
6940 resume: shouldResume,
6941 ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }),
6942 cwd: turnInput.runtimePolicy.cwd,
6943 attachmentsDir,
6944 settings: adapterOptions.settings,
6945 environment: adapterOptions.environment,
6946 tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
6947 ...mcpOverrides,
6948 permissionMode: queryPolicy.permissionMode,
6949 ...(queryPolicy.allowDangerouslySkipPermissions === undefined
6950 ? {}
6951 : {
6952 allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions,
6953 }),
6954 canUseTool,
6955 onUserDialog,
6956 supportedDialogKinds: ["resume_return"],
6957 });
68926958 const querySession = yield* queryRunner
68936959 .open({
68946960 threadId: turnInput.threadId,
68956961 providerSessionId: input.providerSessionId,
6896 options: makeClaudeQueryOptions({
6897 modelSelection: turnInput.modelSelection,
6898 nativeThreadId,
6899 resume: shouldResume,
6900 ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }),
6901 cwd: turnInput.runtimePolicy.cwd,
6902 attachmentsDir,
6903 settings: adapterOptions.settings,
6904 environment: adapterOptions.environment,
6905 tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
6906 ...mcpOverrides,
6907 permissionMode: queryPolicy.permissionMode,
6908 ...(queryPolicy.allowDangerouslySkipPermissions === undefined
6909 ? {}
6910 : {
6911 allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions,
6912 }),
6913 canUseTool,
6914 onUserDialog,
6915 supportedDialogKinds: ["resume_return"],
6916 }),
6962 options: queryOptions,
69176963 })
69186964 .pipe(
69196965 Effect.tapError(() =>
......@@ -6960,6 +7006,8 @@ export function makeClaudeAdapterV2(
69607006 selectionKey: compiledSelection.queryIdentity,
69617007 closed,
69627008 promptEchoMode: "unknown",
7009 openedPermissionMode: queryOptions.permissionMode,
7010 permissionMode: queryOptions.permissionMode,
69637011 stopping: false,
69647012 subagentsFromEarlierProcesses: new Set(
69657013 [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter(
......@@ -6969,7 +7017,16 @@ export function makeClaudeAdapterV2(
69697017 };
69707018 yield* Ref.set(queryContext, context);
69717019 yield* querySession.messages.pipe(
6972 Stream.runForEach((message) => handleSdkMessage({ query: querySession, message })),
7020 Stream.runForEach((message) => {
7021 if (
7022 message.type === "system" &&
7023 (message.subtype === "init" || message.subtype === "status") &&
7024 message.permissionMode !== undefined
7025 ) {
7026 context.permissionMode = message.permissionMode;
7027 }
7028 return handleSdkMessage({ query: querySession, message });
7029 }),
69737030 Effect.exit,
69747031 Effect.flatMap(
69757032 Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) {