plan of record for plan mode, and mark the sessions list built
opusplan is in the picker and does nothing on this install: it is a per-MODE model switch (Opus while in
plan mode, Sonnet at rest — the CLI's own strings call them the upgrade and resting models), and
claude-manager hardcodes permissionMode: 'bypassPermissions', so plan mode is never entered. Not
orchestration, which is what the name suggests and what we both assumed.
Making the mode reachable is the fix. setPermissionMode is a control request on the live query, same channel
as the setModel that landed in 4c24df93, so the mechanism is proven. The hard part is ExitPlanMode: it is a
permission decision, and bypassPermissions auto-approves it, so a naive toggle produces a planner that
instantly approves its own plan. canUseTool is the hook, and whether it can be supplied after session
creation is the one thing to verify before designing any UI.
Records the landmines too — bypassPermissions is deliberate per CLAUDE.md and must be restored on exit, and
a canUseTool promise that never resolves wedges the turn.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# 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<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.
|
||||
|
||||
**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.
|
||||
Reference in New Issue
Block a user