# 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: not started.** Nothing in this document has been implemented. --- ## 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. **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. --- ## 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`). **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 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.) 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` already travels. 4. Composer toggle, and a plan card in the transcript with Approve / Reject. 5. Return to `bypassPermissions` on approval, rejection, and turn end. --- ## 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.