make plan mode reachable in chat

opusplan has been in the picker since 57bbcca9 and did nothing: it means
'Opus while planning, Sonnet at rest', and claude-manager hardcoded
permissionMode: 'bypassPermissions', so a session never entered plan mode
and opusplan rested forever on Sonnet.

verified against a real claude first (SDK 0.3.233), and one finding changed
the design: approving ExitPlanMode drops the harness into 'default', NOT back
into bypassPermissions, and the agent carries on inside the same turn. so the
restore is fired the moment approval is given rather than at turn end, and
canUseTool allows everything that is not ExitPlanMode - for a few hundred ms
it is the only thing between the agent and its tools, and a default-deny there
would jail an agent the platform deliberately does not jail.

canUseTool is passed always. under bypassPermissions the SDK shadows it
entirely (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED) so it costs a log line, and since
it is a query() option read once, passing it unconditionally is what makes the
mode toggleable on a live conversation instead of only at session start.

sticky per session on chat_session_models.plan_mode, beside the model because
they are one decision about what a turn costs. boolean rather than the
PermissionMode string: officer exposes two of the five, and a text column would
accept three the sidecar cannot handle.

plan approval is the only place the agent blocks on officer - a canUseTool
promise held in the sidecar while the plan round-trips to a browser. every path
that ends a session or a turn settles it as refused, plus a 20m timeout, because
a leaked entry is a hung turn rather than a stale value.

not auto-entered for opusplan: the CLI does not either. the toggle says so
instead - 'opusplan rests on Sonnet, turn on Plan for Opus'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:50:24 +00:00
co-authored by Claude Opus 5
parent 9be484a105
commit 1b811dfa28
24 changed files with 946 additions and 28 deletions
+15 -4
View File
@@ -136,10 +136,18 @@ chatRouter.get('/sessions', async (ctx) => {
than guessed" rule the picker follows. Never fatal: a database that will not answer this must not take
the conversation list down with it.
*/
const models = await getChatSessionModels(sessions.map((s) => s.id)).catch(() => new Map<string, string>());
const models = await getChatSessionModels(sessions.map((s) => s.id)).catch(
() => new Map<string, { model: string; planMode: boolean }>(),
);
return ctx.json({
sessions: sessions.map((s) => ({ ...s, model: models.get(s.id) ?? null })),
sessions: sessions.map((s) => {
const stored = models.get(s.id);
// Spelled out field by field rather than spread: this row goes onto the wire untyped (`ctx.json`),
// so widening the stored shape from a string to a record would have silently shipped an OBJECT as
// `model` and broken every consumer that calls `.split('/')` on it — with nothing to catch it.
return { ...s, model: stored?.model ?? null, planMode: stored?.planMode ?? false };
}),
});
});
@@ -191,14 +199,17 @@ chatRouter.get('/sessions/:id', async (ctx) => {
not taken a turn since `chat_session_models` landed. Null when neither can answer — the client shows no
model rather than guessing, which is the whole defect being fixed.
*/
const officerModel =
(await getChatSessionModel(id).catch(() => null)) ?? officerModelForApiModel(detail.model) ?? null;
const stored = await getChatSessionModel(id).catch(() => null);
const officerModel = stored?.model ?? officerModelForApiModel(detail.model) ?? null;
return ctx.json({
...detail,
// `detail.model` stays on the payload untouched — it is the honest record of what the API served, and
// the detail view may want it. `officerModel` is the separate question of what to select in the picker.
officerModel,
// Whether this conversation is planning, so the composer's toggle comes back in the state the user left
// it. Only a stored row can answer — plan mode leaves no trace in the transcript at all.
planMode: stored?.planMode ?? false,
messages,
total,
offset: windowed ? start : 0,
+62 -3
View File
@@ -70,6 +70,12 @@ export type ClientMessage =
context?: string;
contextId?: string;
resumeSummary?: string;
/**
* Run this turn in plan mode. Carried on the turn as well as settable on its own (`plan:mode` below),
* because a brand-new conversation has no live session to toggle and no stored row to update — its
* first turn is the first moment the preference can be acted on OR written down.
*/
planMode?: boolean;
}
| {
type: 'resume';
@@ -111,6 +117,35 @@ export type ClientMessage =
* it is the one claim worth checking against the agent — see `endTurnIfAgentIsGone`.
*/
generating?: boolean;
}
| {
// Flip plan mode between turns — the moment the toggle is actually used, since you decide to plan
// while reading the last answer rather than mid-generation.
type: 'plan:mode';
enabled: boolean;
/**
* The transcript uuid, so the preference can be made sticky right away.
*
* Sent by the client because the client is the only party that reliably has it between turns: it is in
* the address bar, while officer's `UserSession` never holds it (the id is the harness's, and officer
* learns it per-turn through `session:claude`). Absent on a conversation that has not taken a turn
* yet — there is no row to update and nothing to key one on, so the toggle simply rides along on the
* first turn instead.
*/
claudeSessionId?: string;
}
| {
/**
* Approve or reject a plan the agent is blocked on.
*
* `feedback` on a rejection is sent to the agent as the denial message, which the harness turns into
* "Please revise your plan based on the feedback and call ExitPlanMode again" — so rejecting with a
* reason is a revision request, not an abort.
*/
type: 'plan:decide';
requestId: string;
approve: boolean;
feedback?: string;
};
// The `Task` tool's id, when this piece of output came from a subagent rather than the agent you are
@@ -246,7 +281,24 @@ export type ServerMessage =
`ok: false` is a switch the harness refused. It is not an error message — the turn still ran and still
answered, as `from` — so it says what actually happened rather than what was asked for.
*/
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string };
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string }
/*
Plan mode, in three messages.
`plan:mode` is the toggle landing — durable for the same reason `model:switched` is, because "the agent
stopped being able to edit things here" explains a stretch of transcript that otherwise reads as an agent
that inexplicably refused to do the work. `ok: false` is a mode change the harness rejected.
`plan:proposed` is the agent asking to leave plan mode, and it is the one message in this file that the
AGENT IS BLOCKED ON: a `canUseTool` promise inside the sidecar stays pending until a decision comes back
with this `requestId`. Everything else here is a notification.
`plan:decided` records the answer. Both halves are durable so a refresh mid-plan replays the card and its
outcome instead of showing a conversation that stops dead at a plan nobody appears to have answered.
*/
| { type: 'plan:mode'; enabled: boolean; ok: boolean; error?: string }
| { type: 'plan:proposed'; requestId: string; plan: string }
| { type: 'plan:decided'; requestId: string; approved: boolean; feedback?: string };
// The turn-output subset of ServerMessage — everything the agent sidecar produces on its own. The
// remaining members (session:init, sync:messages, disconnected, connection-level errors) are officer's:
@@ -269,7 +321,10 @@ export type TurnMessageType =
| 'task:notification'
| 'compact:start'
| 'compact:done'
| 'model:switched';
| 'model:switched'
| 'plan:mode'
| 'plan:proposed'
| 'plan:decided';
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
@@ -305,7 +360,11 @@ export type ChatEvent =
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number }
// Emitted by `applyModelChange` when a live session moves onto a different tier — see claude-manager.
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string };
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string }
// Plan mode — see the matching ServerMessage members for what each one means.
| { type: 'plan:mode'; enabled: boolean; ok: boolean; error?: string }
| { type: 'plan:proposed'; requestId: string; plan: string }
| { type: 'plan:decided'; requestId: string; approved: boolean; feedback?: string };
export type UserSession = {
sessionId: string;
+112 -3
View File
@@ -24,6 +24,7 @@ import {
appendChatEvent,
getUserById,
setChatSessionModel,
setChatSessionPlanMode,
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState } from '@@/os-user-claude';
@@ -218,6 +219,10 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
await handleResumeCursor(ws, clientMsg);
} else if (clientMsg.type === 'attach') {
await handleAttach(ws, clientMsg);
} else if (clientMsg.type === 'plan:mode') {
await handlePlanMode(ws, clientMsg);
} else if (clientMsg.type === 'plan:decide') {
handlePlanDecision(ws, clientMsg);
}
} catch (err) {
logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) });
@@ -318,7 +323,7 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
}
// Shared by both harnesses now: whichever sidecar ran the turn has already translated and committed it.
function createMessageHandler(sessionId: string, model: string, resumeSessionId?: string) {
function createMessageHandler(sessionId: string, model: string, resumeSessionId?: string, planMode?: boolean) {
/*
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
@@ -353,6 +358,20 @@ function createMessageHandler(sessionId: string, model: string, resumeSessionId?
*/
let transcriptId: string | null = resumeSessionId ?? null;
/*
Plan mode, tracked exactly like `effective` above and for the same reason.
What the turn ASKED for is `planMode`; what the harness is actually on is whatever its `plan:mode`
messages last said, including the one `planGate` emits when a plan is approved and the session drops back
out of planning on its own. Storing the request would leave a session marked as planning long after it
stopped — and since this is the value the composer restores from, that is a toggle that comes back on by
itself.
Undefined until something says otherwise, so a client that never mentions plan mode writes nothing rather
than writing `false` over a session that is deliberately planning.
*/
let effectivePlanMode: boolean | undefined = planMode;
/**
* Write the model down, once both halves are known.
*
@@ -378,7 +397,7 @@ function createMessageHandler(sessionId: string, model: string, resumeSessionId?
answer alone rather than to overwrite it with a non-answer.
*/
if (!effective.includes('/')) return;
void setChatSessionModel(transcriptId, effective).catch((err: unknown) => {
void setChatSessionModel(transcriptId, effective, effectivePlanMode).catch((err: unknown) => {
logger.warn('Could not record the session model', {
sessionId,
transcriptId,
@@ -402,6 +421,13 @@ function createMessageHandler(sessionId: string, model: string, resumeSessionId?
record();
}
// Follow the harness, never the request — a refused switch (`ok: false`) leaves the session where it was,
// and recording the attempt would restore a mode the agent is not in.
if (msg.type === 'plan:mode' && msg.ok) {
effectivePlanMode = msg.enabled;
record();
}
// The transcript id arrives at the START of a turn, and again on the closing `result` for a session
// whose id was assigned mid-turn. Recording on both means a brand-new conversation gets its model
// written on its very first turn rather than on its second.
@@ -583,6 +609,7 @@ async function handleClaudeCodeChat(
cwdRoot?: string;
images?: PromptImage[];
resumeSessionId?: string;
planMode?: boolean;
},
effectivePrompt: string,
): Promise<void> {
@@ -659,7 +686,7 @@ async function handleClaudeCodeChat(
// The resume id is passed so a tier switch on a RESUMED conversation is recorded when it happens rather
// than when the turn ends — see the seed in `createMessageHandler`.
const onMessage = createMessageHandler(sessionId, model, msg.resumeSessionId);
const onMessage = createMessageHandler(sessionId, model, msg.resumeSessionId, msg.planMode);
/**
* One description of the turn, used by both paths below.
@@ -693,6 +720,9 @@ async function handleClaudeCodeChat(
// Never from the client: whose account a turn runs as is resolved here and nowhere else.
member: identity.kind === 'member' ? identity.run : undefined,
resumeSessionId: msg.resumeSessionId,
// Undefined means "no opinion" and leaves a live session's mode alone — see ClaudeSpawnStreamingParams.
// Only the composer's toggle sets it, so an older client sends nothing and nothing changes.
planMode: msg.planMode,
};
try {
@@ -859,6 +889,85 @@ async function handleResume(
}
}
/**
* Flip plan mode between turns.
*
* Two independent effects, and neither implies the other:
*
* - the LIVE session changes mode, if there is one. There often is not: the sidecar collects a session
* after thirty minutes idle, and reading an answer for half an hour before deciding to plan is an
* entirely ordinary thing to do. Nothing is wrong in that case — the next turn carries `planMode` and
* `createSession` opens in the right mode.
* - the PREFERENCE is stored, so a refresh restores the toggle. This is the half that makes the feature
* survive the page, and it is why the sticky column exists.
*
* Claude-only. OpenCode has no plan mode and no permission-mode control request, so a toggle there would be
* a switch that does nothing; the client hides it, and this refuses it rather than trusting that.
*/
async function handlePlanMode(
ws: ServerWebSocket<WSData>,
msg: { enabled: boolean; claudeSessionId?: string },
): Promise<void> {
const sessionId = wsToSessionMap.get(ws);
const session = sessionId ? sessionManager.getSession(sessionId) : null;
if (session && !isClaudeModel(session.model)) {
sendToClient(ws, {
type: 'plan:mode',
enabled: msg.enabled,
ok: false,
error: 'Plan mode is a Claude Code feature. OpenCode has no equivalent.',
});
return;
}
if (sessionId) sidecar.setClaudePlanMode(sessionId, ws.data.userId, msg.enabled);
if (msg.claudeSessionId) {
try {
await setChatSessionPlanMode(msg.claudeSessionId, msg.enabled);
} catch (err) {
// Bookkeeping, like the model record: worth a log, never worth failing the toggle the user just made.
logger.warn('Could not store the session plan mode', {
claudeSessionId: msg.claudeSessionId,
error: err instanceof Error ? err.message : String(err),
});
}
}
/*
Echoed immediately rather than waiting for the sidecar.
A live session will emit its own `plan:mode` through the durable log a moment later and the two agree —
but a session that is NOT live emits nothing at all, and without this the toggle would sit there having
visibly done nothing on exactly the conversations where using it is most natural.
*/
sendToClient(ws, { type: 'plan:mode', enabled: msg.enabled, ok: true });
}
/**
* Relay the user's plan decision to the agent that is blocked on it.
*
* Deliberately silent about the outcome: the sidecar answers through the session log with `plan:decided`,
* which reaches every socket watching this conversation rather than only the one that clicked. Two panes on
* the same chat is an ordinary setup here, and a plan resolved in one of them must not stay pending in the
* other.
*/
function handlePlanDecision(
ws: ServerWebSocket<WSData>,
msg: { requestId: string; approve: boolean; feedback?: string },
): void {
const sessionId = wsToSessionMap.get(ws);
if (!sessionId) return;
sidecar.decideClaudePlan({
sessionKey: sessionId,
userId: ws.data.userId,
requestId: msg.requestId,
approve: msg.approve,
feedback: msg.feedback,
});
}
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
const sessionId = wsToSessionMap.get(ws);