Files
platform/docs/chat-plan-mode.md
T

185 lines
10 KiB
Markdown

# 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<string, unknown>, 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:<key>] 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.