From 25d2e32ea3b905e40a02c35c3e034007989db052 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 7 Nov 2026 20:26:20 -0500 Subject: [PATCH 1/2] fix(server): Claude side chats no longer inherit the main thread's /goal Claude Code restores an unmet /goal Stop hook from a session's transcript on resume, and a native fork copies that transcript. A side chat opened on a thread with a goal kept answering, then got pushed back into the main thread's goal by the hook until its 9-block cap. Codex forks do not carry goals. The Claude adapter now runs /goal clear on a fork whose source has a goal, before recording the fork's head, so rollback keeps the clear. The clear is a local CLI command, about 0.8s and no model call. Made with Claude Opus 5.5 in Claude Code (T3 Code). --- .../Adapters/ClaudeAdapterV2.test.ts | 13 +++++ .../Adapters/ClaudeAdapterV2.ts | 47 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d7d03c779b4bb..7bc59e6cd8ec7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2558,6 +2558,19 @@ describe("ClaudeAdapterV2 native fork", () => { assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id); assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, providerTurnId); + // Claude Code re-arms the copied transcript's unmet goal, including one its Stop-hook cap + // ended, which T3 records as complete. + yield* runtime.forkThread({ + sourceProviderThread: { + ...sourceProviderThread, + goal: { objective: "Ship the release", status: "complete", checks: 9 }, + }, + targetThreadId, + }); + const clearGoal = forkCalls[1]?.clearGoal?.("forked-native-session"); + assert.equal(clearGoal?.resume, "forked-native-session"); + assert.deepEqual(clearGoal?.tools, []); + yield* runtime.startTurn({ appThread: { createdBy: "user", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index df80f1316fb77..e0bc668f2a8d5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -689,6 +689,8 @@ export interface ClaudeAgentSdkSessionForkInput { readonly environment: NodeJS.ProcessEnv; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; + /** Options that resume the fork, given when the copied transcript carries a `/goal` to clear. */ + readonly clearGoal?: (forkSessionId: string) => ClaudeAgentSdkQueryOptions; } export interface ClaudeAgentSdkSubagentLookupInput { @@ -1166,7 +1168,7 @@ export const layerQueryRunner: Layer.Layer< const hostEnvironment = yield* HostProcess.Environment; const portDiscovery = yield* PortScanner.PortDiscovery; - return ClaudeAgentSdkQueryRunner.of({ + const runner: ClaudeAgentSdkQueryRunnerShape = ClaudeAgentSdkQueryRunner.of({ allocateSessionId: random.uuidV4(), open: Effect.fn("ClaudeAgentSdkQueryRunner.open")( function* (input: ClaudeAgentSdkQueryOpenInput) { @@ -1603,6 +1605,26 @@ export const layerQueryRunner: Layer.Layer< sessionId: result.sessionId, }, }); + // Claude Code re-arms an unmet goal's Stop hook from the copied transcript on resume. + // Clearing it is a local command, and runs before the head is read so rollback keeps it. + if (input.clearGoal !== undefined) { + const session = yield* runner.open({ + options: input.clearGoal(result.sessionId), + threadId: input.threadId, + providerSessionId: input.providerSessionId, + }); + yield* session.offer(makeClaudeUserMessage({ text: "/goal clear" })).pipe( + Effect.andThen( + session.messages.pipe( + Stream.takeUntil((message) => message.type === "result"), + Stream.runDrain, + ), + ), + Effect.timeout("30 seconds"), + Effect.mapError((cause) => queryRunnerError(cause, "clearGoal")), + Effect.ensuring(session.close.pipe(Effect.ignore)), + ); + } // The SDK remaps copied UUIDs. Capture the inherited head before the // fork's first run appends messages. const headMessageId = usesHostConfig @@ -1657,6 +1679,7 @@ export const layerQueryRunner: Layer.Layer< ), assertComplete: Effect.void, }); + return runner; }), ); @@ -12266,12 +12289,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(input.runtimePolicy.cwd === null ? {} : { dir: input.runtimePolicy.cwd }), ...(upToMessageId === undefined ? {} : { upToMessageId }), }; + const sourceGoal = forkInput.sourceProviderThread.goal; const forked = yield* queryRunner.forkSession({ sessionId: sourceNativeThreadId, options: forkOptions, environment: adapterOptions.environment, threadId: forkInput.targetThreadId, providerSessionId: input.providerSessionId, + // A fork starts without its source's goal, as Codex forks do. "complete" can + // also mean Claude's Stop-hook cap ended the turn with the goal still armed. + ...(sourceGoal == null + ? {} + : { + clearGoal: (nativeThreadId: string) => { + const options = makeClaudeQueryOptions({ + modelSelection: forkInput.modelSelection ?? input.modelSelection, + modelCatalog, + nativeThreadId, + resume: true, + cwd: input.runtimePolicy.cwd, + settings: adapterOptions.settings, + environment: adapterOptions.environment, + tools: [], + }); + return options.permissionMode === "bypassPermissions" + ? { ...options, allowDangerouslySkipPermissions: true } + : options; + }, + }), }); yield* Ref.update(openedNativeThreads, (current) => { const updated = new Set(current); From 158760a0b0b15232df2686883962cc0b73d7cc2e Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 7 Nov 2026 20:34:16 -0500 Subject: [PATCH 2/2] fix(server): confirm the fork's goal clear and keep its rollback head past it Requires a successful result from /goal clear, passes --settings overrides to the clear query, and rereads the fork's head after the clear for custom CLAUDE_CONFIG_DIR homes too. Made with Claude Opus 5.5 in Claude Code (T3 Code). --- .../Adapters/ClaudeAdapterV2.ts | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index e0bc668f2a8d5..f664f0fa86b80 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1087,6 +1087,9 @@ const decodeClaudeHistoryFork = Schema.decodeUnknownEffect( Schema.Struct({ sessionId: Schema.String, headMessageId: Schema.optionalKey(Schema.String) }), ), ); +const decodeClaudeHistoryMessages = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(Schema.Struct({ uuid: Schema.String }))), +); const claudeConfigDirOf = ( path: Path.Path, @@ -1097,7 +1100,7 @@ const claudeConfigDirOf = ( // The SDK reads CLAUDE_CONFIG_DIR from process.env. Isolate custom homes in a // worker so concurrent providers keep the server's environment. const runClaudeWorker = Effect.fn("runClaudeWorker")(function* (input: { - readonly method: "forkSession"; + readonly method: "forkSession" | "getSessionMessages"; readonly sessionId: string; readonly options?: ForkSessionOptions; readonly environment: NodeJS.ProcessEnv; @@ -1617,9 +1620,17 @@ export const layerQueryRunner: Layer.Layer< Effect.andThen( session.messages.pipe( Stream.takeUntil((message) => message.type === "result"), - Stream.runDrain, + Stream.runLast, ), ), + Effect.filterOrFail( + (last) => + Option.isSome(last) && + last.value.type === "result" && + last.value.subtype === "success" && + !last.value.is_error, + () => new Error("Claude did not confirm /goal clear on the fork."), + ), Effect.timeout("30 seconds"), Effect.mapError((cause) => queryRunnerError(cause, "clearGoal")), Effect.ensuring(session.close.pipe(Effect.ignore)), @@ -1627,20 +1638,32 @@ export const layerQueryRunner: Layer.Layer< } // The SDK remaps copied UUIDs. Capture the inherited head before the // fork's first run appends messages. + const historyOptions = input.options.dir === undefined ? {} : { dir: input.options.dir }; const headMessageId = usesHostConfig ? yield* Effect.tryPromise({ - try: () => - getSessionMessages( - result.sessionId, - input.options.dir === undefined ? {} : { dir: input.options.dir }, - ), + try: () => getSessionMessages(result.sessionId, historyOptions), catch: (cause) => queryRunnerError(cause, "getSessionMessages"), }).pipe( Effect.map((messages) => messages.at(-1)?.uuid), Effect.orElseSucceed(() => undefined), ) - : result.headMessageId; - return headMessageId === undefined ? result : { ...result, headMessageId }; + : input.clearGoal === undefined + ? result.headMessageId + : yield* runClaudeWorker({ + method: "getSessionMessages", + sessionId: result.sessionId, + options: historyOptions, + environment: input.environment, + }).pipe( + Effect.flatMap(decodeClaudeHistoryMessages), + Effect.map((messages) => messages.at(-1)?.uuid), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + Effect.orElseSucceed(() => undefined), + ); + return headMessageId === undefined + ? { sessionId: result.sessionId } + : { sessionId: result.sessionId, headMessageId }; }), subagentLaunchToolUseId: Effect.fn("ClaudeAgentSdkQueryRunner.subagentLaunchToolUseId")( function* (input: ClaudeAgentSdkSubagentLookupInput) { @@ -12290,6 +12313,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(upToMessageId === undefined ? {} : { upToMessageId }), }; const sourceGoal = forkInput.sourceProviderThread.goal; + const launchArgSettings = + sourceGoal == null + ? undefined + : yield* readClaudeLaunchArgSettings({ + settings: adapterOptions.settings, + cwd: input.runtimePolicy.cwd, + fileSystem, + path, + }); const forked = yield* queryRunner.forkSession({ sessionId: sourceNativeThreadId, options: forkOptions, @@ -12309,6 +12341,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resume: true, cwd: input.runtimePolicy.cwd, settings: adapterOptions.settings, + ...(launchArgSettings === undefined + ? {} + : { sdkSettings: claudeSdkLaunchArgSettings(launchArgSettings) }), environment: adapterOptions.environment, tools: [], });