diff --git a/README.md b/README.md index 11ee6b1..4777326 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ The repository owns the workflow. A committed project contract defines setup com - Repository-owned setup, check, Git, and deployment policy - Isolated local ports, process metadata, logs, and clean shutdown - Optional Vercel Development environment injection without persisted secret values +- Agent-aware launch context for Codex, Claude Code, and OpenClaw +- Portable instructions for app-based, remote, and unsupported agent clients - Machine-readable project and workspace state ## Install from source @@ -41,10 +43,23 @@ pagent doctor pagent context pagent enter dashboard-feature --agent openclaw pagent enter dashboard-polish --agent codex +pagent launch dashboard-polish pagent status ``` -`pagent enter` prints the isolated worktree path. Start the selected coding agent from that directory. +`pagent launch` starts the selected coding agent in the isolated worktree and supplies the committed project workflow as its initial context. It does not create, replace, or modify `AGENTS.md`, `CLAUDE.md`, or other repository instruction files. + +Include the first task when launching an agent: + +```bash +pagent launch dashboard-polish --prompt "Polish the dashboard navigation" +``` + +For ChatGPT, a remote OpenClaw agent, or another client that Parallel Agent CLI cannot launch directly, print a portable handoff and paste it into that client: + +```bash +pagent instructions dashboard-polish --agent chatgpt +``` To register a worktree that already exists: @@ -109,6 +124,8 @@ pagent stop dashboard-feature Parallel Agent CLI allocates a local port, records the process and log path, and checks the configured health URL. Running `pagent dev` from inside a registered worktree infers its workspace name. +Agents started through `pagent launch` are explicitly instructed to use `pagent bootstrap`, `pagent dev`, `pagent logs`, and `pagent stop` rather than bypassing the managed runtime lifecycle. Codex and Claude Code run with the worktree as their current project. OpenClaw starts in local TUI mode with the worktree supplied through `OPENCLAW_WORKSPACE_DIR` and in the handoff instructions. An explicit OpenClaw workspace in the user's configuration can take precedence over that environment variable; use `pagent instructions` with an already configured OpenClaw agent in that case. + For a Vercel-backed development environment: ```yaml @@ -137,6 +154,8 @@ pagent doctor pagent bootstrap [--apply] pagent enter --agent [--base ] pagent adopt --agent [--name ] [--base ] +pagent launch [name] [--agent ] [--prompt ] [--dry-run] +pagent instructions [name] [--agent ] [--prompt ] pagent remove pagent dev [name] [--port ] [--no-wait] pagent logs [--lines ] diff --git a/src/cli.ts b/src/cli.ts index 055c367..c98e823 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { doctorCommand } from "./commands/doctor.js"; import { devCommand } from "./commands/dev.js"; import { enterCommand } from "./commands/enter.js"; import { initCommand } from "./commands/init.js"; +import { instructionsCommand, launchCommand } from "./commands/launch.js"; import { logsCommand } from "./commands/logs.js"; import { removeCommand } from "./commands/remove.js"; import { statusCommand } from "./commands/status.js"; @@ -53,6 +54,28 @@ program devCommand(process.cwd(), name, options), ); +program + .command("launch") + .argument("[name]", "workspace name; inferred inside a registered workspace") + .description("Launch a coding agent with the workspace's operational context") + .option("--agent ", "override the workspace's recorded agent launcher") + .option("--prompt ", "include an initial user task") + .option("--dry-run", "print the launch context without starting the agent") + .action( + (name: string | undefined, options: { agent?: string; prompt?: string; dryRun?: boolean }) => + launchCommand(process.cwd(), name, options), + ); + +program + .command("instructions") + .argument("[name]", "workspace name; inferred inside a registered workspace") + .description("Print portable workspace instructions for any coding agent") + .option("--agent ", "name the receiving agent in the instructions") + .option("--prompt ", "include an initial user task") + .action((name: string | undefined, options: { agent?: string; prompt?: string }) => + instructionsCommand(process.cwd(), name, options), + ); + program .command("stop") .argument("", "workspace name") diff --git a/src/commands/enter.ts b/src/commands/enter.ts index 642be1b..d4dd067 100644 --- a/src/commands/enter.ts +++ b/src/commands/enter.ts @@ -83,5 +83,14 @@ export async function enterCommand( ); } console.log(`Worktree: ${worktree}`); - console.log(`\nNext: cd ${JSON.stringify(worktree)} && ${agent}`); + console.log("\nNext:"); + console.log(` pagent launch ${name}`); + console.log( + "\nThe launched agent is instructed to use pagent bootstrap for dependencies and pagent dev for the development server.", + ); + console.log(`\nFor an app, remote agent, or unsupported client:`); + console.log(` pagent instructions ${name}`); + console.log( + "\nUse pagent dev instead of starting the development command directly so ports, environment variables, logs, and cleanup remain managed.", + ); } diff --git a/src/commands/launch.ts b/src/commands/launch.ts new file mode 100644 index 0000000..fdc08f1 --- /dev/null +++ b/src/commands/launch.ts @@ -0,0 +1,217 @@ +import { access } from "node:fs/promises"; +import { loadConfig, type ProjectConfig } from "../config.js"; +import { CliError } from "../errors.js"; +import { gitRoot } from "../git.js"; +import { slugify } from "../format.js"; +import { commandExists, runInteractiveCommand } from "../process.js"; +import { readState, type WorkspaceRecord } from "../state.js"; + +type SupportedAgent = "codex" | "claude" | "openclaw"; + +export interface LaunchOptions { + agent?: string; + prompt?: string; + dryRun?: boolean; +} + +export interface AgentLaunchSpec { + agent: SupportedAgent; + command: string; + args: string[]; + environment: NodeJS.ProcessEnv; +} + +const agentAliases: Record = { + codex: "codex", + "codex-cli": "codex", + claude: "claude", + "claude-code": "claude", + openclaw: "openclaw", +}; + +export async function launchCommand( + cwd: string, + requestedName: string | undefined, + options: LaunchOptions, +): Promise { + const resolved = await resolveWorkspace(cwd, requestedName); + const agent = normalizeAgent(options.agent ?? resolved.workspace.agent); + const instructions = buildAgentInstructions( + resolved.config, + resolved.workspace, + agent, + options.prompt, + ); + const specification = buildLaunchSpec(agent, resolved.workspace, instructions, process.env); + + if (options.dryRun) { + printLaunchPreview(resolved.workspace, specification, instructions); + return; + } + + if (!(await commandExists(specification.command))) { + throw new CliError( + `Agent executable is not installed or not on PATH: ${specification.command}. ` + + `Run "pagent instructions ${resolved.workspace.name}" to hand the context to another client.`, + ); + } + + console.log(`Launching ${agent} in workspace ${resolved.workspace.name}`); + console.log(`Worktree: ${resolved.workspace.worktree}`); + console.log( + "Parallel Agent will provide the repository workflow as the agent's initial instructions.\n", + ); + await runInteractiveCommand( + specification.command, + specification.args, + resolved.workspace.worktree, + specification.environment, + ); +} + +export async function instructionsCommand( + cwd: string, + requestedName: string | undefined, + options: Pick, +): Promise { + const resolved = await resolveWorkspace(cwd, requestedName); + const agent = options.agent?.trim() + ? options.agent.trim() + : (agentAliases[slugify(resolved.workspace.agent)] ?? resolved.workspace.agent); + console.log(buildAgentInstructions(resolved.config, resolved.workspace, agent, options.prompt)); +} + +export function buildLaunchSpec( + agent: SupportedAgent, + workspace: WorkspaceRecord, + instructions: string, + baseEnvironment: NodeJS.ProcessEnv, +): AgentLaunchSpec { + const environment: NodeJS.ProcessEnv = { + ...baseEnvironment, + PARALLEL_AGENT_WORKSPACE: workspace.name, + PARALLEL_AGENT_WORKTREE: workspace.worktree, + }; + + switch (agent) { + case "codex": + return { + agent, + command: "codex", + args: ["--cd", workspace.worktree, instructions], + environment, + }; + case "claude": + return { + agent, + command: "claude", + args: [instructions], + environment, + }; + case "openclaw": + return { + agent, + command: "openclaw", + args: [ + "tui", + "--local", + "--session", + `pagent-${workspace.name}`, + "--message", + instructions, + ], + environment: { + ...environment, + OPENCLAW_WORKSPACE_DIR: workspace.worktree, + }, + }; + } +} + +export function buildAgentInstructions( + config: ProjectConfig, + workspace: WorkspaceRecord, + agent: string, + prompt?: string, +): string { + const install = config.runtime.install ?? "not configured"; + const development = config.runtime.development ?? "not configured"; + const checks = config.checks.length > 0 ? config.checks.join(", ") : "none configured"; + const task = prompt?.trim(); + + return `Parallel Agent workspace instructions + +You are the ${agent} agent assigned to the isolated workspace "${workspace.name}" for project "${config.project.name}". + +Workspace: ${workspace.worktree} +Branch: ${workspace.branch} +Recorded base: ${workspace.baseRef} (${workspace.baseSha}) +Install command in project contract: ${install} +Development command in project contract: ${development} +Required checks: ${checks} + +Operating rules: +1. Work only in the workspace path above. Do not edit another checkout of this repository. +2. Run "pagent context" before making changes so you have the current repository workflow and safety policy. +3. When dependencies are missing, run "pagent bootstrap" first and then "pagent bootstrap --apply" if installation is needed. Do not bypass the configured bootstrap command with a package-manager install. +4. Start the project with "pagent dev". Do not run the development command directly; Pagent must allocate the port, inject the configured environment, and track the process. +5. Inspect the server with "pagent logs ${workspace.name}" and stop it with "pagent stop ${workspace.name}". +6. Repository instructions such as AGENTS.md or CLAUDE.md still apply to implementation work. The committed .parallel-agent/project.yaml contract is authoritative for workspace, runtime, Git, and deployment operations. +7. Direct pushes to ${config.repository.default_branch} are ${config.git.direct_push_to_main ? "allowed by project policy" : "forbidden"}. Force pushes are ${config.git.force_push ? "allowed by project policy" : "forbidden"}. +8. ${config.production.requires_approval ? "Do not deploy or promote to production without explicit user approval." : "Follow the project contract before any production operation."} + +${task ? `User task:\n${task}` : "Load the project context, then ask the user what they want to work on."}`; +} + +function normalizeAgent(rawAgent: string): SupportedAgent { + const normalized = agentAliases[slugify(rawAgent)]; + if (!normalized) { + throw new CliError( + `No interactive launcher is configured for agent "${rawAgent}". ` + + "Supported launchers: codex, claude, openclaw. Use pagent instructions for other clients.", + ); + } + return normalized; +} + +async function resolveWorkspace(cwd: string, requestedName: string | undefined) { + const { root, config } = await loadConfig(cwd); + const state = await readState(root); + let currentRoot: string | undefined; + if (!requestedName) { + try { + currentRoot = await gitRoot(cwd); + } catch { + currentRoot = undefined; + } + } + const workspace = requestedName + ? state.workspaces.find((candidate) => candidate.name === requestedName) + : state.workspaces.find((candidate) => candidate.worktree === currentRoot); + if (!workspace) { + throw new CliError( + requestedName + ? `Unknown workspace: ${requestedName}` + : "Could not infer a workspace from this directory. Pass a workspace name.", + ); + } + try { + await access(workspace.worktree); + } catch { + throw new CliError(`Workspace worktree is missing: ${workspace.worktree}`); + } + return { config, workspace }; +} + +function printLaunchPreview( + workspace: WorkspaceRecord, + specification: AgentLaunchSpec, + instructions: string, +): void { + console.log(`Agent: ${specification.agent}`); + console.log(`Workspace: ${workspace.name}`); + console.log(`Worktree: ${workspace.worktree}`); + console.log(`Executable: ${specification.command}`); + console.log("\nInitial instructions:\n"); + console.log(instructions); +} diff --git a/src/process.ts b/src/process.ts index 5c193ed..a527ad0 100644 --- a/src/process.ts +++ b/src/process.ts @@ -59,3 +59,35 @@ export async function runShellCommand(command: string, cwd: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: environment, + stdio: "inherit", + }); + + child.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + reject(new CliError(`Agent executable is not installed or not on PATH: ${command}`)); + return; + } + reject(error); + }); + child.on("exit", (code, signal) => { + if (code === 0) { + resolve(); + } else if (signal) { + reject(new CliError(`${command} exited after receiving ${signal}.`)); + } else { + reject(new CliError(`${command} exited with code ${code ?? "unknown"}.`)); + } + }); + }); +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 82bd06f..51c4dc2 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -99,6 +99,8 @@ describe("Parallel Agent CLI", () => { repository, ); expect(entered.stdout).toContain("agent/codex/dashboard-polish"); + expect(entered.stdout).toContain("pagent launch dashboard-polish"); + expect(entered.stdout).toContain("Use pagent dev instead of starting"); const status = await exec(process.execPath, [cli, "status", "--json"], repository); const workspaces = JSON.parse(status.stdout) as Array<{ @@ -308,4 +310,42 @@ describe("Parallel Agent CLI", () => { expect(context.repository.defaultBranch).toBe("main"); expect(context.gitPolicy.direct_push_to_main).toBe(false); }); + + it("previews an agent launch with the managed runtime instructions", async () => { + const repository = await createRepository(); + await exec(process.execPath, [cli, "init"], repository); + await exec("git", ["add", "."], repository); + await exec("git", ["commit", "-m", "Add project contract"], repository); + await exec(process.execPath, [cli, "enter", "launch-test", "--agent", "codex"], repository); + + const preview = await exec( + process.execPath, + [cli, "launch", "launch-test", "--prompt", "Polish the dashboard navigation", "--dry-run"], + repository, + ); + + expect(preview.stdout).toContain("Agent: codex"); + expect(preview.stdout).toContain("Executable: codex"); + expect(preview.stdout).toContain('Start the project with "pagent dev"'); + expect(preview.stdout).toContain("Polish the dashboard navigation"); + }); + + it("prints portable instructions for clients it cannot launch", async () => { + const repository = await createRepository(); + await exec(process.execPath, [cli, "init"], repository); + await exec("git", ["add", "."], repository); + await exec("git", ["commit", "-m", "Add project contract"], repository); + await exec(process.execPath, [cli, "enter", "remote-agent", "--agent", "openclaw"], repository); + + const instructions = await exec( + process.execPath, + [cli, "instructions", "remote-agent", "--agent", "chatgpt"], + repository, + ); + + expect(instructions.stdout).toContain("You are the chatgpt agent"); + expect(instructions.stdout).toContain("Work only in the workspace path above"); + expect(instructions.stdout).toContain("pagent bootstrap --apply"); + expect(instructions.stdout).toContain("pagent stop remote-agent"); + }); }); diff --git a/test/launch.test.ts b/test/launch.test.ts new file mode 100644 index 0000000..0154b4e --- /dev/null +++ b/test/launch.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { buildLaunchSpec } from "../src/commands/launch.js"; +import type { WorkspaceRecord } from "../src/state.js"; + +const workspace: WorkspaceRecord = { + id: "workspace-id", + name: "dashboard-polish", + agent: "codex", + branch: "agent/codex/dashboard-polish", + baseRef: "refs/remotes/origin/main", + baseSha: "0123456789abcdef", + worktree: "/tmp/parallel-agent/dashboard-polish", + managed: true, + createdAt: "2026-08-10T00:00:00.000Z", +}; + +describe("agent launch adapters", () => { + it("pins Codex to the managed worktree", () => { + const specification = buildLaunchSpec("codex", workspace, "instructions", { + PATH: "/test/bin", + }); + + expect(specification.command).toBe("codex"); + expect(specification.args).toEqual(["--cd", workspace.worktree, "instructions"]); + expect(specification.environment.PARALLEL_AGENT_WORKSPACE).toBe(workspace.name); + expect(specification.environment.PARALLEL_AGENT_WORKTREE).toBe(workspace.worktree); + }); + + it("provides OpenClaw with an isolated session and workspace override", () => { + const specification = buildLaunchSpec("openclaw", workspace, "instructions", {}); + + expect(specification.command).toBe("openclaw"); + expect(specification.args).toEqual([ + "tui", + "--local", + "--session", + "pagent-dashboard-polish", + "--message", + "instructions", + ]); + expect(specification.environment.OPENCLAW_WORKSPACE_DIR).toBe(workspace.worktree); + }); +});