fix(cli): make new workspace creation explicit (#2315)

Agent callers now stay in their current workspace unless --new-workspace explicitly requests a separate local or worktree workspace.
This commit is contained in:
Mohamed Boudra
2026-07-22 18:26:40 +08:00
committed by GitHub
parent 0afcc96370
commit 246c07fba5
8 changed files with 167 additions and 53 deletions
+16 -3
View File
@@ -10,10 +10,23 @@ describe("canonical CLI surface", () => {
expect(help).not.toContain("worktree");
});
it("hides legacy run worktree syntax", () => {
it("names explicit workspace creation without exposing older syntax", () => {
const run = createCli().commands.find((command) => command.name() === "run");
expect(run?.helpInformation()).toContain("--isolation <local|worktree>");
expect(run?.helpInformation()).not.toContain("--worktree <name>");
const help = run?.helpInformation();
expect(help).toContain("--new-workspace <local|worktree>");
expect(help).not.toContain("--isolation");
expect(help).not.toContain("--worktree <name>");
});
it("offers the worktree creation options on run", () => {
const run = createCli().commands.find((command) => command.name() === "run");
const help = run?.helpInformation();
expect(help).toContain("--worktree-mode <mode>");
expect(help).toContain("--worktree-slug <slug>");
expect(help).toContain("--new-branch <name>");
expect(help).toContain("--branch <name>");
expect(help).toContain("--pr-number <n>");
expect(help).toContain("--forge <forge>");
});
it("uses background for execution and reserves detach for ownership", () => {
+22 -8
View File
@@ -72,23 +72,37 @@ describe("runRunCommand option validation", () => {
});
}
it("rejects --isolation combined with --workspace", async () => {
it("rejects --new-workspace combined with --workspace", async () => {
await expectInvalidOptions(
{ isolation: "worktree", workspace: "ws-1" },
/--isolation and --workspace cannot be combined/,
{ newWorkspace: "worktree", workspace: "ws-1" },
/--new-workspace and --workspace cannot be combined/,
);
});
it("allows explicit worktree isolation through validation", async () => {
// Explicit isolation with no --workspace
it("allows explicit worktree workspace creation through validation", async () => {
// Explicit workspace creation with no --workspace
// must clear validation. It still fails later (provider resolution), which
// is enough to prove the new guard did not reject it.
await expect(
runRunCommand("do something", { isolation: "worktree", provider: undefined }, {} as never),
runRunCommand("do something", { newWorkspace: "worktree", provider: undefined }, {} as never),
).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" });
});
it("rejects unknown workspace isolation", async () => {
await expectInvalidOptions({ isolation: "container" }, /Unsupported workspace isolation/);
it("rejects unknown new workspace kinds", async () => {
await expectInvalidOptions({ newWorkspace: "container" }, /Unsupported new workspace kind/);
});
it("rejects two workspace creation flags", async () => {
await expectInvalidOptions(
{ newWorkspace: "local", worktree: "legacy-slug" },
/--new-workspace and --worktree cannot be combined/,
);
});
it("rejects an unknown worktree creation mode before connecting", async () => {
await expectInvalidOptions(
{ newWorkspace: "worktree", worktreeMode: "container" },
/Unsupported worktree mode/,
);
});
});
+98 -38
View File
@@ -14,6 +14,7 @@ import { lookup } from "mime-types";
import { parseDuration } from "../../utils/duration.js";
import { collectMultiple } from "../../utils/command-options.js";
import { resolveProviderAndModel } from "../../utils/provider-model.js";
import { buildWorkspaceSource } from "../workspace/create.js";
export { resolveProviderAndModel } from "../../utils/provider-model.js";
@@ -38,12 +39,21 @@ export function addRunOptions(cmd: Command): Command {
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--isolation <local|worktree>", "Create a new workspace with this isolation")
.option("--new-workspace <local|worktree>", "Create a separate local or worktree workspace")
.addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp())
.option("--base <branch>", "Base branch for an isolated workspace")
.option(
"--worktree-mode <mode>",
"Worktree mode: branch-off, checkout-branch, or checkout-pr",
)
.option("--worktree-slug <slug>", "Managed worktree path slug")
.option("--new-branch <name>", "New branch name for branch-off mode")
.option("--base <ref>", "Base ref for branch-off mode")
.option("--branch <name>", "Existing branch for checkout-branch mode")
.option("--pr-number <n>", "Pull request or change request number for checkout-pr mode")
.option("--forge <forge>", "Forge for checkout-pr mode")
.option(
"--workspace <id>",
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
"Run in an existing workspace (defaults to the caller workspace when agent-scoped)",
)
.option(
"--image <path>",
@@ -105,9 +115,15 @@ export interface AgentRunOptions extends CommandOptions {
model?: string;
thinking?: string;
mode?: string;
isolation?: string;
newWorkspace?: string;
worktree?: string;
worktreeMode?: string;
worktreeSlug?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
forge?: string;
workspace?: string;
image?: string[];
cwd?: string;
@@ -117,6 +133,25 @@ export interface AgentRunOptions extends CommandOptions {
outputSchema?: string;
}
function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined {
return options.newWorkspace ?? (options.worktree ? "worktree" : undefined);
}
function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) {
const newWorkspace = resolveNewWorkspaceKind(options) ?? "local";
return buildWorkspaceSource({
isolation: newWorkspace,
path: cwd,
mode: options.worktreeMode,
worktreeSlug: options.worktreeSlug ?? options.worktree,
newBranch: options.newBranch,
base: options.base,
branch: options.branch,
prNumber: options.prNumber,
forge: options.forge,
});
}
function toRunResult(
agent: AgentSnapshotPayload,
statusOverride?: AgentRunResult["status"],
@@ -269,37 +304,61 @@ function structuredRunSchema(output: Record<string, unknown>): OutputSchema<Agen
};
}
function validateRunOptions(prompt: string, options: AgentRunOptions, outputSchema: unknown): void {
if (!prompt || prompt.trim().length === 0) {
function validateRunWorkspaceOptions(options: AgentRunOptions): void {
const newWorkspace = resolveNewWorkspaceKind(options);
if (
options.newWorkspace &&
options.newWorkspace !== "local" &&
options.newWorkspace !== "worktree"
) {
throw {
code: "MISSING_PROMPT",
message: "A prompt is required",
details: "Usage: paseo agent run [options] <prompt>",
code: "INVALID_OPTIONS",
message: `Unsupported new workspace kind: ${options.newWorkspace}`,
details: "Use --new-workspace local or --new-workspace worktree",
} satisfies CommandError;
}
const createsIsolatedWorkspace = options.isolation === "worktree" || Boolean(options.worktree);
if (options.isolation && options.isolation !== "local" && options.isolation !== "worktree") {
if (options.newWorkspace && options.worktree) {
throw {
code: "INVALID_OPTIONS",
message: `Unsupported workspace isolation: ${options.isolation}`,
details: "Use --isolation local or --isolation worktree",
message: "--new-workspace and --worktree cannot be combined",
details: "Use --new-workspace worktree and the supported worktree options",
} satisfies CommandError;
}
if (options.base && !createsIsolatedWorkspace) {
const hasWorktreeCreationOptions = [
options.worktreeMode,
options.worktreeSlug,
options.newBranch,
options.base,
options.branch,
options.prNumber,
options.forge,
].some((value) => value !== undefined);
if (hasWorktreeCreationOptions && newWorkspace !== "worktree") {
throw {
code: "INVALID_OPTIONS",
message: "--base can only be used with --isolation worktree",
details: "Usage: paseo agent run --isolation worktree --base <branch> <prompt>",
message: "Worktree options require --new-workspace worktree",
details: "Usage: paseo run --new-workspace worktree [worktree options] <prompt>",
} satisfies CommandError;
}
if (options.isolation && options.workspace) {
if (newWorkspace === "worktree") {
try {
buildRunWorkspaceSource(options, options.cwd ?? process.cwd());
} catch (error) {
throw {
code: "INVALID_OPTIONS",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
if (options.newWorkspace && options.workspace) {
throw {
code: "INVALID_OPTIONS",
message: "--isolation and --workspace cannot be combined",
details: "Select an existing workspace or create a new one with an isolation choice",
message: "--new-workspace and --workspace cannot be combined",
details: "Select an existing workspace or explicitly create a new one",
} satisfies CommandError;
}
@@ -309,9 +368,21 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
throw {
code: "INVALID_OPTIONS",
message: "--worktree and --workspace cannot be combined",
details: "Use --isolation worktree instead of the legacy --worktree flag",
details: "Use --new-workspace worktree instead of the legacy --worktree flag",
} satisfies CommandError;
}
}
function validateRunOptions(prompt: string, options: AgentRunOptions, outputSchema: unknown): void {
if (!prompt || prompt.trim().length === 0) {
throw {
code: "MISSING_PROMPT",
message: "A prompt is required",
details: "Usage: paseo agent run [options] <prompt>",
} satisfies CommandError;
}
validateRunWorkspaceOptions(options);
if (outputSchema && runsInBackground(options)) {
throw {
@@ -465,27 +536,25 @@ export async function resolveExistingRunWorkspace(
// 1. --workspace <id> -> run in that existing workspace
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
// 4. --isolation <kind> -> mint a new workspace with explicit isolation
// 4. --new-workspace <kind> -> mint a new workspace explicitly
// 5. bare run -> mint a new local-backed workspace for cwd
async function resolveRunWorkspace(
client: ConnectedDaemonClient,
options: AgentRunOptions,
cwd: string,
): Promise<RunWorkspace> {
const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined);
const explicit = requestedIsolation ? undefined : options.workspace?.trim();
const newWorkspace = resolveNewWorkspaceKind(options);
const explicit = newWorkspace ? undefined : options.workspace?.trim();
if (explicit) {
console.error(`Using workspace ${explicit}`);
return resolveExistingRunWorkspace(client, explicit);
}
if (!requestedIsolation && resolveRunCallerAgentId()) {
if (!newWorkspace && resolveRunCallerAgentId()) {
return { cwd };
}
const ambientWorkspaceId = requestedIsolation
? undefined
: process.env.PASEO_WORKSPACE_ID?.trim();
const ambientWorkspaceId = newWorkspace ? undefined : process.env.PASEO_WORKSPACE_ID?.trim();
if (ambientWorkspaceId) {
console.error(`Using workspace ${ambientWorkspaceId}`);
return resolveExistingRunWorkspace(client, ambientWorkspaceId);
@@ -493,17 +562,8 @@ async function resolveRunWorkspace(
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
// title/branch generation picks up the task description (U8/U6 deferred).
const result =
requestedIsolation === "worktree"
? await client.createWorkspace({
source: {
kind: "worktree",
cwd,
worktreeSlug: options.worktree,
baseBranch: options.base,
},
})
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
const source = buildRunWorkspaceSource(options, cwd);
const result = await client.createWorkspace({ source });
if (!result.workspace) {
throw {