diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b77e9eaf5..67b1d4b91 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -33,6 +33,12 @@ import { resolveCliVersion } from "./version.js"; const VERSION = resolveCliVersion(); +function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { + if (typeof hostnames === "string") return hostnames; + if (typeof allowedHosts === "string") return allowedHosts; + return undefined; +} + export function createCli(): Command { const program = new Command(); @@ -120,12 +126,7 @@ export function createCli(): Command { return runDaemonRestartCommand( { ...options, - hostnames: - typeof options.hostnames === "string" - ? options.hostnames - : typeof options.allowedHosts === "string" - ? options.allowedHosts - : undefined, + hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts), }, command, ); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index a3d9672e2..323eb46f6 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -87,16 +87,27 @@ export async function runDeleteCommand( agents = [fetchResult.agent]; } - for (const agent of agents) { - try { - if (agent.status === "running") { - await client.cancelAgent(agent.id); + const deleteResults = await Promise.all( + agents.map(async (agent) => { + try { + if (agent.status === "running") { + await client.cancelAgent(agent.id); + } + await client.deleteAgent(agent.id); + return { ok: true as const, id: agent.id }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false as const, id: agent.id, message }; } - await client.deleteAgent(agent.id); - deletedIds.push(agent.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`Warning: Failed to delete agent ${agent.id.slice(0, 7)}: ${message}`); + }), + ); + for (const result of deleteResults) { + if (result.ok) { + deletedIds.push(result.id); + } else { + console.error( + `Warning: Failed to delete agent ${result.id.slice(0, 7)}: ${result.message}`, + ); } } diff --git a/packages/cli/src/commands/agent/inspect.ts b/packages/cli/src/commands/agent/inspect.ts index a330fcb13..e305555ca 100644 --- a/packages/cli/src/commands/agent/inspect.ts +++ b/packages/cli/src/commands/agent/inspect.ts @@ -104,30 +104,28 @@ function resolveModel(snapshot: AgentSnapshotPayload): string | null { return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model); } +function buildLastUsage(snapshot: AgentSnapshotPayload): AgentInspect["LastUsage"] { + if (!snapshot.lastUsage) return null; + return { + InputTokens: snapshot.lastUsage.inputTokens ?? 0, + OutputTokens: snapshot.lastUsage.outputTokens ?? 0, + CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0, + CostUsd: snapshot.lastUsage.totalCostUsd ?? 0, + }; +} + +function buildCapabilities(snapshot: AgentSnapshotPayload): AgentInspect["Capabilities"] { + if (!snapshot.capabilities) return null; + return { + Streaming: snapshot.capabilities.supportsStreaming ?? false, + Persistence: snapshot.capabilities.supportsSessionPersistence ?? false, + DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false, + McpServers: snapshot.capabilities.supportsMcpServers ?? false, + }; +} + /** Convert agent snapshot to inspection data */ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect { - const lastUsage = snapshot.lastUsage - ? { - InputTokens: snapshot.lastUsage.inputTokens ?? 0, - OutputTokens: snapshot.lastUsage.outputTokens ?? 0, - CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0, - CostUsd: snapshot.lastUsage.totalCostUsd ?? 0, - } - : null; - - const capabilities = snapshot.capabilities - ? { - Streaming: snapshot.capabilities.supportsStreaming ?? false, - Persistence: snapshot.capabilities.supportsSessionPersistence ?? false, - DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false, - McpServers: snapshot.capabilities.supportsMcpServers ?? false, - } - : null; - - // Extract worktree and parentAgentId from labels if they exist - const worktree = snapshot.labels?.["paseo.worktree"] ?? null; - const parentAgentId = snapshot.labels?.["paseo.parent-agent-id"] ?? null; - return { Id: snapshot.id, Name: snapshot.title ?? "-", @@ -141,8 +139,8 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect { Cwd: snapshot.cwd, CreatedAt: snapshot.createdAt, UpdatedAt: snapshot.updatedAt, - LastUsage: lastUsage, - Capabilities: capabilities, + LastUsage: buildLastUsage(snapshot), + Capabilities: buildCapabilities(snapshot), AvailableModes: snapshot.availableModes ? snapshot.availableModes.map((m) => ({ id: m.id, label: m.label })) : null, @@ -150,8 +148,8 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect { id: p.id, tool: p.name ?? "unknown", })), - Worktree: worktree, - ParentAgentId: parentAgentId, + Worktree: snapshot.labels?.["paseo.worktree"] ?? null, + ParentAgentId: snapshot.labels?.["paseo.parent-agent-id"] ?? null, }; } diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index b9c00475a..d698a9d2a 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -174,6 +174,40 @@ class StructuredRunStatusError extends Error { } } +async function fetchStructuredOutput( + caller: (structuredPrompt: string) => Promise, + prompt: string, + outputSchema: ReturnType, +): Promise> { + try { + return await getStructuredAgentResponse>({ + 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>; export interface StructuredResponseTimelineClient { @@ -219,6 +253,107 @@ function structuredRunSchema(output: Record): OutputSchema", + } satisfies CommandError; + } + + if (options.base && !options.worktree) { + throw { + code: "INVALID_OPTIONS", + message: "--base can only be used with --worktree", + details: "Usage: paseo agent run --worktree --base ", + } satisfies CommandError; + } + + if (outputSchema && options.detach) { + throw { + code: "INVALID_OPTIONS", + message: "--output-schema cannot be used with --detach", + details: "Structured output requires waiting for the agent to finish", + } satisfies CommandError; + } +} + +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) { + 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) { + 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 { + const labels: Record = {}; + if (!labelFlags) return labels; + for (const labelStr of labelFlags) { + const eqIndex = labelStr.indexOf("="); + if (eqIndex === -1) { + throw { + code: "INVALID_LABEL", + message: `Invalid label format: ${labelStr}`, + details: "Labels must be in key=value format", + } satisfies CommandError; + } + const key = labelStr.slice(0, eqIndex); + labels[key] = labelStr.slice(eqIndex + 1); + } + return labels; +} + +async function connectToDaemonOrThrow( + hostOption: string | undefined, + host: string, +): Promise { + try { + return await connectToDaemon({ host: hostOption }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + } +} + export async function runRunCommand( prompt: string, options: AgentRunOptions, @@ -226,70 +361,14 @@ export async function runRunCommand( ): Promise> { 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] ", - }; - 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 --base ", - }; - 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; - } - } + validateRunOptions(prompt, options, outputSchema); + const waitTimeoutMs = parseWaitTimeoutOption(options.waitTimeout); 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; - } + const client = await connectToDaemonOrThrow(options.host as string | undefined, host); try { // Resolve working directory @@ -305,32 +384,8 @@ export async function runRunCommand( 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"; + const images = loadRunImages(options.image); - // 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}`, { cause: err }); - } - }); - } - - // Build git options if worktree is specified const git = options.worktree ? { createWorktree: true, @@ -339,24 +394,7 @@ export async function runRunCommand( } : undefined; - // Build labels from --label flags - const labels: Record = {}; - 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; - } - } + const labels = parseRunLabels(options.label); if (outputSchema) { let structuredAgent: AgentSnapshotPayload | null = null; @@ -413,36 +451,7 @@ export async function runRunCommand( return lastMessage; }; - let output: Record; - try { - output = await getStructuredAgentResponse>({ - 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; - } + const output = await fetchStructuredOutput(callStructuredTurn, prompt, outputSchema); if (!structuredAgent) { const error: CommandError = { diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index feaa6df1f..04d82eefd 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -51,51 +51,42 @@ export function addSendOptions(cmd: Command): Command { async function readImageFiles( imagePaths: string[], ): Promise> { - const images: Array<{ data: string; mimeType: string }> = []; + return Promise.all( + imagePaths.map(async (path) => { + try { + const buffer = await readFile(path); + const ext = extname(path).toLowerCase(); - for (const path of imagePaths) { - try { - const buffer = await readFile(path); - const ext = extname(path).toLowerCase(); + let mimeType = "image/jpeg"; + switch (ext) { + case ".png": + mimeType = "image/png"; + break; + case ".jpg": + case ".jpeg": + mimeType = "image/jpeg"; + break; + case ".gif": + mimeType = "image/gif"; + break; + case ".webp": + mimeType = "image/webp"; + break; + default: + mimeType = "image/jpeg"; + } - // Determine media type from extension - let mimeType = "image/jpeg"; - switch (ext) { - case ".png": - mimeType = "image/png"; - break; - case ".jpg": - case ".jpeg": - mimeType = "image/jpeg"; - break; - case ".gif": - mimeType = "image/gif"; - break; - case ".webp": - mimeType = "image/webp"; - break; - default: - // Default to jpeg for unknown types - mimeType = "image/jpeg"; + return { data: buffer.toString("base64"), mimeType }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw { + code: "IMAGE_READ_ERROR", + message: `Failed to read image file: ${path}`, + details: message, + } satisfies CommandError; } - - const data = buffer.toString("base64"); - images.push({ - data, - mimeType, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "IMAGE_READ_ERROR", - message: `Failed to read image file: ${path}`, - details: message, - }; - throw error; - } - } - - return images; + }), + ); } async function resolvePromptInput(options: { @@ -147,6 +138,28 @@ async function resolvePromptInput(options: { } } +type SendWaitState = Awaited< + ReturnType>["waitForFinish"]> +>; + +function buildSendResult(agentIdArg: string, state: SendWaitState): AgentSendResult { + const agentId = state.final?.id ?? agentIdArg; + if (state.status === "timeout") { + return { agentId, status: "timeout", message: "Timed out waiting for agent to finish" }; + } + if (state.status === "permission") { + return { agentId, status: "permission", message: "Agent is waiting for permission" }; + } + if (state.status === "error") { + return { + agentId, + status: "error", + message: state.error ?? "Agent finished with error", + }; + } + return { agentId, status: "completed", message: "Agent completed processing the message" }; +} + export async function runSendCommand( agentIdArg: string, prompt: string | undefined, @@ -207,54 +220,12 @@ export async function runSendCommand( }; } - // Wait for agent to finish const state = await client.waitForFinish(agentIdArg, 600000); // 10 minute timeout - await client.close(); - if (state.status === "timeout") { - return { - type: "single", - data: { - agentId: state.final?.id ?? agentIdArg, - status: "timeout", - message: "Timed out waiting for agent to finish", - }, - schema: agentSendSchema, - }; - } - - if (state.status === "permission") { - return { - type: "single", - data: { - agentId: state.final?.id ?? agentIdArg, - status: "permission", - message: "Agent is waiting for permission", - }, - schema: agentSendSchema, - }; - } - - if (state.status === "error") { - return { - type: "single", - data: { - agentId: state.final?.id ?? agentIdArg, - status: "error", - message: state.error ?? "Agent finished with error", - }, - schema: agentSendSchema, - }; - } - return { type: "single", - data: { - agentId: state.final?.id ?? agentIdArg, - status: "completed", - message: "Agent completed processing the message", - }, + data: buildSendResult(agentIdArg, state), schema: agentSendSchema, }; } catch (err) { diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 13366eb06..370000a82 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -95,16 +95,25 @@ export async function runStopCommand( } // Interrupt each running agent. Idle agents are a no-op. - for (const agent of agents) { - try { - if (agent.status === "running") { + const stopResults = await Promise.all( + agents.map(async (agent) => { + if (agent.status !== "running") return { ok: true as const, id: agent.id, stopped: false }; + try { await client.cancelAgent(agent.id); - stoppedIds.push(agent.id); + return { ok: true as const, id: agent.id, stopped: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false as const, id: agent.id, message }; } - } catch (err) { - // Continue interrupting other agents even if one fails - const message = err instanceof Error ? err.message : String(err); - console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`); + }), + ); + for (const result of stopResults) { + if (!result.ok) { + console.error(`Warning: Failed to stop agent ${result.id.slice(0, 7)}: ${result.message}`); + continue; + } + if (result.stopped) { + stoppedIds.push(result.id); } } diff --git a/packages/cli/src/commands/agent/wait.ts b/packages/cli/src/commands/agent/wait.ts index 66ee2ace9..c2846d0bf 100644 --- a/packages/cli/src/commands/agent/wait.ts +++ b/packages/cli/src/commands/agent/wait.ts @@ -53,6 +53,80 @@ async function getRecentActivityTranscript( } } +function parseWaitTimeout(timeout: string | undefined): { + timeoutMs: number; + timeoutLabel: string | null; +} { + if (!timeout) return { timeoutMs: 0, timeoutLabel: null }; + try { + const ms = parseDuration(timeout); + if (ms <= 0) { + throw new Error("Timeout must be positive"); + } + const timeoutSeconds = Math.floor(ms / 1000); + return { + timeoutMs: ms, + timeoutLabel: `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw { + code: "INVALID_TIMEOUT", + message: "Invalid timeout value", + details: message, + } satisfies CommandError; + } +} + +type WaitFinishState = Awaited< + ReturnType>["waitForFinish"]> +>; + +function buildWaitResult(args: { + state: WaitFinishState; + resolvedAgentId: string; + recentActivity: string | null; + timeoutLabel: string | null; +}): AgentWaitResult { + const { state, resolvedAgentId, recentActivity, timeoutLabel } = args; + + if (state.status === "timeout") { + const timeoutMessage = timeoutLabel + ? `Agent did not finish within ${timeoutLabel}. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.` + : `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`; + return { + agentId: resolvedAgentId, + status: "timeout", + message: appendRecentActivity(timeoutMessage, recentActivity), + }; + } + + if (state.status === "permission") { + const permission = state.final?.pendingPermissions?.[0]; + return { + agentId: resolvedAgentId, + status: "permission", + message: permission + ? `Agent is waiting for permission: ${permission.kind}` + : "Agent is waiting for permission", + }; + } + + if (state.status === "error") { + return { + agentId: resolvedAgentId, + status: "error", + message: state.error ?? "Agent finished with error", + }; + } + + return { + agentId: resolvedAgentId, + status: "idle", + message: appendRecentActivity("Agent is idle.", recentActivity), + }; +} + export function addWaitOptions(cmd: Command): Command { return cmd .description("Wait for an agent to become idle") @@ -67,37 +141,15 @@ export async function runWaitCommand( ): Promise> { const host = getDaemonHost({ host: options.host as string | undefined }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { - const error: CommandError = { + throw { code: "MISSING_AGENT_ID", message: "Agent ID is required", details: "Usage: paseo agent wait ", - }; - throw error; + } satisfies CommandError; } - // Parse timeout (no limit unless explicitly provided) - let timeoutMs = 0; - let timeoutLabel: string | null = null; - if (options.timeout) { - try { - timeoutMs = parseDuration(options.timeout); - if (timeoutMs <= 0) { - throw new Error("Timeout must be positive"); - } - const timeoutSeconds = Math.floor(timeoutMs / 1000); - timeoutLabel = `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "INVALID_TIMEOUT", - message: "Invalid timeout value", - details: message, - }; - throw error; - } - } + const { timeoutMs, timeoutLabel } = parseWaitTimeout(options.timeout); let client; try { @@ -123,56 +175,9 @@ export async function runWaitCommand( await client.close(); - if (state.status === "timeout") { - const timeoutMessage = timeoutLabel - ? `Agent did not finish within ${timeoutLabel}. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.` - : `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`; - return { - type: "single", - data: { - agentId: resolvedAgentId, - status: "timeout", - message: appendRecentActivity(timeoutMessage, recentActivity), - }, - schema: agentWaitSchema, - }; - } - - if (state.status === "permission") { - const permission = state.final?.pendingPermissions?.[0]; - return { - type: "single", - data: { - agentId: resolvedAgentId, - status: "permission", - message: permission - ? `Agent is waiting for permission: ${permission.kind}` - : "Agent is waiting for permission", - }, - schema: agentWaitSchema, - }; - } - - if (state.status === "error") { - return { - type: "single", - data: { - agentId: resolvedAgentId, - status: "error", - message: state.error ?? "Agent finished with error", - }, - schema: agentWaitSchema, - }; - } - - // Agent is idle return { type: "single", - data: { - agentId: resolvedAgentId, - status: "idle", - message: appendRecentActivity("Agent is idle.", recentActivity), - }, + data: buildWaitResult({ state, resolvedAgentId, recentActivity, timeoutLabel }), schema: agentWaitSchema, }; } catch (waitErr) { diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index d9ef61297..79bcb59d6 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -7,6 +7,12 @@ import { pairCommand } from "./pair.js"; import { withOutput } from "../../output/index.js"; import { addJsonOption } from "../../utils/command-options.js"; +function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { + if (typeof hostnames === "string") return hostnames; + if (typeof allowedHosts === "string") return allowedHosts; + return undefined; +} + export function createDaemonCommand(): Command { const daemon = new Command("daemon").description("Manage the Paseo daemon"); @@ -46,12 +52,7 @@ export function createDaemonCommand(): Command { return runRestartCommand( { ...options, - hostnames: - typeof options.hostnames === "string" - ? options.hostnames - : typeof options.allowedHosts === "string" - ? options.allowedHosts - : undefined, + hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts), }, command, ); diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 3369dd106..a9d4b9cb8 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -119,25 +119,30 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { return childEnv; } +function resolveServerRunnerFromDir(currentDir: string): string | null { + const packageJsonPath = path.join(currentDir, "package.json"); + if (!existsSync(packageJsonPath)) return null; + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string }; + if (packageJson.name !== "@getpaseo/server") return null; + const distRunner = path.join(currentDir, "dist", "scripts", "supervisor-entrypoint.js"); + if (existsSync(distRunner)) { + return distRunner; + } + return path.join(currentDir, "scripts", "supervisor-entrypoint.ts"); + } catch { + return null; + } +} + function resolveDaemonRunnerEntry(): string { const serverExportPath = require.resolve("@getpaseo/server"); let currentDir = path.dirname(serverExportPath); while (true) { - const packageJsonPath = path.join(currentDir, "package.json"); - if (existsSync(packageJsonPath)) { - try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string }; - if (packageJson.name === "@getpaseo/server") { - const distRunner = path.join(currentDir, "dist", "scripts", "supervisor-entrypoint.js"); - if (existsSync(distRunner)) { - return distRunner; - } - return path.join(currentDir, "scripts", "supervisor-entrypoint.ts"); - } - } catch { - // Continue searching up if package.json exists but is invalid. - } + const entry = resolveServerRunnerFromDir(currentDir); + if (entry) { + return entry; } const parentDir = path.dirname(currentDir); @@ -154,6 +159,22 @@ function pidFilePath(paseoHome: string): string { return path.join(paseoHome, DAEMON_PID_FILENAME); } +function resolveListenField(listen: unknown, sockPath: unknown): string | undefined { + if (typeof listen === "string") return listen; + if (typeof sockPath === "string") return sockPath; + return undefined; +} + +function resolveStopMessage( + forced: boolean, + lifecycleRequested: boolean, + fallbackMessage: string | null | undefined, +): string { + if (forced) return "Daemon owner process was force-stopped"; + if (lifecycleRequested) return "Daemon stopped gracefully"; + return fallbackMessage ?? "Daemon stopped via owner PID signal"; +} + function readPidFile(pidPath: string): LocalDaemonPidInfo | null { try { const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record; @@ -167,12 +188,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null { startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined, hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined, uid: typeof parsed.uid === "number" ? parsed.uid : undefined, - listen: - typeof parsed.listen === "string" - ? parsed.listen - : typeof parsed.sockPath === "string" - ? parsed.sockPath - : undefined, + listen: resolveListenField(parsed.listen, parsed.sockPath), desktopManaged: parsed.desktopManaged === true ? true : undefined, }; } catch { @@ -528,10 +544,6 @@ export async function stopLocalDaemon( home: state.home, pid, forced, - message: forced - ? "Daemon owner process was force-stopped" - : lifecycleRequested - ? "Daemon stopped gracefully" - : (fallbackMessage ?? "Daemon stopped via owner PID signal"), + message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage), }; } diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index bee4841d1..141915fc7 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -208,6 +208,117 @@ function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined return `${uidPart}@${hostPart}`; } +interface DaemonProbeResult { + connectedDaemon: DaemonStatus["connectedDaemon"]; + localDaemonOverride?: DaemonStatus["localDaemon"]; + daemonVersion?: string | null; + runningAgents?: number; + idleAgents?: number; + daemonNodeOverride?: string; + note?: string; +} + +async function probeDaemonOverWebsocket(args: { + host: string; + state: ReturnType; +}): Promise { + const { host, state } = args; + const client = await tryConnectToDaemon({ host, timeout: 1500 }); + if (!client) { + if (state.running) { + return { + connectedDaemon: "unreachable", + localDaemonOverride: "unresponsive", + note: `Local daemon PID is running but websocket at ${host} is not reachable`, + }; + } + return { connectedDaemon: "unreachable" }; + } + + const daemonVersion = client.getLastServerInfoMessage()?.version ?? null; + try { + const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); + const agents = agentsPayload.entries.map((entry) => entry.agent); + const runningAgents = agents.filter((a) => a.status === "running").length; + const idleAgents = agents.filter((a) => a.status === "idle").length; + + if (!state.running) { + return { + connectedDaemon: "reachable", + daemonVersion, + runningAgents, + idleAgents, + daemonNodeOverride: "unknown (API reachable, PID unresolved)", + note: state.pidInfo + ? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale` + : `Connected daemon is reachable at ${host} but no local daemon PID file was found`, + }; + } + + return { + connectedDaemon: "reachable", + daemonVersion, + runningAgents, + idleAgents, + }; + } catch { + return { + connectedDaemon: "reachable", + daemonVersion, + localDaemonOverride: state.running ? "unresponsive" : undefined, + note: state.running + ? `Local daemon PID is running but API requests to ${host} failed` + : `Connected daemon websocket is reachable at ${host} but fetch_agents failed`, + }; + } finally { + await client.close().catch(() => {}); + } +} + +interface ProbeMergeState { + probe: DaemonProbeResult; + connectedDaemon: DaemonStatus["connectedDaemon"]; + localDaemon: DaemonStatus["localDaemon"]; + daemonNode: string; + daemonVersion: string | null; + runningAgents: number | null; + idleAgents: number | null; + note: string | undefined; +} + +function applyProbeToStatus(input: ProbeMergeState): Omit { + const { probe } = input; + return { + connectedDaemon: probe.connectedDaemon, + localDaemon: probe.localDaemonOverride ?? input.localDaemon, + daemonNode: probe.daemonNodeOverride ?? input.daemonNode, + daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion, + runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents, + idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents, + note: probe.note ? appendNote(input.note, probe.note) : input.note, + }; +} + +function resolveServerIdSafely(home: string): { serverId: string | null; error: string | null } { + try { + return { serverId: getOrCreateServerId(home), error: null }; + } catch (error) { + return { + serverId: null, + error: `serverId unavailable: ${shortenMessage(normalizeError(error))}`, + }; + } +} + +async function resolveDaemonNodeLabel( + state: ReturnType, +): Promise { + if (!state.running) return "-"; + if (!state.pidInfo?.pid) return "unknown (no PID available)"; + const fromPid = await resolveNodePathFromPid(state.pidInfo.pid); + return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; +} + export type StatusResult = ListResult; export async function runStatusCommand( @@ -219,15 +330,7 @@ export async function runStatusCommand( const host = resolveTcpHostFromListen(state.listen); const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname); - let daemonNode: string; - if (!state.running) { - daemonNode = "-"; - } else if (state.pidInfo?.pid) { - const fromPid = await resolveNodePathFromPid(state.pidInfo.pid); - daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; - } else { - daemonNode = "unknown (no PID available)"; - } + let daemonNode = await resolveDaemonNodeLabel(state); const cliNode = process.execPath; let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped"; let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed"; @@ -242,58 +345,28 @@ export async function runStatusCommand( } if (host) { - const client = await tryConnectToDaemon({ host, timeout: 1500 }); - if (client) { - connectedDaemon = "reachable"; - daemonVersion = client.getLastServerInfoMessage()?.version ?? null; - try { - const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); - const agents = agentsPayload.entries.map((entry) => entry.agent); - runningAgents = agents.filter((a) => a.status === "running").length; - idleAgents = agents.filter((a) => a.status === "idle").length; - if (!state.running) { - daemonNode = "unknown (API reachable, PID unresolved)"; - note = appendNote( - note, - state.pidInfo - ? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale` - : `Connected daemon is reachable at ${host} but no local daemon PID file was found`, - ); - } - } catch { - if (state.running) { - localDaemon = "unresponsive"; - } - note = appendNote( - note, - state.running - ? `Local daemon PID is running but API requests to ${host} failed` - : `Connected daemon websocket is reachable at ${host} but fetch_agents failed`, - ); - } finally { - await client.close().catch(() => {}); - } - } else if (state.running) { - connectedDaemon = "unreachable"; - localDaemon = "unresponsive"; - note = appendNote( + const probe = await probeDaemonOverWebsocket({ host, state }); + ({ connectedDaemon, localDaemon, daemonNode, daemonVersion, runningAgents, idleAgents, note } = + applyProbeToStatus({ + probe, + connectedDaemon, + localDaemon, + daemonNode, + daemonVersion, + runningAgents, + idleAgents, note, - `Local daemon PID is running but websocket at ${host} is not reachable`, - ); - } else { - connectedDaemon = "unreachable"; - } + })); } else { note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped"); } const cliVersion = resolveCliVersion(); - let serverId: string | null = null; - try { - serverId = getOrCreateServerId(state.home); - } catch (error) { - note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`); + const serverIdResult = resolveServerIdSafely(state.home); + const serverId = serverIdResult.serverId; + if (serverIdResult.error) { + note = appendNote(note, serverIdResult.error); } const providers = await checkProviderBinaries(); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 977a830bc..9a458f70f 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -315,6 +315,102 @@ export function onboardCommand(): Command { }); } +async function resolveAndPersistVoice( + paseoHome: string, + options: OnboardOptions, +): Promise { + let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig; + const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted); + const shouldPrompt = options.voice === "ask" || options.voice === undefined; + let voiceEnabled: boolean; + try { + voiceEnabled = + shouldPrompt && persistedVoiceSelection !== null + ? persistedVoiceSelection + : await resolveVoiceSelection(options.voice); + } catch (error) { + if (error instanceof OnboardCancelledError) { + cancel("Onboarding cancelled."); + process.exit(0); + } + throw error; + } + + if (shouldPrompt && persistedVoiceSelection !== null) { + log.message(`Using saved voice setup from config (${voiceEnabled ? "enabled" : "disabled"}).`); + } + + persisted = applyVoiceSelection(persisted, voiceEnabled); + savePersistedConfig(paseoHome, persisted); + return voiceEnabled; +} + +async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise { + const stateBeforeStart = resolveLocalDaemonState({ home: options.home }); + if (stateBeforeStart.running) { + log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`); + return; + } + + const startSpinner = richUi ? spinner() : null; + try { + if (startSpinner) { + startSpinner.start("Starting daemon..."); + } else { + log.message("Starting daemon..."); + } + const startup = await startLocalDaemonDetached(options); + if (startSpinner) { + startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`); + } else { + log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`); + } + log.message(`Logs: ${startup.logPath}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (startSpinner) { + startSpinner.error(message); + } else { + log.error(message); + } + process.exit(1); + } +} + +async function waitForDaemonReadyWithUi(args: { + home: string; + timeoutMs: number; + richUi: boolean; +}): Promise<{ listen: string; host: string | null }> { + const readySpinner = args.richUi ? spinner() : null; + try { + if (readySpinner) { + readySpinner.start("Waiting for daemon to become ready..."); + } else { + log.message("Waiting for daemon to become ready..."); + } + const readyState = await waitForDaemonReady({ + home: args.home, + timeoutMs: args.timeoutMs, + onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined, + }); + if (readySpinner) { + readySpinner.stop(`Daemon ready on ${readyState.listen}`); + } else { + log.message(`Daemon ready on ${readyState.listen}`); + } + return readyState; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (readySpinner) { + readySpinner.error(message); + } else { + log.error(message); + } + process.exit(1); + } +} + export async function runOnboard(options: OnboardOptions): Promise { const richUi = process.stdin.isTTY && process.stdout.isTTY; if (richUi) { @@ -340,96 +436,21 @@ export async function runOnboard(options: OnboardOptions): Promise { renderNote(paseoHome, "Paseo home"); } - let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig; - const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted); - const shouldPrompt = options.voice === "ask" || options.voice === undefined; - let voiceEnabled: boolean; - try { - voiceEnabled = - shouldPrompt && persistedVoiceSelection !== null - ? persistedVoiceSelection - : await resolveVoiceSelection(options.voice); - } catch (error) { - if (error instanceof OnboardCancelledError) { - cancel("Onboarding cancelled."); - process.exit(0); - return; - } - throw error; - } - - if (shouldPrompt && persistedVoiceSelection !== null) { - log.message(`Using saved voice setup from config (${voiceEnabled ? "enabled" : "disabled"}).`); - } - - persisted = applyVoiceSelection(persisted, voiceEnabled); - savePersistedConfig(paseoHome, persisted); - + const voiceEnabled = await resolveAndPersistVoice(paseoHome, options); const config = loadConfig(paseoHome, { cli: toCliOverrides(options) }); - const voiceStatus = voiceEnabled - ? "Voice features enabled. Local speech models will be downloaded automatically if missing." - : "Voice features disabled. Local speech models will not be downloaded."; - log.message(voiceStatus); + log.message( + voiceEnabled + ? "Voice features enabled. Local speech models will be downloaded automatically if missing." + : "Voice features disabled. Local speech models will not be downloaded.", + ); - const stateBeforeStart = resolveLocalDaemonState({ home: options.home }); - const startSpinner = richUi ? spinner() : null; - - if (!stateBeforeStart.running) { - try { - if (startSpinner) { - startSpinner.start("Starting daemon..."); - } else { - log.message("Starting daemon..."); - } - const startup = await startLocalDaemonDetached(options); - if (startSpinner) { - startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`); - } else { - log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`); - } - log.message(`Logs: ${startup.logPath}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (startSpinner) { - startSpinner.error(message); - } else { - log.error(message); - } - process.exit(1); - } - } else { - log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`); - } - - let readyState: { listen: string; host: string | null }; - const readySpinner = richUi ? spinner() : null; - try { - if (readySpinner) { - readySpinner.start("Waiting for daemon to become ready..."); - } else { - log.message("Waiting for daemon to become ready..."); - } - readyState = await waitForDaemonReady({ - home: options.home ?? paseoHome, - timeoutMs, - onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined, - }); - if (readySpinner) { - readySpinner.stop(`Daemon ready on ${readyState.listen}`); - } else { - log.message(`Daemon ready on ${readyState.listen}`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (readySpinner) { - readySpinner.error(message); - } else { - log.error(message); - } - process.exit(1); - return; - } + await ensureDaemonStarted(options, richUi); + await waitForDaemonReadyWithUi({ + home: options.home ?? paseoHome, + timeoutMs, + richUi, + }); if (config.relayEnabled === false) { log.warn("Relay is disabled; pairing offer is unavailable for this daemon."); diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index 8d653a9cc..9c61fcd28 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -125,20 +125,21 @@ export async function runAllowCommand( } // Allow permissions - const results: PermissionResponseItem[] = []; - for (const permission of permissionsToAllow) { - await client.respondToPermission(resolvedAgentId, permission.id, { - behavior: "allow", - ...(updatedInput ? { updatedInput } : {}), - }); - results.push({ - requestId: permission.id.slice(0, 8), - agentId: resolvedAgentId, - agentShortId: resolvedAgentId.slice(0, 7), - name: permission.name, - result: "allowed", - }); - } + const results: PermissionResponseItem[] = await Promise.all( + permissionsToAllow.map(async (permission) => { + await client.respondToPermission(resolvedAgentId, permission.id, { + behavior: "allow", + ...(updatedInput ? { updatedInput } : {}), + }); + return { + requestId: permission.id.slice(0, 8), + agentId: resolvedAgentId, + agentShortId: resolvedAgentId.slice(0, 7), + name: permission.name, + result: "allowed", + }; + }), + ); await client.close(); diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index efff2d92c..f8a0b137f 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -89,21 +89,22 @@ export async function runDenyCommand( } // Deny permissions - const results: PermissionResponseItem[] = []; - for (const permission of permissionsToDeny) { - await client.respondToPermission(resolvedAgentId, permission.id, { - behavior: "deny", - ...(options.message ? { message: options.message } : {}), - ...(options.interrupt ? { interrupt: true } : {}), - }); - results.push({ - requestId: permission.id.slice(0, 8), - agentId: resolvedAgentId, - agentShortId: resolvedAgentId.slice(0, 7), - name: permission.name, - result: "denied", - }); - } + const results: PermissionResponseItem[] = await Promise.all( + permissionsToDeny.map(async (permission) => { + await client.respondToPermission(resolvedAgentId, permission.id, { + behavior: "deny", + ...(options.message ? { message: options.message } : {}), + ...(options.interrupt ? { interrupt: true } : {}), + }); + return { + requestId: permission.id.slice(0, 8), + agentId: resolvedAgentId, + agentShortId: resolvedAgentId.slice(0, 7), + name: permission.name, + result: "denied", + }; + }), + ); await client.close(); diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index 3da287b78..1d02f9e68 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -83,6 +83,46 @@ export function formatDurationMs(durationMs: number): string { return parts.join(""); } +function resolveScheduleTarget(args: { + targetValue: string | undefined; + hasExplicitProviderSelection: boolean; + newAgentTarget: ScheduleTarget; +}): ScheduleTarget { + const { targetValue, hasExplicitProviderSelection, newAgentTarget } = args; + const currentAgentId = process.env.PASEO_AGENT_ID?.trim(); + + if (!targetValue) { + if (currentAgentId && !hasExplicitProviderSelection) { + return { type: "self", agentId: currentAgentId }; + } + return newAgentTarget; + } + + if (targetValue === "new-agent") { + return newAgentTarget; + } + + if (hasExplicitProviderSelection) { + throw { + code: "INVALID_TARGET", + message: "--provider can only be used with a new-agent target", + details: "Use --target new-agent or omit --target to create a new agent schedule", + } satisfies CommandError; + } + + if (targetValue === "self") { + if (!currentAgentId) { + throw { + code: "INVALID_TARGET", + message: "--target self requires running inside a Paseo agent", + } satisfies CommandError; + } + return { type: "self", agentId: currentAgentId }; + } + + return { type: "agent", agentId: targetValue }; +} + export function parseScheduleCreateInput(options: { prompt: string; every?: string; @@ -127,45 +167,11 @@ export function parseScheduleCreateInput(options: { ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), }, }; - let target: ScheduleTarget; - if (!targetValue) { - const currentAgentId = process.env.PASEO_AGENT_ID?.trim(); - if (currentAgentId && !hasExplicitProviderSelection) { - target = { type: "self", agentId: currentAgentId }; - } else { - target = newAgentTarget; - } - } else if (targetValue === "self") { - if (hasExplicitProviderSelection) { - throw { - code: "INVALID_TARGET", - message: "--provider can only be used with a new-agent target", - details: "Use --target new-agent or omit --target to create a new agent schedule", - } satisfies CommandError; - } - const currentAgentId = process.env.PASEO_AGENT_ID?.trim(); - if (!currentAgentId) { - throw { - code: "INVALID_TARGET", - message: "--target self requires running inside a Paseo agent", - } satisfies CommandError; - } - target = { type: "self", agentId: currentAgentId }; - } else if (targetValue === "new-agent") { - target = newAgentTarget; - } else { - if (hasExplicitProviderSelection) { - throw { - code: "INVALID_TARGET", - message: "--provider can only be used with a new-agent target", - details: "Use --target new-agent or omit --target to create a new agent schedule", - } satisfies CommandError; - } - target = { - type: "agent", - agentId: targetValue, - }; - } + const target = resolveScheduleTarget({ + targetValue, + hasExplicitProviderSelection, + newAgentTarget, + }); const maxRuns = options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs"); diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index b934d19fa..e7f1ba3e7 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -85,11 +85,9 @@ function readPidSocketTarget(paseoHome: string): string | null { listen?: unknown; sockPath?: unknown; }; - return typeof parsed.listen === "string" - ? parsed.listen - : typeof parsed.sockPath === "string" - ? parsed.sockPath - : null; + if (typeof parsed.listen === "string") return parsed.listen; + if (typeof parsed.sockPath === "string") return parsed.sockPath; + return null; } catch { return null; } @@ -143,6 +141,12 @@ function resolveDaemonHostCandidates(options?: ConnectOptions): string[] { return resolveDefaultDaemonHosts(); } +function stripIpcPrefix(trimmed: string): string { + if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim(); + if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim(); + return trimmed; +} + export function resolveDaemonTarget(host: string): DaemonTarget { const trimmed = host.trim(); if ( @@ -150,11 +154,7 @@ export function resolveDaemonTarget(host: string): DaemonTarget { trimmed.startsWith("pipe://") || trimmed.startsWith("\\\\.\\pipe\\") ) { - const socketPath = trimmed.startsWith("unix://") - ? trimmed.slice("unix://".length).trim() - : trimmed.startsWith("pipe://") - ? trimmed.slice("pipe://".length).trim() - : trimmed; + const socketPath = stripIpcPrefix(trimmed); if (!socketPath) { throw new Error("Invalid IPC daemon target: missing socket path"); } diff --git a/packages/cli/tests/e2e/agent-send.test.ts b/packages/cli/tests/e2e/agent-send.test.ts index 6ebc6a79f..b8a5f6d52 100644 --- a/packages/cli/tests/e2e/agent-send.test.ts +++ b/packages/cli/tests/e2e/agent-send.test.ts @@ -147,14 +147,6 @@ async function test_verify_logs(agentId: string): Promise { // Logs should have content from both tasks assert(result.stdout.length > 0, "Logs should have content"); - // The agent was asked to say specific things, check if either appears in logs - // This is a loose check since exact log format may vary - const logsLower = result.stdout.toLowerCase(); - const hasInitialTask = - logsLower.includes("initial") || logsLower.includes("hello") || logsLower.includes("task"); - const hasFollowUp = - logsLower.includes("follow") || logsLower.includes("complete") || logsLower.includes("task"); - // At minimum, there should be log entries (we can't guarantee exact content) assert(result.stdout.split("\n").length > 3, "Should have multiple log entries from both tasks"); diff --git a/packages/cli/tests/e2e/permissions.test.ts b/packages/cli/tests/e2e/permissions.test.ts index da61856bd..ebfbec693 100644 --- a/packages/cli/tests/e2e/permissions.test.ts +++ b/packages/cli/tests/e2e/permissions.test.ts @@ -64,6 +64,29 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +interface PermitResult { + exitCode: number; + stdout: string; +} + +function findMatchingPermission( + result: PermitResult, + agentId: string, +): { agentId?: string } | undefined { + if (result.exitCode !== 0) return undefined; + try { + const permissions = JSON.parse(result.stdout.trim()); + if (!Array.isArray(permissions) || permissions.length === 0) return undefined; + return permissions.find( + (p: { agentId?: string }) => + p.agentId?.startsWith(agentId.slice(0, 7)) || + agentId.startsWith(p.agentId?.slice(0, 7) || ""), + ); + } catch { + return undefined; + } +} + async function test_create_agent_with_permissions(): Promise { console.log("\n--- Test: Create agent in default mode (will request permissions) ---"); @@ -111,28 +134,12 @@ async function test_wait_for_permission_request(agentId: string): Promise while (Date.now() - startTime < maxWait) { const result = await ctx.paseo(["permit", "ls", "--json"]); - - if (result.exitCode === 0) { - try { - const permissions = JSON.parse(result.stdout.trim()); - if (Array.isArray(permissions) && permissions.length > 0) { - // Check if any permission is for our agent - const ourPermission = permissions.find( - (p: { agentId?: string }) => - p.agentId?.startsWith(agentId.slice(0, 7)) || - agentId.startsWith(p.agentId?.slice(0, 7) || ""), - ); - if (ourPermission) { - console.log("Permission request detected:", ourPermission); - console.log("PASS: Agent requested permission"); - return; - } - } - } catch { - // JSON parse failed, continue polling - } + const ourPermission = findMatchingPermission(result, agentId); + if (ourPermission) { + console.log("Permission request detected:", ourPermission); + console.log("PASS: Agent requested permission"); + return; } - await sleep(pollInterval); } diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index cbc127bf1..8be8fd679 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -126,24 +126,17 @@ async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number) signalProcessTree(pid, "SIGTERM"); await new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) { - return; - } - settled = true; + const done = () => resolve(); + const onExit = () => { clearTimeout(timeoutId); - resolve(); + done(); }; - const timeoutId = setTimeout(() => { signalProcessTree(pid, "SIGKILL"); - finish(); + processRef.removeListener("exit", onExit); + done(); }, timeoutMs); - - processRef.once("exit", () => { - finish(); - }); + processRef.once("exit", onExit); }); }