the owner has no time to test any of it, so the checklist has to work for someone with none of today's context: eleven ordered tests, what each should show, and what its failure looks like. test 4 is called out as the one most likely to be wrong - approving ExitPlanMode drops the harness into default rather than back into bypassPermissions, which is the finding that changed the design and therefore the thing the implementation is most likely to have got wrong. also separates what was verified from what was not: the probe runs against a real claude, the turn-stream unit tests, and then everything end to end, which is nothing. the inline 'what to watch' list in chat-plan-mode.md is replaced by a pointer so there is one source of truth, and TODO.md now says live-but-unexercised rather than built-not-run - the restarts are done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
10 KiB
Markdown
181 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`, live since 2026-08-17, and never once exercised.** The restarts are done, so
|
|
the code is running. Nothing has been tested — work through `docs/chat-plan-mode-test-plan.md` before
|
|
believing any of it.
|
|
|
|
---
|
|
|
|
## 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, typechecks and is unit tested at the translation layer; none
|
|
of it has executed inside Officer. The processes were restarted on 2026-08-17 (`officer`,
|
|
`officer-claude-code`, `officer-anthropic-proxy`) so the code IS live — it has simply never been exercised.
|
|
|
|
**The test plan is `docs/chat-plan-mode-test-plan.md`**, written the same day for an owner who had no time
|
|
to run it. Eleven ordered tests, what each should show, and what its failure looks like. Test 4 (approve) is
|
|
the one most likely to be wrong, because it is the case that changed the design.
|
|
|
|
Do not treat this feature as working until that document has been worked through. It is opt-in and off by
|
|
default, so nothing is at risk in the meantime.
|
|
|
|
---
|
|
|
|
## 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.
|