From 246c07fba58468e0044109f62055f589afd1f8c2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 12:26:40 +0200 Subject: [PATCH] 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. --- docs/development.md | 2 +- packages/cli/src/cli-surface.test.ts | 19 ++- packages/cli/src/commands/agent/run.test.ts | 30 +++-- packages/cli/src/commands/agent/run.ts | 136 ++++++++++++++------ packages/cli/tests/05-agent-run.test.ts | 24 ++++ public-docs/cli.md | 6 +- public-docs/worktrees.md | 2 +- skills/paseo/SKILL.md | 1 + 8 files changed, 167 insertions(+), 53 deletions(-) diff --git a/docs/development.md b/docs/development.md index 2bb5a5403..81e43cb3b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -383,7 +383,7 @@ install. Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing. -Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. +Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. ```bash npm run cli -- ls -a -g # List all agents globally diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index fa772f85c..769d2e4a6 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -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 "); - expect(run?.helpInformation()).not.toContain("--worktree "); + const help = run?.helpInformation(); + expect(help).toContain("--new-workspace "); + expect(help).not.toContain("--isolation"); + expect(help).not.toContain("--worktree "); + }); + + 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 "); + expect(help).toContain("--worktree-slug "); + expect(help).toContain("--new-branch "); + expect(help).toContain("--branch "); + expect(help).toContain("--pr-number "); + expect(help).toContain("--forge "); }); it("uses background for execution and reserves detach for ownership", () => { diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 59081c99d..755638d4d 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -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/, + ); }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index d283a6d10..14e494c56 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -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 ", "Thinking option ID to use for this run") .option("--mode ", "Provider-specific mode (e.g., plan, default, bypass)") - .option("--isolation ", "Create a new workspace with this isolation") + .option("--new-workspace ", "Create a separate local or worktree workspace") .addOption(new Option("--worktree ", "Legacy workspace isolation alias").hideHelp()) - .option("--base ", "Base branch for an isolated workspace") + .option( + "--worktree-mode ", + "Worktree mode: branch-off, checkout-branch, or checkout-pr", + ) + .option("--worktree-slug ", "Managed worktree path slug") + .option("--new-branch ", "New branch name for branch-off mode") + .option("--base ", "Base ref for branch-off mode") + .option("--branch ", "Existing branch for checkout-branch mode") + .option("--pr-number ", "Pull request or change request number for checkout-pr mode") + .option("--forge ", "Forge for checkout-pr mode") .option( "--workspace ", - "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 ", @@ -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): OutputSchema", + 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 ", + message: "Worktree options require --new-workspace worktree", + details: "Usage: paseo run --new-workspace worktree [worktree options] ", } 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] ", + } satisfies CommandError; + } + + validateRunWorkspaceOptions(options); if (outputSchema && runsInBackground(options)) { throw { @@ -465,27 +536,25 @@ export async function resolveExistingRunWorkspace( // 1. --workspace -> 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 -> mint a new workspace with explicit isolation +// 4. --new-workspace -> 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 { - 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 { diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 1c2700a50..0af2a6ec7 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -58,6 +58,8 @@ try { assert(result.stdout.includes("--title"), "help should mention --title option"); assert(result.stdout.includes("--provider"), "help should mention --provider option"); assert(result.stdout.includes("--mode"), "help should mention --mode option"); + assert(result.stdout.includes("--new-workspace"), "help should mention --new-workspace option"); + assert(!result.stdout.includes("--isolation"), "help should not mention --isolation"); assert(result.stdout.includes("--cwd"), "help should mention --cwd option"); assert(result.stdout.includes("--output-schema"), "help should mention --output-schema option"); assert(result.stdout.includes("--host"), "help should mention --host option"); @@ -244,6 +246,28 @@ try { assert(output.includes("unknown option"), "should report unknown option for --ui"); console.log("✓ run --ui is rejected\n"); } + + // Test 15: run --new-workspace is accepted + { + console.log("Test 15: run --new-workspace is accepted"); + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --new-workspace local "test prompt"`.nothrow(); + const output = result.stdout + result.stderr; + assert(!output.includes("unknown option"), "should accept --new-workspace"); + assert(!output.includes("error: option"), "should not have option parsing error"); + console.log("✓ run --new-workspace is accepted\n"); + } + + // Test 16: run --isolation is rejected (unreleased flag removed) + { + console.log("Test 16: run --isolation is rejected"); + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --isolation local "test prompt"`.nothrow(); + assert.notStrictEqual(result.exitCode, 0, "should fail for removed --isolation flag"); + const output = result.stdout + result.stderr; + assert(output.includes("unknown option"), "should report unknown option for --isolation"); + console.log("✓ run --isolation is rejected\n"); + } } finally { // Clean up temp directory await rm(paseoHome, { recursive: true, force: true }); diff --git a/public-docs/cli.md b/public-docs/cli.md index f568df3e0..14b564cc1 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -31,13 +31,15 @@ Use `paseo run` to start a new agent with a task: paseo run "implement user authentication" paseo run --provider codex "refactor the API layer" paseo run --background "run the focused test suite" -paseo run --isolation worktree --base main "implement feature X" +paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/x --base main "implement feature X" paseo run --workspace "review the current diff" paseo run --output-schema schema.json "extract release notes" paseo run --output-schema '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' "summarize release notes" ``` -From a human shell, a bare `paseo run` creates a new local workspace for the current directory. Use `--workspace ` to add the agent to an existing workspace, or `--isolation worktree` to create a new workspace backed by an isolated git worktree. +From a human shell, a bare `paseo run` creates a new local workspace for the current directory. Use `--workspace ` to add the agent to an existing workspace, or `--new-workspace local|worktree` to explicitly create a separate workspace for the run. + +Worktree creation accepts `--worktree-mode branch-off|checkout-branch|checkout-pr` plus the matching `--new-branch`/`--base`, `--branch`, or `--pr-number`/`--forge` options. Use `--worktree-slug` to choose the managed directory slug. When an existing Paseo agent runs the same command, Paseo recognizes it through `PASEO_AGENT_ID`. Without explicit placement, the new agent becomes its subagent in the same workspace. `--workspace` can place that subagent elsewhere without changing its parent. diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 23a9bc828..fbe3e72d0 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -256,4 +256,4 @@ paseo run --workspace "implement auth" paseo workspace archive ``` -For the common case, `paseo run --isolation worktree --base main "implement auth"` creates both the workspace and its first agent. +For the common case, `paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/auth --base main "implement auth"` creates both the workspace and its first agent. diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index c94595b93..865972c2e 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -104,6 +104,7 @@ paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x paseo workspace create --isolation worktree --mode checkout-branch --branch existing-work paseo workspace create --isolation worktree --mode checkout-pr --pr-number 42 paseo run --provider codex/gpt-5.4 --mode full-access --workspace "" +paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base main "" paseo send "" paseo ls paseo schedule create --cron "*/15 * * * *" "ping main build"