fix(claude): auto-approve ExitPlanMode via canUseTool#312
Merged
Conversation
ExitPlanMode's native checkPermissions returns `{behavior: "ask",
message: "Exit plan mode?"}` outside sub-agent contexts — even under
`permissionMode: 'bypassPermissions'`. That "ask" routes through the
`can_use_tool` control_request, NOT through PreToolUse hooks. Without a
canUseTool handler the Feishu bridge gets back an is_error tool_result,
the agent stays in plan mode, and the user sees a perpetual "Exit plan
mode?" loop with no way out.
Fix: wire a `canUseTool` callback on the SDK query options for both
`ClaudeExecutor` and `PersistentClaudeExecutor` that auto-allows
ExitPlanMode (and any other unexpected "ask" as a safety net). The
bridge already ships the plan body to the user as a separate card via
StreamProcessor + sendPlanContent, so the user still sees what was
decided before implementation continues.
Two SDK gotchas worth keeping in mind for future canUseTool work:
1. PreToolUse hook `allow` does NOT bypass canUseTool's `ask` —
they're two independent gates. PreToolUse `deny` short-circuits
canUseTool, but allow doesn't.
2. The `allow` branch of the canUseTool return value MUST include
`updatedInput: Record<string, unknown>` — the SDK Zod-validates
the response and rejects `{behavior: 'allow'}` alone with
`ZodError: invalid_type expected record, received undefined at
updatedInput`. Echo `input` back when there's no mutation to apply.
The accompanying tests assert the full return shape (not just
`behavior`) so a regression on (2) fails the suite — a previous
iteration of this fix passed `toEqual({behavior:'allow'})` against the
broken shape because `toEqual` is structural and missing keys are OK.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SimonYeyi
pushed a commit
to SimonYeyi/metabot
that referenced
this pull request
May 26, 2026
ExitPlanMode's native checkPermissions returns `{behavior: "ask",
message: "Exit plan mode?"}` outside sub-agent contexts — even under
`permissionMode: 'bypassPermissions'`. That "ask" routes through the
`can_use_tool` control_request, NOT through PreToolUse hooks. Without a
canUseTool handler the Feishu bridge gets back an is_error tool_result,
the agent stays in plan mode, and the user sees a perpetual "Exit plan
mode?" loop with no way out.
Fix: wire a `canUseTool` callback on the SDK query options for both
`ClaudeExecutor` and `PersistentClaudeExecutor` that auto-allows
ExitPlanMode (and any other unexpected "ask" as a safety net). The
bridge already ships the plan body to the user as a separate card via
StreamProcessor + sendPlanContent, so the user still sees what was
decided before implementation continues.
Two SDK gotchas worth keeping in mind for future canUseTool work:
1. PreToolUse hook `allow` does NOT bypass canUseTool's `ask` —
they're two independent gates. PreToolUse `deny` short-circuits
canUseTool, but allow doesn't.
2. The `allow` branch of the canUseTool return value MUST include
`updatedInput: Record<string, unknown>` — the SDK Zod-validates
the response and rejects `{behavior: 'allow'}` alone with
`ZodError: invalid_type expected record, received undefined at
updatedInput`. Echo `input` back when there's no mutation to apply.
The accompanying tests assert the full return shape (not just
`behavior`) so a regression on (2) fails the suite — a previous
iteration of this fix passed `toEqual({behavior:'allow'})` against the
broken shape because `toEqual` is structural and missing keys are OK.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Flood Sung <floodsung@xvirobotics.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced May 26, 2026
floodsung
pushed a commit
that referenced
this pull request
Jun 15, 2026
GitHub-only commits #312 (ExitPlanMode auto-approve) and #314 (full agent activity content) are already present in this branch via their internal equivalents (exit-plan-mode.ts is byte-identical; #314 = internal 8316195). GitLab is the source of truth, so this branch's tree is kept verbatim (-s ours); this merge only makes GitHub main an ancestor so the cutover is a clean fast-forward.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When the Claude agent enters plan mode and tries to exit it (via the
ExitPlanModetool), the Feishu bridge gets stuck — the user sees a perpetual "Exit plan mode?" prompt with no way to answer it, and the only escape isCtrl+Con the bridge process.Root cause:
ExitPlanMode's nativecheckPermissionsreturns{behavior: "ask", message: "Exit plan mode?"}outside sub-agent contexts, even underpermissionMode: 'bypassPermissions'. That "ask" is routed by the SDK through thecan_use_toolcontrol_request — not throughPreToolUsehooks. Without acanUseToolhandler, the bridge has nothing to reply with, the SDK gives back anis_errortool_result, and the plan-mode gate never lifts.Extracted from the SDK binary:
Fix
Wire a
canUseToolcallback on the SDK query options for bothClaudeExecutorandPersistentClaudeExecutorthat auto-allowsExitPlanMode(and any other unexpected "ask" as a safety net). The user still sees the plan body — the bridge already ships it as a separate card viaStreamProcessor+sendPlanContentbefore this gate is hit.Two SDK gotchas worth keeping in mind
PreToolUsehookallowdoes NOT bypasscanUseTool'sask. They're two independent gates.PreToolUse: denyshort-circuitscanUseTool, butallowdoesn't. So{ matcher: 'ExitPlanMode', hooks: [hook that returns allow] }would log "auto-approving" but never unblock anything.The
allowbranch ofcanUseToolMUST includeupdatedInput: Record<string, unknown>. The SDK Zod-validates the response and rejects{behavior: 'allow'}alone withZodError: invalid_type expected record, received undefined at updatedInput. Echoinputback when there's no mutation to apply.Tests
tests/exit-plan-mode-auto-approve.test.tsasserts the full return shape (not justbehavior) so a regression on (2) would fail the suite — an earlier iteration passedtoEqual({behavior:'allow'})against the broken shape becausetoEqualis structural and missing keys are silently OK.Also verified end-to-end on the internal bridge: EnterPlanMode → ExitPlanMode now succeeds silently, with no
Tool permission request failed: ZodErrorand no perpetual "Exit plan mode?" loop.🤖 Generated with Claude Code