diff --git a/docs/chat-plan-mode.md b/docs/chat-plan-mode.md new file mode 100644 index 00000000..094d1317 --- /dev/null +++ b/docs/chat-plan-mode.md @@ -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, 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. diff --git a/docs/chat-sessions-list.md b/docs/chat-sessions-list.md index d04e7f8b..a9edd926 100644 --- a/docs/chat-sessions-list.md +++ b/docs/chat-sessions-list.md @@ -3,8 +3,11 @@ Agreed with the owner on 2026-08-17. Written down because the build spans several commits and the shape came out of a conversation rather than out of the code, so a context reset must not cost the reasoning. -**Status: not started.** The storage this depends on landed in `813bc200`; nothing in the list itself has -changed yet. +**Status: built.** `d59bc0d1` (one panel, three views), `640a74bc` (tabs, separator, sticky headings), +`773f0e1c` (blank-model fix), `5565867b` (indent). Storage landed in `813bc200`. + +Not done: the OpenCode half of Latest (`?all=1` is Claude-only — OpenCode publishes no all-directories +listing), and nothing here has been exercised against a running server beyond the owner's own screenshots. ---