mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
* feat(server): let clients create internal agents The daemon's own branch-name and commit-message generators run as internal agents: never persisted, listed, flagged, announced, or seen by plugin lifecycle hooks. Nothing outside the daemon could ask for one, because the wire config schema strips `internal` and the agent.create transform re-pins it. A plugin that runs a one-turn helper per event therefore left an archived session in History every time. Add an optional `internal` flag to `create_agent_request` (inherited by `agent.create.request`), copy it onto the config in the session's create path, and skip provider session persistence for it like the generators do. The client SDK forwards the flag on both creation paths and gates it once on `server_info.features.internalAgents`. Two daemon paths assumed every agent is public and had to follow: `resolveAgentIdentifier` now accepts a live agent by exact id, since the id set it matched against came from listings that exclude internal agents and left `wait_for_finish` answering "Agent not found"; and archiving an internal agent now closes it without writing the record that `registry.applySnapshot` used to leave behind, so `autoArchive` does not accumulate hidden files on disk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(server): keep internal agents whole through failure, archive, and wait Review follow-ups for internal agents: - A create that fails after the runtime is registered now closes the internal agent before rethrowing, so a hidden runtime cannot outlive the failed request. Public agents keep today's behaviour. - Archiving an internal agent cascades to attached children like any parent. cascadeArchiveChildren takes the parent record instead of reading it back from storage, where an internal agent never is. - The archived snapshot and last message of an internal agent stay readable by exact id for ten minutes, so create-then-waitForFinish returns the finished turn whether the wait lands before or after an auto-archive. Storage plays that role for public agents. - The internal-agent e2e scenarios live in the existing create_agent_request lifecycle suite rather than a new file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes Internal children never reach storage, so the parent archive cascade scans live agents for them as well. A failed internal create now clears the created id only after the runtime is archived, so worktree cleanup cannot delete the directory under a runtime that would not close. Stored agent payloads carry lastError, so a retained failed helper reports its error through wait_for_finish instead of the generic message. * feat(cli,mcp): expose internal agents and document --no-wait paseo run gains --internal and create_agent gains internal, both forwarded to the create request. --no-wait becomes the documented spelling for returning immediately, matching paseo send; --background and --detach stay as hidden aliases under COMPAT tags. * test: follow the --no-wait rename and stub getRetiredInternalAgent * feat: list internal agents on request and add internal workspaces Internal agents stay hidden by default, but a caller can now ask for them: `fetch_agents_request` takes `filter.includeInternal`, snapshots carry `internal: true`, and `paseo ls --internal` shows them in an INTERNAL column. The app never passes the flag. Workspaces get the same flag. `workspace.create.request` with `internal: true` persists a hidden workspace: listings, update subscriptions, sync reads, empty-project counting, open-by-path, MCP `list_workspaces`, and plugin workspace hooks all skip it unless the caller opts in with `filter.includeInternal`. Every agent created inside an internal workspace is internal and unpersisted; AgentManager applies that from the workspace id before the plugin hook so creators cannot opt out. Archive and shutdown enumerate internal agents so nothing inside an internal workspace outlives it. Surfaces: `paseo workspace create --internal`, `paseo workspace ls --internal`, `paseo run --internal --new-workspace`, MCP `create_workspace({ internal })`, SDK `workspaces.create({ internal })` and `list({ filter: { includeInternal } })`. Clients gate on `features.internalWorkspaces`. Co-Authored-By: Claude <noreply@anthropic.com> * feat(workspaces): add background visibility with inherited creation defaults * fix(cli): resolve workspace callers on the selected daemon * test(workspaces): keep caller integration coverage in server suite * test(cli): exercise workspace callers through CLI integration harness * test(mcp): remove obsolete execution flags from browser fixtures --------- Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
760 lines
24 KiB
TypeScript
760 lines
24 KiB
TypeScript
import { Command, Option } from "commander";
|
|
import {
|
|
getStructuredAgentResponse,
|
|
StructuredAgentResponseError,
|
|
} from "@getpaseo/server/agent-response";
|
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
|
import { resolveCallerAgentId } from "../../utils/caller-agent.js";
|
|
import { connectToDaemon } from "../../utils/client.js";
|
|
import type {
|
|
CommandOptions,
|
|
SingleResult,
|
|
OutputSchema,
|
|
CommandError,
|
|
} from "../../output/index.js";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
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";
|
|
|
|
export function addRunOptions(cmd: Command): Command {
|
|
return (
|
|
cmd
|
|
.description("Create and start an agent with a task")
|
|
.argument("<prompt>", "The task/prompt for the agent")
|
|
.option("--no-wait", "Return immediately without waiting for completion")
|
|
// COMPAT(backgroundRunFlag): --background was the documented spelling of
|
|
// --no-wait. Added in v0.2.0; remove after 2027-04-01.
|
|
.addOption(new Option("-d, --background", "Legacy alias for --no-wait").hideHelp())
|
|
// COMPAT(detachRunFlag): --detach used to mean background execution, not
|
|
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
|
|
.addOption(new Option("--detach", "Legacy alias for --no-wait").hideHelp())
|
|
.option("--title <title>", "Assign a title to the agent")
|
|
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
|
|
.option(
|
|
"--provider <provider>",
|
|
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
|
)
|
|
.option(
|
|
"--model <model>",
|
|
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
|
)
|
|
.option("--thinking <id>", "Thinking option ID to use for this run")
|
|
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
|
.option("--new-workspace <local|worktree>", "Create a separate local or worktree workspace")
|
|
.addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp())
|
|
.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 (defaults to the caller workspace when agent-scoped)",
|
|
)
|
|
.option(
|
|
"--image <path>",
|
|
"Attach image(s) to the initial prompt (can be used multiple times)",
|
|
collectMultiple,
|
|
[],
|
|
)
|
|
.option("--cwd <path>", "Working directory (default: current)")
|
|
.option(
|
|
"--env <key=value>",
|
|
"Set environment variable(s) for the agent process (can be used multiple times)",
|
|
collectMultiple,
|
|
[],
|
|
)
|
|
.option(
|
|
"--label <key=value>",
|
|
"Add label(s) to the agent (can be used multiple times)",
|
|
collectMultiple,
|
|
[],
|
|
)
|
|
.option(
|
|
"--wait-timeout <duration>",
|
|
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
|
|
)
|
|
.option(
|
|
"--output-schema <schema>",
|
|
"Output JSON matching the provided schema file path or inline JSON schema",
|
|
)
|
|
);
|
|
}
|
|
|
|
/** Result type for agent run command */
|
|
export interface AgentRunResult {
|
|
agentId: string;
|
|
status: "created" | "running" | "completed" | "timeout" | "permission" | "error";
|
|
provider: string;
|
|
cwd: string;
|
|
title: string | null;
|
|
}
|
|
|
|
/** Schema for agent run output */
|
|
export const agentRunSchema: OutputSchema<AgentRunResult> = {
|
|
idField: "agentId",
|
|
columns: [
|
|
{ header: "AGENT ID", field: "agentId", width: 12 },
|
|
{ header: "STATUS", field: "status", width: 10 },
|
|
{ header: "PROVIDER", field: "provider", width: 10 },
|
|
{ header: "CWD", field: "cwd", width: 30 },
|
|
{ header: "TITLE", field: "title", width: 20 },
|
|
],
|
|
};
|
|
|
|
export interface AgentRunOptions extends CommandOptions {
|
|
wait?: boolean;
|
|
background?: boolean;
|
|
detach?: boolean;
|
|
title?: string;
|
|
name?: string;
|
|
provider?: string;
|
|
model?: string;
|
|
thinking?: string;
|
|
mode?: 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;
|
|
env?: string[];
|
|
label?: string[];
|
|
waitTimeout?: string;
|
|
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({
|
|
daemonTarget: options.daemonTarget,
|
|
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"],
|
|
): AgentRunResult {
|
|
return {
|
|
agentId: agent.id,
|
|
status: statusOverride ?? (agent.status === "running" ? "running" : "created"),
|
|
provider: agent.provider,
|
|
cwd: agent.cwd,
|
|
title: agent.title,
|
|
};
|
|
}
|
|
|
|
function loadOutputSchema(value: string): Record<string, unknown> {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
const error: CommandError = {
|
|
code: "INVALID_OUTPUT_SCHEMA",
|
|
message: "--output-schema cannot be empty",
|
|
details: "Provide a JSON schema file path or inline JSON object",
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
let source = trimmed;
|
|
if (!trimmed.startsWith("{")) {
|
|
try {
|
|
source = readFileSync(resolve(trimmed), "utf8");
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && "code" in err) throw err;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const error: CommandError = {
|
|
code: "INVALID_OUTPUT_SCHEMA",
|
|
message: `Failed to read output schema file: ${trimmed}`,
|
|
details: message,
|
|
};
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(source);
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && "code" in err) throw err;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const error: CommandError = {
|
|
code: "INVALID_OUTPUT_SCHEMA",
|
|
message: "Failed to parse output schema JSON",
|
|
details: message,
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
const error: CommandError = {
|
|
code: "INVALID_OUTPUT_SCHEMA",
|
|
message: "Output schema must be a JSON object",
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
class StructuredRunStatusError extends Error {
|
|
readonly kind: "timeout" | "permission" | "error" | "empty";
|
|
|
|
constructor(kind: "timeout" | "permission" | "error" | "empty", message: string) {
|
|
super(message);
|
|
this.name = "StructuredRunStatusError";
|
|
this.kind = kind;
|
|
}
|
|
}
|
|
|
|
async function fetchStructuredOutput(
|
|
caller: (structuredPrompt: string) => Promise<string>,
|
|
prompt: string,
|
|
outputSchema: ReturnType<typeof loadOutputSchema>,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
return await getStructuredAgentResponse<Record<string, unknown>>({
|
|
caller,
|
|
prompt,
|
|
schema: outputSchema,
|
|
schemaName: "RunOutput",
|
|
maxRetries: 2,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof StructuredRunStatusError) {
|
|
throw {
|
|
code: "OUTPUT_SCHEMA_FAILED",
|
|
message: err.message,
|
|
} satisfies CommandError;
|
|
}
|
|
if (err instanceof StructuredAgentResponseError) {
|
|
throw {
|
|
code: "OUTPUT_SCHEMA_FAILED",
|
|
message: "Agent response did not match the required output schema",
|
|
details:
|
|
err.validationErrors.length > 0
|
|
? err.validationErrors.join("\n")
|
|
: err.lastResponse || "No response",
|
|
} satisfies CommandError;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>;
|
|
|
|
export interface StructuredResponseTimelineClient {
|
|
fetchAgentTimeline: ConnectedDaemonClient["fetchAgentTimeline"];
|
|
}
|
|
|
|
export async function resolveStructuredResponseMessage(options: {
|
|
client: StructuredResponseTimelineClient;
|
|
agentId: string;
|
|
lastMessage: string | null;
|
|
}): Promise<string | null> {
|
|
const direct = options.lastMessage?.trim();
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
|
|
try {
|
|
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
|
|
direction: "tail",
|
|
limit: 200,
|
|
});
|
|
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
|
|
const entry = timeline.entries[index];
|
|
if (!entry || entry.item.type !== "assistant_message") {
|
|
continue;
|
|
}
|
|
const text = entry.item.text.trim();
|
|
if (text.length > 0) {
|
|
return text;
|
|
}
|
|
}
|
|
} catch {
|
|
// Leave empty; caller will surface a consistent structured-output failure message.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
|
|
return {
|
|
...agentRunSchema,
|
|
serialize: () => output,
|
|
};
|
|
}
|
|
|
|
function validateRunWorkspaceOptions(options: AgentRunOptions): void {
|
|
const newWorkspace = resolveNewWorkspaceKind(options);
|
|
if (
|
|
options.newWorkspace &&
|
|
options.newWorkspace !== "local" &&
|
|
options.newWorkspace !== "worktree"
|
|
) {
|
|
throw {
|
|
code: "INVALID_OPTIONS",
|
|
message: `Unsupported new workspace kind: ${options.newWorkspace}`,
|
|
details: "Use --new-workspace local or --new-workspace worktree",
|
|
} satisfies CommandError;
|
|
}
|
|
|
|
if (options.newWorkspace && options.worktree) {
|
|
throw {
|
|
code: "INVALID_OPTIONS",
|
|
message: "--new-workspace and --worktree cannot be combined",
|
|
details: "Use --new-workspace worktree and the supported worktree options",
|
|
} satisfies CommandError;
|
|
}
|
|
|
|
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: "Worktree options require --new-workspace worktree",
|
|
details: "Usage: paseo run --new-workspace worktree [worktree options] <prompt>",
|
|
} satisfies CommandError;
|
|
}
|
|
|
|
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: "--new-workspace and --workspace cannot be combined",
|
|
details: "Select an existing workspace or explicitly create a new one",
|
|
} satisfies CommandError;
|
|
}
|
|
|
|
// COMPAT(worktreeRunFlag): --worktree implies a new worktree-isolated workspace.
|
|
// Added in v0.2.0; remove after 2027-01-17.
|
|
if (options.worktree && options.workspace) {
|
|
throw {
|
|
code: "INVALID_OPTIONS",
|
|
message: "--worktree and --workspace cannot be combined",
|
|
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;
|
|
}
|
|
|
|
if (options.background) {
|
|
console.error("Warning: --background (-d) is deprecated. Use --no-wait instead.");
|
|
}
|
|
|
|
validateRunWorkspaceOptions(options);
|
|
|
|
if (outputSchema && !waitsForFinish(options)) {
|
|
throw {
|
|
code: "INVALID_OPTIONS",
|
|
message: "--output-schema cannot be used with --no-wait",
|
|
details: "Structured output requires waiting for the agent to finish",
|
|
} satisfies CommandError;
|
|
}
|
|
}
|
|
|
|
export function waitsForFinish(
|
|
options: Pick<AgentRunOptions, "wait" | "background" | "detach">,
|
|
): boolean {
|
|
return options.wait !== false && !options.background && !options.detach;
|
|
}
|
|
|
|
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
|
|
if (!waitTimeout) return 0;
|
|
try {
|
|
const ms = parseDuration(waitTimeout);
|
|
if (ms <= 0) {
|
|
throw new Error("Timeout must be positive");
|
|
}
|
|
return ms;
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && "code" in err) throw err;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw {
|
|
code: "INVALID_TIMEOUT",
|
|
message: "Invalid wait timeout value",
|
|
details: message,
|
|
} satisfies CommandError;
|
|
}
|
|
}
|
|
|
|
function loadRunImages(
|
|
imagePaths: string[] | undefined,
|
|
): Array<{ data: string; mimeType: string }> | undefined {
|
|
if (!imagePaths || imagePaths.length === 0) return undefined;
|
|
return imagePaths.map((imagePath) => {
|
|
const resolvedPath = resolve(imagePath);
|
|
try {
|
|
const imageData = readFileSync(resolvedPath);
|
|
const mimeType = lookup(resolvedPath) || "application/octet-stream";
|
|
if (!mimeType.startsWith("image/")) {
|
|
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`);
|
|
}
|
|
return {
|
|
data: imageData.toString("base64"),
|
|
mimeType,
|
|
};
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && "code" in err) throw err;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err });
|
|
}
|
|
});
|
|
}
|
|
|
|
function parseRunLabels(labelFlags: string[] | undefined): Record<string, string> {
|
|
return parseKeyValueFlags(labelFlags, {
|
|
flagName: "--label",
|
|
code: "INVALID_LABEL",
|
|
noun: "label",
|
|
pluralNoun: "Labels",
|
|
});
|
|
}
|
|
|
|
function parseRunEnv(envFlags: string[] | undefined): Record<string, string> {
|
|
return parseKeyValueFlags(envFlags, {
|
|
flagName: "--env",
|
|
code: "INVALID_ENV",
|
|
noun: "environment variable",
|
|
pluralNoun: "Environment variables",
|
|
});
|
|
}
|
|
|
|
function parseKeyValueFlags(
|
|
flags: string[] | undefined,
|
|
options: {
|
|
flagName: string;
|
|
code: CommandError["code"];
|
|
noun: string;
|
|
pluralNoun: string;
|
|
},
|
|
): Record<string, string> {
|
|
const labels: Record<string, string> = {};
|
|
if (!flags) return labels;
|
|
for (const labelStr of flags) {
|
|
const eqIndex = labelStr.indexOf("=");
|
|
if (eqIndex === -1) {
|
|
throw {
|
|
code: options.code,
|
|
message: `Invalid ${options.noun} format: ${labelStr}`,
|
|
details: `${options.pluralNoun} must be in key=value format`,
|
|
} satisfies CommandError;
|
|
}
|
|
const key = labelStr.slice(0, eqIndex);
|
|
labels[key] = labelStr.slice(eqIndex + 1);
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
// A workspace is the explicit home of a run: it owns the directory the agent
|
|
// runs in. The CLI resolves one before creating any agent, so no run leans on
|
|
// createAgent's legacy cwd->workspace fallback.
|
|
interface RunWorkspace {
|
|
id?: string;
|
|
cwd: string;
|
|
}
|
|
|
|
export interface RunWorkspaceLookupClient {
|
|
fetchWorkspaces(options: { filter: { query: string }; page: { limit: number } }): Promise<{
|
|
entries: Array<{ id: string; workspaceDirectory: string }>;
|
|
pageInfo: { nextCursor: string | null };
|
|
}>;
|
|
}
|
|
|
|
export async function resolveExistingRunWorkspace(
|
|
client: RunWorkspaceLookupClient,
|
|
workspaceId: string,
|
|
): Promise<RunWorkspace> {
|
|
const result = await client.fetchWorkspaces({
|
|
filter: { query: workspaceId },
|
|
page: { limit: 200 },
|
|
});
|
|
const workspace = result.entries.find((entry) => entry.id === workspaceId);
|
|
if (workspace) {
|
|
return { id: workspace.id, cwd: workspace.workspaceDirectory };
|
|
}
|
|
|
|
throw {
|
|
code: "WORKSPACE_NOT_FOUND",
|
|
message: `Workspace not found: ${workspaceId}`,
|
|
} satisfies CommandError;
|
|
}
|
|
|
|
// Workspace policy for `paseo run`. Precedence:
|
|
// 1. --workspace <id> -> run in that existing workspace
|
|
// 2. caller agent -> daemon resolves the caller's workspace
|
|
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
|
// 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,
|
|
callerAgentId: string | undefined,
|
|
): Promise<RunWorkspace> {
|
|
const newWorkspace = resolveNewWorkspaceKind(options);
|
|
const explicit = newWorkspace ? undefined : options.workspace?.trim();
|
|
if (explicit) {
|
|
console.error(`Using workspace ${explicit}`);
|
|
return resolveExistingRunWorkspace(client, explicit);
|
|
}
|
|
|
|
if (!newWorkspace && callerAgentId) {
|
|
return { cwd };
|
|
}
|
|
|
|
const ambientWorkspaceId = newWorkspace ? undefined : process.env.PASEO_WORKSPACE_ID?.trim();
|
|
if (ambientWorkspaceId) {
|
|
console.error(`Using workspace ${ambientWorkspaceId}`);
|
|
return resolveExistingRunWorkspace(client, ambientWorkspaceId);
|
|
}
|
|
|
|
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
|
|
// title/branch generation picks up the task description (U8/U6 deferred).
|
|
const source = buildRunWorkspaceSource(options, cwd);
|
|
const result = await client.createWorkspace({
|
|
source,
|
|
...(callerAgentId ? { callerAgentId } : {}),
|
|
});
|
|
|
|
if (!result.workspace) {
|
|
throw {
|
|
code: "WORKSPACE_CREATE_FAILED",
|
|
message: result.error ?? "Failed to create workspace for this run",
|
|
} satisfies CommandError;
|
|
}
|
|
|
|
const branch = result.workspace.gitRuntime?.currentBranch;
|
|
const label = branch ? `${result.workspace.name} (${branch})` : result.workspace.name;
|
|
console.error(`Created workspace ${result.workspace.id} - ${label}`);
|
|
if (result.setupSkippedReason) console.error(result.setupSkippedReason);
|
|
console.error(
|
|
"Tip: pass --workspace <id> (or set PASEO_WORKSPACE_ID) to run in an existing workspace.",
|
|
);
|
|
return { id: result.workspace.id, cwd: result.workspace.workspaceDirectory ?? cwd };
|
|
}
|
|
|
|
export async function runRunCommand(
|
|
prompt: string,
|
|
options: AgentRunOptions,
|
|
_command: Command,
|
|
): Promise<SingleResult<AgentRunResult>> {
|
|
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined;
|
|
|
|
validateRunOptions(prompt, options, outputSchema);
|
|
const waitTimeoutMs = parseWaitTimeoutOption(options.waitTimeout);
|
|
|
|
const resolvedProviderModel = resolveProviderAndModel(options);
|
|
const resolvedTitle = options.title ?? options.name;
|
|
|
|
const client = await connectToDaemon({ target: options.daemonTarget });
|
|
|
|
try {
|
|
// Resolve working directory
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const thinkingOptionId = options.thinking?.trim();
|
|
if (options.thinking !== undefined && !thinkingOptionId) {
|
|
const error: CommandError = {
|
|
code: "INVALID_THINKING_OPTION",
|
|
message: "--thinking cannot be empty",
|
|
details:
|
|
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
const images = loadRunImages(options.image);
|
|
|
|
const labels = parseRunLabels(options.label);
|
|
const env = parseRunEnv(options.env);
|
|
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
|
|
|
|
const callerAgentId = await resolveCallerAgentId(client);
|
|
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
|
|
const workspaceId = workspace.id;
|
|
const runCwd = workspace.cwd;
|
|
|
|
if (outputSchema) {
|
|
let structuredAgent: AgentSnapshotPayload | null = null;
|
|
|
|
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
|
|
if (!structuredAgent) {
|
|
structuredAgent = await client.createAgent({
|
|
provider: resolvedProviderModel.provider,
|
|
cwd: runCwd,
|
|
workspaceId,
|
|
callerAgentId,
|
|
title: resolvedTitle,
|
|
modeId: options.mode,
|
|
model: resolvedProviderModel.model,
|
|
thinkingOptionId,
|
|
initialPrompt: structuredPrompt,
|
|
outputSchema,
|
|
images,
|
|
env: requestEnv,
|
|
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
|
});
|
|
} else {
|
|
await client.sendMessage(structuredAgent.id, structuredPrompt);
|
|
}
|
|
|
|
const state = await client.waitForFinish(structuredAgent.id, waitTimeoutMs);
|
|
if (state.status === "timeout") {
|
|
throw new StructuredRunStatusError("timeout", "Timed out waiting for structured output");
|
|
}
|
|
if (state.status === "permission") {
|
|
throw new StructuredRunStatusError(
|
|
"permission",
|
|
"Agent is waiting for permission before producing structured output",
|
|
);
|
|
}
|
|
if (state.status === "error") {
|
|
throw new StructuredRunStatusError(
|
|
"error",
|
|
state.error ?? "Agent failed before producing structured output",
|
|
);
|
|
}
|
|
|
|
const lastMessage = await resolveStructuredResponseMessage({
|
|
client,
|
|
agentId: structuredAgent.id,
|
|
lastMessage: state.lastMessage,
|
|
});
|
|
if (!lastMessage) {
|
|
throw new StructuredRunStatusError(
|
|
"empty",
|
|
"Agent finished without a structured output message",
|
|
);
|
|
}
|
|
|
|
return lastMessage;
|
|
};
|
|
|
|
const output = await fetchStructuredOutput(callStructuredTurn, prompt, outputSchema);
|
|
|
|
if (!structuredAgent) {
|
|
const error: CommandError = {
|
|
code: "OUTPUT_SCHEMA_FAILED",
|
|
message: "Agent finished without a structured output message",
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
await client.close();
|
|
|
|
return {
|
|
type: "single",
|
|
data: toRunResult(structuredAgent, "completed"),
|
|
schema: structuredRunSchema(output),
|
|
};
|
|
}
|
|
|
|
// Create the agent
|
|
const agent = await client.createAgent({
|
|
provider: resolvedProviderModel.provider,
|
|
cwd: runCwd,
|
|
workspaceId,
|
|
callerAgentId,
|
|
title: resolvedTitle,
|
|
modeId: options.mode,
|
|
model: resolvedProviderModel.model,
|
|
thinkingOptionId,
|
|
initialPrompt: prompt,
|
|
images,
|
|
env: requestEnv,
|
|
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
|
});
|
|
|
|
// Default run behavior is foreground: wait for completion unless --no-wait is set.
|
|
if (waitsForFinish(options)) {
|
|
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
|
|
await client.close();
|
|
|
|
const finalAgent = state.final ?? agent;
|
|
const status: AgentRunResult["status"] = state.status === "idle" ? "completed" : state.status;
|
|
|
|
return {
|
|
type: "single",
|
|
data: toRunResult(finalAgent, status),
|
|
schema: agentRunSchema,
|
|
};
|
|
}
|
|
|
|
await client.close();
|
|
|
|
return {
|
|
type: "single",
|
|
data: toRunResult(agent),
|
|
schema: agentRunSchema,
|
|
};
|
|
} catch (err) {
|
|
await client.close().catch(() => {});
|
|
|
|
if (err && typeof err === "object" && "code" in err) {
|
|
throw err;
|
|
}
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const error: CommandError = {
|
|
code: "AGENT_CREATE_FAILED",
|
|
message: `Failed to create agent: ${message}`,
|
|
};
|
|
throw error;
|
|
}
|
|
}
|