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:
@@ -108,6 +108,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
// stored row, falling back to a family guess. Null means "unknown", and the picker must then
|
||||
// leave the selection alone rather than guess — see ModelSelector.
|
||||
model: detail.officerModel ?? undefined,
|
||||
planMode: detail.planMode,
|
||||
resumeSessionId: sessionId,
|
||||
initialMessages: detail.messages as unknown as NonNullable<SelectedSession>['initialMessages'],
|
||||
total: detail.total,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -297,6 +297,23 @@ export function interruptClaude(sessionKey: string, userId: number): void {
|
||||
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId });
|
||||
}
|
||||
|
||||
// Plan mode, both directions. Fire-and-forget on purpose: the agent's answer to either one comes back
|
||||
// through the session log as a `plan:mode` / `plan:decided` message, which is durable, ordered with the
|
||||
// rest of the turn, and delivered to every socket watching — none of which a command reply would be.
|
||||
export function setClaudePlanMode(sessionKey: string, userId: number, enabled: boolean): void {
|
||||
sendFire('claude', { type: 'claude:set-plan-mode', id: nextId(), sessionKey, userId, enabled });
|
||||
}
|
||||
|
||||
export function decideClaudePlan(params: {
|
||||
sessionKey: string;
|
||||
userId: number;
|
||||
requestId: string;
|
||||
approve: boolean;
|
||||
feedback?: string;
|
||||
}): void {
|
||||
sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the agent still running a turn for this session?
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -74,8 +74,11 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
selectedModel,
|
||||
hasStarted,
|
||||
thinkingLevel,
|
||||
planMode,
|
||||
setSelectedModel,
|
||||
setThinkingLevel,
|
||||
setPlanMode,
|
||||
decidePlan,
|
||||
sendPrompt,
|
||||
stopGeneration: stopTurn,
|
||||
loadOlder,
|
||||
@@ -572,8 +575,11 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
selectedModel,
|
||||
hasStarted,
|
||||
thinkingLevel,
|
||||
planMode,
|
||||
setSelectedModel,
|
||||
setThinkingLevel,
|
||||
setPlanMode,
|
||||
decidePlan,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
input,
|
||||
|
||||
@@ -41,6 +41,8 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
appendToInput,
|
||||
thinkingLevel,
|
||||
setThinkingLevel,
|
||||
planMode,
|
||||
setPlanMode,
|
||||
queued,
|
||||
sendQueuedNow,
|
||||
unqueue,
|
||||
@@ -162,6 +164,8 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
thinkingLevel={thinkingLevel}
|
||||
onThinkingChange={setThinkingLevel}
|
||||
onBackgroundJob={() => setJobDialogOpen(true)}
|
||||
planMode={planMode}
|
||||
onPlanModeChange={setPlanMode}
|
||||
/>
|
||||
|
||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
RotateCcw,
|
||||
FoldVertical,
|
||||
ArrowLeftRight,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import type { Tone } from '@/components/Data';
|
||||
@@ -120,11 +121,13 @@ type MessageBubbleProps = {
|
||||
message: ChatMessage;
|
||||
onAnswer?: (text: string) => void;
|
||||
onRetry?: (prompt: string) => void;
|
||||
/** Answer a plan the agent is parked on. Absent in read-only views, where the card renders without buttons. */
|
||||
onDecidePlan?: (requestId: string, approve: boolean, feedback?: string) => void;
|
||||
/** Set for rows in the turn you are watching; tool rows start expanded and stay that way until it folds. */
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: MessageBubbleProps) => {
|
||||
export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaultOpen }: MessageBubbleProps) => {
|
||||
switch (message.role) {
|
||||
case 'user': {
|
||||
const rawText = typeof message.text === 'string' ? message.text : '';
|
||||
@@ -215,6 +218,12 @@ export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: Messa
|
||||
case 'model':
|
||||
return <ModelSwitch message={message} />;
|
||||
|
||||
case 'planmode':
|
||||
return <PlanModeSwitch message={message} />;
|
||||
|
||||
case 'plan':
|
||||
return <PlanCard message={message} onDecide={onDecidePlan} />;
|
||||
|
||||
case 'interrupted':
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
@@ -382,6 +391,132 @@ const ModelSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'model
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Plan mode going on or off — the same centred rule as `ModelSwitch`, and the same kind of fact.
|
||||
*
|
||||
* Worth a marker rather than a silent state change because it explains behaviour that otherwise looks like
|
||||
* a fault: in plan mode the agent researches and proposes but will not edit anything, and a transcript with
|
||||
* no notice in it reads as an agent that inexplicably stopped doing the work.
|
||||
*/
|
||||
const PlanModeSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'planmode' }> }) => (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{message.ok ? (
|
||||
message.enabled ? (
|
||||
'Plan mode on — research only'
|
||||
) : (
|
||||
'Plan mode off'
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Plan mode unchanged
|
||||
<span className="normal-case opacity-70">· {message.error ?? 'the agent refused'}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* A plan, with the two buttons the agent is waiting on.
|
||||
*
|
||||
* The only blocking row in the transcript: while `approved` is undefined there is a real `canUseTool`
|
||||
* promise parked in the sidecar, so this card is not a summary of something that happened — it is the thing
|
||||
* that has to happen next. It is styled as a card rather than a rule for exactly that reason; every other
|
||||
* marker here is scenery, and this one is a question.
|
||||
*
|
||||
* Rejection takes a reason and sends it, because the harness treats a denial message as revision feedback
|
||||
* ("revise your plan and call ExitPlanMode again") rather than as an abort. So the useful rejection is a
|
||||
* sentence, and the box is offered before the button rather than after it.
|
||||
*/
|
||||
const PlanCard = ({
|
||||
message,
|
||||
onDecide,
|
||||
}: {
|
||||
message: Extract<ChatMessage, { role: 'plan' }>;
|
||||
onDecide?: (requestId: string, approve: boolean, feedback?: string) => void;
|
||||
}) => {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const decided = message.approved !== undefined;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={`w-full max-w-[85%] min-w-0 rounded-2xl border px-4 py-3 text-sm ${
|
||||
decided ? 'border-border bg-muted/50' : 'border-primary/40 bg-primary/5'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase text-muted-foreground">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{message.approved === undefined
|
||||
? 'Plan — awaiting your approval'
|
||||
: message.approved
|
||||
? 'Plan approved'
|
||||
: 'Plan rejected'}
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MD_COMPONENTS}>
|
||||
{message.plan}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* The reason a rejected plan was rejected, kept on the card. It is what the agent was told, so the
|
||||
next plan is a response to it and the pair only makes sense read together. */}
|
||||
{decided && message.feedback && (
|
||||
<div className="mt-2 border-l-2 border-border pl-2 text-xs text-muted-foreground">{message.feedback}</div>
|
||||
)}
|
||||
|
||||
{!decided && onDecide && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{rejecting && (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={feedback}
|
||||
onChange={(ev) => setFeedback(ev.target.value)}
|
||||
placeholder="What should change? The agent revises the plan from this."
|
||||
rows={2}
|
||||
className="w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-xs outline-none focus:border-primary/50"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{rejecting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRejecting(false)}
|
||||
className="cursor-pointer rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (rejecting ? onDecide(message.requestId, false, feedback.trim()) : setRejecting(true))}
|
||||
className="flex cursor-pointer items-center gap-1 rounded border border-border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{rejecting ? 'Send feedback' : 'Reject'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDecide(message.requestId, true)}
|
||||
className="flex cursor-pointer items-center gap-1 rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The wait between pressing send and the first token — tens of seconds with thinking enabled — used to
|
||||
* render nothing at all, because the streaming bubble bailed on empty text. The transcript looked frozen
|
||||
|
||||
@@ -24,6 +24,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
showJumpToBottom,
|
||||
jumpToBottom,
|
||||
sendPrompt,
|
||||
decidePlan,
|
||||
scrollViewportRef,
|
||||
bottomRef,
|
||||
isLoadingOlder,
|
||||
@@ -131,6 +132,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
message={row.message}
|
||||
onAnswer={(text) => sendPrompt(text)}
|
||||
onRetry={(prompt) => sendPrompt(prompt)}
|
||||
onDecidePlan={decidePlan}
|
||||
// Deliberately not `isGenerating`: a turn's work stays open after it finishes, and
|
||||
// closes when you send the next message and the turn folds behind its summary.
|
||||
defaultOpen={row.live && watched.current}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CirclePlay } from 'lucide-react';
|
||||
import { CirclePlay, ClipboardList } from 'lucide-react';
|
||||
import { toneText } from '@/components/Data';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { ModelOption } from 'state/useModels';
|
||||
@@ -42,6 +42,15 @@ type ModelSelectorProps = {
|
||||
onThinkingChange?: (level: string | null) => void;
|
||||
/** Opens the background-job composer. Absent where the feature is not offered. */
|
||||
onBackgroundJob?: () => void;
|
||||
/**
|
||||
* Plan mode — research and propose, execute nothing until approved.
|
||||
*
|
||||
* Lives here rather than beside the send button because it belongs with the MODEL: `claude-code/opusplan`
|
||||
* is defined in terms of it (Opus while planning, Sonnet at rest), so the two controls are one decision
|
||||
* about what this turn will cost.
|
||||
*/
|
||||
planMode?: boolean;
|
||||
onPlanModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function ModelSelector({
|
||||
@@ -54,6 +63,8 @@ export function ModelSelector({
|
||||
isGenerating,
|
||||
hasStarted,
|
||||
onBackgroundJob,
|
||||
planMode = false,
|
||||
onPlanModeChange,
|
||||
}: ModelSelectorProps) {
|
||||
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
||||
|
||||
@@ -194,6 +205,37 @@ export function ModelSelector({
|
||||
<CirclePlay className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
Plan mode.
|
||||
|
||||
Claude-only, and hidden rather than disabled for OpenCode: it has no plan mode and no permission-mode
|
||||
control request, so there is nothing a greyed-out button here would be promising.
|
||||
|
||||
The label carries the `opusplan` pairing, which is the one thing about this that is not guessable.
|
||||
`claude-code/opusplan` means "Opus while planning, Sonnet at rest" — so with plan mode OFF it is just
|
||||
Sonnet, quietly, and a user who picked it expecting Opus would never find that out. Officer does NOT
|
||||
auto-enter plan mode for it, because the CLI does not either; it says so instead.
|
||||
*/}
|
||||
{onPlanModeChange && currentHarness !== 'opencode' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlanModeChange(!planMode)}
|
||||
disabled={!isConnected}
|
||||
aria-pressed={planMode}
|
||||
title={
|
||||
displayModel?.includes('opusplan') && !planMode
|
||||
? 'opusplan rests on Sonnet — turn on Plan for Opus'
|
||||
: planMode
|
||||
? 'Plan mode on — the agent researches and proposes, and edits nothing until you approve'
|
||||
: 'Plan first — research and propose before making any changes'
|
||||
}
|
||||
className={`shrink-0 cursor-pointer rounded-md p-1.5 transition-colors disabled:cursor-default disabled:opacity-40 ${
|
||||
planMode ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:bg-foreground/10'
|
||||
}`}
|
||||
>
|
||||
<ClipboardList className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
{/* The thinking toggle used to render here. It is gone because it did nothing: `thinking` is
|
||||
accepted on the wire (types.ts, websocket.ts) and forwarded by NEITHER channel — not
|
||||
send-claude-code.ts, not send-opencode.ts — so pressing it changed the label and nothing else.
|
||||
|
||||
@@ -20,7 +20,17 @@ import type { ChatMessage } from '../types';
|
||||
function isStructural(message: ChatMessage): boolean {
|
||||
// `model` belongs here for the same reason `compact` does: it is the answer to "which half of this
|
||||
// conversation did opus write", which is worthless folded away inside a turn summary.
|
||||
return message.role === 'divider' || message.role === 'compact' || message.role === 'model';
|
||||
//
|
||||
// `plan` belongs here for a stronger reason than any of the others: it is INTERACTIVE and the agent is
|
||||
// blocked on it. Folding one away behind a turn summary would hide the buttons a parked turn is waiting
|
||||
// for, and the symptom would be a chat that appears to have stopped for no reason.
|
||||
return (
|
||||
message.role === 'divider' ||
|
||||
message.role === 'compact' ||
|
||||
message.role === 'model' ||
|
||||
message.role === 'planmode' ||
|
||||
message.role === 'plan'
|
||||
);
|
||||
}
|
||||
|
||||
/** The work of a turn: everything that belongs behind the summary row. */
|
||||
|
||||
@@ -104,6 +104,31 @@ export type ChatMessage =
|
||||
to: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
// Where plan mode went on or off. Same reasoning as `model` above: it is a boundary, and without it a
|
||||
// stretch of transcript where the agent researched but refused to edit anything reads as an agent
|
||||
// that lost its nerve.
|
||||
role: 'planmode';
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* A plan, and its decision, in one row that resolves in place — the same shape as `task` and
|
||||
* `compact`.
|
||||
*
|
||||
* This is the only row in the transcript that is INTERACTIVE and BLOCKING: while `approved` is
|
||||
* undefined an agent is genuinely parked on a `canUseTool` promise waiting for one of these buttons.
|
||||
* That is why the decision resolves onto this row instead of appending a second one — two bubbles
|
||||
* would leave the original card sitting there still offering to approve something already approved.
|
||||
*/
|
||||
role: 'plan';
|
||||
requestId: string;
|
||||
plan: string;
|
||||
approved?: boolean;
|
||||
feedback?: string;
|
||||
};
|
||||
|
||||
export type RunningTask = { taskId: string; description: string; taskType?: string };
|
||||
@@ -167,7 +192,12 @@ export type ServerMessage =
|
||||
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
|
||||
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number }
|
||||
/** The conversation moved onto a different model. See the `model` message role. */
|
||||
| { 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 went on or off. See the `planmode` message role. */
|
||||
| { type: 'plan:mode'; enabled: boolean; ok: boolean; error?: string }
|
||||
/** The agent is blocked, waiting for this plan to be approved. See the `plan` message role. */
|
||||
| { type: 'plan:proposed'; requestId: string; plan: string }
|
||||
| { type: 'plan:decided'; requestId: string; approved: boolean; feedback?: string };
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
|
||||
@@ -30,6 +30,8 @@ export type SelectedSession = {
|
||||
title?: string | null;
|
||||
/** Transcripts spliced into `initialMessages`; `> 1` when this is a merged `/clear` chain. */
|
||||
partCount?: number;
|
||||
/** Whether this conversation was left in plan mode, so the composer toggle comes back lit. */
|
||||
planMode?: boolean;
|
||||
} | null;
|
||||
|
||||
type ChatLocationState = {
|
||||
@@ -140,10 +142,12 @@ type NewChatProps = {
|
||||
sessionCwd?: string | null;
|
||||
sessionTitle?: string | null;
|
||||
partCount?: number;
|
||||
/** Plan mode as this session was left. Only the stored row knows — the transcript records nothing. */
|
||||
planMode?: boolean;
|
||||
};
|
||||
|
||||
function NewChat(props: NewChatProps) {
|
||||
const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd } = props;
|
||||
const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd, planMode } = props;
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
||||
@@ -161,6 +165,7 @@ function NewChat(props: NewChatProps) {
|
||||
initialMessages,
|
||||
onTurnComplete,
|
||||
context: 'chat',
|
||||
initialPlanMode: planMode,
|
||||
// Only the tail is loaded up front — let the chat page older messages upward on scroll.
|
||||
paginate:
|
||||
resumeSessionId && typeof total === 'number'
|
||||
@@ -261,6 +266,7 @@ export const ChatDetailPanel = () => {
|
||||
sessionCwd={selected.cwd}
|
||||
sessionTitle={title}
|
||||
partCount={selected.partCount}
|
||||
planMode={selected.planMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Check, Folder, Layers, Loader2, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { Check, ClipboardList, Folder, Layers, Loader2, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { DataRow, MetaItem, RelativeTime } from '@/components/Data';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
@@ -47,6 +47,22 @@ export const ModelBadge = ({ model }: { model?: string | null }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This conversation plans before it acts.
|
||||
*
|
||||
* Worth a badge of its own rather than folding into `ModelBadge`, because it is not a tier — the same model
|
||||
* behaves completely differently under it, and `opusplan` in particular is only Opus while this is on. So a
|
||||
* row reading `opusplan` with no plan badge is a row running Sonnet, which is exactly the thing that is
|
||||
* impossible to work out from the model name alone.
|
||||
*/
|
||||
export const PlanBadge = ({ planMode }: { planMode?: boolean }) =>
|
||||
planMode ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
plan
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/**
|
||||
* This conversation has an agent behind it right now.
|
||||
*
|
||||
@@ -218,6 +234,7 @@ export const SessionRow = ({
|
||||
<RelativeTime key="t" value={session.updatedAt} />,
|
||||
<HarnessBadge key="h" harness={session.harness} />,
|
||||
<ModelBadge key="m" model={session.model} />,
|
||||
<PlanBadge key="pm" planMode={session.planMode} />,
|
||||
// Claude's transcripts give a real turn count. OpenCode's session list has no count field, so the
|
||||
// number on those rows is a hardcoded 0 and costs an HTTP call per row to make real — the badge
|
||||
// stands in its place rather than beside a zero that means "not asked".
|
||||
|
||||
@@ -30,6 +30,8 @@ type UsePiChatOptions = {
|
||||
// When set, `initialMessages` is only the tail of a long transcript; scroll-up pages older ones in.
|
||||
paginate?: { sessionId: string; total: number; initialOffset: number };
|
||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||
/** Plan mode as the session was left, from `chat_session_models` — see `initialPlanMode` in useChat. */
|
||||
initialPlanMode?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
initialMessages: preloadedMessages,
|
||||
paginate,
|
||||
onTurnComplete,
|
||||
initialPlanMode,
|
||||
} = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(preloadedMessages ?? []);
|
||||
/**
|
||||
@@ -88,6 +91,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
|
||||
const [cwd, setCwd] = useState<string | null>(null);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<string | null>(null);
|
||||
/*
|
||||
Plan mode, seeded from the stored row so a refresh comes back planning.
|
||||
|
||||
A ref alongside the state for the same reason `modelRef` has one: `sendPrompt` is called from closures
|
||||
built a render earlier, and the turn must carry what the toggle says NOW rather than what it said when
|
||||
that closure was created.
|
||||
*/
|
||||
const [planMode, setPlanModeState] = useState(initialPlanMode === true);
|
||||
const planModeRef = useRef(initialPlanMode === true);
|
||||
|
||||
// Resilient-transport cursor: the max durable-event `seq` seen. On (re)connect we send it as a
|
||||
// resume-cursor so the server replays anything we missed (e.g. a background task:notification that
|
||||
@@ -119,6 +131,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
if (initialModel) setSelectedModel(initialModel);
|
||||
}, [initialModel]);
|
||||
|
||||
// Same, for plan mode: the detail request that carries it resolves after the first render, so the toggle
|
||||
// has to follow it in. Unlike the model above this syncs `false` as well — plan mode being OFF is a real
|
||||
// answer about a session, not the absence of one.
|
||||
useEffect(() => {
|
||||
if (initialPlanMode === undefined) return;
|
||||
setPlanModeState(initialPlanMode);
|
||||
planModeRef.current = initialPlanMode;
|
||||
}, [initialPlanMode]);
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
@@ -483,6 +504,49 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plan:mode':
|
||||
// Committed first, like every other boundary marker: the notice says where the rules changed, so
|
||||
// whatever was streamed under the old ones has to land above it.
|
||||
commitStreaming();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'planmode', enabled: msg.enabled, ok: msg.ok, ...(msg.error ? { error: msg.error } : {}) },
|
||||
]);
|
||||
// Follow the harness, not the click. This covers the refusal AND the ordinary exit: approving a plan
|
||||
// takes the session out of plan mode server-side, and without this the toggle would stay lit over a
|
||||
// session that has gone back to editing.
|
||||
if (msg.ok) {
|
||||
setPlanModeState(msg.enabled);
|
||||
planModeRef.current = msg.enabled;
|
||||
} else {
|
||||
setPlanModeState(!msg.enabled);
|
||||
planModeRef.current = !msg.enabled;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plan:proposed':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [...prev, { role: 'plan', requestId: msg.requestId, plan: msg.plan }]);
|
||||
break;
|
||||
|
||||
case 'plan:decided':
|
||||
// Resolved ONTO the card rather than appended, so the buttons go away with the decision. Also the
|
||||
// path by which a second pane learns that the plan it is showing has already been answered
|
||||
// elsewhere — the decision is broadcast to every socket on the conversation, not just the one that
|
||||
// clicked.
|
||||
setMessages((prev) => {
|
||||
const at = prev.findLastIndex((m) => m.role === 'plan' && m.requestId === msg.requestId);
|
||||
if (at < 0) return prev;
|
||||
const next = [...prev];
|
||||
next[at] = {
|
||||
...(prev[at] as Extract<ChatMessage, { role: 'plan' }>),
|
||||
approved: msg.approved,
|
||||
feedback: msg.feedback,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'compact:done':
|
||||
setMessages((prev) => {
|
||||
const at = prev.findLastIndex((m) => m.role === 'compact' && !m.done);
|
||||
@@ -653,9 +717,38 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
...(contextId ? { contextId } : {}),
|
||||
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
|
||||
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||
// Always sent, never conditional. This is the ONLY channel that can put a brand-new conversation into
|
||||
// plan mode — there is no live session to control and no stored row to update before the first turn —
|
||||
// and sending it only when true would mean a session could never be turned back off this way either.
|
||||
planMode: planModeRef.current,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn plan mode on or off between turns.
|
||||
*
|
||||
* Optimistic, and corrected by the `plan:mode` the server always answers with: the toggle has to respond
|
||||
* to the click immediately, but the harness is entitled to refuse, and the handler above puts it back when
|
||||
* it does.
|
||||
*
|
||||
* `claudeSessionId` rides along so the preference can be stored right away — the client is the only party
|
||||
* that has it between turns (it is in the URL; officer learns it per-turn).
|
||||
*/
|
||||
function setPlanMode(enabled: boolean) {
|
||||
setPlanModeState(enabled);
|
||||
planModeRef.current = enabled;
|
||||
send({
|
||||
type: 'plan:mode',
|
||||
enabled,
|
||||
...(claudeSessionIdRef.current ? { claudeSessionId: claudeSessionIdRef.current } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Answer a plan the agent is blocked on. `feedback` on a rejection is a revision request, not an abort. */
|
||||
function decidePlan(requestId: string, approve: boolean, feedback?: string) {
|
||||
send({ type: 'plan:decide', requestId, approve, ...(feedback ? { feedback } : {}) });
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
send({ type: 'stop' });
|
||||
}
|
||||
@@ -678,8 +771,11 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
hasStarted,
|
||||
cwd,
|
||||
thinkingLevel,
|
||||
planMode,
|
||||
setSelectedModel,
|
||||
setThinkingLevel,
|
||||
setPlanMode,
|
||||
decidePlan,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
disconnectSession,
|
||||
|
||||
@@ -37,6 +37,8 @@ export type ClaudeSessionSummary = {
|
||||
* yet, and the row shows no model rather than guessing.
|
||||
*/
|
||||
model?: string | null;
|
||||
/** Whether this conversation is in plan mode. Absent on older rows; false is the default everywhere. */
|
||||
planMode?: boolean;
|
||||
};
|
||||
|
||||
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
|
||||
@@ -75,6 +77,13 @@ export type ClaudeSessionDetail = {
|
||||
* catalogue entry — which is the bug this field exists to fix.
|
||||
*/
|
||||
officerModel: string | null;
|
||||
/**
|
||||
* Whether this conversation is in plan mode.
|
||||
*
|
||||
* Like `officerModel`, only the stored row can answer: plan mode leaves no trace in a transcript at all,
|
||||
* so a session resumed without this comes back with the toggle off and quietly starts editing again.
|
||||
*/
|
||||
planMode: boolean;
|
||||
cwd: string;
|
||||
messages: ClaudeSessionMessage[];
|
||||
total: number; // full transcript length (the loaded messages may be a tail window)
|
||||
|
||||
Reference in New Issue
Block a user