From c5c93c43471d5a0dd33763cc82cd8903591e8147 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Sat, 4 Jul 2026 13:42:56 -0600 Subject: [PATCH 1/2] fix: echo tool_input as updatedInput so Claude Code >=2.1.199 honors plan approval Claude Code 2.1.199 added a guard that silently discards an ExitPlanMode allow decision unless it carries updatedInput, so approving a plan in the browser left the session blocked on the terminal's own approval prompt. Echo the hook event's tool_input back verbatim in the allow envelope, and parse tool_input as a loose object so fields like planFilePath survive the round-trip. Verified against Claude Code 2.1.198 (no behavior change) and 2.1.201 (approval works again). Upstream: https://github.com/anthropics/claude-code/issues/74256 --- packages/cli/src/commands/claudeHookSchema.ts | 3 ++- packages/cli/src/commands/hookClaude.test.ts | 12 ++++++++---- packages/cli/src/commands/hookClaude.ts | 2 +- .../formatters/plan/claudeHookResponse.test.ts | 14 +++++++++----- .../src/formatters/plan/claudeHookResponse.ts | 16 ++++++++++++++-- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/claudeHookSchema.ts b/packages/cli/src/commands/claudeHookSchema.ts index 89cef4a..0a1dd5a 100644 --- a/packages/cli/src/commands/claudeHookSchema.ts +++ b/packages/cli/src/commands/claudeHookSchema.ts @@ -8,8 +8,9 @@ export const ClaudeHookPayloadSchema = z.object({ permission_mode: z.string().trim().nonempty().optional(), hook_event_name: z.string().trim().nonempty(), tool_name: z.string().trim().nonempty().optional(), + // Loose: the approve envelope echoes tool_input back verbatim, so unmodeled fields must survive parsing. tool_input: z - .object({ + .looseObject({ plan: z.string().refine((value) => value.trim().length > 0, { message: 'plan content must not be empty' }), }) .optional(), diff --git a/packages/cli/src/commands/hookClaude.test.ts b/packages/cli/src/commands/hookClaude.test.ts index 789fcda..aede770 100644 --- a/packages/cli/src/commands/hookClaude.test.ts +++ b/packages/cli/src/commands/hookClaude.test.ts @@ -9,10 +9,11 @@ import { createAnnotationDependencies, createStubContext, readErrorLogs } from ' import { type HookClaudeDependencies, runHookClaude } from './hookClaude.ts'; describe('hookClaude handler', () => { - it('emits the approved envelope when the review is approved', async () => { + it('emits the approved envelope echoing tool_input when the review is approved', async () => { const { context, io } = createStubContext(); const submission = annotationSubmission.build({ status: 'approved', threads: [] }); const deps = createHookDependencies({ submission }); + const toolInput = { plan: '# Plan\n\nStep 1.\n', planFilePath: '/home/user/.claude/plans/sample.md' }; io.stdin.write( JSON.stringify({ session_id: 'sess_123', @@ -21,14 +22,17 @@ describe('hookClaude handler', () => { permission_mode: 'plan', hook_event_name: 'PermissionRequest', tool_name: 'ExitPlanMode', - tool_input: { plan: '# Plan\n\nStep 1.\n' }, + tool_input: toolInput, }), ); io.stdin.end(); await runHookClaude(context, deps); - expect(io.stdout.text()).toBe(`${JSON.stringify(claudeHookResponse(submission, '# Plan\n\nStep 1.\n'))}\n`); + expect(io.stdout.text()).toBe(`${JSON.stringify(claudeHookResponse(submission, toolInput))}\n`); + const parsed = JSON.parse(io.stdout.text().trim()) as ReturnType; + if (parsed.hookSpecificOutput.decision.behavior !== 'allow') throw new Error('expected allow'); + expect(parsed.hookSpecificOutput.decision.updatedInput).toEqual(toolInput); expect(deps.calls).toEqual([annotationArgs.build({ content: '# Plan\n\nStep 1.\n', entrypoint: 'hook_claude' })]); }); @@ -52,7 +56,7 @@ describe('hookClaude handler', () => { await runHookClaude(context, deps); - const expected = claudeHookResponse(submission, planContent); + const expected = claudeHookResponse(submission, { plan: planContent }); expect(io.stdout.text()).toBe(`${JSON.stringify(expected)}\n`); const parsed = JSON.parse(io.stdout.text().trim()) as typeof expected; expect(parsed.hookSpecificOutput.decision.behavior).toBe('deny'); diff --git a/packages/cli/src/commands/hookClaude.ts b/packages/cli/src/commands/hookClaude.ts index be31ecf..b5a8355 100644 --- a/packages/cli/src/commands/hookClaude.ts +++ b/packages/cli/src/commands/hookClaude.ts @@ -104,7 +104,7 @@ async function handleExitPlanMode( abort(ctx, 'runtime', getErrorMessage(err)); } - return claudeHookResponse(submission, planContent); + return claudeHookResponse(submission, payload.tool_input); } function abort(ctx: CliContext, kind: 'input' | 'runtime', message: string): never { diff --git a/packages/cli/src/formatters/plan/claudeHookResponse.test.ts b/packages/cli/src/formatters/plan/claudeHookResponse.test.ts index 25aa43d..425b780 100644 --- a/packages/cli/src/formatters/plan/claudeHookResponse.test.ts +++ b/packages/cli/src/formatters/plan/claudeHookResponse.test.ts @@ -12,17 +12,21 @@ import { claudeHookResponse } from './claudeHookResponse.ts'; import { PLAN_TEMPLATES } from './templates.ts'; describe('claudeHookResponse', () => { - it('returns an allow envelope that switches the session to acceptEdits for approved submissions', () => { + it('returns an allow envelope that echoes tool_input and switches the session to acceptEdits for approved submissions', () => { const submission = annotationSubmission.build({ status: 'approved', threads: [] }); - const planContent = '# ignored by approved template\n'; + const toolInput = { + plan: '# ignored by approved template\n', + planFilePath: '/home/user/.claude/plans/sample.md', + }; - const response = claudeHookResponse(submission, planContent); + const response = claudeHookResponse(submission, toolInput); expect(response).toEqual({ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', + updatedInput: toolInput, updatedPermissions: [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }], }, }, @@ -61,7 +65,7 @@ describe('claudeHookResponse', () => { ], }); - const response = claudeHookResponse(submission, planContent); + const response = claudeHookResponse(submission, { plan: planContent }); const decision = response.hookSpecificOutput.decision; expect(response.hookSpecificOutput.hookEventName).toBe('PermissionRequest'); @@ -108,7 +112,7 @@ describe('claudeHookResponse', () => { ], }); - const response = claudeHookResponse(submission, planContent); + const response = claudeHookResponse(submission, { plan: planContent }); const decision = response.hookSpecificOutput.decision; if (decision.behavior !== 'deny') throw new Error('expected deny'); diff --git a/packages/cli/src/formatters/plan/claudeHookResponse.ts b/packages/cli/src/formatters/plan/claudeHookResponse.ts index c3a29ac..42a86d9 100644 --- a/packages/cli/src/formatters/plan/claudeHookResponse.ts +++ b/packages/cli/src/formatters/plan/claudeHookResponse.ts @@ -7,8 +7,19 @@ export interface ClaudeHookResponse { hookSpecificOutput: PermissionRequestHookSpecificOutput; } -export function claudeHookResponse(submission: AnnotationSubmission, planContent: string): ClaudeHookResponse { +export interface ClaudeExitPlanModeInput { + plan: string; + [key: string]: unknown; +} + +export function claudeHookResponse( + submission: AnnotationSubmission, + toolInput: ClaudeExitPlanModeInput, +): ClaudeHookResponse { if (submission.status === 'approved') { + // Claude Code >=2.1.199 discards an ExitPlanMode allow without updatedInput, so echo the + // input verbatim: https://github.com/anthropics/claude-code/issues/74256 + // // setMode → acceptEdits is what actually exits plan mode for the session. // Without it, the allow only grants this ExitPlanMode call — the session // stays in `plan` and the agent can't touch the filesystem on its next turn. @@ -17,6 +28,7 @@ export function claudeHookResponse(submission: AnnotationSubmission, planContent hookEventName: 'PermissionRequest', decision: { behavior: 'allow', + updatedInput: toolInput, updatedPermissions: [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }], }, }, @@ -26,7 +38,7 @@ export function claudeHookResponse(submission: AnnotationSubmission, planContent return { hookSpecificOutput: { hookEventName: 'PermissionRequest', - decision: { behavior: 'deny', message: formatAgentResponse(PLAN_TEMPLATES, submission, planContent) }, + decision: { behavior: 'deny', message: formatAgentResponse(PLAN_TEMPLATES, submission, toolInput.plan) }, }, }; } From 1953246e9e278fd2fafdb01cccca5bad647af652 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Sat, 4 Jul 2026 13:53:46 -0600 Subject: [PATCH 2/2] test: add a claudeHookPayload factory for Claude hook tests Valid hook events now come from a fishery factory (matching the existing codexStopHookPayload one) instead of seven hand-rolled literals. Malformed payload tests keep inline literals since they intentionally violate the type. --- packages/cli/src/commands/hookClaude.test.ts | 72 ++++---------------- packages/cli/src/testFactories.ts | 12 ++++ 2 files changed, 24 insertions(+), 60 deletions(-) diff --git a/packages/cli/src/commands/hookClaude.test.ts b/packages/cli/src/commands/hookClaude.test.ts index aede770..e2c3e26 100644 --- a/packages/cli/src/commands/hookClaude.test.ts +++ b/packages/cli/src/commands/hookClaude.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'bun:test'; import { CommanderError } from 'commander'; import { type RunAnnotationArgs, runAnnotation } from '#src/annotation/runAnnotation.ts'; import { claudeHookResponse } from '#src/formatters/plan/claudeHookResponse.ts'; -import { annotationArgs } from '#src/testFactories.ts'; +import { annotationArgs, claudeHookPayload } from '#src/testFactories.ts'; import { createAnnotationDependencies, createStubContext, readErrorLogs } from '#src/testHelpers/index.ts'; import { type HookClaudeDependencies, runHookClaude } from './hookClaude.ts'; @@ -13,50 +13,30 @@ describe('hookClaude handler', () => { const { context, io } = createStubContext(); const submission = annotationSubmission.build({ status: 'approved', threads: [] }); const deps = createHookDependencies({ submission }); - const toolInput = { plan: '# Plan\n\nStep 1.\n', planFilePath: '/home/user/.claude/plans/sample.md' }; - io.stdin.write( - JSON.stringify({ - session_id: 'sess_123', - transcript_path: '/tmp/transcript.json', - cwd: '/work', - permission_mode: 'plan', - hook_event_name: 'PermissionRequest', - tool_name: 'ExitPlanMode', - tool_input: toolInput, - }), - ); + const payload = claudeHookPayload.build(); + io.stdin.write(JSON.stringify(payload)); io.stdin.end(); await runHookClaude(context, deps); - expect(io.stdout.text()).toBe(`${JSON.stringify(claudeHookResponse(submission, toolInput))}\n`); + expect(io.stdout.text()).toBe(`${JSON.stringify(claudeHookResponse(submission, payload.tool_input))}\n`); const parsed = JSON.parse(io.stdout.text().trim()) as ReturnType; if (parsed.hookSpecificOutput.decision.behavior !== 'allow') throw new Error('expected allow'); - expect(parsed.hookSpecificOutput.decision.updatedInput).toEqual(toolInput); - expect(deps.calls).toEqual([annotationArgs.build({ content: '# Plan\n\nStep 1.\n', entrypoint: 'hook_claude' })]); + expect(parsed.hookSpecificOutput.decision.updatedInput).toEqual(payload.tool_input); + expect(deps.calls).toEqual([annotationArgs.build({ content: payload.tool_input.plan, entrypoint: 'hook_claude' })]); }); it('emits a deny envelope with the markdown feedback when changes are requested', async () => { const { context, io } = createStubContext(); const submission = annotationSubmission.build(); const deps = createHookDependencies({ submission }); - const planContent = '# Plan\n\nStep 1.\n'; - io.stdin.write( - JSON.stringify({ - session_id: 'sess_123', - transcript_path: '/tmp/transcript.json', - cwd: '/work', - permission_mode: 'plan', - hook_event_name: 'PermissionRequest', - tool_name: 'ExitPlanMode', - tool_input: { plan: planContent }, - }), - ); + const payload = claudeHookPayload.build(); + io.stdin.write(JSON.stringify(payload)); io.stdin.end(); await runHookClaude(context, deps); - const expected = claudeHookResponse(submission, { plan: planContent }); + const expected = claudeHookResponse(submission, payload.tool_input); expect(io.stdout.text()).toBe(`${JSON.stringify(expected)}\n`); const parsed = JSON.parse(io.stdout.text().trim()) as typeof expected; expect(parsed.hookSpecificOutput.decision.behavior).toBe('deny'); @@ -70,17 +50,7 @@ describe('hookClaude handler', () => { const deps: HookClaudeDependencies = { runReview: (reviewCtx, args) => runAnnotation(reviewCtx, args, createAnnotationDependencies({ submission })), }; - io.stdin.write( - JSON.stringify({ - session_id: 'sess_123', - transcript_path: '/tmp/transcript.json', - cwd: '/work', - permission_mode: 'plan', - hook_event_name: 'PermissionRequest', - tool_name: 'ExitPlanMode', - tool_input: { plan: '# Plan\n\nStep 1.\n' }, - }), - ); + io.stdin.write(JSON.stringify(claudeHookPayload.build())); io.stdin.end(); await runHookClaude(context, deps); @@ -99,16 +69,7 @@ describe('hookClaude handler', () => { it('aborts when hook_event_name is unsupported', () => { const { context, io, logs } = createStubContext(); const deps = createHookDependencies(); - io.stdin.write( - JSON.stringify({ - session_id: 'sess_123', - transcript_path: '/tmp/transcript.json', - cwd: '/work', - hook_event_name: 'PreToolUse', - tool_name: 'ExitPlanMode', - tool_input: { plan: '# x' }, - }), - ); + io.stdin.write(JSON.stringify(claudeHookPayload.build({ hook_event_name: 'PreToolUse' }))); io.stdin.end(); expect(runHookClaude(context, deps)).rejects.toBeInstanceOf(CommanderError); @@ -120,16 +81,7 @@ describe('hookClaude handler', () => { it('aborts when PermissionRequest arrives for a tool other than ExitPlanMode', () => { const { context, io, logs } = createStubContext(); const deps = createHookDependencies(); - io.stdin.write( - JSON.stringify({ - session_id: 'sess_123', - transcript_path: '/tmp/transcript.json', - cwd: '/work', - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { plan: 'unused' }, - }), - ); + io.stdin.write(JSON.stringify(claudeHookPayload.build({ tool_name: 'Bash' }))); io.stdin.end(); expect(runHookClaude(context, deps)).rejects.toBeInstanceOf(CommanderError); diff --git a/packages/cli/src/testFactories.ts b/packages/cli/src/testFactories.ts index 1823351..820aaeb 100644 --- a/packages/cli/src/testFactories.ts +++ b/packages/cli/src/testFactories.ts @@ -1,12 +1,24 @@ import type { HarnessDescriptor } from '@contextbridge/harness'; import { Factory } from 'fishery'; import type { RunAnnotationArgs } from '#src/annotation/runAnnotation.ts'; +import type { ClaudeHookPayload } from '#src/commands/claudeHookSchema.ts'; import type { CodexStopHookPayload, CodexTranscriptHookPromptLine, CodexTranscriptPlanLine, } from '#src/commands/codexHookSchema.ts'; import type { Environment } from '#src/environment.ts'; +import type { ClaudeExitPlanModeInput } from '#src/formatters/plan/claudeHookResponse.ts'; + +export const claudeHookPayload = Factory.define(() => ({ + session_id: 'sess_123', + transcript_path: '/tmp/transcript.json', + cwd: '/work', + permission_mode: 'plan', + hook_event_name: 'PermissionRequest', + tool_name: 'ExitPlanMode', + tool_input: { plan: '# Plan\n\nStep 1.\n', planFilePath: '/home/user/.claude/plans/sample.md' }, +})); export const codexStopHookPayload = Factory.define(() => ({ session_id: 'sess_123',