168 lines
9.3 KiB
Markdown
168 lines
9.3 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: 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.
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
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`
|
|
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.
|