From f6a5651fb08dd10c0d13e7ccde3a066cabf03b74 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:51:20 -0700 Subject: [PATCH] feat(coding-agent): expose plan mode interfaces Expose native plan mode through public exports, CLI parsing, authentication checks, and non-interactive execution. --- packages/coding-agent/src/cli/args.ts | 9 +- packages/coding-agent/src/cli/auth-command.ts | 2 +- packages/coding-agent/src/core/index.ts | 13 +++ .../coding-agent/src/core/slash-commands.ts | 1 + packages/coding-agent/src/index.ts | 11 ++ packages/coding-agent/src/main.ts | 31 ++++++ .../src/modes/non-interactive-plan-mode.ts | 29 +++++ packages/coding-agent/test/args.test.ts | 8 ++ packages/coding-agent/test/auth-check.test.ts | 8 ++ .../coding-agent/test/main-plan-mode.test.ts | 103 ++++++++++++++++++ .../coding-agent/test/slash-commands.test.ts | 8 ++ 11 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/src/modes/non-interactive-plan-mode.ts create mode 100644 packages/coding-agent/test/main-plan-mode.test.ts diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index ea9262fcc..0da2ee305 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -36,6 +36,7 @@ export interface Args { extensions?: string[]; noExtensions?: boolean; print?: boolean; + plan?: boolean; export?: string; noSkills?: boolean; skills?: string[]; @@ -146,6 +147,8 @@ export function parseArgs(args: string[]): Args { result.messages.push(next); i++; } + } else if (arg === "--plan") { + result.plan = true; } else if (arg === "--export" && i + 1 < args.length) { result.export = args[++i]; } else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) { @@ -259,6 +262,7 @@ ${chalk.bold("Options:")} --append-system-prompt Append text or file contents to the system prompt (can be used multiple times) --mode Output mode: text (default), json, or rpc --print, -p Non-interactive mode: process prompt and exit + --plan Start in plan mode (interactive only) --continue, -c Continue previous session --resume, -r Select a session to resume --session Use specific session file or partial UUID @@ -269,12 +273,13 @@ ${chalk.bold("Options:")} --name, -n Set session display name --models Comma-separated model patterns for Ctrl+P cycling Supports globs (anthropic/*, *sonnet*) and fuzzy matching - --no-tools, -nt Disable all tools by default (built-in and extension) + --no-tools, -nt Disable normal tools and plan-mode inspection tools by default --no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled --tools, -t Comma-separated allowlist of tool names to enable Applies to built-in, extension, and custom tools --exclude-tools, -xt Comma-separated denylist of tool names to disable Applies to built-in, extension, and custom tools + Plan mode always retains its intrinsic submit_plan tool --thinking Set thinking level: off, minimal, low, medium, high, xhigh, max --extension, -e Load an extension file (can be used multiple times) --no-extensions, -ne Disable extension discovery (explicit -e paths still work) @@ -295,7 +300,7 @@ ${chalk.bold("Options:")} --help, -h Show this help --version, -v Show version number -Extensions can register additional flags (e.g., --plan from plan-mode extension).${extensionFlagsText} +Extensions can register additional flags.${extensionFlagsText} ${chalk.bold("Examples:")} # Print a provider API key for an external client diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts index 133e41502..1b78b8730 100644 --- a/packages/coding-agent/src/cli/auth-command.ts +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -102,7 +102,7 @@ export function validateAuthCommandArgs(args: Args, kind: AuthCommandKind): { pr const option = args.unknownFlags.keys().next().value; throw new AuthCommandError(`Unknown option --${option} for "${getAuthCommandName(kind)}".`); } - if (args.apiKey !== undefined || args.messages.length > 0 || args.fileArgs.length > 0) { + if (args.apiKey !== undefined || args.plan || args.messages.length > 0 || args.fileArgs.length > 0) { throw new AuthCommandError("Auth commands only accept --provider and --model"); } if (kind === "check") { diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 924932e67..d0a68330c 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -8,6 +8,8 @@ export { type AgentSessionEvent, type AgentSessionEventListener, type ModelCycleResult, + type PlanContinuation, + type PlanContinuationPromptOptions, type PromptOptions, type SessionStats, } from "./agent-session.ts"; @@ -16,6 +18,11 @@ export { type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeResult, createAgentSessionRuntime, + type ProfileSelectionOption, + type ProfileSelectionProposal, + type ProfileSelectionResolution, + type ProfileSelectionResolver, + type RuntimePlanContinuationPromptOptions, } from "./agent-session-runtime.ts"; export { type AgentSessionRuntimeDiagnostic, @@ -77,4 +84,10 @@ export { type TurnStartEvent, type WorkingIndicatorOptions, } from "./extensions/index.ts"; +export type { CollaborationMode, PlanArtifact, PlanResolution } from "./plan-mode.ts"; +export { + type CollaborationModeChangeEntry, + type PlanEntry, + type PlanResolutionEntry, +} from "./session-manager.ts"; export { createSyntheticSourceInfo } from "./source-info.ts"; diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 861972e4c..c0f6e628a 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -19,6 +19,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, + { name: "plan", description: "Toggle plan mode", argumentHint: "[on|off]" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 858c940bc..f4dba0924 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -19,6 +19,8 @@ export { type AgentSessionEventListener, type ModelCycleResult, type ParsedSkillBlock, + type PlanContinuation, + type PlanContinuationPromptOptions, type PromptOptions, parseSkillBlock, type SessionStats, @@ -196,6 +198,11 @@ export type { ResolvedResource, } from "./core/package-manager.ts"; export { DefaultPackageManager } from "./core/package-manager.ts"; +export type { + CollaborationMode, + PlanArtifact, + PlanResolution, +} from "./core/plan-mode.ts"; export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.ts"; export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.ts"; // SDK for programmatic usage @@ -226,12 +233,14 @@ export { createUpdatePlanTool, createWriteTool, type PromptTemplate, + type RuntimePlanContinuationPromptOptions, type SubagentOptions, } from "./core/sdk.ts"; export { type BranchSummaryEntry, buildContextEntries, buildSessionContext, + type CollaborationModeChangeEntry, type CompactionEntry, CURRENT_SESSION_VERSION, type CustomEntry, @@ -241,6 +250,8 @@ export { type ModelChangeEntry, migrateSessionEntries, type NewSessionOptions, + type PlanEntry, + type PlanResolutionEntry, parseSessionEntries, type SessionContext, type SessionEntry, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 38621e22b..e2a40160f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -47,6 +47,7 @@ import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dis import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; import { ModelRuntime } from "./core/model-runtime.ts"; import { restoreStdout, takeOverStdout } from "./core/output-guard.ts"; +import type { CollaborationMode } from "./core/plan-mode.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import type { CreateAgentSessionOptions } from "./core/sdk.ts"; import { @@ -63,6 +64,10 @@ import { builtInExtensions } from "./extensions/index.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; +import { + isNonInteractivePlanStateUnsupported, + NON_INTERACTIVE_PLAN_MODE_ERROR, +} from "./modes/non-interactive-plan-mode.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; @@ -128,6 +133,10 @@ function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean) return "interactive"; } +export function appModeSupportsCollaborationMode(appMode: AppMode, collaborationMode: CollaborationMode): boolean { + return collaborationMode !== "plan" || appMode === "interactive"; +} + function toPrintOutputMode(appMode: AppMode): Exclude { return appMode === "json" ? "json" : "text"; } @@ -622,6 +631,10 @@ export async function main(args: string[], options?: MainOptions) { console.log(VERSION); process.exit(0); } + if (parsed.plan && parsed.export) { + console.error(chalk.red("Error: --plan is only supported in interactive mode")); + process.exit(1); + } if (parsed.export) { let result: string; @@ -638,6 +651,10 @@ export async function main(args: string[], options?: MainOptions) { } let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY); + if (parsed.plan && !appModeSupportsCollaborationMode(appMode, "plan") && !isPlainRuntimeMetadataCommand(parsed)) { + console.error(chalk.red("Error: --plan is only supported in interactive mode")); + process.exit(1); + } const shouldTakeOverStdout = appMode !== "interactive" && !isPlainRuntimeMetadataCommand(parsed); if (shouldTakeOverStdout) { takeOverStdout(); @@ -689,6 +706,14 @@ export async function main(args: string[], options?: MainOptions) { process.exit(1); } } + if ( + appMode !== "interactive" && + !isPlainRuntimeMetadataCommand(parsed) && + isNonInteractivePlanStateUnsupported(sessionManager.buildSessionContext()) + ) { + console.error(chalk.red(`Error: ${NON_INTERACTIVE_PLAN_MODE_ERROR}`)); + process.exit(1); + } if (parsed.name !== undefined) { const name = parsed.name.trim(); if (!name) { @@ -827,6 +852,8 @@ export async function main(args: string[], options?: MainOptions) { excludeTools: sessionOptions.excludeTools, noTools: sessionOptions.noTools, customTools: sessionOptions.customTools, + collaborationMode: + isInitialRuntime && parsed.plan && !isPlainRuntimeMetadataCommand(parsed) ? "plan" : undefined, }); const cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel; if (created.session.model && cliThinkingOverride) { @@ -874,6 +901,10 @@ export async function main(args: string[], options?: MainOptions) { } } time("readPipedStdin"); + if (appMode !== "interactive" && isNonInteractivePlanStateUnsupported(session)) { + console.error(chalk.red(`Error: ${NON_INTERACTIVE_PLAN_MODE_ERROR}`)); + process.exit(1); + } const { initialMessage, initialImages } = await prepareInitialMessage( parsed, diff --git a/packages/coding-agent/src/modes/non-interactive-plan-mode.ts b/packages/coding-agent/src/modes/non-interactive-plan-mode.ts new file mode 100644 index 000000000..6f28e6235 --- /dev/null +++ b/packages/coding-agent/src/modes/non-interactive-plan-mode.ts @@ -0,0 +1,29 @@ +import type { CollaborationMode } from "../core/plan-mode.ts"; +import { buildSessionContext, type SessionManager } from "../core/session-manager.ts"; + +export const NON_INTERACTIVE_PLAN_MODE_ERROR = "Plan mode is not supported in print, JSON, or RPC mode."; + +interface NonInteractivePlanState { + collaborationMode: CollaborationMode; + pendingPlan?: unknown; + pendingPlanContinuation?: unknown; +} + +export function isNonInteractivePlanStateUnsupported(state: NonInteractivePlanState): boolean { + return ( + state.collaborationMode === "plan" || + state.pendingPlan !== undefined || + state.pendingPlanContinuation !== undefined + ); +} + +export function assertNonInteractivePlanModeDisabled(state: NonInteractivePlanState): void { + if (isNonInteractivePlanStateUnsupported(state)) { + throw new Error(NON_INTERACTIVE_PLAN_MODE_ERROR); + } +} + +export function validateNonInteractiveSessionTarget(sessionManager: SessionManager, leafId: string | null): void { + const context = buildSessionContext(sessionManager.getEntries(), leafId); + assertNonInteractivePlanModeDisabled(context); +} diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 575d3666e..41f43ce4d 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -60,6 +60,14 @@ describe("parseArgs", () => { }); }); + describe("--plan flag", () => { + test("parses --plan as a built-in flag", () => { + const result = parseArgs(["--plan"]); + expect(result.plan).toBe(true); + expect(result.unknownFlags.size).toBe(0); + }); + }); + describe("--continue flag", () => { test("parses --continue flag", () => { const result = parseArgs(["--continue"]); diff --git a/packages/coding-agent/test/auth-check.test.ts b/packages/coding-agent/test/auth-check.test.ts index ce9e84b48..e02f54845 100644 --- a/packages/coding-agent/test/auth-check.test.ts +++ b/packages/coding-agent/test/auth-check.test.ts @@ -109,6 +109,14 @@ describe("auth check command", () => { }); }); + test("rejects plan mode for auth commands", async () => { + const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } })); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai", "--plan"]), runtime)).rejects.toThrow( + "Auth commands only accept --provider and --model", + ); + }); + test("does not treat an unresolved stored environment reference as configured", async () => { const authPath = join(tempDir, "auth.json"); writeFileSync(authPath, JSON.stringify({ openai: { type: "api_key", key: "$MISSING_AUTH_CHECK_KEY" } }), "utf-8"); diff --git a/packages/coding-agent/test/main-plan-mode.test.ts b/packages/coding-agent/test/main-plan-mode.test.ts new file mode 100644 index 000000000..98a6362d9 --- /dev/null +++ b/packages/coding-agent/test/main-plan-mode.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AssistantMessage } from "@earendil-works/diffex-ai"; +import { describe, expect, it, vi } from "vitest"; +import type { ExtensionFactory } from "../src/core/extensions/types.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { appModeSupportsCollaborationMode, main } from "../src/main.ts"; + +vi.mock("../src/core/output-guard.ts", () => ({ + restoreStdout: vi.fn(), + takeOverStdout: vi.fn(), +})); + +vi.mock("../src/migrations.ts", () => ({ + runMigrations: vi.fn(() => ({ migratedAuthProviders: [], deprecationWarnings: [] })), + showDeprecationWarnings: vi.fn(), +})); + +vi.mock("../src/package-manager-cli.ts", () => ({ + handleConfigCommand: vi.fn(async () => false), + handlePackageCommand: vi.fn(async () => false), +})); + +describe("plan-mode host support", () => { + it.each(["print", "json", "rpc"] as const)("rejects %s output mode", (appMode) => { + expect(appModeSupportsCollaborationMode(appMode, "plan")).toBe(false); + }); + + it("allows interactive mode", () => { + expect(appModeSupportsCollaborationMode("interactive", "plan")).toBe(true); + }); + + it("rejects --plan with session export before reading the export target", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const exit = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit:${String(code)}`); + }); + try { + await expect(main(["--plan", "--export", "/missing/session.jsonl"])).rejects.toThrow("exit:1"); + expect(error).toHaveBeenCalledWith(expect.stringContaining("--plan is only supported in interactive mode")); + } finally { + exit.mockRestore(); + error.mockRestore(); + } + }); + + it("rejects a resumed accepted plan continuation before runtime or piped-input setup", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "diffex-main-plan-continuation-")); + const agentDir = join(tempDir, "agent"); + const sessionManager = SessionManager.create(tempDir, join(tempDir, "sessions")); + const assistant: AssistantMessage = { + role: "assistant", + content: [], + api: "openai-responses", + provider: "openai", + model: "gpt-4o-mini", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + sessionManager.appendMessage(assistant); + sessionManager.appendCollaborationModeChange("plan"); + const plan = { id: "accepted-plan", title: "Accepted plan", markdown: "1. Stay interactive." }; + sessionManager.appendPlan(plan); + sessionManager.appendPlanResolution(plan.id, "implement"); + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session"); + expect(SessionManager.open(sessionFile).buildSessionContext().pendingPlanContinuation).toEqual(plan); + + const extensionFactory = vi.fn(); + const stdinResume = vi.spyOn(process.stdin, "resume").mockReturnValue(process.stdin); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const exit = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit:${String(code)}`); + }); + vi.stubEnv("DIFFEX_CODING_AGENT_DIR", agentDir); + + try { + await expect( + main(["--print", "--session", sessionFile], { extensionFactories: [extensionFactory] }), + ).rejects.toThrow("exit:1"); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("Plan mode is not supported in print, JSON, or RPC mode"), + ); + expect(extensionFactory).not.toHaveBeenCalled(); + expect(stdinResume).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + exit.mockRestore(); + error.mockRestore(); + stdinResume.mockRestore(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts index a2b48dcd2..2f3c3d37d 100644 --- a/packages/coding-agent/test/slash-commands.test.ts +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -17,4 +17,12 @@ describe("BUILTIN_SLASH_COMMANDS", () => { description: "Show sub-agents in the current session", }); }); + + it("registers plan mode with optional on/off arguments", () => { + expect(BUILTIN_SLASH_COMMANDS).toContainEqual({ + name: "plan", + description: "Toggle plan mode", + argumentHint: "[on|off]", + }); + }); });