Skip to content
Closed
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
9 changes: 7 additions & 2 deletions packages/coding-agent/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface Args {
extensions?: string[];
noExtensions?: boolean;
print?: boolean;
plan?: boolean;
export?: string;
noSkills?: boolean;
skills?: string[];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -259,6 +262,7 @@ ${chalk.bold("Options:")}
--append-system-prompt <text> Append text or file contents to the system prompt (can be used multiple times)
--mode <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 <path|id> Use specific session file or partial UUID
Expand All @@ -269,12 +273,13 @@ ${chalk.bold("Options:")}
--name, -n <name> Set session display name
--models <patterns> 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 <tools> Comma-separated allowlist of tool names to enable
Applies to built-in, extension, and custom tools
--exclude-tools, -xt <tools> 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 <level> Set thinking level: off, minimal, low, medium, high, xhigh, max
--extension, -e <path> Load an extension file (can be used multiple times)
--no-extensions, -ne Disable extension discovery (explicit -e paths still work)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/cli/auth-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
13 changes: 13 additions & 0 deletions packages/coding-agent/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export {
type AgentSessionEvent,
type AgentSessionEventListener,
type ModelCycleResult,
type PlanContinuation,
type PlanContinuationPromptOptions,
type PromptOptions,
type SessionStats,
} from "./agent-session.ts";
Expand All @@ -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,
Expand Down Expand Up @@ -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";
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface BuiltinSlashCommand {
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "settings", description: "Open settings menu" },
{ name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },
{ 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" },
Expand Down
11 changes: 11 additions & 0 deletions packages/coding-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export {
type AgentSessionEventListener,
type ModelCycleResult,
type ParsedSkillBlock,
type PlanContinuation,
type PlanContinuationPromptOptions,
type PromptOptions,
parseSkillBlock,
type SessionStats,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -241,6 +250,8 @@ export {
type ModelChangeEntry,
migrateSessionEntries,
type NewSessionOptions,
type PlanEntry,
type PlanResolutionEntry,
parseSessionEntries,
type SessionContext,
type SessionEntry,
Expand Down
31 changes: 31 additions & 0 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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<Mode, "rpc"> {
return appMode === "json" ? "json" : "text";
}
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions packages/coding-agent/src/modes/non-interactive-plan-mode.ts
Original file line number Diff line number Diff line change
@@ -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);
}
8 changes: 8 additions & 0 deletions packages/coding-agent/test/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
8 changes: 8 additions & 0 deletions packages/coding-agent/test/auth-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
103 changes: 103 additions & 0 deletions packages/coding-agent/test/main-plan-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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<ExtensionFactory>();
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 });
}
});
});
Loading