import { Command, Option } from "commander"; import { getStructuredAgentResponse, StructuredAgentResponseError, type AgentSnapshotPayload, } from "@getpaseo/server"; import { connectToDaemon, getDaemonHost } 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"; 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("", "The task/prompt for the agent") .option("-d, --detach", "Run in background (detached)") .option("--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)", "claude", ) .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("--worktree <name>", "Create agent in a new git worktree") .option("--base <branch>", "Base branch for worktree (default: current branch)") .option( "--image <path>", "Attach image(s) to the initial prompt (can be used multiple times)", collectMultiple, [], ) .option("--cwd <path>", "Working directory (default: current)") .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 { detach?: boolean; title?: string; name?: string; provider?: string; model?: string; thinking?: string; mode?: string; worktree?: string; base?: string; image?: string[]; cwd?: string; label?: string[]; waitTimeout?: string; outputSchema?: string; } 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) { 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) { 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; } } 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, }; } export async function runRunCommand( prompt: string, options: AgentRunOptions, _command: Command, ): Promise<SingleResult<AgentRunResult>> { const host = getDaemonHost({ host: options.host as string | undefined }); const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined; let waitTimeoutMs = 0; // Validate prompt is provided if (!prompt || prompt.trim().length === 0) { const error: CommandError = { code: "MISSING_PROMPT", message: "A prompt is required", details: "Usage: paseo agent run [options] <prompt>", }; throw error; } // Validate --base is only used with --worktree if (options.base && !options.worktree) { const error: CommandError = { code: "INVALID_OPTIONS", message: "--base can only be used with --worktree", details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>", }; throw error; } // --output-schema always runs in attached/wait mode if (outputSchema && options.detach) { const error: CommandError = { code: "INVALID_OPTIONS", message: "--output-schema cannot be used with --detach", details: "Structured output requires waiting for the agent to finish", }; throw error; } if (options.waitTimeout) { try { waitTimeoutMs = parseDuration(options.waitTimeout); if (waitTimeoutMs <= 0) { throw new Error("Timeout must be positive"); } } catch (err) { const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "INVALID_TIMEOUT", message: "Invalid wait timeout value", details: message, }; throw error; } } const resolvedProviderModel = resolveProviderAndModel(options); const resolvedTitle = options.title ?? options.name; let client; try { client = await connectToDaemon({ host: options.host as string | undefined }); } catch (err) { const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "DAEMON_NOT_RUNNING", message: `Cannot connect to daemon at ${host}: ${message}`, details: "Start the daemon with: paseo daemon start", }; throw error; } 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; } // Process images if provided let images: Array<{ data: string; mimeType: string }> | undefined; if (options.image && options.image.length > 0) { images = options.image.map((imagePath) => { const resolvedPath = resolve(imagePath); try { const imageData = readFileSync(resolvedPath); const mimeType = lookup(resolvedPath) || "application/octet-stream"; // Verify it's an image MIME type if (!mimeType.startsWith("image/")) { throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`); } return { data: imageData.toString("base64"), mimeType, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to read image ${imagePath}: ${message}`); } }); } // Build git options if worktree is specified const git = options.worktree ? { createWorktree: true, worktreeSlug: options.worktree, baseBranch: options.base, } : undefined; // Build labels from --label flags const labels: Record<string, string> = {}; if (options.label) { for (const labelStr of options.label) { const eqIndex = labelStr.indexOf("="); if (eqIndex === -1) { const error: CommandError = { code: "INVALID_LABEL", message: `Invalid label format: ${labelStr}`, details: "Labels must be in key=value format", }; throw error; } const key = labelStr.slice(0, eqIndex); const value = labelStr.slice(eqIndex + 1); labels[key] = value; } } if (outputSchema) { let structuredAgent: AgentSnapshotPayload | null = null; const callStructuredTurn = async (structuredPrompt: string): Promise<string> => { if (!structuredAgent) { structuredAgent = await client.createAgent({ provider: resolvedProviderModel.provider, cwd, title: resolvedTitle, modeId: options.mode, model: resolvedProviderModel.model, thinkingOptionId, initialPrompt: structuredPrompt, outputSchema, images, git, worktreeName: options.worktree, 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; }; let output: Record<string, unknown>; try { output = await getStructuredAgentResponse<Record<string, unknown>>({ caller: callStructuredTurn, prompt, schema: outputSchema, schemaName: "RunOutput", maxRetries: 2, }); } catch (err) { if (err instanceof StructuredRunStatusError) { const error: CommandError = { code: "OUTPUT_SCHEMA_FAILED", message: err.message, }; throw error; } if (err instanceof StructuredAgentResponseError) { const error: CommandError = { 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", }; throw error; } throw err; } 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, title: resolvedTitle, modeId: options.mode, model: resolvedProviderModel.model, thinkingOptionId, initialPrompt: prompt, images, git, worktreeName: options.worktree, labels: Object.keys(labels).length > 0 ? labels : undefined, }); // Default run behavior is foreground: wait for completion unless --detach is set. if (!options.detach) { 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; } }