Split out of #364 as that issue's Notes said to.
ExitPlanMode also declares requiresUserInteraction(), and that check runs
before the bypass branch in the SDK's permission evaluator — the same reason
AskUserQuestion routed through canUseTool even under Bypass. So it reaches
the host as an ordinary approval and renders through ApprovalPrompt's generic
path.
headline() resolves a non-Bash tool to its bare name, so the user sees
TOOL ExitPlanMode and nothing else: not the plan being proposed, and no
indication that accepting means leave plan mode and start executing.
Why this is worth its own fix
#364 established the shape. ApprovalKind::Question and the payload channel
through chat_approve → approve → agent_chat_approve already exist, and
ApprovalPrompt already dispatches per-kind. So this is a renderer plus a kind,
not new plumbing.
But the contract is different and needs checking against the SDK rather than
assumed: ExitPlanMode carries the plan text as its input, and what the tool
expects back on approval is not the answers shape. Read the SDK's own TUI
handler before designing the response, the way #364 did — guessing the shape
is exactly how that bug shipped.
Scope
ApprovalKind::Plan (or reuse Question if the response shape turns out to
match — decide from the SDK, not from symmetry)
approval_kind("ExitPlanMode") mapping
- surface the plan text through
parseApprovalDetail
- a renderer that shows the plan and makes accept/reject mean what they do
- exempt from
alwaysAllow for the same reason AskUserQuestion is:
approvalScopeKey collapses every call to ExitPlanMode\0"", so one session
grant would auto-approve every future plan exit silently
Validation
The live tier from #394 is the right home for the end-to-end proof:
PICKFORGE_E2E_CLAUDE=1 bun run e2e:claude already drives the real CLI through
the real bridge. Force a plan-mode turn, accept the exit, and assert the tool's
own result — the same load-bearing check that caught #364's regression.
Refs #364
Split out of #364 as that issue's Notes said to.
ExitPlanModealso declaresrequiresUserInteraction(), and that check runsbefore the bypass branch in the SDK's permission evaluator — the same reason
AskUserQuestionrouted throughcanUseTooleven under Bypass. So it reachesthe host as an ordinary approval and renders through
ApprovalPrompt's genericpath.
headline()resolves a non-Bash tool to its bare name, so the user seesTOOL ExitPlanModeand nothing else: not the plan being proposed, and noindication that accepting means leave plan mode and start executing.
Why this is worth its own fix
#364 established the shape.
ApprovalKind::Questionand the payload channelthrough
chat_approve→approve→agent_chat_approvealready exist, andApprovalPromptalready dispatches per-kind. So this is a renderer plus a kind,not new plumbing.
But the contract is different and needs checking against the SDK rather than
assumed:
ExitPlanModecarries the plan text as its input, and what the toolexpects back on approval is not the
answersshape. Read the SDK's own TUIhandler before designing the response, the way #364 did — guessing the shape
is exactly how that bug shipped.
Scope
ApprovalKind::Plan(or reuseQuestionif the response shape turns out tomatch — decide from the SDK, not from symmetry)
approval_kind("ExitPlanMode")mappingparseApprovalDetailalwaysAllowfor the same reasonAskUserQuestionis:approvalScopeKeycollapses every call toExitPlanMode\0"", so one sessiongrant would auto-approve every future plan exit silently
Validation
The live tier from #394 is the right home for the end-to-end proof:
PICKFORGE_E2E_CLAUDE=1 bun run e2e:claudealready drives the real CLI throughthe real bridge. Force a plan-mode turn, accept the exit, and assert the tool's
own result — the same load-bearing check that caught #364's regression.
Refs #364