diff --git a/docs/chat-plan-mode.md b/docs/chat-plan-mode.md index 094d1317..b5513979 100644 --- a/docs/chat-plan-mode.md +++ b/docs/chat-plan-mode.md @@ -3,7 +3,8 @@ Agreed with the owner on 2026-08-17, immediately before a context compaction. Written so the build can start from here with nothing re-derived. -**Status: not started.** Nothing in this document has been implemented. +**Status: step 1 verified against a real `claude` (SDK 0.3.233), nothing built yet.** The mechanism works and +the open question below is answered — see "Verified" before implementing. --- @@ -72,12 +73,47 @@ type CanUseTool = (toolName: string, input: Record, options: { So the shape is: intercept `ExitPlanMode`, surface the plan to the user, and resolve the promise with allow or deny. That makes the plan a first-class thing in the transcript rather than a message that scrolls past. -**Open question to settle first:** does `canUseTool` fire at all under `permissionMode: 'plan'` when the -session was created with `bypassPermissions`, and can the mode be changed by control request _after_ -`createSession` has already passed `canUseTool` (or not passed it)? `canUseTool` is a `query()` OPTION, read -once at session creation — so it may have to be supplied unconditionally from the start, refusing nothing -until plan mode is entered. **Verify this before designing the UI**, because it decides whether plan mode can -be toggled on a live session or only chosen when a conversation starts. +### Verified — 2026-08-17, against a real `claude`, SDK 0.3.233 + +Three probe runs against a scratch directory. Every answer below was observed, not read. + +1. **`setPermissionMode('plan')` works on a session created with `bypassPermissions` + `allowDangerouslySkipPermissions`.** + It resolves cleanly, including before the first turn has been sent. + +2. **`canUseTool` does fire in plan mode**, on that same session — for `ExitPlanMode` and for + `AskUserQuestion`. It does NOT fire for read-only tools (`Read`, `Bash`, `Task`). So the interception + point the design needs exists, and it is quiet. + +3. **Plan mode overrides `bypassPermissions` for execution.** The agent emitted a `Write` tool_use while + planning; it reached neither `canUseTool` nor the disk (`notes.txt` was byte-identical afterwards). The + harness refuses execution tools upstream of the permission layer, so plan mode is a real constraint here + and not merely advisory. + +4. **Supplying `canUseTool` unconditionally at `createSession` is free.** Under `bypassPermissions` the SDK + shadows it entirely and says so, once, at query creation: + + ``` + Warning: canUseTool will not be invoked: permissionMode 'bypassPermissions' auto-approves every tool + call (except explicit deny rules) before the callback is consulted. code: CLAUDE_SDK_CAN_USE_TOOL_SHADOWED + ``` + + A `bypassPermissions` session asked to edit a file made the edit with **zero** `canUseTool` calls. This + settles the design question: because `canUseTool` is a `query()` option read once, it must be passed from + the start — and passing it always costs nothing but a log line. **Plan mode can therefore be toggled on a + live session**, which is what the composer toggle needs. + +5. **Approving `ExitPlanMode` exits plan mode by itself — into `default`, NOT back into `bypassPermissions`.** + This is the one thing the plan above got wrong, and it is the dangerous one. Returning `{behavior:'allow'}` + for `ExitPlanMode` caused the harness to leave plan mode and the agent to carry straight on **within the + same turn** — and its follow-up `Edit` DID hit `canUseTool`. So approval silently converts an unsandboxed + session into a gated one. Restoring `bypassPermissions` is not tidying up after the feature; it is required + for the session to behave as it did before plan mode was ever entered. + +6. **Denial is the revision path.** `{behavior:'deny', message}` ends the tool call, not the turn, and the CLI + carries a matching string: _"Please revise your plan based on the feedback and call ExitPlanMode again."_ + So the reject button should take a reason and send it as `message`. + +The probe that produced this was `scripts/probe-plan-mode.ts`, deleted once these answers were written down. --- @@ -92,6 +128,12 @@ to `bypassPermissions` or every subsequent tool call starts prompting and the ch socket is closed must resolve (deny, or on a timeout) or the session wedges with `isGenerating` true — the same failure the stall timer exists to catch (`claude-manager.ts`, `stallTimer`). +**Restore `bypassPermissions` the moment `ExitPlanMode` is allowed, not at turn end.** Finding 5 above: the +agent resumes work inside the same turn, so a restore deferred to the `result` message leaves a window in +which every tool call is gated by a callback written to gate exactly one tool. `canUseTool` must therefore +allow everything that is not `ExitPlanMode` — never deny by default — because for a few hundred milliseconds +it is the only thing standing between the agent and its own tools. + **Mode is per-session state and belongs with the model.** `session.model` on `PersistentSession` was added for exactly this class of thing; a `session.permissionMode` beside it keeps `applyModelChange`'s shape. @@ -99,9 +141,7 @@ exactly this class of thing; a `session.permissionMode` beside it keeps `applyMo ## Sequence -1. **Verify the `canUseTool` question above** with a scratch script against a real `claude` — do not design - the UI first. (Note: a scratch `query()` failed to reach `supportedModels()` from a nested Claude Code - session earlier today; the sidecar itself is the reliable place to test.) +1. ~~Verify the `canUseTool` question~~ — **done**, see "Verified" above. 2. `session.permissionMode` on `PersistentSession`, plus `applyPermissionModeChange` mirroring `applyModelChange` — control request, effective-not-requested, durable event on success and on refusal. 3. `plan:entered` / `plan:proposed` / `plan:exited` durable events through the five files `model:switched`