# Plan mode in Officer's chat — plan of record 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: built in `1b811dfa`, not yet run.** Needs `pm2 restart officer` and `pm2 restart officer-claude-code` before it does anything — see "Sequence" at the end for what to watch on the first plan. --- ## Why `claude-code/opusplan` is in the model picker as of `57bbcca9`, and on this install **it almost certainly does nothing** — it silently runs Sonnet. `opusplan` is not orchestration. The CLI's own strings say what it is: ``` Plan mode: the opusplan upgrade model is not permitted by the org model restrictions (availableModels allowlist or model_access entitlement); planning uses the newest permitted Opus instead Plan mode: … planning uses the resting model instead opusplan-mode-reminder ``` An **upgrade model** (Opus) used _while in plan mode_, and a **resting model** (Sonnet) the rest of the time. One session, one context, one agent — the model changes with the MODE. That is a different mechanism from subagent delegation (the Task tool), which is what people usually assume `opusplan` means. Plan mode is a `permissionMode`, and Officer hardcodes a different one: - `node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:963` — `PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk'` - `src/servers/sidecar/claude/claude-manager.ts:505` — `permissionMode: 'bypassPermissions'` So Officer's chat never enters plan mode, and `opusplan` therefore rests forever. Making the mode reachable is what makes that picker entry honest — and it is the owner's main lever for spending fewer tokens on long tasks, which is why it is worth building rather than deleting the entry. --- ## What already exists to build on **The control channel is proven.** `Query.setPermissionMode(mode)` (`sdk.d.ts:1103`) is a control request on the live query, exactly like `Query.setModel()` — which Officer already calls in `applyModelChange` (`claude-manager.ts`, landed `4c24df93`). Same constraint: streaming input mode only, which is the only mode this file uses. So switching mode mid-conversation needs no teardown, no resume, and the pattern to copy is twenty lines away. **The durable-event pattern is proven too.** `model:switched` (`4c24df93`) is the worked example of a sidecar-originated event that reaches the client, is persisted, and renders as a marker in the transcript — `ChatEvent` → `turn-stream.ts` → `TurnMessage` → `useChat` → `MessageBubble`. A `plan:*` event follows the same five files. --- ## The hard part: approving the plan In plan mode the agent researches, then calls **`ExitPlanMode`** with its plan and waits for a decision. That decision is a PERMISSION decision, and `bypassPermissions` auto-approves everything — so naively setting the mode gives a planner that instantly approves its own plan and starts editing, which is worse than no plan mode at all. The SDK's hook for this is `canUseTool` (`sdk.d.ts:95`, and an option at `:590`): ```ts type CanUseTool = (toolName: string, input: Record, options: { signal: AbortSignal; suggestions?: …; // "always allow" style permission updates }) => Promise<…>; ``` 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. ### 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. --- ## Landmines **`bypassPermissions` is deliberate and must remain the default.** `CLAUDE.md`: _"Agents run unsandboxed as the server owner, with `--dangerously-skip-permissions`. This is deliberate — it is the owner's own machine. Do not add a jail without being asked."_ Plan mode is opt-in and temporary; on exit the session must return to `bypassPermissions` or every subsequent tool call starts prompting and the chat appears to hang. **Do not let a `canUseTool` promise hang forever.** It gates the turn. A plan awaiting approval while the 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. --- ## Sequence — all landed in `1b811dfa` 1. ~~Verify the `canUseTool` question~~ — see "Verified" above. 2. ~~`session.planMode` on `PersistentSession`, plus `applyPlanModeChange` mirroring `applyModelChange`~~ — control request, effective-not-requested, durable event on success and on refusal. 3. ~~`plan:mode` / `plan:proposed` / `plan:decided` durable events~~ through the same path `model:switched` travels. 4. ~~Composer toggle, and a plan card in the transcript with Approve / Reject~~ — rejection takes a reason and sends it as the denial message, which the harness turns into a revision request. 5. ~~Return to `bypassPermissions` on approval~~ — fired from inside `planGate`, not at turn end. **Not yet run.** Everything above is on disk and typechecks; none of it has executed on this machine. It needs `pm2 restart officer` AND `pm2 restart officer-claude-code` — the sidecar holds all of the interesting half, and restarting only the server would leave the old one running with no `canUseTool` at all. Worth watching on the first real plan, in this order: - the composer's clipboard icon lights, and `[claude:plan:] plan mode on` appears in `pm2 logs officer-claude-code` - the agent researches and refuses to edit — a `Write` in plan mode should reach neither disk nor the gate - the plan card renders with Approve / Reject, and **the turn is genuinely parked** while it sits there - approving continues the same turn, and `plan mode off` follows immediately rather than at turn end - refreshing mid-plan replays the card with its decision, rather than a conversation that stops dead The one failure mode to watch for is a turn that never ends after a plan is answered: that would mean a `canUseTool` promise was left unresolved, and `settleAllPlans` is what is meant to make it impossible. --- ## Also open, unrelated to this doc - **`pm2 restart officer`** is outstanding: the tier-less-model fix (`773f0e1c`) and the sessions-list server changes are on disk but not running. - **Spend visibility vs enforcement** — the owner is on a **Max OAuth subscription**, so the binding limit is Claude Code's usage window, not dollars; `totalUSD` on each result is a notional API-equivalent price. Officer accumulates cost per session in `websocket.ts:302-304` **in memory only**, so it dies on restart and nothing aggregates it. Unanswered: is the goal visibility or enforcement, and do members share the owner's subscription? - **Listener agents** — future work. They will be real chat sessions but on a separate route, with a marker in the database so they do not pollute the sessions list. `chat_session_models` is the precedent for a narrow table keyed by transcript uuid; a `kind` marker would follow it. No design yet. - **`docs/chat-sessions-list.md`** — the sessions-list work, now largely built (`d59bc0d1`, `640a74bc`, `773f0e1c`, `5565867b`). Its "Status: not started" header is stale.