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
+194 -1
View File
@@ -230,6 +230,28 @@ type PersistentSession = {
* and answered as another.
*/
model?: string;
/**
* Whether this session is currently in plan mode.
*
* Same shape and same reason as `model` above: `permissionMode` is a `query()` option read once, so this
* is the only record of what the live session is actually on, and a change has to go through a control
* request rather than the next turn's options.
*
* It is also what `canUseTool` reads to decide whether an `ExitPlanMode` is a real decision or a formality
* — see `planGate`.
*/
planMode: boolean;
/**
* Plans waiting on a human, keyed by request id.
*
* Each entry is a `canUseTool` promise the AGENT IS BLOCKED ON, so this map is the one piece of session
* state whose leak is a hang rather than a stale value: an entry that is never resolved is a turn that
* never ends. Everything that can end a session resolves these first — see `settleAllPlans`.
*/
pendingPlans: Map<
string,
{ resolve: (decision: { approve: boolean; feedback?: string }) => void; timer: ReturnType<typeof setTimeout> }
>;
/** The tail of the child's stderr, so its exit can carry a reason instead of just a number. */
stderrTail: string;
/** Whether the child ever produced a single message. False plus a non-zero exit means it never started. */
@@ -467,6 +489,8 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
emit: onEvent,
isGenerating: false,
pendingTasks: new Set<string>(),
planMode: params.planMode === true,
pendingPlans: new Map(),
interrupted: false,
stderrTail: '',
sawOutput: false,
@@ -502,8 +526,30 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
// HOST_HOME is this process's home — the owner's. Defaulting a member's turn to it would start them in
// a directory they cannot read, and the failure would look like a broken agent rather than a wrong cwd.
cwd: params.cwd ?? params.member?.home ?? HOST_HOME,
permissionMode: 'bypassPermissions',
// `bypassPermissions` unless the conversation opened in plan mode. CLAUDE.md is explicit that the
// unsandboxed default is deliberate — this is the owner's own machine — so plan mode is the only
// thing that may ever move it, and it moves back the moment a plan is approved.
permissionMode: session.planMode ? 'plan' : 'bypassPermissions',
allowDangerouslySkipPermissions: true,
/**
* Passed ALWAYS, and shadowed almost always.
*
* Under `bypassPermissions` the SDK never consults this and says so once at startup
* (`CLAUDE_SDK_CAN_USE_TOOL_SHADOWED`) — verified 2026-08-17: a bypassing session made a file edit with
* zero calls here. But `canUseTool` is a `query()` option read once at session creation, so a session
* that did not pass it can never gate anything later. Passing it unconditionally is what makes plan
* mode toggleable on a LIVE conversation instead of only choosable when one starts, and it costs a
* log line.
*
* It allows everything that is not `ExitPlanMode`, and that is not laziness. Approving a plan drops
* the harness into `default` — not back into `bypassPermissions` — so for the rest of that turn this
* callback is the only thing between the agent and its tools. A default-deny here would jail an agent
* the platform deliberately does not jail.
*/
canUseTool: async (toolName, toolInput) => {
if (toolName !== 'ExitPlanMode' || !session.planMode) return { behavior: 'allow', updatedInput: toolInput };
return planGate(session, toolInput);
},
includePartialMessages: true,
// The only warning that compaction is about to happen. Everything else the harness does narrates
// itself through the message stream; compaction goes silent for as long as it takes — 2.5 minutes
@@ -649,6 +695,9 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
} finally {
if (session.idleTimer) clearTimeout(session.idleTimer);
if (session.stallTimer) clearTimeout(session.stallTimer);
// Before the session leaves the map, so nothing is left holding a promise whose resolver has just
// become unreachable.
settleAllPlans(session, 'The session ended before the plan was answered.');
sessions.delete(sessionKey);
}
})();
@@ -674,6 +723,7 @@ export async function spawnClaudeStreaming(
if (session) {
session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing)
await applyModelChange(session, params.model);
await applyPlanModeChange(session, params.planMode);
} else {
session = createSession(params, onEvent);
}
@@ -715,6 +765,145 @@ async function applyModelChange(session: PersistentSession, model: string | unde
}
}
/*
How long a plan may sit unanswered before it is refused on the user's behalf.
This is a deadlock timer, not a UX one. `canUseTool` gates the turn: while a plan is pending the agent is
blocked, `isGenerating` stays true, and no further events are emitted — which also means the stall
watchdog above sees a silent turn and would eventually tear the whole session down. Refusing after twenty
minutes gives a plan a realistic reading time while guaranteeing the turn ends by a route that keeps the
conversation, since a denial is something the agent can answer.
*/
const PLAN_DECISION_TIMEOUT_MS = 20 * 60 * 1000;
/**
* Hold `ExitPlanMode` open while a human decides.
*
* The one place in this file where the agent waits on Officer rather than the other way round. The promise
* returned here is the harness's permission answer, so every path out of it must resolve — a throw or a
* forgotten branch is a wedged turn, and the symptom would be a chat that shows a plan and then nothing.
*/
async function planGate(
session: PersistentSession,
toolInput: Record<string, unknown>,
): Promise<{ behavior: 'allow'; updatedInput: Record<string, unknown> } | { behavior: 'deny'; message: string }> {
const plan = typeof toolInput.plan === 'string' ? toolInput.plan : '';
const requestId = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const decision = await new Promise<{ approve: boolean; feedback?: string }>((resolve) => {
const timer = setTimeout(() => {
session.pendingPlans.delete(requestId);
console.warn(`[claude:plan:${session.sessionKey}] no decision in 20m — refusing on the user's behalf`);
resolve({ approve: false, feedback: 'No decision was made in time. The plan was not approved.' });
}, PLAN_DECISION_TIMEOUT_MS);
session.pendingPlans.set(requestId, { resolve, timer });
session.emit({ type: 'plan:proposed', requestId, plan });
});
session.emit({ type: 'plan:decided', requestId, approved: decision.approve, feedback: decision.feedback });
if (!decision.approve) {
// Denial is the REVISION path, not an abort: the harness feeds this string back and asks the agent to
// call ExitPlanMode again. So the message is the feedback, and a bare refusal still needs to say
// something the agent can act on.
return {
behavior: 'deny',
message: decision.feedback?.trim() || 'The plan was rejected. Revise it and ask again.',
};
}
/*
Approval leaves plan mode — but into `default`, NOT back into `bypassPermissions` (verified 2026-08-17).
The agent carries straight on inside this same turn, so the restore is fired here rather than at turn end.
Deliberately NOT awaited, and the ordering is the reason: the harness is waiting on the promise this
function returns, so awaiting a control request before answering it invites a deadlock. Any tool call
that lands in the gap is allowed by the callback above, which makes the window a matter of overhead
rather than correctness.
*/
session.planMode = false;
void session.query.setPermissionMode('bypassPermissions').catch((err: unknown) => {
console.error(`[claude:plan:${session.sessionKey}] could not restore bypassPermissions: ${String(err)}`);
});
session.emit({ type: 'plan:mode', enabled: false, ok: true });
return { behavior: 'allow', updatedInput: toolInput };
}
/**
* Resolve every outstanding plan as refused.
*
* Called from every path that ends a session or a turn. A pending plan is a promise the agent is blocked
* on, so leaving one behind when the session goes away is the difference between a turn that ends and a
* turn that hangs until the stall watchdog notices.
*/
function settleAllPlans(session: PersistentSession, reason: string): void {
for (const [requestId, pending] of session.pendingPlans) {
clearTimeout(pending.timer);
session.pendingPlans.delete(requestId);
pending.resolve({ approve: false, feedback: reason });
}
}
/**
* The user's answer to a pending plan. Returns false when there is nothing waiting under that id.
*
* A miss is ordinary rather than exceptional: a client that reconnects and replays its last action can
* answer a plan that has already timed out, and the honest response is to ignore it — the alternative is
* resolving a promise that no longer exists, or worse, a newer plan that happens to be pending.
*/
export function decideClaudePlan(
sessionKey: string,
userId: number,
requestId: string,
approve: boolean,
feedback?: string,
): boolean {
const session = ownedSession(sessionKey, userId);
const pending = session?.pendingPlans.get(requestId);
if (!session || !pending) return false;
clearTimeout(pending.timer);
session.pendingPlans.delete(requestId);
pending.resolve({ approve, feedback });
return true;
}
/**
* Turn plan mode on or off on a live session — the same control-request shape as `applyModelChange`.
*
* Reachable two ways, because the toggle is used at two different moments: on the turn itself (a new
* conversation whose first turn should plan) and on its own between turns (the common case — you decide to
* plan while reading the last answer, when there is no turn to carry the flag).
*
* A refusal is reported and swallowed for the same reason a model refusal is: the conversation is worth more
* than the mode, so the session stays on what it was and says so rather than losing the turn.
*/
async function applyPlanModeChange(session: PersistentSession, enabled: boolean | undefined): Promise<void> {
if (enabled === undefined || enabled === session.planMode) return;
try {
await session.query.setPermissionMode(enabled ? 'plan' : 'bypassPermissions');
session.planMode = enabled;
console.log(`[claude:plan:${session.sessionKey}] plan mode ${enabled ? 'on' : 'off'}`);
session.emit({ type: 'plan:mode', enabled, ok: true });
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.error(`[claude:plan:${session.sessionKey}] refused plan mode ${enabled ? 'on' : 'off'}: ${reason}`);
// `session.planMode` is deliberately untouched: believing a switch that failed would put the gate above
// out of step with the harness, and the visible symptom of that is an ExitPlanMode nobody is asked about.
session.emit({ type: 'plan:mode', enabled, ok: false, error: reason });
}
}
/** Flip plan mode on a live session from outside a turn. Returns false when no such session is live. */
export async function setClaudePlanMode(sessionKey: string, userId: number, enabled: boolean): Promise<boolean> {
const session = ownedSession(sessionKey, userId);
if (!session) return false;
await applyPlanModeChange(session, enabled);
return true;
}
/** Interrupt the current turn but KEEP the session alive (the "stop" button). */
export async function interruptClaudeSession(sessionKey: string, userId: number): Promise<boolean> {
const session = ownedSession(sessionKey, userId);
@@ -722,6 +911,9 @@ export async function interruptClaudeSession(sessionKey: string, userId: number)
// Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the
// consumer loop reads this flag to tell a stop from a fault.
session.interrupted = true;
// Stop cannot reach a turn that is blocked on a plan — the agent is not running, it is waiting on us —
// so the promise has to be released for the interrupt to have anything to interrupt.
settleAllPlans(session, 'The turn was stopped before the plan was answered.');
try {
await session.query.interrupt();
session.isGenerating = false;
@@ -740,6 +932,7 @@ export function killClaudeSession(sessionKey: string, userId: number): boolean {
if (!session) return false;
if (session.idleTimer) clearTimeout(session.idleTimer);
if (session.stallTimer) clearTimeout(session.stallTimer);
settleAllPlans(session, 'The session was closed before the plan was answered.');
try {
session.abort.abort();
} catch {
@@ -186,6 +186,49 @@ describe('createTurnStream', () => {
]);
});
/*
Plan mode's three events, and the one property that is not obvious.
A plan is the only DURABLE row the transcript needs in order to stay honest after a refresh: the card is
interactive, so a plan that replayed without its decision would come back offering to approve something
that was approved an hour ago. Both halves therefore have to survive, and `plan:proposed` has to land
after the agent's introduction to it rather than inside the sentence.
*/
test('a plan is durable and lands after the words that introduce it', () => {
const { durable } = run([
{ type: 'delta', text: 'Here is what I propose:' },
{ type: 'plan:proposed', requestId: 'plan_1', plan: '# Plan\n\nDo the thing.' },
]);
expect(durable).toEqual([
{ type: 'assistant:text', text: 'Here is what I propose:' },
{ type: 'plan:proposed', requestId: 'plan_1', plan: '# Plan\n\nDo the thing.' },
]);
});
test('a decision is durable and carries its feedback, so a rejected plan replays with its reason', () => {
// The feedback IS what the agent was told to revise against, so the pair only makes sense together.
const { durable } = run([{ type: 'plan:decided', requestId: 'plan_1', approved: false, feedback: 'too broad' }]);
expect(durable).toEqual([{ type: 'plan:decided', requestId: 'plan_1', approved: false, feedback: 'too broad' }]);
});
test('plan mode going on or off is a durable boundary, like a model switch', () => {
// Same reasoning as the model marker: without it, a stretch where the agent researched and refused to
// edit anything reads as an agent that lost its nerve.
const { durable } = run([
{ type: 'delta', text: 'ok' },
{ type: 'plan:mode', enabled: true, ok: true },
]);
expect(durable).toEqual([
{ type: 'assistant:text', text: 'ok' },
{ type: 'plan:mode', enabled: true, ok: true },
]);
});
test('a refused plan-mode change is recorded with its reason', () => {
const { durable } = run([{ type: 'plan:mode', enabled: true, ok: false, error: 'nope' }]);
expect(durable).toEqual([{ type: 'plan:mode', enabled: true, ok: false, error: 'nope' }]);
});
test('a refused switch is recorded too, with its reason', () => {
// Silence here would be the worst outcome: the turn answers as the OLD model while the picker shows the
// new one, which is the exact mismatch this whole change exists to remove.
+37
View File
@@ -150,6 +150,43 @@ export function createTurnStream(sessionId: string): TurnStream {
},
];
case 'plan:mode':
// Flushed like `model:switched`, and for the same reason: the marker's job is to say WHERE in the
// conversation the rules changed, which is worthless if it lands mid-paragraph.
return [
...flushAll(),
{
msg: {
type: 'plan:mode',
enabled: event.enabled,
ok: event.ok,
...(event.error ? { error: event.error } : {}),
},
durable: true,
},
];
case 'plan:proposed':
// Flushed because the agent's closing words before a plan are its introduction to it, and a card
// spliced into an unfinished sentence reads as an interruption rather than a conclusion.
return [
...flushAll(),
{ msg: { type: 'plan:proposed', requestId: event.requestId, plan: event.plan }, durable: true },
];
case 'plan:decided':
return [
{
msg: {
type: 'plan:decided',
requestId: event.requestId,
approved: event.approved,
...(event.feedback ? { feedback: event.feedback } : {}),
},
durable: true,
},
];
case 'compact:done':
return [
{
@@ -297,6 +297,18 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
reply({ type: 'claude:interrupted', id: cmd.id });
break;
// Both plan commands answer through the SESSION LOG rather than with a reply, and that is the point:
// what the user needs to see is `plan:mode` / `plan:decided` arriving in the conversation, durably and
// in order with everything else. A command reply would be a second, weaker channel saying the same
// thing to one socket — and officer sends both of these fire-and-forget, so nothing is waiting on one.
case 'claude:set-plan-mode':
await claudeManager.setClaudePlanMode(cmd.sessionKey, cmd.userId, cmd.enabled);
break;
case 'claude:plan-decision':
claudeManager.decideClaudePlan(cmd.sessionKey, cmd.userId, cmd.requestId, cmd.approve, cmd.feedback);
break;
case 'claude:list':
reply({ type: 'claude:sessions', id: cmd.id, sessions: claudeManager.listSessions(cmd.userId) });
break;
+30
View File
@@ -59,6 +59,27 @@ export type SidecarCommand =
// Is a turn still running for this session? Only the process that owns the session can say, which is
// exactly why it is asked over the wire — see `isClaudeGenerating` in sidecar-registry.
| { type: 'claude:is-generating'; id: string; sessionKey: string; userId: number }
// Turn plan mode on or off on a session that is already live, without waiting for the next turn. The
// toggle is meant to be usable while reading the agent's last answer, which is exactly when no turn is
// in flight to carry it.
| { type: 'claude:set-plan-mode'; id: string; sessionKey: string; userId: number; enabled: boolean }
/**
* The user's answer to a plan the agent asked to exit with.
*
* This is the only command that resolves a promise the AGENT is blocked on: `canUseTool` is holding
* `ExitPlanMode` open inside the sidecar while this round-trips to a browser and back. `requestId`
* identifies which one — a session can only have a single plan outstanding today, but keying on the id
* means a stale answer from a reconnected client cannot resolve a newer plan.
*/
| {
type: 'claude:plan-decision';
id: string;
sessionKey: string;
userId: number;
requestId: string;
approve: boolean;
feedback?: string;
}
// Which session key owns this transcript? The map lives on the agent's disk, so only it can answer —
// see `findClaudeSessionKey` in sidecar-registry, and `attach` in the chat socket for why it is asked.
| { type: 'claude:find-session'; id: string; claudeSessionId: string; userId: number }
@@ -187,6 +208,15 @@ export type ClaudeSpawnStreamingParams = {
cwd?: string;
model?: string;
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
/**
* Run this turn in PLAN mode — research and propose, execute nothing until the plan is approved.
*
* Carried on the turn for the same reason `model` is: both are `query()` options read once at session
* creation, so both need a control request to change on a live session, and the turn is where the client's
* current intent arrives. Absent means "no opinion" and leaves a live session's mode alone; an explicit
* `false` turns planning off.
*/
planMode?: boolean;
// Whether turn output should be committed to chat_session_events (default true). A chat session wants
// it — that is what survives an officer restart. A pipeline step does not: its sessionKey is a throwaway
// uuid no browser will ever replay, and the job's own event log is its record.