From 23c6b56a7f290c7ab099e6502a689b4df9226473 Mon Sep 17 00:00:00 2001 From: sholub-dev Date: Sun, 9 Aug 2026 11:33:49 -0700 Subject: [PATCH 1/4] Apply permission escalation per turn in the Claude adapter Escalation follows the turn initiator, but it was pinned into Claude session construction, so every user/system turn alternation replaced the session and killed the background tasks inside it. Turn commands now carry escalation, the bridge adopts it in place, and the runtime records escalation-only changes without a session-replacing reconfigure. --- packages/agent-runtime/src/acp/adapter.ts | 1 + .../agent-runtime/src/claude-code/adapter.ts | 3 + .../bridge/__tests__/bridge.test.ts | 113 ++++++++++++++---- .../src/claude-code/bridge/bridge.ts | 53 +++++++- .../src/claude-code/bridge/commands.ts | 2 + .../src/claude-code/bridge/session-options.ts | 27 +++-- packages/agent-runtime/src/codex/adapter.ts | 1 + .../agent-runtime/src/execution-options.ts | 4 +- packages/agent-runtime/src/pi/adapter.ts | 1 + .../agent-runtime/src/provider-adapter.ts | 6 + .../src/runtime.lifecycle.test.ts | 51 ++++++++ packages/agent-runtime/src/runtime.ts | 16 ++- .../agent-runtime/src/test/fake-adapter.ts | 1 + .../src/test/runtime-test-harness.ts | 2 + 14 files changed, 239 insertions(+), 42 deletions(-) diff --git a/packages/agent-runtime/src/acp/adapter.ts b/packages/agent-runtime/src/acp/adapter.ts index acd019180e..1bfe6e5156 100644 --- a/packages/agent-runtime/src/acp/adapter.ts +++ b/packages/agent-runtime/src/acp/adapter.ts @@ -1341,6 +1341,7 @@ export function createAcpProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities: providerInfo.capabilities, + appliesPermissionEscalationPerTurn: false, process: { command: opts.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ diff --git a/packages/agent-runtime/src/claude-code/adapter.ts b/packages/agent-runtime/src/claude-code/adapter.ts index 4d3f852af2..a170ed171d 100644 --- a/packages/agent-runtime/src/claude-code/adapter.ts +++ b/packages/agent-runtime/src/claude-code/adapter.ts @@ -1042,6 +1042,7 @@ export function createClaudeCodeProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, + appliesPermissionEscalationPerTurn: true, process: { command: opts?.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ @@ -1240,6 +1241,7 @@ export function createClaudeCodeProviderAdapter( ...(command.options?.model ? { model: command.options.model } : {}), + permissionEscalation: command.options.permissionEscalation, }, }; case "turn/steer": @@ -1261,6 +1263,7 @@ export function createClaudeCodeProviderAdapter( ), } : {}), + permissionEscalation: command.options.permissionEscalation, }, }; case "thread/fork": { diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts index 0d61ebcaf7..8aa0af1421 100644 --- a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts +++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts @@ -150,6 +150,7 @@ interface StartBridgeThreadArgs { interface ResumeBridgeThreadArgs { bridge: BridgeJsonRpcTestHarness; + permissionEscalation?: "ask" | "deny"; providerThreadId: string | null; requestId: number; threadId: string; @@ -461,7 +462,7 @@ function sendResumeThread(args: ResumeBridgeThreadArgs): void { baseInstructions: "test", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + permissionEscalation: args.permissionEscalation ?? "ask", permissionMode: "default", approvedPlanPermissionMode: "default", permissionScope: "workspace", @@ -557,7 +558,7 @@ describe("bridge", () => { cwd: "/tmp/worktree", disallowedTools: ["ExitPlanMode", "NotebookEdit", "Task"], instructionMode: "replace", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -582,7 +583,7 @@ describe("bridge", () => { instructionMode: "append", reasoningLevel: "ultracode", workflowsEnabled: true, - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -605,7 +606,7 @@ describe("bridge", () => { instructionMode: "append", reasoningLevel: "high", workflowsEnabled: true, - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -627,7 +628,7 @@ describe("bridge", () => { cwd: "/tmp/worktree", instructionMode: "append", reasoningLevel: "xhigh", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -644,7 +645,7 @@ describe("bridge", () => { memoryEnabled: false, cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -662,7 +663,7 @@ describe("bridge", () => { cwd: "/tmp/worktree", instructionMode: "append", reasoningLevel: "xhigh", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -690,7 +691,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", plugins: [{ type: "local", path: "/tmp/bb-skills" }], @@ -711,7 +712,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "deny", + getPermissionEscalation: () => "deny", permissionMode: "dontAsk", permissionScope: "workspace", }, @@ -729,7 +730,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -754,7 +755,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -772,7 +773,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -793,7 +794,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -818,7 +819,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -837,7 +838,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "acceptEdits", permissionScope: "workspace", }, @@ -849,7 +850,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "deny", + getPermissionEscalation: () => "deny", permissionMode: "auto", permissionScope: "workspace", }, @@ -869,7 +870,7 @@ describe("bridge", () => { enabled: true, failIfUnavailable: false, autoAllowBashIfSandboxed: true, - allowUnsandboxedCommands: false, + allowUnsandboxedCommands: true, network: { allowLocalBinding: true }, }); }); @@ -882,7 +883,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "plan", permissionScope: "workspace", }, @@ -905,7 +906,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "deny", + getPermissionEscalation: () => "deny", permissionMode: "auto", permissionScope: "workspace", }, @@ -920,7 +921,7 @@ describe("bridge", () => { enabled: true, failIfUnavailable: false, autoAllowBashIfSandboxed: true, - allowUnsandboxedCommands: false, + allowUnsandboxedCommands: true, network: { allowLocalBinding: true }, filesystem: { allowWrite: ["/repo/.git/worktrees/bb13", "/repo/.git/objects"], @@ -935,7 +936,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "ask", + getPermissionEscalation: () => "ask", permissionMode: "default", permissionScope: "workspace", }, @@ -947,7 +948,7 @@ describe("bridge", () => { baseInstructions: "You are a coder.", cwd: "/tmp/worktree", instructionMode: "append", - permissionEscalation: "deny", + getPermissionEscalation: () => "deny", permissionMode: "dontAsk", permissionScope: "workspace", }, @@ -1250,6 +1251,23 @@ describe("bridge", () => { messageIncludes: "bb's workspace sandbox allows work inside", }, }, + { + id: "escalation-deny-unsandboxed-bash", + name: "escalation deny blocks unsandboxed Bash retry", + permissionMode: "auto", + permissionEscalation: "deny", + toolName: "Bash", + decisionReason: "dangerouslyDisableSandbox", + input: { + command: "echo hi", + dangerouslyDisableSandbox: true, + description: "Permission boundary test", + }, + expected: { + behavior: "deny", + messageIncludes: "bb's workspace sandbox allows work inside", + }, + }, { id: "full-bypass-allow", name: "full bypass does not rewrite via readonly Bash auto-allow", @@ -1786,6 +1804,7 @@ describe("bridge", () => { }); bridge.sendRequest(collidingId, "turn/start", { + permissionEscalation: "ask", threadId, providerThreadId: null, input: [{ type: "text", text: "colliding turn", mentions: [] }], @@ -1821,6 +1840,7 @@ describe("bridge", () => { // Any params mismatch used to be dropped without a reply, so the caller // learned nothing until its 30s timeout. The reply now names the field. bridge.sendRequest(11, "turn/start", { + permissionEscalation: "ask", threadId: "thread-invalid-params", providerThreadId: null, input: [{ type: "text", text: "hi", mentions: [] }], @@ -2525,7 +2545,7 @@ describe("bridge", () => { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, - allowUnsandboxedCommands: false, + allowUnsandboxedCommands: true, }, }); @@ -2563,6 +2583,45 @@ describe("bridge", () => { } }); + it("keeps a live Claude session across an escalation-only resume change", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + const threadId = "thread-resume-escalation-only"; + const providerThreadId = "provider-thread-escalation-only"; + sendResumeThread({ bridge, providerThreadId, requestId: 1, threadId }); + await bridge.waitForResponse(1); + + expect(queries).toHaveLength(1); + + sendResumeThread({ + bridge, + permissionEscalation: "deny", + providerThreadId, + requestId: 2, + threadId, + }); + await bridge.waitForResponse(2); + + expect(queries).toHaveLength(1); + expect(queries[0]?.close).not.toHaveBeenCalled(); + + bridge.sendRequest(3, "thread/stop", { threadId }); + await bridge.flushWork(); + queries[0]?.finish(); + await bridge.waitForResponse(3); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }); + it("replaces a live thread/resume session when the provider thread differs", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const queries: ControlledClaudeQuery[] = []; @@ -2799,6 +2858,7 @@ describe("bridge", () => { ).toBe(true); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [{ type: "text", text: inputText }], providerThreadId, threadId, @@ -2854,6 +2914,7 @@ describe("bridge", () => { await bridge.flushWork(); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [{ type: "text", text: "" }], providerThreadId, threadId, @@ -2901,6 +2962,7 @@ describe("bridge", () => { const providerThreadId = getProviderThreadIdFromResult(startResponse); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [ { type: "text", text: "First grouped input" }, { type: "text", text: "\n\n" }, @@ -2951,6 +3013,7 @@ describe("bridge", () => { await startBridgeThread({ bridge, threadId }); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [ { type: "text", text: "First grouped input" }, { type: "text", text: "\n\n" }, @@ -3019,6 +3082,7 @@ describe("bridge", () => { }); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input: [{ type: "text", text: inputText }], providerThreadId: staleProviderThreadId, threadId, @@ -3177,6 +3241,7 @@ describe("bridge", () => { await startBridgeThread({ bridge, threadId }); bridge.sendRequest(2, "turn/steer", { + permissionEscalation: "ask", expectedTurnId: "turn-1", input: [{ type: "text", text: "Please account for the restart" }], providerThreadId: null, @@ -3213,6 +3278,7 @@ describe("bridge", () => { await startBridgeThread({ bridge, threadId }); bridge.sendRequest(2, "turn/steer", { + permissionEscalation: "ask", expectedTurnId: "turn-1", input: [ { type: "text", text: "First grouped steer" }, @@ -3255,6 +3321,7 @@ describe("bridge", () => { ): Promise { await startBridgeThread({ bridge, threadId }); bridge.sendRequest(2, "turn/start", { + permissionEscalation: "ask", input, providerThreadId: null, threadId, diff --git a/packages/agent-runtime/src/claude-code/bridge/bridge.ts b/packages/agent-runtime/src/claude-code/bridge/bridge.ts index 4f54ec7e9f..d371de770b 100644 --- a/packages/agent-runtime/src/claude-code/bridge/bridge.ts +++ b/packages/agent-runtime/src/claude-code/bridge/bridge.ts @@ -233,7 +233,10 @@ interface SessionConstructionConfig { claudeCodeMockCliTraffic: ThreadResumeParams["claudeCodeMockCliTraffic"]; config: ThreadResumeParams["config"]; dynamicTools: ThreadResumeParams["dynamicTools"]; - sessionOptions: BuildSessionOptionsArgs; + // Escalation is per-turn state, so it is not part of the comparable + // construction config: two configs differing only in escalation must reuse + // the live session instead of replacing it (killing its background tasks). + sessionOptions: Omit; } type SessionConstructionParams = @@ -478,7 +481,6 @@ function toSessionConstructionConfig( instructionMode: params.instructionMode, memoryEnabled: params.memoryEnabled, model: params.model, - permissionEscalation: params.permissionEscalation, permissionMode: params.permissionMode, permissionScope: params.permissionScope, plugins: params.plugins, @@ -488,6 +490,17 @@ function toSessionConstructionConfig( }; } +function withLivePermissionEscalation( + params: SessionConstructionParams, + threadIdRef: ThreadIdRef, +): BuildSessionOptionsArgs { + return { + ...toSessionConstructionConfig(params).sessionOptions, + getPermissionEscalation: () => + sessions.get(threadIdRef.current)?.permissionEscalation ?? null, + }; +} + function createThreadSession(args: CreateThreadSessionArgs): ThreadSession { const sessionSerial = nextSessionSerial(); const session = new SdkSession( @@ -1180,6 +1193,24 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { }; const requestedPermissions = toPendingInteractionPermissionProfile(requestContext); + if ( + toolName === "Bash" && + shouldAutoDenyInteractiveRequest(threadSession) && + typeof input === "object" && + input !== null && + (input as { dangerouslyDisableSandbox?: unknown }) + .dangerouslyDisableSandbox === true + ) { + // With `allowUnsandboxedCommands` permanently enabled, this deny is the + // only gate on the unsandboxed retry for escalation-denied turns. It must + // run before the session-grant shortcut: grants survive escalation flips + // now that an escalation-only change reuses the session. + return { + behavior: "deny", + message: buildWorkspaceWriteDenialMessage(), + toolUseID: options.toolUseID, + }; + } if ( hasClaudeSessionPermissionGrant({ grants: threadSession.sessionPermissionGrants, @@ -1309,7 +1340,10 @@ async function handleThreadStart( } const preparedEnv = await prepareSessionEnv(params); - const sessionOptions = buildSessionOptions(params, preparedEnv.env); + const sessionOptions = buildSessionOptions( + withLivePermissionEscalation(params, threadIdRef), + preparedEnv.env, + ); const providerThreadId = randomUUID(); sessionOptions.sessionId = providerThreadId; sessionOptions.canUseTool = createCanUseTool(threadIdRef); @@ -1360,6 +1394,7 @@ async function handleThreadResume( sessionConstructionConfig, ) ) { + existing.permissionEscalation = params.permissionEscalation; sendResult(id, { threadId, providerThreadId: requestedProviderThreadId, @@ -1377,7 +1412,10 @@ async function handleThreadResume( const preparedEnv = await prepareSessionEnv(params); const threadIdRef = { current: threadId }; - const sessionOptions = buildSessionOptions(params, preparedEnv.env); + const sessionOptions = buildSessionOptions( + withLivePermissionEscalation(params, threadIdRef), + preparedEnv.env, + ); sessionOptions.canUseTool = createCanUseTool(threadIdRef); if (params.dynamicTools && params.dynamicTools.length > 0) { const mcpServer = buildBridgeMcpServer( @@ -1438,7 +1476,10 @@ async function handleThreadFork( const preparedEnv = await prepareSessionEnv(params); const threadIdRef = { current: threadId }; - const sessionOptions = buildSessionOptions(params, preparedEnv.env); + const sessionOptions = buildSessionOptions( + withLivePermissionEscalation(params, threadIdRef), + preparedEnv.env, + ); sessionOptions.canUseTool = createCanUseTool(threadIdRef); if (params.dynamicTools && params.dynamicTools.length > 0) { const mcpServer = buildBridgeMcpServer( @@ -1498,6 +1539,7 @@ async function handleTurnStart( return; } + threadSession.permissionEscalation = params.permissionEscalation; queuePromptInputs(threadSession, inputs); sendResult(id, { threadId: params.threadId }); } @@ -1518,6 +1560,7 @@ async function handleTurnSteer( return; } + threadSession.permissionEscalation = params.permissionEscalation; if (inputs.length > 1) { queuePromptInputs(threadSession, inputs); sendResult(id, { threadId: params.threadId }); diff --git a/packages/agent-runtime/src/claude-code/bridge/commands.ts b/packages/agent-runtime/src/claude-code/bridge/commands.ts index 4922479ca6..1d14a0cc5f 100644 --- a/packages/agent-runtime/src/claude-code/bridge/commands.ts +++ b/packages/agent-runtime/src/claude-code/bridge/commands.ts @@ -141,6 +141,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ inputGroups: z.array(z.array(z.unknown()).min(1)).optional(), model: z.string().optional(), config: z.record(z.string(), z.unknown()).optional(), + permissionEscalation: bridgePermissionEscalationSchema, }), }), z.object({ @@ -151,6 +152,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ expectedTurnId: z.string(), input: z.array(z.unknown()), inputGroups: z.array(z.array(z.unknown()).min(1)).optional(), + permissionEscalation: bridgePermissionEscalationSchema, }), }), z.object({ diff --git a/packages/agent-runtime/src/claude-code/bridge/session-options.ts b/packages/agent-runtime/src/claude-code/bridge/session-options.ts index a91f5f71f0..332c8a2b8e 100644 --- a/packages/agent-runtime/src/claude-code/bridge/session-options.ts +++ b/packages/agent-runtime/src/claude-code/bridge/session-options.ts @@ -18,7 +18,11 @@ export interface BuildSessionOptionsArgs { disallowedTools?: readonly string[]; instructionMode: InstructionMode; model?: string; - permissionEscalation: PermissionEscalation | null; + /** + * Escalation changes per turn without replacing the session, so hook + * closures must read it at call time instead of capturing a value. + */ + getPermissionEscalation: () => PermissionEscalation | null; permissionMode: ClaudePermissionMode; permissionScope: RuntimePermissionScope; plugins?: Options["plugins"]; @@ -47,6 +51,8 @@ const READONLY_ALLOWED_TOOLS = new Set([ "TodoRead", ]); const READONLY_BASH_TOOL_NAME = "Bash"; +const READONLY_ASK_REASON = + "bb readonly mode requires approval before using tools that can modify state, run commands, access network, or perform non-read actions."; const SUMMARIZED_ADAPTIVE_THINKING = { type: "adaptive", display: "summarized", @@ -97,12 +103,7 @@ function buildReadonlyHooks( return undefined; } - const permissionDecision = - params.permissionEscalation === "deny" ? "deny" : "ask"; - const permissionDecisionReason = - permissionDecision === "deny" - ? buildReadonlyDenialMessage() - : "bb readonly mode requires approval before using tools that can modify state, run commands, access network, or perform non-read actions."; + const getPermissionEscalation = params.getPermissionEscalation; return { PreToolUse: [ @@ -131,12 +132,17 @@ function buildReadonlyHooks( } } + const permissionDecision = + getPermissionEscalation() === "deny" ? "deny" : "ask"; return { continue: true, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, - permissionDecisionReason, + permissionDecisionReason: + permissionDecision === "deny" + ? buildReadonlyDenialMessage() + : READONLY_ASK_REASON, }, }; }, @@ -175,7 +181,10 @@ function buildWorkspaceWriteSandbox( // back to bb's own `canUseTool` gating instead of running wide open. failIfUnavailable: false, autoAllowBashIfSandboxed: true, - allowUnsandboxedCommands: params.permissionEscalation === "ask", + // Sandbox settings are session-fixed while escalation changes per turn; + // the unsandboxed retry stays enabled and `canUseTool` auto-denies it on + // escalation-denied turns. + allowUnsandboxedCommands: true, // The bb CLI needs loopback to reach the local server, and // escalation-denied turns have no unsandboxed-retry path around a block. // macOS-only and coarse (all localhost ports, binding on all interfaces); diff --git a/packages/agent-runtime/src/codex/adapter.ts b/packages/agent-runtime/src/codex/adapter.ts index dc8303b70a..6998945567 100644 --- a/packages/agent-runtime/src/codex/adapter.ts +++ b/packages/agent-runtime/src/codex/adapter.ts @@ -1850,6 +1850,7 @@ export function createCodexProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, + appliesPermissionEscalationPerTurn: false, // Codex app-server connections are owned by the runtime process manager. // BB runs live Codex threads on thread-scoped app-server processes, while // provider-only probes can still use a provider-scoped maintenance process. diff --git a/packages/agent-runtime/src/execution-options.ts b/packages/agent-runtime/src/execution-options.ts index f3bc2abb03..2fe85014fc 100644 --- a/packages/agent-runtime/src/execution-options.ts +++ b/packages/agent-runtime/src/execution-options.ts @@ -23,6 +23,7 @@ interface ToProviderExecutionContextArgs { interface SameExecutionSettingsArgs { left: AgentRuntimeExecutionOptions; right: AgentRuntimeExecutionOptions; + ignorePermissionEscalation: boolean; } export function assertProviderSupportsExecutionOptions( @@ -82,7 +83,8 @@ export function sameExecutionSettings( args.left.permissionMode === args.right.permissionMode && args.left.permissionScope === args.right.permissionScope && args.left.approvalReviewer === args.right.approvalReviewer && - args.left.permissionEscalation === args.right.permissionEscalation + (args.ignorePermissionEscalation || + args.left.permissionEscalation === args.right.permissionEscalation) ); } diff --git a/packages/agent-runtime/src/pi/adapter.ts b/packages/agent-runtime/src/pi/adapter.ts index ac2b4d974a..26ba4b32bd 100644 --- a/packages/agent-runtime/src/pi/adapter.ts +++ b/packages/agent-runtime/src/pi/adapter.ts @@ -1252,6 +1252,7 @@ export function createPiProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, + appliesPermissionEscalationPerTurn: false, process: { command: opts?.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ diff --git a/packages/agent-runtime/src/provider-adapter.ts b/packages/agent-runtime/src/provider-adapter.ts index a400d402ad..aa1798b261 100644 --- a/packages/agent-runtime/src/provider-adapter.ts +++ b/packages/agent-runtime/src/provider-adapter.ts @@ -253,6 +253,12 @@ export interface ProviderAdapter { id: string; displayName: string; capabilities: ProviderCapabilities; + /** + * The adapter applies `permissionEscalation` from each turn command, so the + * runtime must not replace the session (killing its background tasks) over + * an escalation-only change. + */ + appliesPermissionEscalationPerTurn: boolean; process: { command: string; args: string[]; env?: Record }; buildCommandPlan(command: AdapterCommand): ProviderCommandPlan; diff --git a/packages/agent-runtime/src/runtime.lifecycle.test.ts b/packages/agent-runtime/src/runtime.lifecycle.test.ts index 608e62ed35..4e524bb550 100644 --- a/packages/agent-runtime/src/runtime.lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.lifecycle.test.ts @@ -499,6 +499,57 @@ rl.on("line", (line) => { await runtime.shutdown(); }); + it("skips session reconfigure for an escalation-only change when the adapter applies escalation per turn", async () => { + const recordedCommands: AdapterCommand[] = []; + const runtime = createAgentRuntimeWithAdapters({ + workspacePath: tmpDir, + onEvent: () => undefined, + onToolCall: async () => ({ + contentItems: [{ type: "inputText", text: "ok" }], + success: true, + }), + adapterFactory: () => ({ + ...createRecordingAdapter({ recordedCommands, scriptPath }), + appliesPermissionEscalationPerTurn: true, + }), + }); + + await runtime.startThread({ + environmentId: "env-1", + threadId: "t1", + projectId: "p1", + providerId: "fake", + instructions: "Initial instructions", + options: { + ...fullRuntimeOptions, + permissionMode: "auto", + permissionScope: "workspace", + approvalReviewer: "automatic", + permissionEscalation: "ask", + }, + }); + + await runtime.runTurn({ + clientRequestId: "creq_222222224h", + threadId: "t1", + input: [promptTextInput({ text: "follow up" })], + instructions: "Initial instructions", + options: { + ...fullRuntimeOptions, + permissionMode: "auto", + permissionScope: "workspace", + approvalReviewer: "automatic", + permissionEscalation: "deny", + }, + }); + + expect( + recordedCommands.some((command) => command.type === "thread/resume"), + ).toBe(false); + + await runtime.shutdown(); + }); + it("passes the workspace cwd when resuming a thread", async () => { const recordedCommands: AdapterCommand[] = []; const runtime = createAgentRuntimeWithAdapters({ diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index b4bdb3c252..30d0ad88b7 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -819,19 +819,27 @@ function createAgentRuntimeInternal( // instructions) must never force a thread/resume, because a resume can // replace the live CLI session and kill its running background tasks. // Fresh instructions apply when the next session is constructed. + const proc = requireProviderProcess({ + processKey: currentConfig.processKey, + providerId: currentConfig.providerId, + }); if ( sameExecutionSettings({ left: currentConfig.options, right: nextOptions, + ignorePermissionEscalation: + proc.adapter.appliesPermissionEscalationPerTurn, }) ) { + // Escalation rides on each turn command; record it without replacing + // the session (which would kill its background tasks). + setThreadRuntimeConfig(args.threadId, { + ...currentConfig, + options: nextOptions, + }); return; } - const proc = requireProviderProcess({ - processKey: currentConfig.processKey, - providerId: currentConfig.providerId, - }); const providerSkillRoots = currentConfig.skillRoots; const envVars = buildThreadShellEnvironment({ baseShellEnv: options.shellEnv, diff --git a/packages/agent-runtime/src/test/fake-adapter.ts b/packages/agent-runtime/src/test/fake-adapter.ts index cc4fb0c07e..c9221a12c2 100644 --- a/packages/agent-runtime/src/test/fake-adapter.ts +++ b/packages/agent-runtime/src/test/fake-adapter.ts @@ -471,6 +471,7 @@ export function createFakeAdapter( supportsFork: true, supportedPermissionModes: ["accept-edits", "auto", "full"], }, + appliesPermissionEscalationPerTurn: false, decodeToolCallRequest, decodeInteractiveRequest: supportsUserQuestion ? decodeInteractiveRequest diff --git a/packages/agent-runtime/src/test/runtime-test-harness.ts b/packages/agent-runtime/src/test/runtime-test-harness.ts index b0b59be074..068db9e74d 100644 --- a/packages/agent-runtime/src/test/runtime-test-harness.ts +++ b/packages/agent-runtime/src/test/runtime-test-harness.ts @@ -213,6 +213,7 @@ export function createWarningEventAdapter(scriptPath: string): ProviderAdapter { supportsFork: false, supportedPermissionModes: ["accept-edits", "auto", "full"], }, + appliesPermissionEscalationPerTurn: false, process: { command: "node", args: buildNodeScriptArgs(scriptPath), @@ -325,6 +326,7 @@ export function createStartedEventAdapter(scriptPath: string): ProviderAdapter { supportsFork: false, supportedPermissionModes: ["accept-edits", "auto", "full"], }, + appliesPermissionEscalationPerTurn: false, process: { command: "node", args: buildNodeScriptArgs(scriptPath), From 9ef988bc149c0b6764b369583e121d8f1e46a05c Mon Sep 17 00:00:00 2001 From: sholub-dev Date: Mon, 10 Aug 2026 09:04:52 -0700 Subject: [PATCH 2/4] Apply a steer's escalation only once its input is accepted --- .../bridge/__tests__/bridge.test.ts | 61 +++++++++++++++++++ .../src/claude-code/bridge/bridge.ts | 4 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts index 8aa0af1421..b2a9017dd5 100644 --- a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts +++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts @@ -3312,6 +3312,67 @@ describe("bridge", () => { } }); + it("keeps the prior escalation when a turn steer fails to push input", async () => { + const threadId = "thread-steer-failed-escalation"; + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + bridge.sendRequest(1, "thread/start", { + workflowsEnabled: false, + claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, + baseInstructions: "test", + cwd: "/tmp/worktree", + instructionMode: "append", + permissionEscalation: "deny", + permissionMode: "auto", + approvedPlanPermissionMode: "auto", + permissionScope: "workspace", + threadId, + }); + await bridge.waitForResponse(1); + + await getLatestQueryCall().prompt[Symbol.asyncIterator]().return?.(); + + bridge.sendRequest(2, "turn/steer", { + permissionEscalation: "ask", + expectedTurnId: "turn-1", + input: [{ type: "text", text: "loosen permissions" }], + providerThreadId: null, + threadId, + }); + await expect(bridge.waitForResponse(2)).resolves.toMatchObject({ + error: { code: -32000 }, + }); + + const canUseTool = getLastCanUseTool(); + await expect( + canUseTool( + "Bash", + { command: "echo hi", dangerouslyDisableSandbox: true }, + { + decisionReason: "dangerouslyDisableSandbox", + signal: new AbortController().signal, + toolUseID: "tool-steer-failed", + }, + ), + ).resolves.toMatchObject({ behavior: "deny" }); + + bridge.sendRequest(3, "thread/stop", { threadId }); + await bridge.flushWork(); + queries[0]?.finish(); + await bridge.waitForResponse(3); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }); + describe("prompt attachment text markers", () => { async function sendTurnAndReadPrompt( bridge: BridgeJsonRpcTestHarness, diff --git a/packages/agent-runtime/src/claude-code/bridge/bridge.ts b/packages/agent-runtime/src/claude-code/bridge/bridge.ts index d371de770b..1d6928d053 100644 --- a/packages/agent-runtime/src/claude-code/bridge/bridge.ts +++ b/packages/agent-runtime/src/claude-code/bridge/bridge.ts @@ -1560,8 +1560,8 @@ async function handleTurnSteer( return; } - threadSession.permissionEscalation = params.permissionEscalation; if (inputs.length > 1) { + threadSession.permissionEscalation = params.permissionEscalation; queuePromptInputs(threadSession, inputs); sendResult(id, { threadId: params.threadId }); return; @@ -1569,6 +1569,8 @@ async function handleTurnSteer( try { await threadSession.session.pushInput(inputs[0] ?? ""); + // A failed steer must not change the running turn's escalation. + threadSession.permissionEscalation = params.permissionEscalation; sendResult(id, { threadId: params.threadId }); } catch (error) { const message = error instanceof Error ? error.message : String(error); From e397f9058181e75701d6aeb1fa64d611c9eaa324 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 10 Aug 2026 15:36:11 -0700 Subject: [PATCH 3/4] Preserve originating-turn permissions for Claude work --- .../bridge/__tests__/bridge.test.ts | 314 ++++++++++++++---- .../bridge/__tests__/sdk-session.test.ts | 6 +- .../src/claude-code/bridge/bridge.ts | 257 +++++++++++++- .../src/claude-code/bridge/sdk-session.ts | 10 +- .../src/claude-code/bridge/session-options.ts | 27 +- 5 files changed, 535 insertions(+), 79 deletions(-) diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts index b2a9017dd5..94f0cbfccc 100644 --- a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts +++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts @@ -103,6 +103,7 @@ interface ControlledClaudeQuery { interface ClaudeQueryCallOptions { canUseTool?: CanUseTool; env?: Record; + hooks?: BridgeSessionHooks; model?: string; permissionMode?: ClaudePermissionMode; resume?: string; @@ -354,18 +355,44 @@ function createControlledClaudeQuery(): ControlledClaudeQuery { }; } -async function readNextPromptText(call: ClaudeQueryCall): Promise { +async function readNextPrompt(call: ClaudeQueryCall): Promise { const result = await call.prompt[Symbol.asyncIterator]().next(); if (result.done) { throw new Error("Expected Claude prompt input"); } - const content = result.value.message.content; + return result.value; +} + +async function readNextPromptText(call: ClaudeQueryCall): Promise { + const content = (await readNextPrompt(call)).message.content; if (typeof content !== "string") { throw new Error("Expected Claude prompt text content"); } return content; } +async function invokeBridgeHooks( + matchers: + | readonly { + hooks: readonly BridgePreToolUseHook[]; + }[] + | undefined, + input: Parameters[0], + toolUseId?: string, +): Promise>[]> { + const outputs: Awaited>[] = []; + for (const matcher of matchers ?? []) { + for (const hook of matcher.hooks) { + outputs.push( + await hook(input, toolUseId, { + signal: new AbortController().signal, + }), + ); + } + } + return outputs; +} + function createResultUsage(): SdkResultUsage { return { cache_creation: { @@ -2622,6 +2649,162 @@ describe("bridge", () => { } }); + it("keeps background subagents on their originating prompt escalation", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + const threadId = "thread-background-escalation"; + bridge.sendRequest(1, "thread/start", { + workflowsEnabled: false, + claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, + baseInstructions: "test", + cwd: "/tmp/worktree", + instructionMode: "append", + permissionEscalation: "deny", + permissionMode: "default", + approvedPlanPermissionMode: "default", + permissionScope: "workspace", + threadId, + }); + await bridge.waitForResponse(1); + + const call = getLatestQueryCall(); + const hooks = call.options.hooks; + if (!hooks) { + throw new Error("Expected Claude SDK hooks"); + } + + bridge.sendRequest(2, "turn/start", { + permissionEscalation: "deny", + input: [{ type: "text", text: "Start denied background work" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(2); + const deniedPrompt = await readNextPrompt(call); + if (!deniedPrompt.uuid) { + throw new Error("Expected denied prompt UUID"); + } + + bridge.sendRequest(3, "turn/start", { + permissionEscalation: "ask", + input: [{ type: "text", text: "Start interactive background work" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(3); + const askPrompt = await readNextPrompt(call); + if (!askPrompt.uuid) { + throw new Error("Expected ask prompt UUID"); + } + + await invokeBridgeHooks(hooks.SubagentStart, { + hook_event_name: "SubagentStart", + agent_id: "agent-deny", + agent_type: "general-purpose", + prompt_id: deniedPrompt.uuid, + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }); + + const denyToolUseId = "tool-background-deny"; + const deniedOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + agent_id: "agent-deny", + prompt_id: askPrompt.uuid, + tool_name: "Bash", + tool_input: { command: "git reset -- package.json" }, + tool_use_id: denyToolUseId, + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + denyToolUseId, + ); + expect(deniedOutputs).toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + hookEventName: "PreToolUse", + permissionDecision: "deny", + }), + }), + ); + await expect( + getLastCanUseTool()( + "Bash", + { command: "echo hi", dangerouslyDisableSandbox: true }, + { + agentID: "agent-deny", + decisionReason: "dangerouslyDisableSandbox", + signal: new AbortController().signal, + toolUseID: denyToolUseId, + }, + ), + ).resolves.toMatchObject({ behavior: "deny" }); + + bridge.sendRequest(4, "turn/start", { + permissionEscalation: "deny", + input: [{ type: "text", text: "Return to denied work" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(4); + const latestPrompt = await readNextPrompt(call); + + await invokeBridgeHooks(hooks.SubagentStart, { + hook_event_name: "SubagentStart", + agent_id: "agent-ask", + agent_type: "general-purpose", + prompt_id: askPrompt.uuid, + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }); + + const askToolUseId = "tool-background-ask"; + const askOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + agent_id: "agent-ask", + prompt_id: latestPrompt.uuid, + tool_name: "Bash", + tool_input: { command: "git reset -- package.json" }, + tool_use_id: askToolUseId, + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + askToolUseId, + ); + expect(askOutputs).toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + hookEventName: "PreToolUse", + permissionDecision: "ask", + }), + }), + ); + + bridge.sendRequest(5, "thread/stop", { threadId }); + await bridge.flushWork(); + queries[0]?.finish(); + await bridge.waitForResponse(5); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }); + it("replaces a live thread/resume session when the provider thread differs", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const queries: ControlledClaudeQuery[] = []; @@ -3312,66 +3495,83 @@ describe("bridge", () => { } }); - it("keeps the prior escalation when a turn steer fails to push input", async () => { - const threadId = "thread-steer-failed-escalation"; - const bridge = createBridgeJsonRpcTestHarness(handleLine); - const queries: ControlledClaudeQuery[] = []; - queryMock.mockImplementation(() => { - const query = createControlledClaudeQuery(); - queries.push(query); - return query; - }); - - try { - bridge.sendRequest(1, "thread/start", { - workflowsEnabled: false, - claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, - baseInstructions: "test", - cwd: "/tmp/worktree", - instructionMode: "append", - permissionEscalation: "deny", - permissionMode: "auto", - approvedPlanPermissionMode: "auto", - permissionScope: "workspace", - threadId, + it.each([ + { grouped: false, method: "turn/start", name: "turn start" }, + { grouped: false, method: "turn/steer", name: "single turn steer" }, + { grouped: true, method: "turn/steer", name: "grouped turn steer" }, + ] as const)( + "keeps the prior escalation when a rejected $name cannot push input", + async (testCase) => { + const threadId = `thread-rejected-${testCase.name.replaceAll(" ", "-")}`; + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; }); - await bridge.waitForResponse(1); - await getLatestQueryCall().prompt[Symbol.asyncIterator]().return?.(); + try { + bridge.sendRequest(1, "thread/start", { + workflowsEnabled: false, + claudeCodeMockCliTraffic: + DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, + baseInstructions: "test", + cwd: "/tmp/worktree", + instructionMode: "append", + permissionEscalation: "deny", + permissionMode: "auto", + approvedPlanPermissionMode: "auto", + permissionScope: "workspace", + threadId, + }); + await bridge.waitForResponse(1); + + await getLatestQueryCall().prompt[Symbol.asyncIterator]().return?.(); - bridge.sendRequest(2, "turn/steer", { - permissionEscalation: "ask", - expectedTurnId: "turn-1", - input: [{ type: "text", text: "loosen permissions" }], - providerThreadId: null, - threadId, - }); - await expect(bridge.waitForResponse(2)).resolves.toMatchObject({ - error: { code: -32000 }, - }); + bridge.sendRequest(2, testCase.method, { + permissionEscalation: "ask", + ...(testCase.method === "turn/steer" + ? { expectedTurnId: "turn-1" } + : {}), + input: [{ type: "text", text: "loosen permissions" }], + ...(testCase.grouped + ? { + inputGroups: [ + [{ type: "text", text: "first grouped input" }], + [{ type: "text", text: "second grouped input" }], + ], + } + : {}), + providerThreadId: null, + threadId, + }); + await expect(bridge.waitForResponse(2)).resolves.toMatchObject({ + error: { code: -32000 }, + }); - const canUseTool = getLastCanUseTool(); - await expect( - canUseTool( - "Bash", - { command: "echo hi", dangerouslyDisableSandbox: true }, - { - decisionReason: "dangerouslyDisableSandbox", - signal: new AbortController().signal, - toolUseID: "tool-steer-failed", - }, - ), - ).resolves.toMatchObject({ behavior: "deny" }); + await expect( + getLastCanUseTool()( + "Bash", + { command: "echo hi", dangerouslyDisableSandbox: true }, + { + decisionReason: "dangerouslyDisableSandbox", + signal: new AbortController().signal, + toolUseID: `tool-rejected-${testCase.method}`, + }, + ), + ).resolves.toMatchObject({ behavior: "deny" }); - bridge.sendRequest(3, "thread/stop", { threadId }); - await bridge.flushWork(); - queries[0]?.finish(); - await bridge.waitForResponse(3); - } finally { - queries.forEach((query) => query.finish()); - bridge.restore(); - } - }); + bridge.sendRequest(3, "thread/stop", { threadId }); + await bridge.flushWork(); + queries[0]?.finish(); + await bridge.waitForResponse(3); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }, + ); describe("prompt attachment text markers", () => { async function sendTurnAndReadPrompt( diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts index 5192ec8cc4..b997d16eec 100644 --- a/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts +++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts @@ -103,9 +103,11 @@ describe("SdkSession", () => { it("resolves pushed input after the SDK prompt iterator yields it", async () => { keepSdkStreamOpen(); const session = new SdkSession(defaultOptions, vi.fn(), vi.fn()); + const promptId = "00000000-0000-0000-0000-000000000001"; session.start(); - const consumed = session.pushInput("hello"); + expect(session.canPushInput()).toBe(true); + const consumed = session.pushInput("hello", promptId); let consumedResolved = false; void consumed.then(() => { consumedResolved = true; @@ -116,6 +118,7 @@ describe("SdkSession", () => { const result = await getLatestPrompt()[Symbol.asyncIterator]().next(); expect(result.done).toBe(false); expect(result.value?.message.content).toBe("hello"); + expect(result.value?.uuid).toBe(promptId); await consumed; expect(consumedResolved).toBe(true); session.stop(); @@ -126,6 +129,7 @@ describe("SdkSession", () => { const consumed = session.pushInput("hello"); session.stop(); + expect(session.canPushInput()).toBe(false); await expect(consumed).rejects.toThrow( "Claude SDK session stopped before input consumed", diff --git a/packages/agent-runtime/src/claude-code/bridge/bridge.ts b/packages/agent-runtime/src/claude-code/bridge/bridge.ts index 1d6928d053..34b65c28f8 100644 --- a/packages/agent-runtime/src/claude-code/bridge/bridge.ts +++ b/packages/agent-runtime/src/claude-code/bridge/bridge.ts @@ -29,6 +29,7 @@ import { import { forkSession, type CanUseTool, + type HookCallback, type PermissionResult, type SDKMessage, } from "@anthropic-ai/claude-agent-sdk"; @@ -57,6 +58,7 @@ import { buildSessionOptions, buildWorkspaceWriteDenialMessage, type BuildSessionOptionsArgs, + type PermissionEscalationWorkContext, } from "./session-options.js"; import { startClaudeCodeMockCliTrafficProxy, @@ -197,7 +199,15 @@ interface ThreadSession { mockCliTrafficProxy: ClaudeCodeMockCliTrafficProxy | null; pendingToolCalls: Map; pendingInteractiveRequests: Map; + /** Current-turn fallback when Claude supplies no originating-work metadata. */ permissionEscalation: PermissionEscalation | null; + permissionEscalationByAgentId: Map; + /** + * Retained for the session lifetime because background work can wake after + * multiple newer prompts have run. + */ + permissionEscalationByPromptId: Map; + permissionEscalationByToolUseId: Map; permissionMode: ClaudePermissionMode; /** Mode to return to once the user approves a plan. See commands.ts. */ approvedPlanPermissionMode: ClaudePermissionMode; @@ -433,13 +443,36 @@ function ignoreInputConsumption(promise: Promise): void { void promise.catch(() => {}); } +function pushPromptInput( + threadSession: ThreadSession, + input: string, + permissionEscalation: PermissionEscalation | null, +): Promise { + const promptId = randomUUID(); + threadSession.permissionEscalationByPromptId.set( + promptId, + permissionEscalation, + ); + return threadSession.session.pushInput(input, promptId).catch((error) => { + threadSession.permissionEscalationByPromptId.delete(promptId); + throw error; + }); +} + function queuePromptInputs( threadSession: ThreadSession, inputs: readonly string[], -): void { + permissionEscalation: PermissionEscalation | null, +): boolean { + if (!threadSession.session.canPushInput()) { + return false; + } for (const input of inputs) { - ignoreInputConsumption(threadSession.session.pushInput(input)); + ignoreInputConsumption( + pushPromptInput(threadSession, input, permissionEscalation), + ); } + return true; } function sendSdkMessage(threadId: string, message: SDKMessage): void { @@ -490,14 +523,18 @@ function toSessionConstructionConfig( }; } -function withLivePermissionEscalation( +function withTrackedPermissionEscalation( params: SessionConstructionParams, threadIdRef: ThreadIdRef, ): BuildSessionOptionsArgs { return { ...toSessionConstructionConfig(params).sessionOptions, - getPermissionEscalation: () => - sessions.get(threadIdRef.current)?.permissionEscalation ?? null, + getPermissionEscalation: (context) => { + const threadSession = sessions.get(threadIdRef.current); + return threadSession + ? resolvePermissionEscalationForWork(threadSession, context) + : null; + }, }; } @@ -526,6 +563,9 @@ function createThreadSession(args: CreateThreadSessionArgs): ThreadSession { pendingToolCalls: new Map(), pendingInteractiveRequests: new Map(), permissionEscalation: args.permissionEscalation, + permissionEscalationByAgentId: new Map(), + permissionEscalationByPromptId: new Map(), + permissionEscalationByToolUseId: new Map(), permissionMode: args.permissionMode, approvedPlanPermissionMode: args.approvedPlanPermissionMode, ...(args.providerThreadId @@ -536,6 +576,169 @@ function createThreadSession(args: CreateThreadSessionArgs): ThreadSession { }; } +function getTrackedPermissionEscalation( + values: Map, + key: string | undefined, +): PermissionEscalation | null | undefined { + if (key === undefined || !values.has(key)) { + return undefined; + } + return values.get(key) ?? null; +} + +function resolvePermissionEscalationForWork( + threadSession: ThreadSession, + context: PermissionEscalationWorkContext, +): PermissionEscalation | null { + const toolPermissionEscalation = getTrackedPermissionEscalation( + threadSession.permissionEscalationByToolUseId, + context.toolUseId, + ); + if (toolPermissionEscalation !== undefined) { + return toolPermissionEscalation; + } + + const agentPermissionEscalation = getTrackedPermissionEscalation( + threadSession.permissionEscalationByAgentId, + context.agentId, + ); + if (agentPermissionEscalation !== undefined) { + return agentPermissionEscalation; + } + + const promptPermissionEscalation = getTrackedPermissionEscalation( + threadSession.permissionEscalationByPromptId, + context.promptId, + ); + return promptPermissionEscalation === undefined + ? threadSession.permissionEscalation + : promptPermissionEscalation; +} + +function buildPermissionEscalationTrackingHooks( + threadIdRef: ThreadIdRef, +): NonNullable { + const trackPreToolUse: HookCallback = async (input) => { + if (input.hook_event_name !== "PreToolUse") { + return { continue: true }; + } + const threadSession = sessions.get(threadIdRef.current); + if (threadSession) { + threadSession.permissionEscalationByToolUseId.set( + input.tool_use_id, + resolvePermissionEscalationForWork(threadSession, { + ...(input.agent_id !== undefined + ? { agentId: input.agent_id } + : {}), + ...(input.prompt_id !== undefined + ? { promptId: input.prompt_id } + : {}), + }), + ); + } + return { continue: true }; + }; + + const trackSubagentStart: HookCallback = async (input) => { + if (input.hook_event_name !== "SubagentStart") { + return { continue: true }; + } + const threadSession = sessions.get(threadIdRef.current); + if (threadSession) { + threadSession.permissionEscalationByAgentId.set( + input.agent_id, + resolvePermissionEscalationForWork(threadSession, { + ...(input.prompt_id !== undefined + ? { promptId: input.prompt_id } + : {}), + }), + ); + } + return { continue: true }; + }; + + const clearSubagent: HookCallback = async (input) => { + if (input.hook_event_name === "SubagentStop") { + sessions + .get(threadIdRef.current) + ?.permissionEscalationByAgentId.delete(input.agent_id); + } + return { continue: true }; + }; + + const clearToolUse: HookCallback = async (input) => { + if ( + input.hook_event_name === "PostToolUse" || + input.hook_event_name === "PostToolUseFailure" || + input.hook_event_name === "PermissionDenied" + ) { + sessions + .get(threadIdRef.current) + ?.permissionEscalationByToolUseId.delete(input.tool_use_id); + } + return { continue: true }; + }; + + return { + PermissionDenied: [{ hooks: [clearToolUse] }], + PostToolUse: [{ hooks: [clearToolUse] }], + PostToolUseFailure: [{ hooks: [clearToolUse] }], + PreToolUse: [{ hooks: [trackPreToolUse] }], + SubagentStart: [{ hooks: [trackSubagentStart] }], + SubagentStop: [{ hooks: [clearSubagent] }], + }; +} + +function addPermissionEscalationTrackingHooks( + sessionOptions: SdkSessionOptions, + threadIdRef: ThreadIdRef, +): void { + const existingHooks = sessionOptions.hooks; + const trackingHooks = buildPermissionEscalationTrackingHooks(threadIdRef); + // PreToolUse tracking must run before enforcement hooks so those hooks can + // resolve the tool ID back to the prompt or subagent that originated it. + sessionOptions.hooks = { + ...existingHooks, + PermissionDenied: [ + ...(trackingHooks.PermissionDenied ?? []), + ...(existingHooks?.PermissionDenied ?? []), + ], + PostToolUse: [ + ...(trackingHooks.PostToolUse ?? []), + ...(existingHooks?.PostToolUse ?? []), + ], + PostToolUseFailure: [ + ...(trackingHooks.PostToolUseFailure ?? []), + ...(existingHooks?.PostToolUseFailure ?? []), + ], + PreToolUse: [ + ...(trackingHooks.PreToolUse ?? []), + ...(existingHooks?.PreToolUse ?? []), + ], + SubagentStart: [ + ...(trackingHooks.SubagentStart ?? []), + ...(existingHooks?.SubagentStart ?? []), + ], + SubagentStop: [ + ...(trackingHooks.SubagentStop ?? []), + ...(existingHooks?.SubagentStop ?? []), + ], + }; +} + +function buildTrackedSessionOptions( + params: SessionConstructionParams, + env: NodeJS.ProcessEnv, + threadIdRef: ThreadIdRef, +): SdkSessionOptions { + const sessionOptions = buildSessionOptions( + withTrackedPermissionEscalation(params, threadIdRef), + env, + ); + addPermissionEscalationTrackingHooks(sessionOptions, threadIdRef); + return sessionOptions; +} + function replaceThreadSession(args: ReplaceThreadSessionArgs): void { args.threadSession.closing = true; args.threadSession.mockCliTrafficProxy = null; @@ -1181,6 +1384,15 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { }); } + const interactiveRequestPolicy = { + permissionEscalation: resolvePermissionEscalationForWork(threadSession, { + ...(options.agentID !== undefined + ? { agentId: options.agentID } + : {}), + toolUseId: options.toolUseID, + }), + }; + const suggestions = parseClaudeSuggestedPermissionUpdates( options.suggestions, ); @@ -1195,7 +1407,7 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { toPendingInteractionPermissionProfile(requestContext); if ( toolName === "Bash" && - shouldAutoDenyInteractiveRequest(threadSession) && + shouldAutoDenyInteractiveRequest(interactiveRequestPolicy) && typeof input === "object" && input !== null && (input as { dangerouslyDisableSandbox?: unknown }) @@ -1265,7 +1477,7 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { } if ( - shouldAutoDenyInteractiveRequest(threadSession) || + shouldAutoDenyInteractiveRequest(interactiveRequestPolicy) || threadSession.permissionMode === "dontAsk" ) { const policyMessage = @@ -1340,9 +1552,10 @@ async function handleThreadStart( } const preparedEnv = await prepareSessionEnv(params); - const sessionOptions = buildSessionOptions( - withLivePermissionEscalation(params, threadIdRef), + const sessionOptions = buildTrackedSessionOptions( + params, preparedEnv.env, + threadIdRef, ); const providerThreadId = randomUUID(); sessionOptions.sessionId = providerThreadId; @@ -1412,9 +1625,10 @@ async function handleThreadResume( const preparedEnv = await prepareSessionEnv(params); const threadIdRef = { current: threadId }; - const sessionOptions = buildSessionOptions( - withLivePermissionEscalation(params, threadIdRef), + const sessionOptions = buildTrackedSessionOptions( + params, preparedEnv.env, + threadIdRef, ); sessionOptions.canUseTool = createCanUseTool(threadIdRef); if (params.dynamicTools && params.dynamicTools.length > 0) { @@ -1476,9 +1690,10 @@ async function handleThreadFork( const preparedEnv = await prepareSessionEnv(params); const threadIdRef = { current: threadId }; - const sessionOptions = buildSessionOptions( - withLivePermissionEscalation(params, threadIdRef), + const sessionOptions = buildTrackedSessionOptions( + params, preparedEnv.env, + threadIdRef, ); sessionOptions.canUseTool = createCanUseTool(threadIdRef); if (params.dynamicTools && params.dynamicTools.length > 0) { @@ -1539,8 +1754,11 @@ async function handleTurnStart( return; } + if (!queuePromptInputs(threadSession, inputs, params.permissionEscalation)) { + sendError(id, -32000, "Claude SDK input stream is closed"); + return; + } threadSession.permissionEscalation = params.permissionEscalation; - queuePromptInputs(threadSession, inputs); sendResult(id, { threadId: params.threadId }); } @@ -1561,14 +1779,21 @@ async function handleTurnSteer( } if (inputs.length > 1) { + if (!queuePromptInputs(threadSession, inputs, params.permissionEscalation)) { + sendError(id, -32000, "Claude SDK input stream is closed"); + return; + } threadSession.permissionEscalation = params.permissionEscalation; - queuePromptInputs(threadSession, inputs); sendResult(id, { threadId: params.threadId }); return; } try { - await threadSession.session.pushInput(inputs[0] ?? ""); + await pushPromptInput( + threadSession, + inputs[0] ?? "", + params.permissionEscalation, + ); // A failed steer must not change the running turn's escalation. threadSession.permissionEscalation = params.permissionEscalation; sendResult(id, { threadId: params.threadId }); diff --git a/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts b/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts index 47bfd12528..0426c6dfac 100644 --- a/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts +++ b/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts @@ -148,6 +148,10 @@ export class SdkSession { return this.isProcessing; } + canPushInput(): boolean { + return !this.inputDone; + } + /** * Change the permission mode of the live session. Used to leave Plan mode * once the user approves a plan. The new mode is also recorded on the @@ -237,12 +241,16 @@ export class SdkSession { void this.consumeStream(); } - pushInput(text: string): Promise { + pushInput( + text: string, + promptId?: NonNullable, + ): Promise { const message: SDKUserMessage = { type: "user", message: { role: "user", content: text }, parent_tool_use_id: null, session_id: this.sessionId ?? "", + ...(promptId !== undefined ? { uuid: promptId } : {}), }; if (this.inputDone) { diff --git a/packages/agent-runtime/src/claude-code/bridge/session-options.ts b/packages/agent-runtime/src/claude-code/bridge/session-options.ts index 332c8a2b8e..00db788053 100644 --- a/packages/agent-runtime/src/claude-code/bridge/session-options.ts +++ b/packages/agent-runtime/src/claude-code/bridge/session-options.ts @@ -19,10 +19,13 @@ export interface BuildSessionOptionsArgs { instructionMode: InstructionMode; model?: string; /** - * Escalation changes per turn without replacing the session, so hook - * closures must read it at call time instead of capturing a value. + * Escalation changes per turn without replacing the session. Hook closures + * resolve the originating prompt or subagent at call time, falling back to + * the current turn when Claude provides no correlation metadata. */ - getPermissionEscalation: () => PermissionEscalation | null; + getPermissionEscalation: ( + context: PermissionEscalationWorkContext, + ) => PermissionEscalation | null; permissionMode: ClaudePermissionMode; permissionScope: RuntimePermissionScope; plugins?: Options["plugins"]; @@ -31,6 +34,12 @@ export interface BuildSessionOptionsArgs { memoryEnabled?: boolean; } +export interface PermissionEscalationWorkContext { + agentId?: string; + promptId?: string; + toolUseId?: string; +} + interface ResolveExecutableOnPathArgs { executableName: string; pathEnv: string | undefined; @@ -133,7 +142,17 @@ function buildReadonlyHooks( } const permissionDecision = - getPermissionEscalation() === "deny" ? "deny" : "ask"; + getPermissionEscalation({ + ...(input.agent_id !== undefined + ? { agentId: input.agent_id } + : {}), + ...(input.prompt_id !== undefined + ? { promptId: input.prompt_id } + : {}), + toolUseId: input.tool_use_id, + }) === "deny" + ? "deny" + : "ask"; return { continue: true, hookSpecificOutput: { From 3b81ef395f311bd2525beaec9cd56aeb5d7cd160 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 10 Aug 2026 16:44:23 -0700 Subject: [PATCH 4/4] Apply Claude execution settings without replacing sessions --- .../src/services/threads/thread-commands.ts | 17 -- .../threads/thread-runtime-config.test.ts | 4 +- packages/agent-runtime/src/acp/adapter.ts | 3 +- .../agent-runtime/src/claude-code/adapter.ts | 26 ++- .../bridge/__tests__/bridge.test.ts | 212 ++++++++++++++++-- .../bridge/__tests__/sdk-session.test.ts | 36 ++- .../src/claude-code/bridge/bridge.ts | 174 ++++++++++++-- .../src/claude-code/bridge/commands.ts | 12 + .../src/claude-code/bridge/sdk-session.ts | 43 ++++ .../src/claude-code/bridge/session-options.ts | 29 ++- packages/agent-runtime/src/codex/adapter.ts | 3 +- .../src/execution-options.test.ts | 88 ++++++++ .../agent-runtime/src/execution-options.ts | 60 ++++- packages/agent-runtime/src/pi/adapter.ts | 3 +- .../agent-runtime/src/provider-adapter.ts | 18 +- .../src/runtime.lifecycle.test.ts | 25 ++- packages/agent-runtime/src/runtime.ts | 18 +- .../agent-runtime/src/test/fake-adapter.ts | 3 +- .../src/test/runtime-test-harness.ts | 5 +- packages/host-daemon-contract/src/commands.ts | 2 +- .../test/contract.test.ts | 8 +- 21 files changed, 704 insertions(+), 85 deletions(-) create mode 100644 packages/agent-runtime/src/execution-options.test.ts diff --git a/apps/server/src/services/threads/thread-commands.ts b/apps/server/src/services/threads/thread-commands.ts index 7ee9b7691d..98536e5f61 100644 --- a/apps/server/src/services/threads/thread-commands.ts +++ b/apps/server/src/services/threads/thread-commands.ts @@ -220,18 +220,6 @@ function resolveProviderSubagentsEnabled( return true; } -function resolveProviderDisallowedTools( - deps: Pick, - providerId: string, -): string[] | undefined { - if (providerId !== "claude-code") return undefined; - const settings = getAppSettings(deps.db); - const disallowedTools: string[] = []; - if (settings.claudeCodeSubagentsDisabled) disallowedTools.push("Task"); - if (settings.claudeCodeWorkflowsDisabled) disallowedTools.push("Workflow"); - return disallowedTools.length > 0 ? disallowedTools : undefined; -} - function resolveProviderWorkflowsEnabled( deps: Pick, providerId: string, @@ -359,7 +347,6 @@ export async function buildThreadStartCommand( }), instructions: runtimeContext.instructions, dynamicTools: runtimeContext.dynamicTools, - disallowedTools: resolveProviderDisallowedTools(deps, args.providerId), injectedSkillSources: runtimeContext.injectedSkillSources, instructionMode: runtimeContext.instructionMode, threadStoragePath: runtimeContext.threadStoragePath, @@ -413,10 +400,6 @@ function buildPreparedTurnSubmitCommandPayload( providerThreadId: args.providerThreadId, instructions: args.runtimeContext.instructions, dynamicTools: args.runtimeContext.dynamicTools, - disallowedTools: resolveProviderDisallowedTools( - args.deps, - args.runtimeContext.providerId, - ), injectedSkillSources: args.runtimeContext.injectedSkillSources, instructionMode: args.runtimeContext.instructionMode, }, diff --git a/apps/server/test/threads/thread-runtime-config.test.ts b/apps/server/test/threads/thread-runtime-config.test.ts index bda42337bc..a9d676e853 100644 --- a/apps/server/test/threads/thread-runtime-config.test.ts +++ b/apps/server/test/threads/thread-runtime-config.test.ts @@ -918,7 +918,7 @@ describe("thread runtime config", () => { }); }); - it("disables provider-native subagent tools independently", async () => { + it("carries provider-native feature settings independently", async () => { await withTestHarness(async (harness) => { setAppSettings(harness.db, { ...defaultAppSettings, @@ -971,7 +971,7 @@ describe("thread runtime config", () => { const claudeCode = await build("claude-code"); expect(claudeCode.options.providerSubagentsEnabled).toBe(false); expect(claudeCode.options.workflowsEnabled).toBe(false); - expect(claudeCode.disallowedTools).toEqual(["Task", "Workflow"]); + expect(claudeCode.disallowedTools).toBeUndefined(); }); }); diff --git a/packages/agent-runtime/src/acp/adapter.ts b/packages/agent-runtime/src/acp/adapter.ts index 1bfe6e5156..cb3d971657 100644 --- a/packages/agent-runtime/src/acp/adapter.ts +++ b/packages/agent-runtime/src/acp/adapter.ts @@ -41,6 +41,7 @@ import { flattenPromptInputGroups, noPreparedProviderCommandDispatch, } from "../provider-adapter.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import { ProviderResponseEncodeError } from "../runtime-json-rpc.js"; import type { ProviderInboundRequest, @@ -1341,7 +1342,7 @@ export function createAcpProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities: providerInfo.capabilities, - appliesPermissionEscalationPerTurn: false, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, process: { command: opts.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ diff --git a/packages/agent-runtime/src/claude-code/adapter.ts b/packages/agent-runtime/src/claude-code/adapter.ts index a170ed171d..d1e3af9abe 100644 --- a/packages/agent-runtime/src/claude-code/adapter.ts +++ b/packages/agent-runtime/src/claude-code/adapter.ts @@ -80,6 +80,7 @@ import type { ProviderAdapterFactoryOptions, } from "../provider-adapter.js"; import { noPreparedProviderCommandDispatch } from "../provider-adapter.js"; +import { classifyClaudeExecutionSettingsChange } from "../execution-options.js"; import { type JsonRpcMessage, type ProviderInboundRequest, @@ -1042,7 +1043,7 @@ export function createClaudeCodeProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, - appliesPermissionEscalationPerTurn: true, + classifyExecutionSettingsChange: classifyClaudeExecutionSettingsChange, process: { command: opts?.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ @@ -1135,6 +1136,8 @@ export function createClaudeCodeProviderAdapter( : {}), workflowsEnabled: command.options.workflowsEnabled, memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, ...(dynamicTools && dynamicTools.length > 0 ? { dynamicTools } : {}), @@ -1205,6 +1208,8 @@ export function createClaudeCodeProviderAdapter( : {}), workflowsEnabled: command.options.workflowsEnabled, memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, ...(dynamicTools && dynamicTools.length > 0 ? { dynamicTools } : {}), @@ -1241,6 +1246,13 @@ export function createClaudeCodeProviderAdapter( ...(command.options?.model ? { model: command.options.model } : {}), + ...(command.options?.reasoningLevel + ? { reasoningLevel: command.options.reasoningLevel } + : {}), + workflowsEnabled: command.options.workflowsEnabled, + memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, permissionEscalation: command.options.permissionEscalation, }, }; @@ -1263,6 +1275,16 @@ export function createClaudeCodeProviderAdapter( ), } : {}), + ...(command.options?.model + ? { model: command.options.model } + : {}), + ...(command.options?.reasoningLevel + ? { reasoningLevel: command.options.reasoningLevel } + : {}), + workflowsEnabled: command.options.workflowsEnabled, + memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, permissionEscalation: command.options.permissionEscalation, }, }; @@ -1327,6 +1349,8 @@ export function createClaudeCodeProviderAdapter( : {}), workflowsEnabled: command.options.workflowsEnabled, memoryEnabled: command.options.memoryEnabled, + providerSubagentsEnabled: + command.options.providerSubagentsEnabled, ...(dynamicTools && dynamicTools.length > 0 ? { dynamicTools } : {}), diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts index 94f0cbfccc..f861100121 100644 --- a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts +++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts @@ -92,11 +92,14 @@ interface CanUseToolPolicyCase { } interface ControlledClaudeQuery { + applyFlagSettings: ReturnType; close: ReturnType; emit(message: SDKMessage): void; fail(error: Error): void; finish(): void; initializationResult: ReturnType; + setModel: ReturnType; + setPermissionMode: ReturnType; [Symbol.asyncIterator](): AsyncIterator; } @@ -336,6 +339,7 @@ function createControlledClaudeQuery(): ControlledClaudeQuery { }, }; return { + applyFlagSettings: vi.fn().mockResolvedValue(undefined), close: vi.fn(() => { pushResult({ value: undefined, done: true }); }), @@ -349,6 +353,8 @@ function createControlledClaudeQuery(): ControlledClaudeQuery { pushResult({ value: undefined, done: true }); }, initializationResult: vi.fn(), + setModel: vi.fn().mockResolvedValue(undefined), + setPermissionMode: vi.fn().mockResolvedValue(undefined), [Symbol.asyncIterator]() { return iterator; }, @@ -644,6 +650,7 @@ describe("bridge", () => { expect(options.settings).toEqual({ autoMemoryEnabled: true, enableWorkflows: true, + ultracode: false, }); }); @@ -662,7 +669,11 @@ describe("bridge", () => { {}, ); - expect(options.settings).toEqual({ autoMemoryEnabled: true }); + expect(options.settings).toEqual({ + autoMemoryEnabled: true, + enableWorkflows: false, + ultracode: false, + }); }); it("disables Claude auto-memory reads and writes", () => { @@ -679,7 +690,11 @@ describe("bridge", () => { {}, ); - expect(options.settings).toEqual({ autoMemoryEnabled: false }); + expect(options.settings).toEqual({ + autoMemoryEnabled: false, + enableWorkflows: false, + ultracode: false, + }); }); it("leaves standard sessions on the default Claude tool preset", () => { @@ -2493,7 +2508,7 @@ describe("bridge", () => { } }); - it("rebuilds a live Claude session when enforcement options change", async () => { + it("rebuilds for enforcement changes but applies model changes live", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const queries: ControlledClaudeQuery[] = []; queryMock.mockImplementation(() => { @@ -2592,17 +2607,13 @@ describe("bridge", () => { }); await bridge.waitForResponse(4); - expect(queries).toHaveLength(4); - expect(queries[2]?.close).toHaveBeenCalledTimes(1); - expect(getLatestQueryOptions()).toMatchObject({ - model: "claude-opus-4-1", - permissionMode: "auto", - resume: providerThreadId, - }); + expect(queries).toHaveLength(3); + expect(queries[2]?.close).not.toHaveBeenCalled(); + expect(queries[2]?.setModel).toHaveBeenCalledWith("claude-opus-4-1"); bridge.sendRequest(5, "thread/stop", { threadId }); await bridge.flushWork(); - queries[3]?.finish(); + queries[2]?.finish(); await bridge.waitForResponse(5); } finally { queries.forEach((query) => query.finish()); @@ -2649,6 +2660,182 @@ describe("bridge", () => { } }); + it("applies turn model, reasoning, memory, workflow, and subagent settings live", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); + + try { + const threadId = "thread-live-settings"; + bridge.sendRequest(1, "thread/start", { + workflowsEnabled: false, + memoryEnabled: true, + providerSubagentsEnabled: true, + reasoningLevel: "low", + model: "claude-haiku-4-5", + claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, + baseInstructions: "test", + cwd: "/tmp/worktree", + instructionMode: "append", + permissionEscalation: "ask", + permissionMode: "default", + approvedPlanPermissionMode: "default", + permissionScope: "workspace", + threadId, + }); + await bridge.waitForResponse(1); + + const query = queries[0]; + const call = getLatestQueryCall(); + const hooks = call.options.hooks; + if (!query || !hooks) { + throw new Error("Expected live Claude query and hooks"); + } + + bridge.sendRequest(2, "turn/start", { + workflowsEnabled: true, + memoryEnabled: false, + providerSubagentsEnabled: false, + reasoningLevel: "max", + model: "claude-opus-5[1m]", + permissionEscalation: "ask", + input: [{ type: "text", text: "Use the new live settings" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(2); + await readNextPrompt(call); + + expect(queries).toHaveLength(1); + expect(query.close).not.toHaveBeenCalled(); + expect(query.setModel).toHaveBeenCalledWith("claude-opus-5[1m]"); + expect(query.applyFlagSettings).toHaveBeenLastCalledWith({ + autoMemoryEnabled: false, + enableWorkflows: true, + effortLevel: "max", + ultracode: false, + }); + + for (const toolName of ["Agent", "Task"]) { + const toolUseId = `tool-disabled-${toolName.toLowerCase()}`; + const disabledSubagentOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + tool_name: toolName, + tool_input: {}, + tool_use_id: toolUseId, + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + toolUseId, + ); + expect(disabledSubagentOutputs).toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + permissionDecision: "deny", + }), + }), + ); + } + const enabledWorkflowOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + tool_name: "Workflow", + tool_input: {}, + tool_use_id: "tool-enabled-workflow", + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + "tool-enabled-workflow", + ); + expect(enabledWorkflowOutputs).not.toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + permissionDecision: "deny", + }), + }), + ); + + bridge.sendRequest(3, "turn/start", { + workflowsEnabled: false, + memoryEnabled: true, + providerSubagentsEnabled: true, + reasoningLevel: "xhigh", + model: "claude-opus-5[1m]", + permissionEscalation: "ask", + input: [{ type: "text", text: "Flip the live feature settings" }], + providerThreadId: null, + threadId, + }); + await bridge.waitForResponse(3); + await readNextPrompt(call); + + expect(queries).toHaveLength(1); + expect(query.applyFlagSettings).toHaveBeenLastCalledWith({ + autoMemoryEnabled: true, + enableWorkflows: false, + effortLevel: "xhigh", + ultracode: false, + }); + const enabledSubagentOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + tool_name: "Agent", + tool_input: {}, + tool_use_id: "tool-enabled-agent", + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + "tool-enabled-agent", + ); + expect(enabledSubagentOutputs).not.toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + permissionDecision: "deny", + }), + }), + ); + const disabledWorkflowOutputs = await invokeBridgeHooks( + hooks.PreToolUse, + { + hook_event_name: "PreToolUse", + tool_name: "Workflow", + tool_input: {}, + tool_use_id: "tool-disabled-workflow", + session_id: "session-1", + transcript_path: "/tmp/transcript.jsonl", + cwd: "/tmp/worktree", + }, + "tool-disabled-workflow", + ); + expect(disabledWorkflowOutputs).toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + permissionDecision: "deny", + }), + }), + ); + + bridge.sendRequest(4, "thread/stop", { threadId }); + await bridge.flushWork(); + query.finish(); + await bridge.waitForResponse(4); + } finally { + queries.forEach((query) => query.finish()); + bridge.restore(); + } + }); + it("keeps background subagents on their originating prompt escalation", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const queries: ControlledClaudeQuery[] = []; @@ -3514,8 +3701,7 @@ describe("bridge", () => { try { bridge.sendRequest(1, "thread/start", { workflowsEnabled: false, - claudeCodeMockCliTraffic: - DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, + claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG, baseInstructions: "test", cwd: "/tmp/worktree", instructionMode: "append", diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts index b997d16eec..201cad8bbb 100644 --- a/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts +++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/sdk-session.test.ts @@ -6,8 +6,11 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; const mockQueryInstance = { + applyFlagSettings: vi.fn(), close: vi.fn(), interrupt: vi.fn(), + setModel: vi.fn(), + setPermissionMode: vi.fn(), [Symbol.asyncIterator]: vi.fn(), }; const { queryMock } = vi.hoisted(() => ({ @@ -34,7 +37,9 @@ interface RejectSdkStreamArgs { error: Error; } -function isClaudeQueryPromptCall(value: unknown): value is ClaudeQueryPromptCall { +function isClaudeQueryPromptCall( + value: unknown, +): value is ClaudeQueryPromptCall { return ( value !== null && typeof value === "object" && @@ -81,6 +86,9 @@ describe("SdkSession", () => { beforeEach(() => { vi.clearAllMocks(); queryMock.mockImplementation(() => mockQueryInstance); + mockQueryInstance.applyFlagSettings.mockResolvedValue(undefined); + mockQueryInstance.setModel.mockResolvedValue(undefined); + mockQueryInstance.setPermissionMode.mockResolvedValue(undefined); // Make the query async iterable return immediately mockQueryInstance[Symbol.asyncIterator].mockReturnValue({ next: vi.fn().mockResolvedValue({ value: undefined, done: true }), @@ -100,6 +108,32 @@ describe("SdkSession", () => { expect(session.getIsProcessing()).toBe(false); }); + it("applies model and mutable flag settings to the live query", async () => { + keepSdkStreamOpen(); + const session = new SdkSession(defaultOptions, vi.fn(), vi.fn()); + session.start(); + + await session.setModel("claude-sonnet-5"); + await session.applyMutableSettings({ + effort: "max", + settings: { + autoMemoryEnabled: false, + enableWorkflows: true, + effortLevel: "max", + ultracode: false, + }, + }); + + expect(mockQueryInstance.setModel).toHaveBeenCalledWith("claude-sonnet-5"); + expect(mockQueryInstance.applyFlagSettings).toHaveBeenCalledWith({ + autoMemoryEnabled: false, + enableWorkflows: true, + effortLevel: "max", + ultracode: false, + }); + session.stop(); + }); + it("resolves pushed input after the SDK prompt iterator yields it", async () => { keepSdkStreamOpen(); const session = new SdkSession(defaultOptions, vi.fn(), vi.fn()); diff --git a/packages/agent-runtime/src/claude-code/bridge/bridge.ts b/packages/agent-runtime/src/claude-code/bridge/bridge.ts index 34b65c28f8..ab3a139e6a 100644 --- a/packages/agent-runtime/src/claude-code/bridge/bridge.ts +++ b/packages/agent-runtime/src/claude-code/bridge/bridge.ts @@ -25,6 +25,7 @@ import { DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_ENDPOINT, type PendingInteractionGrantedPermissionProfile, type PermissionEscalation, + type ReasoningLevel, } from "@bb/domain"; import { forkSession, @@ -55,8 +56,10 @@ import { } from "./commands.js"; import { buildReadonlyDenialMessage, + buildMutableFlagSettings, buildSessionOptions, buildWorkspaceWriteDenialMessage, + toSdkEffort, type BuildSessionOptionsArgs, type PermissionEscalationWorkContext, } from "./session-options.js"; @@ -113,6 +116,9 @@ const promptInputItemSchema = z.discriminatedUnion("type", [ }), ]); +const CLAUDE_PROVIDER_SUBAGENT_TOOL_NAMES = new Set(["Agent", "Task"]); +const CLAUDE_WORKFLOW_TOOL_NAME = "Workflow"; + interface JsonRpcResponse { jsonrpc: "2.0"; id: string | number; @@ -209,6 +215,7 @@ interface ThreadSession { permissionEscalationByPromptId: Map; permissionEscalationByToolUseId: Map; permissionMode: ClaudePermissionMode; + liveSettings: ClaudeLiveSessionSettings; /** Mode to return to once the user approves a plan. See commands.ts. */ approvedPlanPermissionMode: ClaudePermissionMode; providerThreadId?: string; @@ -226,6 +233,7 @@ interface CreateThreadSessionArgs { mockCliTrafficProxy: ClaudeCodeMockCliTrafficProxy | null; permissionEscalation: PermissionEscalation | null; permissionMode: ClaudePermissionMode; + liveSettings: ClaudeLiveSessionSettings; approvedPlanPermissionMode: ClaudePermissionMode; providerThreadId?: string; sessionConstructionConfig: SessionConstructionConfig; @@ -243,10 +251,24 @@ interface SessionConstructionConfig { claudeCodeMockCliTraffic: ThreadResumeParams["claudeCodeMockCliTraffic"]; config: ThreadResumeParams["config"]; dynamicTools: ThreadResumeParams["dynamicTools"]; - // Escalation is per-turn state, so it is not part of the comparable - // construction config: two configs differing only in escalation must reuse - // the live session instead of replacing it (killing its background tasks). - sessionOptions: Omit; + // Live settings are not part of the comparable construction config: the + // bridge applies them through SDK controls without replacing the session. + sessionOptions: Omit< + BuildSessionOptionsArgs, + | "getPermissionEscalation" + | "memoryEnabled" + | "model" + | "reasoningLevel" + | "workflowsEnabled" + >; +} + +interface ClaudeLiveSessionSettings { + memoryEnabled: boolean; + model?: string; + providerSubagentsEnabled: boolean; + reasoningLevel?: ReasoningLevel; + workflowsEnabled: boolean; } type SessionConstructionParams = @@ -475,6 +497,36 @@ function queuePromptInputs( return true; } +async function applyLiveSessionSettings( + threadSession: ThreadSession, + next: ClaudeLiveSessionSettings, +): Promise { + const current = threadSession.liveSettings; + if (current.model !== next.model) { + await threadSession.session.setModel(next.model); + } + + if ( + current.memoryEnabled !== next.memoryEnabled || + current.reasoningLevel !== next.reasoningLevel || + current.workflowsEnabled !== next.workflowsEnabled + ) { + await threadSession.session.applyMutableSettings({ + effort: + next.reasoningLevel === undefined + ? undefined + : toSdkEffort(next.reasoningLevel), + settings: buildMutableFlagSettings({ + memoryEnabled: next.memoryEnabled, + reasoningLevel: next.reasoningLevel, + workflowsEnabled: next.workflowsEnabled, + }), + }); + } + + threadSession.liveSettings = next; +} + function sendSdkMessage(threadId: string, message: SDKMessage): void { send({ jsonrpc: "2.0", @@ -512,23 +564,50 @@ function toSessionConstructionConfig( cwd: params.cwd, disallowedTools: params.disallowedTools, instructionMode: params.instructionMode, - memoryEnabled: params.memoryEnabled, - model: params.model, permissionMode: params.permissionMode, permissionScope: params.permissionScope, plugins: params.plugins, - reasoningLevel: params.reasoningLevel, - workflowsEnabled: params.workflowsEnabled, }, }; } +function toInitialLiveSessionSettings( + params: SessionConstructionParams, +): ClaudeLiveSessionSettings { + return { + memoryEnabled: params.memoryEnabled ?? true, + ...(params.model !== undefined ? { model: params.model } : {}), + providerSubagentsEnabled: params.providerSubagentsEnabled ?? true, + ...(params.reasoningLevel !== undefined + ? { reasoningLevel: params.reasoningLevel } + : {}), + workflowsEnabled: params.workflowsEnabled, + }; +} + +function withTurnLiveSessionSettings( + current: ClaudeLiveSessionSettings, + params: TurnStartParams | TurnSteerParams, +): ClaudeLiveSessionSettings { + const model = params.model ?? current.model; + const reasoningLevel = params.reasoningLevel ?? current.reasoningLevel; + return { + memoryEnabled: params.memoryEnabled ?? current.memoryEnabled, + ...(model !== undefined ? { model } : {}), + providerSubagentsEnabled: + params.providerSubagentsEnabled ?? current.providerSubagentsEnabled, + ...(reasoningLevel !== undefined ? { reasoningLevel } : {}), + workflowsEnabled: params.workflowsEnabled ?? current.workflowsEnabled, + }; +} + function withTrackedPermissionEscalation( params: SessionConstructionParams, threadIdRef: ThreadIdRef, ): BuildSessionOptionsArgs { return { ...toSessionConstructionConfig(params).sessionOptions, + ...toInitialLiveSessionSettings(params), getPermissionEscalation: (context) => { const threadSession = sessions.get(threadIdRef.current); return threadSession @@ -567,6 +646,7 @@ function createThreadSession(args: CreateThreadSessionArgs): ThreadSession { permissionEscalationByPromptId: new Map(), permissionEscalationByToolUseId: new Map(), permissionMode: args.permissionMode, + liveSettings: args.liveSettings, approvedPlanPermissionMode: args.approvedPlanPermissionMode, ...(args.providerThreadId ? { providerThreadId: args.providerThreadId } @@ -627,14 +707,40 @@ function buildPermissionEscalationTrackingHooks( threadSession.permissionEscalationByToolUseId.set( input.tool_use_id, resolvePermissionEscalationForWork(threadSession, { - ...(input.agent_id !== undefined - ? { agentId: input.agent_id } - : {}), + ...(input.agent_id !== undefined ? { agentId: input.agent_id } : {}), ...(input.prompt_id !== undefined ? { promptId: input.prompt_id } : {}), }), ); + if ( + !threadSession.liveSettings.providerSubagentsEnabled && + CLAUDE_PROVIDER_SUBAGENT_TOOL_NAMES.has(input.tool_name) + ) { + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: + "bb has disabled Claude Code native subagents; use bb delegation instead.", + }, + }; + } + if ( + !threadSession.liveSettings.workflowsEnabled && + input.tool_name === CLAUDE_WORKFLOW_TOOL_NAME + ) { + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: + "bb has disabled the Claude Code Workflow tool.", + }, + }; + } } return { continue: true }; }; @@ -766,6 +872,7 @@ function replaceEndedThreadSession( const replacementSession = createThreadSession({ mockCliTrafficProxy: args.threadSession.mockCliTrafficProxy, + liveSettings: args.threadSession.liveSettings, permissionEscalation: args.threadSession.permissionEscalation, // Carries the live mode, so a session replaced after an approved plan // keeps the restored preset instead of dropping back into Plan mode. @@ -1386,9 +1493,7 @@ function createCanUseTool(threadIdRef: ThreadIdRef): CanUseTool { const interactiveRequestPolicy = { permissionEscalation: resolvePermissionEscalationForWork(threadSession, { - ...(options.agentID !== undefined - ? { agentId: options.agentID } - : {}), + ...(options.agentID !== undefined ? { agentId: options.agentID } : {}), toolUseId: options.toolUseID, }), }; @@ -1571,6 +1676,7 @@ async function handleThreadStart( const threadSession = createThreadSession({ mockCliTrafficProxy: preparedEnv.mockCliTrafficProxy, + liveSettings: toInitialLiveSessionSettings(params), permissionEscalation: params.permissionEscalation, permissionMode: params.permissionMode, approvedPlanPermissionMode: params.approvedPlanPermissionMode, @@ -1607,6 +1713,10 @@ async function handleThreadResume( sessionConstructionConfig, ) ) { + await applyLiveSessionSettings( + existing, + toInitialLiveSessionSettings(params), + ); existing.permissionEscalation = params.permissionEscalation; sendResult(id, { threadId, @@ -1641,6 +1751,7 @@ async function handleThreadResume( } const threadSession = createThreadSession({ mockCliTrafficProxy: preparedEnv.mockCliTrafficProxy, + liveSettings: toInitialLiveSessionSettings(params), permissionEscalation: params.permissionEscalation, permissionMode: params.permissionMode, approvedPlanPermissionMode: params.approvedPlanPermissionMode, @@ -1706,6 +1817,7 @@ async function handleThreadFork( } const threadSession = createThreadSession({ mockCliTrafficProxy: preparedEnv.mockCliTrafficProxy, + liveSettings: toInitialLiveSessionSettings(params), permissionEscalation: params.permissionEscalation, permissionMode: params.permissionMode, approvedPlanPermissionMode: params.approvedPlanPermissionMode, @@ -1754,6 +1866,21 @@ async function handleTurnStart( return; } + if (!threadSession.session.canPushInput()) { + sendError(id, -32000, "Claude SDK input stream is closed"); + return; + } + try { + await applyLiveSessionSettings( + threadSession, + withTurnLiveSessionSettings(threadSession.liveSettings, params), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); + return; + } + if (!queuePromptInputs(threadSession, inputs, params.permissionEscalation)) { sendError(id, -32000, "Claude SDK input stream is closed"); return; @@ -1778,8 +1905,25 @@ async function handleTurnSteer( return; } + if (!threadSession.session.canPushInput()) { + sendError(id, -32000, "Claude SDK input stream is closed"); + return; + } + try { + await applyLiveSessionSettings( + threadSession, + withTurnLiveSessionSettings(threadSession.liveSettings, params), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); + return; + } + if (inputs.length > 1) { - if (!queuePromptInputs(threadSession, inputs, params.permissionEscalation)) { + if ( + !queuePromptInputs(threadSession, inputs, params.permissionEscalation) + ) { sendError(id, -32000, "Claude SDK input stream is closed"); return; } diff --git a/packages/agent-runtime/src/claude-code/bridge/commands.ts b/packages/agent-runtime/src/claude-code/bridge/commands.ts index 1d14a0cc5f..ef8fb88cdb 100644 --- a/packages/agent-runtime/src/claude-code/bridge/commands.ts +++ b/packages/agent-runtime/src/claude-code/bridge/commands.ts @@ -69,6 +69,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ reasoningLevel: bridgeReasoningLevelSchema.optional(), workflowsEnabled: z.boolean(), memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), instructionMode: bridgeInstructionModeSchema, dynamicTools: z.array(dynamicToolSchema).optional(), disallowedTools: z.array(z.string()).optional(), @@ -98,6 +99,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ reasoningLevel: bridgeReasoningLevelSchema.optional(), workflowsEnabled: z.boolean(), memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), instructionMode: bridgeInstructionModeSchema, dynamicTools: z.array(dynamicToolSchema).optional(), disallowedTools: z.array(z.string()).optional(), @@ -127,6 +129,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ reasoningLevel: bridgeReasoningLevelSchema.optional(), workflowsEnabled: z.boolean(), memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), instructionMode: bridgeInstructionModeSchema, dynamicTools: z.array(dynamicToolSchema).optional(), disallowedTools: z.array(z.string()).optional(), @@ -140,6 +143,10 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ input: z.array(z.unknown()), inputGroups: z.array(z.array(z.unknown()).min(1)).optional(), model: z.string().optional(), + reasoningLevel: bridgeReasoningLevelSchema.optional(), + workflowsEnabled: z.boolean().optional(), + memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), config: z.record(z.string(), z.unknown()).optional(), permissionEscalation: bridgePermissionEscalationSchema, }), @@ -152,6 +159,11 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [ expectedTurnId: z.string(), input: z.array(z.unknown()), inputGroups: z.array(z.array(z.unknown()).min(1)).optional(), + model: z.string().optional(), + reasoningLevel: bridgeReasoningLevelSchema.optional(), + workflowsEnabled: z.boolean().optional(), + memoryEnabled: z.boolean().optional(), + providerSubagentsEnabled: z.boolean().optional(), permissionEscalation: bridgePermissionEscalationSchema, }), }), diff --git a/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts b/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts index 0426c6dfac..12a4200e52 100644 --- a/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts +++ b/packages/agent-runtime/src/claude-code/bridge/sdk-session.ts @@ -37,6 +37,24 @@ export interface SdkSessionOptions { settings?: Options["settings"]; } +export type ClaudeSdkReasoningEffort = + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +export interface ClaudeMutableFlagSettings { + autoMemoryEnabled: boolean; + enableWorkflows: boolean; + effortLevel?: ClaudeSdkReasoningEffort; + ultracode: boolean; +} + +interface ClaudeMutableSettingsQueryBoundary { + applyFlagSettings(settings: ClaudeMutableFlagSettings): Promise; +} + type SdkSessionMessageHandler = (message: SDKMessage) => void; type SdkSessionDoneHandler = (error?: unknown) => void; @@ -165,6 +183,31 @@ export class SdkSession { await this.query?.setPermissionMode(mode); } + async setModel(model: string | undefined): Promise { + await this.query?.setModel(model); + this.options.model = model; + } + + async applyMutableSettings(args: { + effort: ClaudeSdkReasoningEffort | undefined; + settings: ClaudeMutableFlagSettings; + }): Promise { + // Claude CLI accepts `max` through apply_flag_settings (and reports max + // from its hook context), but Agent SDK 0.3.197's Settings type omits it. + // Keep the compatibility assertion at this external SDK boundary. + await ( + this.query as ClaudeMutableSettingsQueryBoundary | undefined + )?.applyFlagSettings(args.settings); + this.options.effort = args.effort; + const { effortLevel: _effortLevel, ...sessionSettings } = args.settings; + const currentSettings = + typeof this.options.settings === "object" ? this.options.settings : {}; + this.options.settings = { + ...currentSettings, + ...sessionSettings, + }; + } + start(resumeSessionId?: string): void { if (resumeSessionId) { this.sessionId = resumeSessionId; diff --git a/packages/agent-runtime/src/claude-code/bridge/session-options.ts b/packages/agent-runtime/src/claude-code/bridge/session-options.ts index 00db788053..83eb18d703 100644 --- a/packages/agent-runtime/src/claude-code/bridge/session-options.ts +++ b/packages/agent-runtime/src/claude-code/bridge/session-options.ts @@ -9,7 +9,11 @@ import type { } from "@bb/domain"; import type { ClaudePermissionMode } from "../interactive-contract.js"; import { buildReadonlyBashUpdatedInput } from "./readonly-bash-policy.js"; -import type { SdkSessionOptions } from "./sdk-session.js"; +import type { + ClaudeMutableFlagSettings, + ClaudeSdkReasoningEffort, + SdkSessionOptions, +} from "./sdk-session.js"; export interface BuildSessionOptionsArgs { additionalWorkspaceWriteRoots?: readonly string[]; @@ -74,9 +78,9 @@ const CLAUDE_CODE_EXECUTABLE_ENV = "BB_CLAUDE_CODE_EXECUTABLE"; * dynamic-workflow orchestration). The SDK Settings flag tier is otherwise * unused by BB, so it is owned entirely here. */ -function toSdkEffort( +export function toSdkEffort( reasoningLevel: ReasoningLevel, -): Exclude { +): ClaudeSdkReasoningEffort { if (reasoningLevel === "ultracode") return "xhigh"; // "none" (thinking-off) is a Cursor-only level; Claude Code models never // expose it, so this is a defensive floor that reconciliation never reaches. @@ -89,8 +93,23 @@ function toSdkEffort( function buildFlagSettings(params: BuildSessionOptionsArgs): Settings { return { autoMemoryEnabled: params.memoryEnabled ?? true, - ...(params.workflowsEnabled ? { enableWorkflows: true } : {}), - ...(params.reasoningLevel === "ultracode" ? { ultracode: true } : {}), + enableWorkflows: params.workflowsEnabled, + ultracode: params.reasoningLevel === "ultracode", + }; +} + +export function buildMutableFlagSettings(args: { + memoryEnabled: boolean; + reasoningLevel: ReasoningLevel | undefined; + workflowsEnabled: boolean; +}): ClaudeMutableFlagSettings { + return { + autoMemoryEnabled: args.memoryEnabled, + enableWorkflows: args.workflowsEnabled, + ...(args.reasoningLevel !== undefined + ? { effortLevel: toSdkEffort(args.reasoningLevel) } + : {}), + ultracode: args.reasoningLevel === "ultracode", }; } diff --git a/packages/agent-runtime/src/codex/adapter.ts b/packages/agent-runtime/src/codex/adapter.ts index 6998945567..50446b8b6f 100644 --- a/packages/agent-runtime/src/codex/adapter.ts +++ b/packages/agent-runtime/src/codex/adapter.ts @@ -56,6 +56,7 @@ import type { ProviderExecutionContext, } from "../provider-adapter.js"; import { flattenPromptInputGroups } from "../provider-adapter.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import type { JsonRpcMessage, ProviderInboundRequest, @@ -1850,7 +1851,7 @@ export function createCodexProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, - appliesPermissionEscalationPerTurn: false, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, // Codex app-server connections are owned by the runtime process manager. // BB runs live Codex threads on thread-scoped app-server processes, while // provider-only probes can still use a provider-scoped maintenance process. diff --git a/packages/agent-runtime/src/execution-options.test.ts b/packages/agent-runtime/src/execution-options.test.ts new file mode 100644 index 0000000000..28edbd3e38 --- /dev/null +++ b/packages/agent-runtime/src/execution-options.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import type { RuntimeThreadExecutionOptions } from "@bb/domain"; +import { + classifyClaudeExecutionSettingsChange, + classifySessionExecutionSettingsChange, +} from "./execution-options.js"; + +const baseOptions = { + model: "claude-opus-5[1m]", + serviceTier: "default", + reasoningLevel: "high", + workflowsEnabled: true, + memoryEnabled: true, + providerSubagentsEnabled: true, + permissionMode: "auto", + permissionScope: "workspace", + approvalReviewer: "automatic", + permissionEscalation: "ask", +} satisfies RuntimeThreadExecutionOptions; + +describe("execution setting classification", () => { + it("classifies Claude turn-mutable settings as live", () => { + const liveChanges: RuntimeThreadExecutionOptions[] = [ + { ...baseOptions, model: "claude-sonnet-5" }, + { ...baseOptions, reasoningLevel: "max" }, + { ...baseOptions, workflowsEnabled: false }, + { ...baseOptions, memoryEnabled: false }, + { ...baseOptions, providerSubagentsEnabled: false }, + { ...baseOptions, permissionEscalation: "deny" }, + ]; + + for (const next of liveChanges) { + expect( + classifyClaudeExecutionSettingsChange({ + current: baseOptions, + next, + }), + ).toBe("live"); + } + }); + + it("ignores Claude service-tier drift because Claude does not support tiers", () => { + expect( + classifyClaudeExecutionSettingsChange({ + current: baseOptions, + next: { ...baseOptions, serviceTier: "fast" }, + }), + ).toBe("unchanged"); + }); + + it("keeps Claude construction-time settings session-scoped", () => { + const sessionChanges: RuntimeThreadExecutionOptions[] = [ + { ...baseOptions, claudeCodePermissionMode: "plan" }, + { + ...baseOptions, + claudeCodeMockCliTraffic: { + enabled: true, + endpoint: "http://127.0.0.1:19001", + }, + }, + { + ...baseOptions, + permissionMode: "full", + permissionScope: "full", + approvalReviewer: null, + permissionEscalation: null, + }, + ]; + + for (const next of sessionChanges) { + expect( + classifyClaudeExecutionSettingsChange({ + current: baseOptions, + next, + }), + ).toBe("session"); + } + }); + + it("keeps setting changes session-scoped for adapters without live controls", () => { + expect( + classifySessionExecutionSettingsChange({ + current: baseOptions, + next: { ...baseOptions, model: "another-model" }, + }), + ).toBe("session"); + }); +}); diff --git a/packages/agent-runtime/src/execution-options.ts b/packages/agent-runtime/src/execution-options.ts index 2fe85014fc..898941401d 100644 --- a/packages/agent-runtime/src/execution-options.ts +++ b/packages/agent-runtime/src/execution-options.ts @@ -1,4 +1,8 @@ import type { ProviderAdapter } from "./provider-adapter.js"; +import type { + ClassifyProviderExecutionSettingsChangeArgs, + ProviderExecutionSettingsChange, +} from "./provider-adapter.js"; import type { AgentRuntimeExecutionOptions, AgentRuntimeSkillRoot, @@ -23,7 +27,6 @@ interface ToProviderExecutionContextArgs { interface SameExecutionSettingsArgs { left: AgentRuntimeExecutionOptions; right: AgentRuntimeExecutionOptions; - ignorePermissionEscalation: boolean; } export function assertProviderSupportsExecutionOptions( @@ -83,11 +86,62 @@ export function sameExecutionSettings( args.left.permissionMode === args.right.permissionMode && args.left.permissionScope === args.right.permissionScope && args.left.approvalReviewer === args.right.approvalReviewer && - (args.ignorePermissionEscalation || - args.left.permissionEscalation === args.right.permissionEscalation) + args.left.permissionEscalation === args.right.permissionEscalation + ); +} + +export function classifySessionExecutionSettingsChange( + args: ClassifyProviderExecutionSettingsChangeArgs, +): ProviderExecutionSettingsChange { + return sameExecutionSettings({ left: args.current, right: args.next }) + ? "unchanged" + : "session"; +} + +function sameClaudeSessionSettings( + args: ClassifyProviderExecutionSettingsChangeArgs, +): boolean { + const currentMockCliTraffic = + args.current.claudeCodeMockCliTraffic ?? + DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG; + const nextMockCliTraffic = + args.next.claudeCodeMockCliTraffic ?? + DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG; + return ( + args.current.claudeCodePermissionMode === + args.next.claudeCodePermissionMode && + currentMockCliTraffic.enabled === nextMockCliTraffic.enabled && + currentMockCliTraffic.endpoint === nextMockCliTraffic.endpoint && + args.current.permissionMode === args.next.permissionMode && + args.current.permissionScope === args.next.permissionScope && + args.current.approvalReviewer === args.next.approvalReviewer ); } +function sameClaudeLiveSettings( + args: ClassifyProviderExecutionSettingsChangeArgs, +): boolean { + return ( + args.current.model === args.next.model && + args.current.reasoningLevel === args.next.reasoningLevel && + args.current.workflowsEnabled === args.next.workflowsEnabled && + (args.current.memoryEnabled ?? true) === + (args.next.memoryEnabled ?? true) && + (args.current.providerSubagentsEnabled ?? true) === + (args.next.providerSubagentsEnabled ?? true) && + args.current.permissionEscalation === args.next.permissionEscalation + ); +} + +export function classifyClaudeExecutionSettingsChange( + args: ClassifyProviderExecutionSettingsChangeArgs, +): ProviderExecutionSettingsChange { + if (!sameClaudeSessionSettings(args)) { + return "session"; + } + return sameClaudeLiveSettings(args) ? "unchanged" : "live"; +} + export function toProviderExecutionContext( args: ToProviderExecutionContextArgs, ): ProviderExecutionContext { diff --git a/packages/agent-runtime/src/pi/adapter.ts b/packages/agent-runtime/src/pi/adapter.ts index 26ba4b32bd..df3ff20db8 100644 --- a/packages/agent-runtime/src/pi/adapter.ts +++ b/packages/agent-runtime/src/pi/adapter.ts @@ -23,6 +23,7 @@ import type { import { threadScope, toPositiveNumber, turnScope } from "@bb/domain"; import { decodeNormalizedProviderToolCallRequest } from "../shared/provider-tool-call-contract.js"; import { resolveBridgeProcessArgs } from "../shared/bridge-path.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import { bashArgsSchema, textBlockSchema } from "../shared/tool-arg-schemas.js"; import { buildEditDiff, @@ -1252,7 +1253,7 @@ export function createPiProviderAdapter( id: providerInfo.id, displayName: providerInfo.displayName, capabilities, - appliesPermissionEscalationPerTurn: false, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, process: { command: opts?.bridgeNodeExecutablePath ?? "node", args: resolveBridgeProcessArgs({ diff --git a/packages/agent-runtime/src/provider-adapter.ts b/packages/agent-runtime/src/provider-adapter.ts index aa1798b261..3d659e8d40 100644 --- a/packages/agent-runtime/src/provider-adapter.ts +++ b/packages/agent-runtime/src/provider-adapter.ts @@ -10,6 +10,7 @@ import type { ProviderCapabilities, ReasoningLevel, RuntimePermissionPolicy, + RuntimeThreadExecutionOptions, ServiceTier, ThreadEvent, } from "@bb/domain"; @@ -245,6 +246,13 @@ export function noPreparedProviderCommandDispatch( return null; } +export type ProviderExecutionSettingsChange = "unchanged" | "live" | "session"; + +export interface ClassifyProviderExecutionSettingsChangeArgs { + current: RuntimeThreadExecutionOptions; + next: RuntimeThreadExecutionOptions; +} + // --------------------------------------------------------------------------- // ProviderAdapter — internal extension contract // --------------------------------------------------------------------------- @@ -254,11 +262,13 @@ export interface ProviderAdapter { displayName: string; capabilities: ProviderCapabilities; /** - * The adapter applies `permissionEscalation` from each turn command, so the - * runtime must not replace the session (killing its background tasks) over - * an escalation-only change. + * Classifies execution-setting drift for this provider. `live` settings are + * carried by the next turn command; `session` settings require rebuilding + * the provider session. */ - appliesPermissionEscalationPerTurn: boolean; + classifyExecutionSettingsChange( + args: ClassifyProviderExecutionSettingsChangeArgs, + ): ProviderExecutionSettingsChange; process: { command: string; args: string[]; env?: Record }; buildCommandPlan(command: AdapterCommand): ProviderCommandPlan; diff --git a/packages/agent-runtime/src/runtime.lifecycle.test.ts b/packages/agent-runtime/src/runtime.lifecycle.test.ts index 4e524bb550..f9c2ac6236 100644 --- a/packages/agent-runtime/src/runtime.lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.lifecycle.test.ts @@ -9,6 +9,7 @@ import type { } from "./provider-adapter.js"; import { promptTextInput } from "./test/prompt-input.js"; import { createAgentRuntimeWithAdapters } from "./runtime.js"; +import { classifyClaudeExecutionSettingsChange } from "./execution-options.js"; import { fakeProviderScriptPath } from "./test/index.js"; import { createFakeAdapter, @@ -499,7 +500,7 @@ rl.on("line", (line) => { await runtime.shutdown(); }); - it("skips session reconfigure for an escalation-only change when the adapter applies escalation per turn", async () => { + it("skips session reconfigure when the adapter classifies settings as live", async () => { const recordedCommands: AdapterCommand[] = []; const runtime = createAgentRuntimeWithAdapters({ workspacePath: tmpDir, @@ -510,7 +511,8 @@ rl.on("line", (line) => { }), adapterFactory: () => ({ ...createRecordingAdapter({ recordedCommands, scriptPath }), - appliesPermissionEscalationPerTurn: true, + classifyExecutionSettingsChange: + classifyClaudeExecutionSettingsChange, }), }); @@ -522,10 +524,12 @@ rl.on("line", (line) => { instructions: "Initial instructions", options: { ...fullRuntimeOptions, + memoryEnabled: true, permissionMode: "auto", permissionScope: "workspace", approvalReviewer: "automatic", permissionEscalation: "ask", + providerSubagentsEnabled: true, }, }); @@ -536,16 +540,33 @@ rl.on("line", (line) => { instructions: "Initial instructions", options: { ...fullRuntimeOptions, + memoryEnabled: false, + model: "test-model-2", permissionMode: "auto", permissionScope: "workspace", approvalReviewer: "automatic", permissionEscalation: "deny", + providerSubagentsEnabled: false, + reasoningLevel: "high", + workflowsEnabled: true, }, }); expect( recordedCommands.some((command) => command.type === "thread/resume"), ).toBe(false); + expect( + findLastRecordedCommand(recordedCommands, "turn/start"), + ).toMatchObject({ + options: { + memoryEnabled: false, + model: "test-model-2", + permissionEscalation: "deny", + providerSubagentsEnabled: false, + reasoningLevel: "high", + workflowsEnabled: true, + }, + }); await runtime.shutdown(); }); diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 30d0ad88b7..0d1205a680 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -19,7 +19,6 @@ import type { } from "./provider-adapter.js"; import { assertProviderSupportsExecutionOptions, - sameExecutionSettings, toProviderExecutionContext, } from "./execution-options.js"; import { @@ -823,16 +822,13 @@ function createAgentRuntimeInternal( processKey: currentConfig.processKey, providerId: currentConfig.providerId, }); - if ( - sameExecutionSettings({ - left: currentConfig.options, - right: nextOptions, - ignorePermissionEscalation: - proc.adapter.appliesPermissionEscalationPerTurn, - }) - ) { - // Escalation rides on each turn command; record it without replacing - // the session (which would kill its background tasks). + const settingsChange = proc.adapter.classifyExecutionSettingsChange({ + current: currentConfig.options, + next: nextOptions, + }); + if (settingsChange !== "session") { + // Live settings ride on the next turn command; record them without + // replacing the session (which would kill its background tasks). setThreadRuntimeConfig(args.threadId, { ...currentConfig, options: nextOptions, diff --git a/packages/agent-runtime/src/test/fake-adapter.ts b/packages/agent-runtime/src/test/fake-adapter.ts index c9221a12c2..4190dfbcfd 100644 --- a/packages/agent-runtime/src/test/fake-adapter.ts +++ b/packages/agent-runtime/src/test/fake-adapter.ts @@ -30,6 +30,7 @@ import type { } from "../runtime-json-rpc.js"; import { ProviderResponseEncodeError } from "../runtime-json-rpc.js"; import { parseAvailableModelList } from "../shared/available-models.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import { decodeNormalizedProviderToolCallRequest } from "../shared/provider-tool-call-contract.js"; type FakeUserQuestionCapability = ProviderCapabilities["supportsUserQuestion"]; @@ -471,7 +472,7 @@ export function createFakeAdapter( supportsFork: true, supportedPermissionModes: ["accept-edits", "auto", "full"], }, - appliesPermissionEscalationPerTurn: false, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, decodeToolCallRequest, decodeInteractiveRequest: supportsUserQuestion ? decodeInteractiveRequest diff --git a/packages/agent-runtime/src/test/runtime-test-harness.ts b/packages/agent-runtime/src/test/runtime-test-harness.ts index 068db9e74d..8103cb1d3a 100644 --- a/packages/agent-runtime/src/test/runtime-test-harness.ts +++ b/packages/agent-runtime/src/test/runtime-test-harness.ts @@ -7,6 +7,7 @@ import type { ProviderCommandPlan, } from "../provider-adapter.js"; import { noPreparedProviderCommandDispatch } from "../provider-adapter.js"; +import { classifySessionExecutionSettingsChange } from "../execution-options.js"; import { ProviderRequestDecodeError } from "../runtime-json-rpc.js"; import { parseAvailableModelList } from "../shared/available-models.js"; import type { AgentRuntimeExecutionOptions } from "../types.js"; @@ -213,7 +214,7 @@ export function createWarningEventAdapter(scriptPath: string): ProviderAdapter { supportsFork: false, supportedPermissionModes: ["accept-edits", "auto", "full"], }, - appliesPermissionEscalationPerTurn: false, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, process: { command: "node", args: buildNodeScriptArgs(scriptPath), @@ -326,7 +327,7 @@ export function createStartedEventAdapter(scriptPath: string): ProviderAdapter { supportsFork: false, supportedPermissionModes: ["accept-edits", "auto", "full"], }, - appliesPermissionEscalationPerTurn: false, + classifyExecutionSettingsChange: classifySessionExecutionSettingsChange, process: { command: "node", args: buildNodeScriptArgs(scriptPath), diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index bc2a1ed1de..51eff4ac0d 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -36,7 +36,7 @@ import { providerCliStatusResponseSchema, } from "./local.js"; -export const HOST_DAEMON_PROTOCOL_VERSION = 95 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 96 as const; export { BRANCH_LIST_LIMIT_MAX, diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 05cc306a30..9d46de35f5 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1051,10 +1051,10 @@ describe("host-daemon local schemas", () => { }); describe("host-daemon command schemas", () => { - // Version 95 lets ACP sessions send context-window usage outside a turn. - // An older daemon omits these events, so it must update before connecting. - it("uses protocol version 95 for ACP context-window usage", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(95); + // Version 96 moves Claude workflow/subagent enforcement from the session + // payload into live adapter controls, so an older daemon must update. + it("uses protocol version 96 for live Claude feature settings", () => { + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(96); }); it("binds Plan cancellation to a required turn id and typed result", () => {