Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/plan-approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

`exit_plan_mode` submits a written proposal for review. Leaving plan mode and
approving the proposal are separate events. In headless and RPC sessions, a valid
proposal exits plan mode and restores the previous tools, while the caller remains
responsible for obtaining approval before execution. This also applies to RPC
contexts with `hasUI: true`; their UI bridge does not provide interactive approval.
proposal exits plan mode, while the caller remains responsible for obtaining
approval before execution. This also applies to RPC contexts with `hasUI: true`;
their UI bridge does not provide interactive approval.

Plan mode does not hide tools: it denies mutating calls outside the plan file
through the `tool_call` policy, so the request's tool block stays byte-identical
and the provider's prompt cache keeps its prefix. Only a profile that lacks a
plan tool has it appended on entry, and that addition alone is reverted on exit.

Successful headless and RPC exits return the existing model-facing text plus
structured `details`, for example:
Expand Down
19 changes: 13 additions & 6 deletions packages/coding-agent/docs/step-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,14 @@ for path rules and retention responsibilities.
effect immediately; it does not request approval. An explicit `--plan`
applies at process startup, including a continued session whose mode was off;
later reloads and session/branch navigation do not reapply it. The plan path is
announced, and `write_file` / `edit_file` remain available for that file.
Other file-write targets are blocked while planning. This is **not a shell
sandbox**: command tools retain the existing permission policy, and the model
is instructed to keep commands read-only while planning.
announced. Plan mode does not remove tools from the request — that would
invalidate the provider's cached prompt prefix — so enforcement lives in the
`tool_call` policy: `write_file`, `edit_file`, `write`, and `edit` may only
target the plan file, and every other target is rejected with an actionable
denial. Other file-writing tools, including MCP servers, are not covered by
that check. This is **not a shell sandbox**: command tools retain the existing
permission policy, and the model is instructed to keep commands read-only
while planning.
- `exit_plan_mode` submits the written proposal for review, requiring a readable,
nonempty regular plan file. In the TUI, the user can approve execution, stay in
plan mode, or provide refinement notes. Cancel
Expand Down Expand Up @@ -415,8 +419,11 @@ The existing six tool names and `/todos` remain; the task schemas add explicit
new-plan, history-query, and resume fields. Legacy support remains limited to
migration of legacy plan-mode todos into tasks on the active branch and restoring
existing task snapshots as a single plan. Older
saved tool lists retain currently active plan/task tools on restore and exit;
ordinary custom tools still follow the saved branch selection. There is no
saved tool lists — written by builds that narrowed the active list on entry —
retain currently active plan/task tools on restore and exit; ordinary custom
tools still follow the saved branch selection. Builds that append only the plan
tools a profile lacks (`version: 2` entries) store no replaced list, so a branch
written by them never replays an older snapshot. There is no
additional `TodoWrite` / `update_plan` alias or second checklist store.

The separation follows [Claude Code's plan and task tools](https://code.claude.com/docs/en/tools-reference)
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/features/plan-mode-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export interface StepPlanModeController {
* /plan toggle owned by the extension. Returns the session plan file path.
*/
enterPlanMode(extensionContext: ExtensionContext): string;
/** Leave plan mode: restore tools, persist state, record telemetry. */
/** Leave plan mode: restore any tools plan mode added, persist state, record telemetry. */
exitPlanMode(outcome: StepPlanExitOutcome, extensionContext: ExtensionContext): void;
/** Plan file path for this session (persisted or derived from the session id). */
resolvePlanFilePath(extensionContext: ExtensionContext): string;
Expand Down
104 changes: 85 additions & 19 deletions packages/coding-agent/src/features/step-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
*/

import { existsSync } from "node:fs";
import path from "node:path";
import type { AgentMessage } from "@step-harness/agent-core";
import type { EventBus } from "../core/event-bus.ts";
import type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "../core/extensions/types.ts";
import { resolveToCwd } from "../core/tools/path-utils.ts";
import { type StepTelemetryReporter, trackStepTelemetry } from "../step/telemetry.ts";
import { type LegacyPlanTodoFields, migrateLegacyPlanTodos } from "./plan-mode-migration.ts";
import {
Expand Down Expand Up @@ -44,7 +44,15 @@ const PLAN_TASK_TOOLS = new Set([
export type StepPlanSource = "user" | "agent" | "unknown";

interface PlanState {
/** Written by this build. Its presence marks an entry that never narrowed the tool list. */
version?: 2;
enabled: boolean;
/**
* Legacy only: the list a narrowing entry replaced. Older builds dropped the
* editing tools on entry and could only restore by replaying this snapshot,
* so sessions written by them still rely on it. This build appends only the
* plan tools a profile lacks and remembers just those additions.
*/
toolsBeforePlanMode?: string[];
planFilePath?: string;
/** "user" for /plan or --plan, "agent" for enter_plan_mode. */
Expand All @@ -55,6 +63,11 @@ function unique(values: readonly string[]): string[] {
return [...new Set(values)];
}

/** True when both lists hold the same tool names in the same order. */
function sameToolOrder(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((name, index) => name === right[index]);
}

const publishedPlanModes = new WeakMap<object, boolean>();
export function getStepPlanMode(manager: object): string {
const value = publishedPlanModes.get(manager);
Expand Down Expand Up @@ -102,24 +115,63 @@ export const createStepPlanExtension =
(options: { telemetry?: StepTelemetryReporter } = {}): ExtensionFactory =>
(pi: ExtensionAPI): void => {
let enabled = false;
let toolsBeforePlanMode: string[] | undefined;
/** Plan tools this process appended on entry; the only thing exit removes. */
let planModeAddedTools: string[] | undefined;
let planFilePath: string | undefined;
let planSource: StepPlanSource | undefined;
// The event bus is optional so minimal embedder/test harnesses that stub
// ExtensionAPI keep working without one.
const events = (pi as { events?: EventBus }).events;

const persistPlanState = (): void => {
pi.appendEntry("step-plan", { enabled, toolsBeforePlanMode, planFilePath, planSource });
/**
* Persist the branch's plan state.
*
* `carriesLegacyTools` is only set when rewriting a legacy entry during
* migration: the entry it replaces is the sole record of the list plan mode
* narrowed, so dropping it would strand that session on the next restore.
* Ordinary entries never carry a snapshot — this build appends tools
* instead of replacing the list.
*/
const persistPlanState = (carriesLegacyTools?: string[]): void => {
pi.appendEntry("step-plan", {
version: 2,
enabled,
planFilePath,
planSource,
...(carriesLegacyTools ? { toolsBeforePlanMode: carriesLegacyTools } : {}),
});
};
/**
* Plan mode is a permission narrowing, not a capability change.
*
* The model keeps every tool it already had; the `tool_call` hook below
* denies mutations outside the plan file. Rebuilding the list would rewrite
* the request's tool block, which the provider serializes ahead of the
* system prompt and messages: one such edit invalidates the cached prefix
* for the whole conversation, so a single plan entry can re-bill hundreds of
* thousands of prompt tokens.
*
* A session that does not already offer every plan tool still has to gain
* them (a Pi-native profile lacks the Step aliases). Append only what is
* missing and keep the existing order, so the common Step profile — which
* already offers all of them — leaves the tool block byte-identical.
*/
const enable = (): void => {
toolsBeforePlanMode ??= pi.getActiveTools();
// Keep plan-file writes available; tool_call restricts their target.
pi.setActiveTools(unique([...toolsBeforePlanMode.filter((name) => !MUTATING_TOOLS.has(name)), ...PLAN_TOOLS]));
const active = pi.getActiveTools();
const missing = PLAN_TOOLS.filter((name) => !active.includes(name));
if (missing.length === 0) return;
planModeAddedTools = missing;
pi.setActiveTools([...active, ...missing]);
};
const restoreTools = (): void => {
if (toolsBeforePlanMode) pi.setActiveTools(toolsBeforePlanMode);
toolsBeforePlanMode = undefined;
const added = planModeAddedTools;
planModeAddedTools = undefined;
if (!added || added.length === 0) return;
// Remove only what entering added, so tools activated in the meantime
// (a late MCP catalog, find_tools) keep their place in the list.
const live = pi.getActiveTools();
const remaining = live.filter((name) => !added.includes(name));
if (remaining.length !== live.length) pi.setActiveTools(remaining);
};
const resolvePlanFilePath = (ctx: ExtensionContext): string =>
planFilePath ?? getPlanFilePath(ctx.sessionManager.getSessionId(), ctx.cwd);
Expand Down Expand Up @@ -185,17 +237,25 @@ export const createStepPlanExtension =
pi.on("tool_call", async (event, ctx) => {
if (!enabled || !MUTATING_TOOLS.has(event.toolName)) return;
const planPath = resolvePlanFilePath(ctx);
const toolInput = event.input as Record<string, unknown>;
const toolInput = (event.input ?? {}) as Record<string, unknown>;
const targetPath = typeof toolInput.path === "string" ? toolInput.path : "";
if (targetPath && path.resolve(ctx.cwd, targetPath) === path.resolve(planPath)) {
// Resolve exactly as the file tools do (`~`, `@`, unicode spaces), so an
// allowed target cannot land somewhere else than the guard checked.
if (targetPath && resolveToCwd(targetPath, ctx.cwd) === resolveToCwd(planPath, ctx.cwd)) {
// Allowed: the mutation targets the plan file itself. This runs
// before the write lands, so a missing file marks the plan's creation.
recordPlanUpdate(options.telemetry, planSource, !existsSync(planPath));
return;
}
return {
block: true,
reason: `Plan mode blocked this file mutation. Only the plan file may be written: ${planPath}\nTarget: ${targetPath || "(no path provided)"}`,
reason: [
`Plan mode is active, so this file mutation was blocked. Only the plan file may be written: ${planPath}`,
`Target: ${targetPath || "(no path provided)"}`,
"Plan mode is a permission restriction, not a missing tool: do not retry this edit or rephrase the path.",
"run_command keeps its normal permissions, but stay read-only while planning: do not use it to write or patch files.",
`Write the implementation proposal to the plan file with write_file, then call exit_plan_mode to submit it for review.`,
].join("\n"),
};
});

Expand All @@ -218,7 +278,7 @@ export const createStepPlanExtension =
// Legacy tool lists predate these capabilities. Preserve only the ones
// already active; ordinary tools still follow the saved branch selection.
const activePlanTaskTools = pi.getActiveTools().filter((name) => PLAN_TASK_TOOLS.has(name));
// Undo the previous branch's tool restriction before reading the new one.
// Undo whatever the previous branch's entry applied before reading the next.
restoreTools();
const planStates = [...ctx.sessionManager.getBranch()]
.reverse()
Expand All @@ -228,24 +288,30 @@ export const createStepPlanExtension =
: [],
);
const restoredPlanState = planStates[0];
// Off snapshots omit transient tools; a preceding plan entry retains the baseline.
const savedTools = planStates.find((state) => state?.toolsBeforePlanMode)?.toolsBeforePlanMode;
// The list to undo belongs to whichever entry narrowed it. An on-entry
// wins over an older carried snapshot, so a newer plan session never
// replays an earlier one's list; an off branch falls back to the last
// entry that recorded one. This build records nothing on entry, so its
// own entries only reach here after carrying a legacy list forward.
const governingEntry = planStates.find((state) => state?.enabled || state?.toolsBeforePlanMode !== undefined);
const legacyBaseline = governingEntry?.toolsBeforePlanMode;
enabled = restoredPlanState?.enabled ?? false;
toolsBeforePlanMode = savedTools ? unique([...savedTools, ...activePlanTaskTools]) : undefined;
planFilePath = restoredPlanState?.planFilePath;
planSource = enabled ? (restoredPlanState?.planSource ?? "unknown") : undefined;
if (legacyBaseline) {
const baseline = unique([...legacyBaseline, ...activePlanTaskTools]);
if (!sameToolOrder(pi.getActiveTools(), baseline)) pi.setActiveTools(baseline);
}
if (enabled) {
planFilePath = resolvePlanFilePath(ctx);
enable();
} else {
restoreTools();
}
if (restoredPlanState) {
migrateLegacyPlanTodos({
persistedPlanState: restoredPlanState,
events,
extensionContext: ctx,
persistMigratedPlanState: persistPlanState,
persistMigratedPlanState: () => persistPlanState(legacyBaseline),
});
}
if (startWithPlan && !enabled) {
Expand Down
Loading
Loading