/**
* Per-session bridge for omp plan mode.
*
* Mirrors the TUI's `InteractiveMode.#enterPlanMode` lifecycle on top of
* the deck's WebSocket protocol:
*
* 1. Client sends `set_plan_mode {enabled:true}` → `enter()`:
* - snapshot active tools, splice in `resolve` if missing
* - `setActiveToolsByName(planTools)`
* - `setPlanModeState({ enabled, planFilePath, workflow })`
* - `setStandingResolveHandler(#handlePlanResolve)`
* - broadcast `plan_mode_changed{enabled:true}`
*
* 2. Agent works under plan-mode restrictions (SDK's
* `#enforcePlanModeToolDecision` blocks writes via the system
* prompt + tool-decision intercept), writes `local://PLAN.md`,
* calls `resolve apply`. The SDK invokes our standing handler
* via `runResolveInvocation`.
*
* 3. `#handlePlanResolve`'s `apply` callback:
* - validates plan-mode is still active
* - reads the plan file via `local://` resolver
* - derives a title via `resolvePlanTitle` (handles issue #1179
* empty-`extra.title` corner case)
* - broadcasts `plan_proposed` to the deck UI
* - **blocks** on a Promise the deck UI settles via
* `plan_response` → `respond(proposalId, response)`
*
* 4. On approve: write edited content (if any), rename PLAN.md to
* the title-derived final path, exit plan mode (restoring the
* previous tool set + clearing handler + clearing SDK state),
* and queue the SDK's `planModeApprovedPrompt` as a follow-up
* so the next turn executes the plan with full tools.
*
* 5. On reject: exit plan mode and surface a clear rejection
* message to the agent.
*
* 6. On cancel (user toggles plan mode off mid-approval) or session
* dispose: reject the pending promise so the resolve tool
* returns with an error the agent can recover from.
*
* SDK reference impl: `@oh-my-pi/pi-coding-agent/src/modes/interactive-mode.ts`
* (`#enterPlanMode`, `#runPlanApprovalResolve`, `#exitPlanMode`,
* `#approvePlan`).
*/
import * as fs from "node:fs/promises";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent";
import type { AgentToolResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { resolveLocalUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls";
import {
type PlanApprovalDetails,
renameApprovedPlanFile,
resolvePlanTitle,
} from "@oh-my-pi/pi-coding-agent/plan-mode/approved-plan";
import { type ResolveToolDetails, runResolveInvocation } from "@oh-my-pi/pi-coding-agent/tools/resolve";
import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
import type {
PendingPlanApprovalWire,
PlanModeContextWire,
ServerFrame,
} from "@omp-deck/protocol";
import type { PlanApprovalResponse } from "./types.ts";
import { logger } from "../log.ts";
const log = logger("bridge:plan-mode");
/** Canonical plan file URL. The SDK's `resolve` tool, the TUI, and the
* plan-mode system prompt all use this exact path; do not vary per-session. */
const PLAN_FILE_URL = "local://PLAN.md";
/** Tool the SDK requires for plan-mode submission. Spliced into the active
* tool set on enter if it isn't already there. */
const RESOLVE_TOOL = "resolve";
/** Workflow flavor passed to `setPlanModeState`. MVP only supports
* `"parallel"`; `"iterative"` (TUI-only) is explicitly out of scope. */
const PLAN_WORKFLOW = "parallel" as const;
/**
* Pre-rendered companion to
* `@oh-my-pi/pi-coding-agent/src/prompts/system/plan-mode-approved.md`
* with the deck's fixed branches baked in:
* - `contextPreserved: true` (deck never compacts at the plan boundary;
* deferred to v1.1 — see design doc §"open questions" #2)
* - `tools` includes `todo_write` (deck's session tool set always has it)
*
* Inlined because the SDK's `exports` map doesn't expose `.md` assets, and
* we want a stable contract that's visible alongside the lifecycle code
* rather than a fragile runtime fetch. **Mirror SDK changes here on
* upgrade.** Diff against the upstream file when bumping
* `@oh-my-pi/pi-coding-agent`.
*/
const PLAN_APPROVED_PROMPT_TEMPLATE = `
Plan approved. You MUST execute it now.
Finalized plan artifact: \`{{finalPlanFilePath}}\`
Context preserved. Use conversation history when useful; the finalized plan is the source of truth if it conflicts with earlier exploration.
## Plan
{{planContent}}
You MUST execute this plan step by step from \`{{finalPlanFilePath}}\`. You have full tool access.
You MUST verify each step before proceeding to the next.
Before execution, initialize todo tracking with \`todo_write\`.
After each completed step, immediately update \`todo_write\`.
If \`todo_write\` fails, fix the payload and retry before continuing.
You MUST keep going until complete. This matters.
`;
type PlanModeChangedFrame = Extract;
type PlanProposedFrame = Extract;
type PlanProposalResolvedFrame = Extract;
export type PlanModeFrame = PlanModeChangedFrame | PlanProposedFrame | PlanProposalResolvedFrame;
type FrameListener = (frame: PlanModeFrame) => void;
interface PendingApproval {
proposalId: string;
planFilePath: string;
planContent: string;
suggestedTitle: string;
suggestedFinalPath: string;
resolve: (resp: PlanApprovalResponse) => void;
reject: (err: Error) => void;
}
/**
* Minimal `AgentSession` surface this bridge needs. Listed here as a
* structural interface so tests can substitute a hand-rolled fake without
* spinning up the full SDK.
*/
export interface PlanModeSessionSurface {
getActiveToolNames(): string[];
setActiveToolsByName(toolNames: string[]): Promise;
setPlanModeState(state: { enabled: boolean; planFilePath: string; workflow: "parallel" | "iterative" } | undefined): void;
setStandingResolveHandler(
handler: ((input: unknown) => Promise | unknown) | null,
): void;
markPlanReferenceSent(): void;
readonly isStreaming: boolean;
prompt(
text: string,
options?: { synthetic?: boolean; streamingBehavior?: "steer" | "followUp" },
): Promise;
}
export interface PlanModeBridgeArgs {
sessionId: string;
session: PlanModeSessionSurface;
/** SDK `sessionManager.getArtifactsDir()` — feeds `local://` resolution. */
getArtifactsDir: () => string | null;
/** SDK `sessionManager.getSessionId()` — feeds `local://` resolution. */
getSessionId: () => string | null;
}
/** Bridge over the SDK's plan-mode primitives, scoped to one session. */
export class PlanModeBridge {
private readonly sessionId: string;
private readonly session: PlanModeSessionSurface;
private readonly getArtifactsDir: () => string | null;
private readonly getSessionId: () => string | null;
private readonly listeners = new Set();
private nextProposalCounter = 1;
private enabled = false;
private planFilePath: string = PLAN_FILE_URL;
private previousTools: string[] = [];
private pendingApproval: PendingApproval | undefined;
private disposed = false;
constructor(args: PlanModeBridgeArgs) {
this.sessionId = args.sessionId;
this.session = args.session;
this.getArtifactsDir = args.getArtifactsDir;
this.getSessionId = args.getSessionId;
}
// ─── Snapshot + replay surface (consumed by InProcessAgentBridge) ─────
isEnabled(): boolean {
return this.enabled;
}
hasPendingApproval(): boolean {
return this.pendingApproval !== undefined;
}
getPlanModeContext(): PlanModeContextWire | undefined {
if (!this.enabled) return undefined;
return { enabled: true, planFilePath: this.planFilePath };
}
getPendingPlanApproval(): PendingPlanApprovalWire | undefined {
const p = this.pendingApproval;
if (!p) return undefined;
return {
proposalId: p.proposalId,
planFilePath: p.planFilePath,
planContent: p.planContent,
suggestedTitle: p.suggestedTitle,
suggestedFinalPath: p.suggestedFinalPath,
};
}
/** Replay frames sent verbatim to a late subscriber so a page-reload
* during plan mode immediately re-renders the pill + any open card. */
getReplayFrames(): PlanModeFrame[] {
const out: PlanModeFrame[] = [];
if (this.enabled) {
out.push({
type: "plan_mode_changed",
sessionId: this.sessionId,
enabled: true,
planFilePath: this.planFilePath,
});
}
const p = this.pendingApproval;
if (p) {
out.push({
type: "plan_proposed",
sessionId: this.sessionId,
proposalId: p.proposalId,
planFilePath: p.planFilePath,
planContent: p.planContent,
suggestedTitle: p.suggestedTitle,
suggestedFinalPath: p.suggestedFinalPath,
});
}
return out;
}
subscribeFrames(listener: FrameListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
// ─── Lifecycle ────────────────────────────────────────────────────────
/** Enter plan mode. Idempotent — re-entry is a no-op. */
async enter(): Promise {
if (this.disposed || this.enabled) return;
const previousTools = this.session.getActiveToolNames();
const planTools = previousTools.includes(RESOLVE_TOOL)
? previousTools
: [...previousTools, RESOLVE_TOOL];
await this.session.setActiveToolsByName(planTools);
this.previousTools = previousTools;
this.planFilePath = PLAN_FILE_URL;
this.enabled = true;
this.session.setPlanModeState({
enabled: true,
planFilePath: this.planFilePath,
workflow: PLAN_WORKFLOW,
});
this.session.setStandingResolveHandler((input) => this.#handlePlanResolve(input));
this.#broadcast({
type: "plan_mode_changed",
sessionId: this.sessionId,
enabled: true,
planFilePath: this.planFilePath,
});
log.info(`plan mode entered for ${this.sessionId}`);
}
/**
* Exit plan mode. Idempotent. Rejects any pending approval first so the
* standing handler unblocks with a clear error the agent can surface as
* the resolve tool's failure result.
*
* `reason` differentiates user-cancel (Shift+Tab off, Reject click) from
* server-side cleanup (session disposed, approve path that already did
* the rename + synthetic prompt).
*/
async exit(
reason: "user_cancelled" | "session_disposed" | "approved" | "rejected" = "user_cancelled",
): Promise {
if (this.disposed && reason !== "session_disposed") return;
if (!this.enabled && !this.pendingApproval) return;
if (this.pendingApproval) {
const pending = this.pendingApproval;
this.pendingApproval = undefined;
if (reason === "user_cancelled" || reason === "session_disposed") {
const message =
reason === "user_cancelled"
? "Plan approval cancelled: user exited plan mode."
: "Plan approval abandoned: session disposed.";
pending.reject(new Error(message));
this.#broadcast({
type: "plan_proposal_resolved",
sessionId: this.sessionId,
proposalId: pending.proposalId,
outcome: reason === "user_cancelled" ? "rejected" : "expired",
});
}
}
if (this.enabled) {
if (this.previousTools.length > 0) {
try {
await this.session.setActiveToolsByName(this.previousTools);
} catch (err) {
log.warn(`tool restore failed during exit for ${this.sessionId}`, err);
}
}
this.session.setStandingResolveHandler(null);
this.session.setPlanModeState(undefined);
this.enabled = false;
this.previousTools = [];
this.#broadcast({
type: "plan_mode_changed",
sessionId: this.sessionId,
enabled: false,
});
}
log.info(`plan mode exited for ${this.sessionId} (${reason})`);
}
/**
* Settle the pending approval. Returns `"unknown"` when the proposalId
* does not match the live pending entry (already-resolved by a sibling
* tab; the caller surfaces a 409 + the client rolls back optimistic UI).
*/
respond(proposalId: string, response: PlanApprovalResponse): "settled" | "unknown" {
const pending = this.pendingApproval;
if (!pending || pending.proposalId !== proposalId) {
return "unknown";
}
// Do NOT clear pendingApproval here — the apply callback clears it
// after the promise resolves so any concurrent respond() racing
// with the resolve still sees "settled" until the callback exits.
pending.resolve(response);
return "settled";
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
// Fire-and-forget — dispose is sync; the SDK call chain in exit() is
// best-effort during teardown.
void this.exit("session_disposed");
this.listeners.clear();
}
// ─── Internal ─────────────────────────────────────────────────────────
#broadcast(frame: PlanModeFrame): void {
for (const listener of this.listeners) {
try {
listener(frame);
} catch (err) {
log.warn(`plan-mode frame listener threw`, err);
}
}
}
/**
* Standing resolve handler. The SDK calls this when the agent submits
* `resolve { action: "apply" | "discard", ... }` while plan-mode is
* active. We use the SDK's own `runResolveInvocation` to validate the
* envelope (handles `action="discard"` and grammar-constrained input
* shapes) and shape the result as `AgentToolResult`.
*
* The `apply` callback blocks on the user's `plan_response` reply.
* Returning from it ends the agent's resolve tool with the supplied
* content + details; the deferred `session.prompt(..., followUp)` then
* starts a fresh turn that executes the approved plan.
*/
#handlePlanResolve(input: unknown): Promise> {
return runResolveInvocation(input as Parameters[0], {
sourceToolName: "plan_approval",
label: "Plan ready for approval",
apply: async (_reason, extra) => {
if (!this.enabled) {
throw new ToolError("Plan mode is not active.");
}
const planContent = await this.#readPlanFile(this.planFilePath);
if (planContent === null) {
throw new ToolError(
`Plan file not found at ${this.planFilePath}. Write the finalized plan before requesting approval.`,
);
}
const normalized = resolvePlanTitle({
suppliedTitle: extra?.title,
planContent,
planFilePath: this.planFilePath,
});
const suggestedFinalPath = `local://${normalized.fileName}`;
const proposalId = this.#allocateProposalId();
// Block on user approval. Stash the proposal so reconnects can
// replay it and a parallel `set_plan_mode(false)` can reject it.
const userResponse = await new Promise((resolve, reject) => {
this.pendingApproval = {
proposalId,
planFilePath: this.planFilePath,
planContent,
suggestedTitle: normalized.title,
suggestedFinalPath,
resolve,
reject,
};
this.#broadcast({
type: "plan_proposed",
sessionId: this.sessionId,
proposalId,
planFilePath: this.planFilePath,
planContent,
suggestedTitle: normalized.title,
suggestedFinalPath,
});
});
// Clear pending — anything after this point is post-decision.
this.pendingApproval = undefined;
const planFilePathAtApproval = this.planFilePath;
if (!userResponse.approved) {
this.#broadcast({
type: "plan_proposal_resolved",
sessionId: this.sessionId,
proposalId,
outcome: "rejected",
});
await this.exit("rejected");
return {
content: [
{
type: "text" as const,
text: "User rejected the plan. Plan mode disabled; do not auto-execute.",
},
],
details: {
planFilePath: planFilePathAtApproval,
finalPlanFilePath: suggestedFinalPath,
title: normalized.title,
planExists: true,
} satisfies PlanApprovalDetails,
};
}
// Approve path: optionally write edited content, rename
// PLAN.md → final, exit plan mode, queue the synthetic
// approved-prompt for the next turn.
let finalContent = planContent;
if (typeof userResponse.editedContent === "string") {
await this.#writePlanFile(planFilePathAtApproval, userResponse.editedContent);
finalContent = userResponse.editedContent;
}
const finalPlanFilePath = sanitizeFinalPath(userResponse.finalPath) ?? suggestedFinalPath;
await renameApprovedPlanFile({
planFilePath: planFilePathAtApproval,
finalPlanFilePath,
getArtifactsDir: this.getArtifactsDir,
getSessionId: this.getSessionId,
});
this.#broadcast({
type: "plan_proposal_resolved",
sessionId: this.sessionId,
proposalId,
outcome: "approved",
});
await this.exit("approved");
this.session.markPlanReferenceSent();
const approvedPrompt = renderApprovedPrompt({
planContent: finalContent,
finalPlanFilePath,
});
// Fire-and-forget: the resolve tool is still streaming at
// this point (we haven't returned yet), so the SDK queues
// the prompt as followUp and fires it once the current
// turn ends. The `synthetic` flag is intentionally absent
// — the SDK's queue path doesn't preserve it; we accept
// the resulting user-role bubble so the user sees a
// visible "execute" handoff. v1.1 may swap to a deferred
// turn_end listener if the synthetic distinction matters.
void this.session
.prompt(approvedPrompt, { streamingBehavior: "followUp" })
.catch((err) => {
log.warn(`synthetic approved-plan prompt failed for ${this.sessionId}`, err);
});
return {
content: [
{
type: "text" as const,
text: `Plan approved. Executing from ${finalPlanFilePath}.`,
},
],
details: {
planFilePath: planFilePathAtApproval,
finalPlanFilePath,
title: stripMdExtension(extractFileName(finalPlanFilePath)),
planExists: true,
} satisfies PlanApprovalDetails,
};
},
});
}
async #readPlanFile(planFilePath: string): Promise {
const fsPath = resolveLocalUrlToPath(planFilePath, {
getArtifactsDir: this.getArtifactsDir,
getSessionId: this.getSessionId,
});
try {
return await fs.readFile(fsPath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
}
async #writePlanFile(planFilePath: string, content: string): Promise {
const fsPath = resolveLocalUrlToPath(planFilePath, {
getArtifactsDir: this.getArtifactsDir,
getSessionId: this.getSessionId,
});
await fs.writeFile(fsPath, content, "utf-8");
}
#allocateProposalId(): string {
const id = `pa_${this.sessionId}_${this.nextProposalCounter}`;
this.nextProposalCounter += 1;
return id;
}
}
function renderApprovedPrompt(args: { planContent: string; finalPlanFilePath: string }): string {
return PLAN_APPROVED_PROMPT_TEMPLATE.replaceAll(
"{{planContent}}",
args.planContent,
).replaceAll("{{finalPlanFilePath}}", args.finalPlanFilePath);
}
/**
* Validate a client-supplied override of the final plan path. Returns
* `undefined` when the input is missing or shaped wrong; the caller falls
* back to the SDK-suggested path. We deliberately don't throw — a malformed
* `finalPath` shouldn't fail the whole approval; falling back to the
* suggested path is the user-friendly default.
*/
function sanitizeFinalPath(input: string | undefined): string | undefined {
if (!input) return undefined;
const trimmed = input.trim();
if (!trimmed.startsWith("local://")) return undefined;
// Strip the scheme and reject anything that has path separators or `..`
// anywhere — must be a single safe filename, NOT a nested path or
// traversal attempt. (Stripping then taking the basename would silently
// "sanitize" `local://../escape.md` into `escape.md`; reject instead.)
const remainder = trimmed.replace(/^local:\/+/, "");
if (remainder.includes("/") || remainder.includes("\\")) return undefined;
if (remainder.includes("..")) return undefined;
if (!remainder.endsWith(".md")) return undefined;
const stem = remainder.slice(0, -".md".length);
if (stem.length === 0) return undefined;
if (!/^[A-Za-z0-9_-]+$/.test(stem)) return undefined;
return `local://${remainder}`;
}
function extractFileName(localUrl: string): string {
return localUrl.replace(/^local:\/+/, "").split(/[\\/]/).pop() ?? "";
}
function stripMdExtension(fileName: string): string {
return fileName.replace(/\.md$/i, "");
}