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
+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);