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
@@ -1,5 +1,6 @@
export {
setChatSessionModel,
setChatSessionPlanMode,
getChatSessionModel,
getChatSessionModels,
seedMissingChatSessionModels,
@@ -10,24 +10,46 @@ import { chatSessionModels } from './schema';
* the agent that answered. That is the same distinction the per-message attribution makes in `websocket.ts`,
* and it is the reason this takes a plain string rather than reading the client's message itself.
*/
export async function setChatSessionModel(sessionId: string, model: string): Promise<void> {
export async function setChatSessionModel(sessionId: string, model: string, planMode?: boolean): Promise<void> {
await db
.insert(chatSessionModels)
.values({ sessionId, model })
.values({ sessionId, model, ...(planMode === undefined ? {} : { planMode }) })
.onConflictDoUpdate({
target: chatSessionModels.sessionId,
set: { model, updatedAt: new Date() },
// `planMode` is written only when the caller has an opinion. A turn that carries no plan-mode flag
// must not silently switch a planning session back off — the same rule the model half follows, where
// a bare harness id leaves the stored tier alone rather than overwriting it with a non-answer.
set: { model, ...(planMode === undefined ? {} : { planMode }), updatedAt: new Date() },
});
}
/** One session's stored model, or null when nothing has recorded one yet. */
export async function getChatSessionModel(sessionId: string): Promise<string | null> {
/**
* Turn plan mode on or off for a session that already has a row — UPDATE, deliberately not an upsert.
*
* The toggle can be flipped on a conversation that has never taken a turn, and such a session has no row
* yet and no model to invent for one (`model` is NOT NULL, and guessing it here would write a tier the user
* never picked). Rather than fabricate a row, this no-ops: the client holds the toggle in its own state and
* sends it with the next turn, and `setChatSessionModel` writes both fields together at the point where the
* model is finally known.
*
* So the two writers divide cleanly — this one is for a session with history, that one for a session
* acquiring it.
*/
export async function setChatSessionPlanMode(sessionId: string, planMode: boolean): Promise<void> {
await db
.update(chatSessionModels)
.set({ planMode, updatedAt: new Date() })
.where(inArray(chatSessionModels.sessionId, [sessionId]));
}
/** One session's stored model and plan mode, or null when nothing has recorded one yet. */
export async function getChatSessionModel(sessionId: string): Promise<{ model: string; planMode: boolean } | null> {
const [row] = await db
.select({ model: chatSessionModels.model })
.select({ model: chatSessionModels.model, planMode: chatSessionModels.planMode })
.from(chatSessionModels)
.where(inArray(chatSessionModels.sessionId, [sessionId]))
.limit(1);
return row?.model ?? null;
return row ?? null;
}
/**
@@ -37,13 +59,19 @@ export async function getChatSessionModel(sessionId: string): Promise<string | n
* per session on a screen that routinely shows a hundred of them. Returns an empty Map for an empty input
* rather than issuing `IN ()`, which Postgres rejects.
*/
export async function getChatSessionModels(sessionIds: string[]): Promise<Map<string, string>> {
export async function getChatSessionModels(
sessionIds: string[],
): Promise<Map<string, { model: string; planMode: boolean }>> {
if (!sessionIds.length) return new Map();
const rows = await db
.select({ sessionId: chatSessionModels.sessionId, model: chatSessionModels.model })
.select({
sessionId: chatSessionModels.sessionId,
model: chatSessionModels.model,
planMode: chatSessionModels.planMode,
})
.from(chatSessionModels)
.where(inArray(chatSessionModels.sessionId, sessionIds));
return new Map(rows.map((r) => [r.sessionId, r.model]));
return new Map(rows.map((r) => [r.sessionId, { model: r.model, planMode: r.planMode }]));
}
/**
@@ -1,4 +1,4 @@
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
/**
* Which model each chat session is using — the one fact about a session the transcript cannot answer.
@@ -49,5 +49,25 @@ export const chatSessionModels = pgTable('chat_session_models', {
* new model must never be a schema migration.
*/
model: text('model').notNull(),
/**
* Whether this conversation is in PLAN mode — the second thing about a session the transcript cannot say.
*
* It belongs beside the model rather than in a table of its own because it is the same kind of fact and
* answers the same question. `claude-code/opusplan` means "Opus while planning, Sonnet at rest", so the
* tier a session is actually paying for is not `model` alone — it is `model` AND this. Split across two
* tables, every read that wanted one would want the other.
*
* ── Why a boolean and not the PermissionMode string ──
*
* The SDK has five (`default`, `acceptEdits`, `bypassPermissions`, `plan`, `dontAsk`) and Officer exposes
* exactly two of them: plan, and the `bypassPermissions` the platform has always run on. A text column
* would accept the other three from any writer, and the sidecar has no handling for them — an
* `acceptEdits` here would restore as a mode nothing in the UI can show or turn off. A boolean cannot hold
* a value the system does not implement.
*
* `false` is the default in every sense: it is what every existing row means, and it is the unsandboxed
* behaviour CLAUDE.md requires the platform to keep as its baseline.
*/
planMode: boolean('plan_mode').notNull().default(false),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});