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,