Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions

View File

@@ -1,207 +1,230 @@
import { Command } from 'commander'
import { createRequire } from 'node:module'
import { createAgentCommand } from './commands/agent/index.js'
import { createDaemonCommand } from './commands/daemon/index.js'
import { createPermitCommand } from './commands/permit/index.js'
import { createProviderCommand } from './commands/provider/index.js'
import { createSpeechCommand } from './commands/speech/index.js'
import { createWorktreeCommand } from './commands/worktree/index.js'
import { startCommand as daemonStartCommand } from './commands/daemon/start.js'
import { runStatusCommand as runDaemonStatusCommand } from './commands/daemon/status.js'
import { runRestartCommand as runDaemonRestartCommand } from './commands/daemon/restart.js'
import { runLsCommand } from './commands/agent/ls.js'
import { runRunCommand } from './commands/agent/run.js'
import { runLogsCommand } from './commands/agent/logs.js'
import { runDeleteCommand } from './commands/agent/delete.js'
import { runStopCommand } from './commands/agent/stop.js'
import { runSendCommand } from './commands/agent/send.js'
import { runInspectCommand } from './commands/agent/inspect.js'
import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { withOutput } from './output/index.js'
import { onboardCommand } from './commands/onboard.js'
import { Command } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js";
import { runLsCommand } from "./commands/agent/ls.js";
import { runRunCommand } from "./commands/agent/run.js";
import { runLogsCommand } from "./commands/agent/logs.js";
import { runDeleteCommand } from "./commands/agent/delete.js";
import { runStopCommand } from "./commands/agent/stop.js";
import { runSendCommand } from "./commands/agent/send.js";
import { runInspectCommand } from "./commands/agent/inspect.js";
import { runWaitCommand } from "./commands/agent/wait.js";
import { runAttachCommand } from "./commands/agent/attach.js";
import { withOutput } from "./output/index.js";
import { onboardCommand } from "./commands/onboard.js";
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
addJsonOption,
collectMultiple,
} from './utils/command-options.js'
} from "./utils/command-options.js";
const require = createRequire(import.meta.url)
const require = createRequire(import.meta.url);
type CliPackageJson = {
version?: unknown
}
version?: unknown;
};
function resolveCliVersion(): string {
const packageJson = require('../package.json') as CliPackageJson
if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) {
return packageJson.version.trim()
const packageJson = require("../package.json") as CliPackageJson;
if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
return packageJson.version.trim();
}
throw new Error('Unable to resolve @getpaseo/cli version from package.json.')
throw new Error("Unable to resolve @getpaseo/cli version from package.json.");
}
const VERSION = resolveCliVersion()
const VERSION = resolveCliVersion();
export function createCli(): Command {
const program = new Command()
const program = new Command();
program
.name('paseo')
.description('Paseo CLI - control your AI coding agents from the command line')
.version(VERSION, '-v, --version', 'output the version number')
.name("paseo")
.description("Paseo CLI - control your AI coding agents from the command line")
.version(VERSION, "-v, --version", "output the version number")
// Global output options
.option('-o, --format <format>', 'output format: table, json, yaml', 'table')
.option('--json', 'output in JSON format (alias for --format json)')
.option('-q, --quiet', 'minimal output (IDs only)')
.option('--no-headers', 'omit table headers')
.option('--no-color', 'disable colored output')
.option("-o, --format <format>", "output format: table, json, yaml", "table")
.option("--json", "output in JSON format (alias for --format json)")
.option("-q, --quiet", "minimal output (IDs only)")
.option("--no-headers", "omit table headers")
.option("--no-color", "disable colored output");
// Primary agent commands (top-level)
addJsonAndDaemonHostOptions(
program
.command('ls')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
).action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
program
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.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)')
.command("ls")
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "Legacy no-op (kept for compatibility)")
.option(
'--image <path>',
'Attach image(s) to the initial prompt (can be used multiple times)',
"--label <key=value>",
"Filter by label (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('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
).action(withOutput(runRunCommand))
.option("--thinking <id>", "Filter by thinking option ID"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
program
.command("run")
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--name <name>", "Assign a name/title to the agent")
.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(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
),
).action(withOutput(runRunCommand));
addDaemonHostOption(
program
.command('attach')
.command("attach")
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
).action(runAttachCommand)
.argument("<id>", "Agent ID (or prefix)"),
).action(runAttachCommand);
addDaemonHostOption(
program
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--since <time>', 'Show logs since timestamp')
).action(runLogsCommand)
.command("logs")
.description("View agent activity/timeline")
.argument("<id>", "Agent ID (or prefix)")
.option("-f, --follow", "Follow log output (streaming)")
.option("--tail <n>", "Show last n entries")
.option("--filter <type>", "Filter by event type (tools, text, errors, permissions)")
.option("--since <time>", "Show logs since timestamp"),
).action(runLogsCommand);
addJsonAndDaemonHostOptions(
program
.command('stop')
.description('Interrupt an agent if it is running (no-op for idle agents)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
).action(withOutput(runStopCommand))
.command("stop")
.description("Interrupt an agent if it is running (no-op for idle agents)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Stop all agents")
.option("--cwd <path>", "Stop all agents in directory"),
).action(withOutput(runStopCommand));
addJsonAndDaemonHostOptions(
program
.command('delete')
.description('Delete an agent (interrupt if running, then hard-delete)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
).action(withOutput(runDeleteCommand))
.command("delete")
.description("Delete an agent (interrupt if running, then hard-delete)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Delete all agents")
.option("--cwd <path>", "Delete all agents in directory"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
program
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('[prompt]', 'The message to send')
.option('--prompt <text>', 'Provide the message inline as a flag')
.option('--prompt-file <path>', 'Read the message from a UTF-8 text file')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
).action(withOutput(runSendCommand))
.command("send")
.description("Send a message/task to an existing agent")
.argument("<id>", "Agent ID (or prefix)")
.argument("[prompt]", "The message to send")
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--no-wait", "Return immediately without waiting for completion")
.option("--image <path>", "Attach image(s) to the message", collectMultiple, []),
).action(withOutput(runSendCommand));
addJsonAndDaemonHostOptions(
program
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
).action(withOutput(runInspectCommand))
.command("inspect")
.description("Show detailed information about an agent")
.argument("<id>", "Agent ID (or prefix)"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
program
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
).action(withOutput(runWaitCommand))
.command("wait")
.description("Wait for an agent to become idle")
.argument("<id>", "Agent ID (or prefix)")
.option("--timeout <seconds>", "Maximum wait time (default: no limit)"),
).action(withOutput(runWaitCommand));
// Top-level local daemon shortcuts
program.addCommand(onboardCommand())
program.addCommand(daemonStartCommand())
program.addCommand(onboardCommand());
program.addCommand(daemonStartCommand());
addJsonOption(
program
.command('status')
.description('Show local daemon status (alias for "paseo daemon status")')
.command("status")
.description('Show local daemon status (alias for "paseo daemon status")'),
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(withOutput(runDaemonStatusCommand))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runDaemonStatusCommand));
addJsonOption(
program
.command('restart')
.description('Restart local daemon (alias for "paseo daemon restart")')
.command("restart")
.description('Restart local daemon (alias for "paseo daemon restart")'),
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before force step (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.option('--listen <listen>', 'Listen target for restarted daemon (host:port, port, or unix socket)')
.option('--port <port>', 'Port for restarted daemon listen target')
.option('--no-relay', 'Disable relay on restarted daemon')
.option('--no-mcp', 'Disable Agent MCP on restarted daemon')
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
"--listen <listen>",
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.action(withOutput(runDaemonRestartCommand))
.option("--port <port>", "Port for restarted daemon listen target")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
)
.action(withOutput(runDaemonRestartCommand));
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand())
program.addCommand(createAgentCommand());
// Daemon commands
program.addCommand(createDaemonCommand())
program.addCommand(createDaemonCommand());
// Permission commands
program.addCommand(createPermitCommand())
program.addCommand(createPermitCommand());
// Provider commands
program.addCommand(createProviderCommand())
program.addCommand(createProviderCommand());
// Speech model commands
program.addCommand(createSpeechCommand())
program.addCommand(createSpeechCommand());
// Worktree commands
program.addCommand(createWorktreeCommand())
program.addCommand(createWorktreeCommand());
return program
return program;
}

View File

@@ -1,126 +1,131 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for agent archive command */
export interface AgentArchiveResult {
agentId: string
status: 'archived'
archivedAt: string
agentId: string;
status: "archived";
archivedAt: string;
}
/** Schema for archive command output */
export const archiveSchema: OutputSchema<AgentArchiveResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'STATUS', field: 'status' },
{ header: 'ARCHIVED AT', field: 'archivedAt' },
{ header: "AGENT ID", field: "agentId" },
{ header: "STATUS", field: "status" },
{ header: "ARCHIVED AT", field: "archivedAt" },
],
}
};
export interface AgentArchiveOptions extends CommandOptions {
force?: boolean
host?: string
force?: boolean;
host?: string;
}
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>;
export async function runArchiveCommand(
agentIdArg: string,
options: AgentArchiveOptions,
_command: Command
_command: Command,
): Promise<AgentArchiveCommandResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent archive <id-or-name>',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent archive <id-or-name>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
const agentId = resolveAgentId(agentIdArg, agents)
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
const agentId = resolveAgentId(agentIdArg, agents);
if (!agentId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
const agent = agents.find((entry) => entry.id === agentId)
const agent = agents.find((entry) => entry.id === agentId);
if (!agent) {
throw new Error(`Resolved agent missing from fetched agents: ${agentId}`)
throw new Error(`Resolved agent missing from fetched agents: ${agentId}`);
}
// Check if agent is already archived
if (agent.archivedAt) {
const error: CommandError = {
code: 'AGENT_ALREADY_ARCHIVED',
code: "AGENT_ALREADY_ARCHIVED",
message: `Agent ${agentId.slice(0, 7)} is already archived`,
details: `Archived at: ${agent.archivedAt}`,
}
throw error
};
throw error;
}
// Check if agent is running and reject unless --force is set
if (agent.status === 'running' && !options.force) {
if (agent.status === "running" && !options.force) {
const error: CommandError = {
code: 'AGENT_RUNNING',
code: "AGENT_RUNNING",
message: `Agent ${agentId.slice(0, 7)} is currently running`,
details:
'Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop. Use paseo agent delete to hard-delete it.',
}
throw error
"Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop. Use paseo agent delete to hard-delete it.",
};
throw error;
}
// Archive the agent
const result = await client.archiveAgent(agentId)
const result = await client.archiveAgent(agentId);
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
agentId,
status: 'archived',
status: "archived",
archivedAt: result.archivedAt,
},
schema: archiveSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'ARCHIVE_FAILED',
code: "ARCHIVE_FAILED",
message: `Failed to archive agent: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,16 +1,16 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import { fetchProjectedTimelineItems } from '../../utils/timeline.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import type {
DaemonClient,
AgentStreamMessage,
AgentStreamEventPayload,
AgentTimelineItem,
} from '@getpaseo/server'
} from "@getpaseo/server";
export interface AgentAttachOptions {
host?: string
[key: string]: unknown
host?: string;
[key: string]: unknown;
}
/**
@@ -18,41 +18,41 @@ export interface AgentAttachOptions {
*/
function printTimelineItem(item: AgentTimelineItem): void {
switch (item.type) {
case 'assistant_message':
case "assistant_message":
// Print assistant text directly
process.stdout.write(item.text)
break
process.stdout.write(item.text);
break;
case 'reasoning':
case "reasoning":
// Print reasoning in a muted color if available
console.log(`\n[Reasoning] ${item.text}`)
break
console.log(`\n[Reasoning] ${item.text}`);
break;
case 'tool_call': {
const toolName = item.name
const status = item.status ?? 'started'
console.log(`\n[Tool: ${toolName}] ${status}`)
break
case "tool_call": {
const toolName = item.name;
const status = item.status ?? "started";
console.log(`\n[Tool: ${toolName}] ${status}`);
break;
}
case 'todo': {
const completed = item.items.filter((i) => i.completed).length
const total = item.items.length
console.log(`\n[Todo] ${completed}/${total} completed`)
break
case "todo": {
const completed = item.items.filter((i) => i.completed).length;
const total = item.items.length;
console.log(`\n[Todo] ${completed}/${total} completed`);
break;
}
case 'error':
console.error(`\n[Error] ${item.message}`)
break
case "error":
console.error(`\n[Error] ${item.message}`);
break;
case 'user_message':
console.log(`\n[User] ${item.text}`)
break
case "user_message":
console.log(`\n[User] ${item.text}`);
break;
default:
// Unknown item type, skip
break
break;
}
}
@@ -61,33 +61,33 @@ function printTimelineItem(item: AgentTimelineItem): void {
*/
function printStreamEvent(event: AgentStreamEventPayload): void {
switch (event.type) {
case 'timeline':
case "timeline":
// Print the timeline item
printTimelineItem(event.item)
break
printTimelineItem(event.item);
break;
case 'permission_requested':
console.log(`\n[Permission Required] ${event.request.name}`)
case "permission_requested":
console.log(`\n[Permission Required] ${event.request.name}`);
if (event.request.description) {
console.log(` ${event.request.description}`)
console.log(` ${event.request.description}`);
}
break
break;
case 'permission_resolved':
console.log(`\n[Permission ${event.resolution.behavior}]`)
break
case "permission_resolved":
console.log(`\n[Permission ${event.resolution.behavior}]`);
break;
case 'turn_failed':
console.error(`\n[Turn Failed] ${event.error}`)
break
case "turn_failed":
console.error(`\n[Turn Failed] ${event.error}`);
break;
case 'attention_required':
console.log(`\n[Attention Required: ${event.reason}]`)
break
case "attention_required":
console.log(`\n[Attention Required: ${event.reason}]`);
break;
default:
// Other event types are internal
break
break;
}
}
@@ -97,91 +97,91 @@ function printStreamEvent(event: AgentStreamEventPayload): void {
export async function runAttachCommand(
id: string,
options: AgentAttachOptions,
_command: Command
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
if (!id) {
console.error('Error: Agent ID required')
console.error('Usage: paseo attach <id>')
process.exit(1)
console.error("Error: Agent ID required");
console.error("Usage: paseo attach <id>");
process.exit(1);
}
let client: DaemonClient
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
console.error('Start the daemon with: paseo daemon start')
process.exit(1)
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
try {
const fetchResult = await client.fetchAgent(id)
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`)
console.error('Use `paseo ls` to list available agents')
await client.close()
process.exit(1)
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
await client.close();
process.exit(1);
}
const resolvedId = fetchResult.agent.id
const resolvedId = fetchResult.agent.id;
// Print header
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
console.log(`(Press Ctrl+C to detach)\n`)
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`);
console.log(`(Press Ctrl+C to detach)\n`);
// Print existing output from timeline fetch.
try {
const timelineItems = await fetchProjectedTimelineItems({
client,
agentId: resolvedId,
})
});
for (const item of timelineItems) {
printTimelineItem(item)
printTimelineItem(item);
}
} catch (error) {
console.warn('Warning: failed to fetch existing timeline', error)
console.warn("Warning: failed to fetch existing timeline", error);
}
// Subscribe to new events
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage
if (message.type !== 'agent_stream') return
if (message.payload.agentId !== resolvedId) return
const unsubscribe = client.on("agent_stream", (msg: unknown) => {
const message = msg as AgentStreamMessage;
if (message.type !== "agent_stream") return;
if (message.payload.agentId !== resolvedId) return;
printStreamEvent(message.payload.event)
})
printStreamEvent(message.payload.event);
});
// Handle Ctrl+C to detach gracefully
let detached = false
let detached = false;
const detach = () => {
if (detached) return
detached = true
if (detached) return;
detached = true;
console.log('\n\nDetaching from agent...')
unsubscribe()
console.log("\n\nDetaching from agent...");
unsubscribe();
client
.close()
.then(() => {
process.exit(0)
process.exit(0);
})
.catch(() => {
process.exit(1)
})
}
process.exit(1);
});
};
process.on('SIGINT', detach)
process.on('SIGTERM', detach)
process.on("SIGINT", detach);
process.on("SIGTERM", detach);
// Keep the process alive
await new Promise(() => {
// Wait indefinitely until interrupted
})
});
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Failed to attach to agent: ${message}`)
process.exit(1)
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Failed to attach to agent: ${message}`);
process.exit(1);
}
}

View File

@@ -1,114 +1,119 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
export interface DeleteResult {
deletedCount: number
agentIds: string[]
deletedCount: number;
agentIds: string[];
}
export const deleteSchema: OutputSchema<DeleteResult> = {
idField: (item) => item.agentIds.join('\n'),
columns: [{ header: 'DELETED', field: 'deletedCount' }],
}
idField: (item) => item.agentIds.join("\n"),
columns: [{ header: "DELETED", field: "deletedCount" }],
};
export interface AgentDeleteOptions extends CommandOptions {
all?: boolean
cwd?: string
all?: boolean;
cwd?: string;
}
export type AgentDeleteResult = SingleResult<DeleteResult>
export type AgentDeleteResult = SingleResult<DeleteResult>;
export async function runDeleteCommand(
id: string | undefined,
options: AgentDeleteOptions,
_command: Command
_command: Command,
): Promise<AgentDeleteResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Agent ID required unless --all or --cwd is specified',
details: 'Usage: paseo agent delete <id> | --all | --cwd <path>',
}
throw error
code: "MISSING_ARGUMENT",
message: "Agent ID required unless --all or --cwd is specified",
details: "Usage: paseo agent delete <id> | --all | --cwd <path>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } })
let agents = fetchPayload.entries.map((entry) => entry.agent)
const deletedIds: string[] = []
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
let agents = fetchPayload.entries.map((entry) => entry.agent);
const deletedIds: string[] = [];
if (options.all) {
agents = agents.filter((a) => !a.archivedAt)
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
const filterCwd = options.cwd
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = filterCwd.replace(/\/$/, '')
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
const fetchResult = await client.fetchAgent(id)
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
details: "Use `paseo ls` to list available agents",
};
throw error;
}
agents = [fetchResult.agent]
agents = [fetchResult.agent];
}
for (const agent of agents) {
try {
if (agent.status === 'running') {
await client.cancelAgent(agent.id)
if (agent.status === "running") {
await client.cancelAgent(agent.id);
}
await client.deleteAgent(agent.id)
deletedIds.push(agent.id)
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}`)
const message = err instanceof Error ? err.message : String(err);
console.error(`Warning: Failed to delete agent ${agent.id.slice(0, 7)}: ${message}`);
}
}
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
deletedCount: deletedIds.length,
agentIds: deletedIds,
},
schema: deleteSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
if (err && typeof err === 'object' && 'code' in err) {
throw 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 message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DELETE_AGENT_FAILED',
code: "DELETE_AGENT_FAILED",
message: `Failed to delete agent(s): ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,149 +1,169 @@
import { Command } from 'commander'
import { runModeCommand } from './mode.js'
import { runArchiveCommand } from './archive.js'
import { runDeleteCommand } from './delete.js'
import { runLsCommand } from './ls.js'
import { runRunCommand } from './run.js'
import { runLogsCommand } from './logs.js'
import { runStopCommand } from './stop.js'
import { runSendCommand } from './send.js'
import { runInspectCommand } from './inspect.js'
import { runWaitCommand } from './wait.js'
import { runAttachCommand } from './attach.js'
import { runUpdateCommand } from './update.js'
import { withOutput } from '../../output/index.js'
import { Command } from "commander";
import { runModeCommand } from "./mode.js";
import { runArchiveCommand } from "./archive.js";
import { runDeleteCommand } from "./delete.js";
import { runLsCommand } from "./ls.js";
import { runRunCommand } from "./run.js";
import { runLogsCommand } from "./logs.js";
import { runStopCommand } from "./stop.js";
import { runSendCommand } from "./send.js";
import { runInspectCommand } from "./inspect.js";
import { runWaitCommand } from "./wait.js";
import { runAttachCommand } from "./attach.js";
import { runUpdateCommand } from "./update.js";
import { withOutput } from "../../output/index.js";
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
collectMultiple,
} from '../../utils/command-options.js'
} from "../../utils/command-options.js";
export function createAgentCommand(): Command {
const agent = new Command('agent').description('Manage agents (advanced operations)')
const agent = new Command("agent").description("Manage agents (advanced operations)");
// Primary agent commands (same as top-level)
addJsonAndDaemonHostOptions(
agent
.command('ls')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
).action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
agent
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.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('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.command("ls")
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "Legacy no-op (kept for compatibility)")
.option(
'--wait-timeout <duration>',
'Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit'
"--label <key=value>",
"Filter by label (can be used multiple times)",
collectMultiple,
[],
)
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
).action(withOutput(runRunCommand))
.option("--thinking <id>", "Filter by thinking option ID"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
agent
.command("run")
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--name <name>", "Assign a name/title to the agent")
.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("--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",
),
).action(withOutput(runRunCommand));
addDaemonHostOption(
agent
.command('attach')
.command("attach")
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
).action(runAttachCommand)
.argument("<id>", "Agent ID (or prefix)"),
).action(runAttachCommand);
addDaemonHostOption(
agent
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
).action(runLogsCommand)
.command("logs")
.description("View agent activity/timeline")
.argument("<id>", "Agent ID (or prefix)")
.option("-f, --follow", "Follow log output (streaming)")
.option("--tail <n>", "Show last n entries")
.option("--filter <type>", "Filter by event type (tools, text, errors, permissions)"),
).action(runLogsCommand);
addJsonAndDaemonHostOptions(
agent
.command('stop')
.description('Interrupt an agent if it is running (no-op for idle agents)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
).action(withOutput(runStopCommand))
.command("stop")
.description("Interrupt an agent if it is running (no-op for idle agents)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Stop all agents")
.option("--cwd <path>", "Stop all agents in directory"),
).action(withOutput(runStopCommand));
addJsonAndDaemonHostOptions(
agent
.command('delete')
.description('Delete an agent (interrupt if running, then hard-delete)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
).action(withOutput(runDeleteCommand))
.command("delete")
.description("Delete an agent (interrupt if running, then hard-delete)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Delete all agents")
.option("--cwd <path>", "Delete all agents in directory"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
agent
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('[prompt]', 'The message to send')
.option('--prompt <text>', 'Provide the message inline as a flag')
.option('--prompt-file <path>', 'Read the message from a UTF-8 text file')
.option('--no-wait', 'Return immediately without waiting for completion')
).action(withOutput(runSendCommand))
.command("send")
.description("Send a message/task to an existing agent")
.argument("<id>", "Agent ID (or prefix)")
.argument("[prompt]", "The message to send")
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--no-wait", "Return immediately without waiting for completion"),
).action(withOutput(runSendCommand));
addJsonAndDaemonHostOptions(
agent
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
).action(withOutput(runInspectCommand))
.command("inspect")
.description("Show detailed information about an agent")
.argument("<id>", "Agent ID (or prefix)"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
agent
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
).action(withOutput(runWaitCommand))
.command("wait")
.description("Wait for an agent to become idle")
.argument("<id>", "Agent ID (or prefix)")
.option("--timeout <seconds>", "Maximum wait time (default: no limit)"),
).action(withOutput(runWaitCommand));
// Advanced agent commands (less common operations)
addJsonAndDaemonHostOptions(
agent
.command('mode')
.command("mode")
.description("Change an agent's operational mode")
.argument('<id>', 'Agent ID (or prefix)')
.argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent')
).action(withOutput(runModeCommand))
.argument("<id>", "Agent ID (or prefix)")
.argument("[mode]", "Mode to set (required unless --list)")
.option("--list", "List available modes for this agent"),
).action(withOutput(runModeCommand));
addJsonAndDaemonHostOptions(
agent
.command('archive')
.description('Archive an agent (soft-delete)')
.argument('<id>', 'Agent ID, prefix, or name')
.option('--force', 'Force archive running agent (interrupts active run first)')
).action(withOutput(runArchiveCommand))
.command("archive")
.description("Archive an agent (soft-delete)")
.argument("<id>", "Agent ID, prefix, or name")
.option("--force", "Force archive running agent (interrupts active run first)"),
).action(withOutput(runArchiveCommand));
addJsonAndDaemonHostOptions(
agent
.command('update')
.command("update")
.description("Update an agent's metadata")
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.argument("<id>", "Agent ID (or prefix)")
.option("--name <name>", "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
"--label <label>",
"Add/set label(s) on the agent (can be used multiple times or comma-separated)",
collectMultiple,
[]
)
).action(withOutput(runUpdateCommand))
[],
),
).action(withOutput(runUpdateCommand));
return agent
return agent;
}

View File

@@ -1,101 +1,101 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Agent inspect data for display (matches CLI spec format) */
interface AgentInspect {
Id: string
Name: string
Provider: string
Model: string
Thinking: string
Status: string
Archived: boolean
ArchivedAt: string | null
Mode: string
Cwd: string
CreatedAt: string
UpdatedAt: string
Id: string;
Name: string;
Provider: string;
Model: string;
Thinking: string;
Status: string;
Archived: boolean;
ArchivedAt: string | null;
Mode: string;
Cwd: string;
CreatedAt: string;
UpdatedAt: string;
LastUsage: {
InputTokens: number
OutputTokens: number
CachedTokens: number
CostUsd: number
} | null
InputTokens: number;
OutputTokens: number;
CachedTokens: number;
CostUsd: number;
} | null;
Capabilities: {
Streaming: boolean
Persistence: boolean
DynamicModes: boolean
McpServers: boolean
} | null
Streaming: boolean;
Persistence: boolean;
DynamicModes: boolean;
McpServers: boolean;
} | null;
AvailableModes: Array<{
id: string
label: string
}> | null
id: string;
label: string;
}> | null;
PendingPermissions: Array<{
id: string
tool: string
}>
Worktree: string | null
ParentAgentId: string | null
id: string;
tool: string;
}>;
Worktree: string | null;
ParentAgentId: string | null;
}
/** Key-value row for table display */
interface InspectRow {
key: string
value: string
key: string;
value: string;
}
/** Schema for key-value display with custom serialization for JSON/YAML */
function createInspectSchema(agent: AgentInspect): OutputSchema<InspectRow> {
return {
idField: 'key',
idField: "key",
columns: [
{ header: 'KEY', field: 'key' },
{ header: "KEY", field: "key" },
{
header: 'VALUE',
field: 'value',
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === 'Status') {
if (item.value === 'running') return 'green'
if (item.value === 'idle') return 'yellow'
if (item.value === 'error') return 'red'
if (item.key === "Status") {
if (item.value === "running") return "green";
if (item.value === "idle") return "yellow";
if (item.value === "error") return "red";
}
return undefined
return undefined;
},
},
],
// For JSON/YAML, return the structured agent object
serialize: (_item) => agent,
}
};
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
const home = process.env.HOME;
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
return "~" + path.slice(home.length);
}
return path
return path;
}
/** Format cost in USD */
function formatCost(costUsd: number): string {
if (costUsd === 0) return '$0.00'
if (costUsd < 0.01) return `$${costUsd.toFixed(4)}`
return `$${costUsd.toFixed(2)}`
if (costUsd === 0) return "$0.00";
if (costUsd < 0.01) return `$${costUsd.toFixed(4)}`;
return `$${costUsd.toFixed(2)}`;
}
function normalizeModelId(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim()
if (!normalized || normalized.toLowerCase() === 'default') return null
return normalized
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.toLowerCase() === "default") return null;
return normalized;
}
function resolveModel(snapshot: AgentSnapshotPayload): string | null {
return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model)
return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model);
}
/** Convert agent snapshot to inspection data */
@@ -107,7 +107,7 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
}
: null
: null;
const capabilities = snapshot.capabilities
? {
@@ -116,22 +116,22 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
}
: null
: 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
const worktree = snapshot.labels?.["paseo.worktree"] ?? null;
const parentAgentId = snapshot.labels?.["paseo.parent-agent-id"] ?? null;
return {
Id: snapshot.id,
Name: snapshot.title ?? '-',
Name: snapshot.title ?? "-",
Provider: snapshot.provider,
Model: resolveModel(snapshot) ?? '-',
Thinking: snapshot.effectiveThinkingOptionId ?? 'auto',
Model: resolveModel(snapshot) ?? "-",
Thinking: snapshot.effectiveThinkingOptionId ?? "auto",
Status: snapshot.status,
Archived: snapshot.archivedAt != null,
ArchivedAt: snapshot.archivedAt ?? null,
Mode: snapshot.currentModeId ?? 'default',
Mode: snapshot.currentModeId ?? "default",
Cwd: snapshot.cwd,
CreatedAt: snapshot.createdAt,
UpdatedAt: snapshot.updatedAt,
@@ -142,133 +142,134 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
: null,
PendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
id: p.id,
tool: p.name ?? 'unknown',
tool: p.name ?? "unknown",
})),
Worktree: worktree,
ParentAgentId: parentAgentId,
}
};
}
/** Convert agent to key-value rows for table display */
function toInspectRows(agent: AgentInspect): InspectRow[] {
const rows: InspectRow[] = [
{ key: 'Id', value: agent.Id },
{ key: 'Name', value: agent.Name },
{ key: 'Provider', value: agent.Provider },
{ key: 'Model', value: agent.Model },
{ key: 'Thinking', value: agent.Thinking },
{ key: 'Status', value: agent.Status },
{ key: 'Archived', value: String(agent.Archived) },
{ key: 'ArchivedAt', value: agent.ArchivedAt ?? 'null' },
{ key: 'Mode', value: agent.Mode },
{ key: 'Cwd', value: shortenPath(agent.Cwd) },
{ key: 'CreatedAt', value: agent.CreatedAt },
{ key: 'UpdatedAt', value: agent.UpdatedAt },
]
{ key: "Id", value: agent.Id },
{ key: "Name", value: agent.Name },
{ key: "Provider", value: agent.Provider },
{ key: "Model", value: agent.Model },
{ key: "Thinking", value: agent.Thinking },
{ key: "Status", value: agent.Status },
{ key: "Archived", value: String(agent.Archived) },
{ key: "ArchivedAt", value: agent.ArchivedAt ?? "null" },
{ key: "Mode", value: agent.Mode },
{ key: "Cwd", value: shortenPath(agent.Cwd) },
{ key: "CreatedAt", value: agent.CreatedAt },
{ key: "UpdatedAt", value: agent.UpdatedAt },
];
if (agent.LastUsage) {
rows.push({
key: 'LastUsage',
key: "LastUsage",
value: `InputTokens: ${agent.LastUsage.InputTokens}, OutputTokens: ${agent.LastUsage.OutputTokens}, CachedTokens: ${agent.LastUsage.CachedTokens}, CostUsd: ${formatCost(agent.LastUsage.CostUsd)}`,
})
});
}
if (agent.Capabilities) {
rows.push({
key: 'Capabilities',
key: "Capabilities",
value: `Streaming: ${agent.Capabilities.Streaming}, Persistence: ${agent.Capabilities.Persistence}, DynamicModes: ${agent.Capabilities.DynamicModes}, McpServers: ${agent.Capabilities.McpServers}`,
})
});
}
if (agent.AvailableModes && agent.AvailableModes.length > 0) {
rows.push({
key: 'AvailableModes',
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(', '),
})
key: "AvailableModes",
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(", "),
});
}
rows.push({
key: 'PendingPermissions',
value: agent.PendingPermissions.length > 0
? agent.PendingPermissions.map(p => `${p.id} (${p.tool})`).join(', ')
: '[]'
})
key: "PendingPermissions",
value:
agent.PendingPermissions.length > 0
? agent.PendingPermissions.map((p) => `${p.id} (${p.tool})`).join(", ")
: "[]",
});
rows.push({ key: 'Worktree', value: agent.Worktree ?? 'null' })
rows.push({ key: 'ParentAgentId', value: agent.ParentAgentId ?? 'null' })
rows.push({ key: "Worktree", value: agent.Worktree ?? "null" });
rows.push({ key: "ParentAgentId", value: agent.ParentAgentId ?? "null" });
return rows
return rows;
}
export type AgentInspectResult = ListResult<InspectRow>
export type AgentInspectResult = ListResult<InspectRow>;
export interface AgentInspectOptions extends CommandOptions {
host?: string
host?: string;
}
export async function runInspectCommand(
agentIdArg: string,
options: AgentInspectOptions,
_command: Command
_command: Command,
): Promise<AgentInspectResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent inspect <id>',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent inspect <id>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchResult = await client.fetchAgent(agentIdArg)
const fetchResult = await client.fetchAgent(agentIdArg);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
await client.close()
await client.close();
const inspectData = toInspectData(fetchResult.agent)
const inspectData = toInspectData(fetchResult.agent);
return {
type: 'list',
type: "list",
data: toInspectRows(inspectData),
schema: createInspectSchema(inspectData),
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INSPECT_FAILED',
code: "INSPECT_FAILED",
message: `Failed to inspect agent: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,155 +1,151 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions } from '../../output/index.js'
import { fetchProjectedTimelineItems } from '../../utils/timeline.js'
import type {
DaemonClient,
AgentStreamMessage,
AgentTimelineItem,
} from '@getpaseo/server'
import { curateAgentActivity } from '@getpaseo/server'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions } from "../../output/index.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import type { DaemonClient, AgentStreamMessage, AgentTimelineItem } from "@getpaseo/server";
import { curateAgentActivity } from "@getpaseo/server";
export interface AgentLogsOptions extends CommandOptions {
follow?: boolean
tail?: string
filter?: string
since?: string
follow?: boolean;
tail?: string;
filter?: string;
since?: string;
}
// Logs command returns void - it outputs directly to console
export type AgentLogsResult = void
export type AgentLogsResult = void;
export const NO_ACTIVITY_MESSAGE = 'No activity to display.'
export const NO_ACTIVITY_MESSAGE = "No activity to display.";
export async function fetchAgentTimelineItems(
client: DaemonClient,
agentId: string
agentId: string,
): Promise<AgentTimelineItem[]> {
return fetchProjectedTimelineItems({ client, agentId })
return fetchProjectedTimelineItems({ client, agentId });
}
export function formatAgentActivityTranscript(
timelineItems: AgentTimelineItem[],
tailCount?: number
tailCount?: number,
): string {
if (tailCount === 0) {
return ''
return "";
}
return curateAgentActivity(
timelineItems,
tailCount !== undefined ? { maxItems: tailCount } : undefined
)
tailCount !== undefined ? { maxItems: tailCount } : undefined,
);
}
function parseTailCount(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined
const parsed = Number.parseInt(raw, 10)
if (raw === undefined) return undefined;
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed < 0) {
return undefined
return undefined;
}
return parsed
return parsed;
}
/**
* Check if a timeline item matches the filter type
*/
function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
if (!filter) return true
if (!filter) return true;
const filterLower = filter.toLowerCase()
const type = item.type.toLowerCase()
const filterLower = filter.toLowerCase();
const type = item.type.toLowerCase();
switch (filterLower) {
case 'tools':
return type === 'tool_call'
case 'text':
return type === 'user_message' || type === 'assistant_message' || type === 'reasoning'
case 'errors':
return type === 'error'
case 'permissions':
case "tools":
return type === "tool_call";
case "text":
return type === "user_message" || type === "assistant_message" || type === "reasoning";
case "errors":
return type === "error";
case "permissions":
// Permissions might be in tool_call status or a separate event type
return type.includes('permission')
return type.includes("permission");
default:
// If filter doesn't match predefined types, match against the actual type
return type.includes(filterLower)
return type.includes(filterLower);
}
}
export async function runLogsCommand(
id: string,
options: AgentLogsOptions,
_command: Command
_command: Command,
): Promise<AgentLogsResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
if (!id) {
console.error('Error: Agent ID required')
console.error('Usage: paseo agent logs <id>')
process.exit(1)
console.error("Error: Agent ID required");
console.error("Usage: paseo agent logs <id>");
process.exit(1);
}
let client: DaemonClient
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
console.error('Start the daemon with: paseo daemon start')
process.exit(1)
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
try {
const fetchResult = await client.fetchAgent(id)
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`)
console.error('Use `paseo ls` to list available agents')
await client.close()
process.exit(1)
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
await client.close();
process.exit(1);
}
const resolvedId = fetchResult.agent.id
const resolvedId = fetchResult.agent.id;
// For follow mode, we stream events continuously
if (options.follow) {
if (options.tail !== undefined && parseTailCount(options.tail) === undefined) {
console.error(`Error: Invalid --tail value: ${options.tail}`)
console.error('Usage: --tail <n> (where n is >= 0)')
await client.close().catch(() => {})
process.exit(1)
console.error(`Error: Invalid --tail value: ${options.tail}`);
console.error("Usage: --tail <n> (where n is >= 0)");
await client.close().catch(() => {});
process.exit(1);
}
await runFollowMode(client, resolvedId, options)
return
await runFollowMode(client, resolvedId, options);
return;
}
// Fetch timeline directly via cursor RPC.
let timelineItems = await fetchAgentTimelineItems(client, resolvedId)
let timelineItems = await fetchAgentTimelineItems(client, resolvedId);
// Apply filter
if (options.filter) {
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter));
}
const tailCount = parseTailCount(options.tail)
const tailCount = parseTailCount(options.tail);
if (options.tail !== undefined && tailCount === undefined) {
console.error(`Error: Invalid --tail value: ${options.tail}`)
console.error('Usage: --tail <n> (where n is >= 0)')
await client.close().catch(() => {})
process.exit(1)
console.error(`Error: Invalid --tail value: ${options.tail}`);
console.error("Usage: --tail <n> (where n is >= 0)");
await client.close().catch(() => {});
process.exit(1);
}
await client.close()
await client.close();
// Use curateAgentActivity to format the transcript
if (tailCount === 0) {
return
return;
}
const transcript = formatAgentActivityTranscript(timelineItems, tailCount)
console.log(transcript)
const transcript = formatAgentActivityTranscript(timelineItems, tailCount);
console.log(transcript);
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Failed to get logs: ${message}`)
await client.close().catch(() => {})
process.exit(1)
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Failed to get logs: ${message}`);
await client.close().catch(() => {});
process.exit(1);
}
}
@@ -159,60 +155,61 @@ export async function runLogsCommand(
async function runFollowMode(
client: DaemonClient,
agentId: string,
options: AgentLogsOptions
options: AgentLogsOptions,
): Promise<void> {
const DEFAULT_FOLLOW_TAIL = 10
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
// First, get existing timeline.
let existingItems = await fetchAgentTimelineItems(client, agentId)
let existingItems = await fetchAgentTimelineItems(client, agentId);
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
}
// Print existing transcript (tail-like behavior)
if (tailCount > 0) {
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount)
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
console.log(existingTranscript)
console.log(existingTranscript);
}
}
// Subscribe to new events
const tailLabel = tailCount === 0 ? 'no history' : `last ${tailCount} entr${tailCount === 1 ? 'y' : 'ies'}`
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`)
const tailLabel =
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage
if (message.type !== 'agent_stream') return
if (message.payload.agentId !== agentId) return
const unsubscribe = client.on("agent_stream", (msg: unknown) => {
const message = msg as AgentStreamMessage;
if (message.type !== "agent_stream") return;
if (message.payload.agentId !== agentId) return;
if (message.payload.event.type === 'timeline') {
const item = message.payload.event.item
if (message.payload.event.type === "timeline") {
const item = message.payload.event.item;
// Apply filter
if (options.filter && !matchesFilter(item, options.filter)) {
return
return;
}
// Print each timeline item as it arrives using the curator format
const transcript = formatAgentActivityTranscript([item])
const transcript = formatAgentActivityTranscript([item]);
if (transcript !== NO_ACTIVITY_MESSAGE) {
console.log(transcript)
console.log(transcript);
}
}
})
});
// Wait for interrupt
await new Promise<void>((resolve) => {
const cleanup = () => {
unsubscribe()
resolve()
}
unsubscribe();
resolve();
};
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
})
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
});
await client.close()
await client.close();
}

View File

@@ -1,102 +1,102 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Agent list item for display */
export interface AgentListItem {
id: string
shortId: string
name: string
provider: string
thinking: string
status: string
cwd: string
created: string
id: string;
shortId: string;
name: string;
provider: string;
thinking: string;
status: string;
cwd: string;
created: string;
}
/** Helper to get relative time string */
function relativeTime(date: Date | string): string {
const now = Date.now()
const then = new Date(date).getTime()
const seconds = Math.floor((now - then) / 1000)
const now = Date.now();
const then = new Date(date).getTime();
const seconds = Math.floor((now - then) / 1000);
if (seconds < 60) return 'just now'
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
return `${Math.floor(seconds / 86400)} days ago`
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
return `${Math.floor(seconds / 86400)} days ago`;
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
const home = process.env.HOME;
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
return "~" + path.slice(home.length);
}
return path
return path;
}
function normalizeModelId(modelId: string | null | undefined): string | null {
if (typeof modelId !== 'string') return null
const normalized = modelId.trim()
if (!normalized || normalized.toLowerCase() === 'default') return null
return normalized
if (typeof modelId !== "string") return null;
const normalized = modelId.trim();
if (!normalized || normalized.toLowerCase() === "default") return null;
return normalized;
}
/** Schema for agent ls output */
export const agentLsSchema: OutputSchema<AgentListItem> = {
idField: 'shortId',
idField: "shortId",
columns: [
{ header: 'AGENT ID', field: 'shortId', width: 12 },
{ header: 'NAME', field: 'name', width: 20 },
{ header: 'PROVIDER', field: 'provider', width: 15 },
{ header: 'THINKING', field: 'thinking', width: 12 },
{ header: "AGENT ID", field: "shortId", width: 12 },
{ header: "NAME", field: "name", width: 20 },
{ header: "PROVIDER", field: "provider", width: 15 },
{ header: "THINKING", field: "thinking", width: 12 },
{
header: 'STATUS',
field: 'status',
header: "STATUS",
field: "status",
width: 10,
color: (value) => {
if (value === 'running') return 'green'
if (value === 'idle') return 'yellow'
if (value === 'error') return 'red'
return undefined
if (value === "running") return "green";
if (value === "idle") return "yellow";
if (value === "error") return "red";
return undefined;
},
},
{ header: 'CWD', field: 'cwd', width: 30 },
{ header: 'CREATED', field: 'created', width: 15 },
{ header: "CWD", field: "cwd", width: 30 },
{ header: "CREATED", field: "created", width: 15 },
],
}
};
/** Transform agent snapshot to AgentListItem */
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model)
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model);
return {
id: agent.id,
shortId: agent.id.slice(0, 7),
name: agent.title ?? '-',
name: agent.title ?? "-",
provider: model ? `${agent.provider}/${model}` : agent.provider,
thinking: agent.effectiveThinkingOptionId ?? 'auto',
thinking: agent.effectiveThinkingOptionId ?? "auto",
status: agent.status,
cwd: shortenPath(agent.cwd),
created: relativeTime(agent.createdAt),
}
};
}
export type AgentLsResult = ListResult<AgentListItem>
export type AgentLsResult = ListResult<AgentListItem>;
export interface AgentLsOptions extends CommandOptions {
/** -a: Include archived agents */
all?: boolean
all?: boolean;
/** Legacy flag retained for CLI compatibility */
global?: boolean
global?: boolean;
/** Filter by specific status */
status?: string
status?: string;
/** Filter by specific cwd */
cwd?: string
cwd?: string;
/** Filter by labels (key=value format) */
label?: string[]
label?: string[];
/** Filter by thinking option ID */
thinking?: string
thinking?: string;
}
/**
@@ -106,128 +106,128 @@ export interface AgentLsOptions extends CommandOptions {
*/
export async function runLsCommand(
options: AgentLsOptions,
_command: Command
_command: Command,
): Promise<AgentLsResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const normalizedThinkingOptionId = options.thinking?.trim()
const normalizedThinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !normalizedThinkingOptionId) {
const error: CommandError = {
code: 'INVALID_THINKING_OPTION',
message: '--thinking cannot be empty',
}
throw error
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
};
throw error;
}
// Parse --label filters (key=value).
const labelFilters: Record<string, string> = {}
const labelFilters: Record<string, string> = {};
if (options.label) {
for (const labelStr of options.label) {
const eqIndex = labelStr.indexOf('=')
const eqIndex = labelStr.indexOf("=");
if (eqIndex !== -1) {
const key = labelStr.slice(0, eqIndex)
const value = labelStr.slice(eqIndex + 1)
labelFilters[key] = value
const key = labelStr.slice(0, eqIndex);
const value = labelStr.slice(eqIndex + 1);
labelFilters[key] = value;
}
}
}
const daemonFilter: {
includeArchived?: boolean
labels?: Record<string, string>
thinkingOptionId?: string
} = {}
includeArchived?: boolean;
labels?: Record<string, string>;
thinkingOptionId?: string;
} = {};
if (options.all) {
daemonFilter.includeArchived = true
daemonFilter.includeArchived = true;
}
if (Object.keys(labelFilters).length > 0) {
daemonFilter.labels = labelFilters
daemonFilter.labels = labelFilters;
}
if (normalizedThinkingOptionId) {
daemonFilter.thinkingOptionId = normalizedThinkingOptionId
daemonFilter.thinkingOptionId = normalizedThinkingOptionId;
}
const fetchPayload = await client.fetchAgents({
filter: Object.keys(daemonFilter).length > 0 ? daemonFilter : undefined,
})
let agents = fetchPayload.entries.map((entry) => entry.agent)
});
let agents = fetchPayload.entries.map((entry) => entry.agent);
// By default, exclude archived agents. `-a` includes them.
if (!options.all) {
agents = agents.filter((a) => !a.archivedAt)
agents = agents.filter((a) => !a.archivedAt);
}
// If explicit status filter is provided, apply it.
if (options.status) {
agents = agents.filter((a) => a.status === options.status)
agents = agents.filter((a) => a.status === options.status);
}
// Optional cwd filter.
if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, '')
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, '')
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
}
// Apply label filtering only when explicitly requested.
if (Object.keys(labelFilters).length > 0) {
agents = agents.filter((a) => {
const agentLabels = a.labels
const agentLabels = a.labels;
for (const [key, value] of Object.entries(labelFilters)) {
if (agentLabels[key] !== value) {
return false
return false;
}
}
return true
})
return true;
});
}
await client.close()
await client.close();
// Sort agents: running first, then idle, then others; within each group, most recent first
const statusOrder = { running: 0, idle: 1 } as Record<string, number>
const statusOrder = { running: 0, idle: 1 } as Record<string, number>;
agents.sort((a, b) => {
// Primary sort: by status
const aOrder = statusOrder[a.status] ?? 999
const bOrder = statusOrder[b.status] ?? 999
if (aOrder !== bOrder) return aOrder - bOrder
const aOrder = statusOrder[a.status] ?? 999;
const bOrder = statusOrder[b.status] ?? 999;
if (aOrder !== bOrder) return aOrder - bOrder;
// Secondary sort: by creation time (most recent first)
const aTime = new Date(a.createdAt).getTime()
const bTime = new Date(b.createdAt).getTime()
return bTime - aTime
})
const aTime = new Date(a.createdAt).getTime();
const bTime = new Date(b.createdAt).getTime();
return bTime - aTime;
});
const items = agents.map(toListItem)
const items = agents.map(toListItem);
return {
type: 'list',
type: "list",
data: items,
schema: agentLsSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'LIST_AGENTS_FAILED',
code: "LIST_AGENTS_FAILED",
message: `Failed to list agents: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,139 +1,139 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
OutputSchema,
CommandError,
AnyCommandResult,
} from '../../output/index.js'
import type { AgentMode } from '@getpaseo/server'
} from "../../output/index.js";
import type { AgentMode } from "@getpaseo/server";
/** Result for setting mode */
export interface SetModeResult {
agentId: string
mode: string
agentId: string;
mode: string;
}
/** Schema for mode list output */
export const modeListSchema: OutputSchema<AgentMode> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'MODE', field: 'id', width: 15 },
{ header: 'LABEL', field: 'label', width: 25 },
{ header: 'DESCRIPTION', field: 'description', width: 40 },
{ header: "MODE", field: "id", width: 15 },
{ header: "LABEL", field: "label", width: 25 },
{ header: "DESCRIPTION", field: "description", width: 40 },
],
}
};
/** Schema for set mode output */
export const setModeSchema: OutputSchema<SetModeResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'MODE', field: 'mode', width: 20 },
{ header: "AGENT ID", field: "agentId", width: 12 },
{ header: "MODE", field: "mode", width: 20 },
],
}
};
export interface AgentModeOptions extends CommandOptions {
list?: boolean
list?: boolean;
}
const missingModeError = (): CommandError => ({
code: 'MISSING_ARGUMENT',
message: 'Mode argument required unless --list is specified',
details: 'Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>',
})
code: "MISSING_ARGUMENT",
message: "Mode argument required unless --list is specified",
details: "Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>",
});
// This command returns two different data shapes (set result vs mode list).
// Keep `any` here to match the existing output wrapper generic contract.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AgentModeResult = AnyCommandResult<any>
export type AgentModeResult = AnyCommandResult<any>;
export async function runModeCommand(
id: string,
mode: string | undefined,
options: AgentModeOptions,
_command: Command
_command: Command,
): Promise<AgentModeResult> {
const normalizedMode = mode?.trim()
const host = getDaemonHost({ host: options.host as string | undefined })
const normalizedMode = mode?.trim();
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!options.list && !normalizedMode) {
throw missingModeError()
throw missingModeError();
}
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
const fetchResult = await client.fetchAgent(id)
client = await connectToDaemon({ host: options.host as string | undefined });
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
details: "Use `paseo ls` to list available agents",
};
throw error;
}
const agent = fetchResult.agent
const resolvedId = agent.id
const agent = fetchResult.agent;
const resolvedId = agent.id;
if (options.list) {
// List available modes for this agent
const availableModes = agent.availableModes ?? []
const availableModes = agent.availableModes ?? [];
const items: AgentMode[] = availableModes.map((m) => ({
id: m.id,
label: m.label,
description: m.description,
}))
}));
return {
type: 'list',
type: "list",
data: items,
schema: modeListSchema,
}
};
}
if (!normalizedMode) {
throw missingModeError()
throw missingModeError();
}
// Set the agent mode
await client.setAgentMode(resolvedId, normalizedMode)
await client.setAgentMode(resolvedId, normalizedMode);
return {
type: 'single',
type: "single",
data: {
agentId: resolvedId.slice(0, 7),
mode: normalizedMode,
},
schema: setModeSchema,
}
};
} catch (err) {
// Re-throw if it's already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
if (!client) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'MODE_OPERATION_FAILED',
message: `Failed to ${options.list ? 'list modes' : 'set mode'}: ${message}`,
}
throw error
code: "MODE_OPERATION_FAILED",
message: `Failed to ${options.list ? "list modes" : "set mode"}: ${message}`,
};
throw error;
} finally {
if (client) {
await client.close().catch(() => {})
await client.close().catch(() => {});
}
}
}

View File

@@ -1,331 +1,338 @@
import type { Command } from 'commander'
import type { Command } 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'
} 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";
/** 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
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',
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 },
{ 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
name?: string
provider?: string
model?: string
thinking?: string
mode?: string
worktree?: string
base?: string
image?: string[]
cwd?: string
label?: string[]
waitTimeout?: string
outputSchema?: string
detach?: boolean;
name?: string;
provider?: string;
model?: string;
thinking?: string;
mode?: string;
worktree?: string;
base?: string;
image?: string[];
cwd?: string;
label?: string[];
waitTimeout?: string;
outputSchema?: string;
}
interface ResolvedProviderModel {
provider: string
model: string | undefined
provider: string;
model: string | undefined;
}
function toRunResult(
agent: AgentSnapshotPayload,
statusOverride?: AgentRunResult['status']
statusOverride?: AgentRunResult["status"],
): AgentRunResult {
return {
agentId: agent.id,
status: statusOverride ?? (agent.status === 'running' ? 'running' : 'created'),
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()
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
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('{')) {
let source = trimmed;
if (!trimmed.startsWith("{")) {
try {
source = readFileSync(resolve(trimmed), 'utf8')
source = readFileSync(resolve(trimmed), "utf8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
code: "INVALID_OUTPUT_SCHEMA",
message: `Failed to read output schema file: ${trimmed}`,
details: message,
}
throw error
};
throw error;
}
}
let parsed: unknown
let parsed: unknown;
try {
parsed = JSON.parse(source)
parsed = JSON.parse(source);
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Failed to parse output schema JSON',
code: "INVALID_OUTPUT_SCHEMA",
message: "Failed to parse output schema JSON",
details: message,
}
throw error
};
throw error;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
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
code: "INVALID_OUTPUT_SCHEMA",
message: "Output schema must be a JSON object",
};
throw error;
}
return parsed as Record<string, unknown>
return parsed as Record<string, unknown>;
}
class StructuredRunStatusError extends Error {
readonly kind: 'timeout' | 'permission' | 'error' | 'empty'
readonly kind: "timeout" | "permission" | "error" | "empty";
constructor(kind: 'timeout' | 'permission' | 'error' | 'empty', message: string) {
super(message)
this.name = 'StructuredRunStatusError'
this.kind = kind
constructor(kind: "timeout" | "permission" | "error" | "empty", message: string) {
super(message);
this.name = "StructuredRunStatusError";
this.kind = kind;
}
}
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>;
export interface StructuredResponseTimelineClient {
fetchAgentTimeline: ConnectedDaemonClient['fetchAgentTimeline']
fetchAgentTimeline: ConnectedDaemonClient["fetchAgentTimeline"];
}
export async function resolveStructuredResponseMessage(options: {
client: StructuredResponseTimelineClient
agentId: string
lastMessage: string | null
client: StructuredResponseTimelineClient;
agentId: string;
lastMessage: string | null;
}): Promise<string | null> {
const direct = options.lastMessage?.trim()
const direct = options.lastMessage?.trim();
if (direct) {
return direct
return direct;
}
try {
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
direction: 'tail',
projection: 'projected',
direction: "tail",
projection: "projected",
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 entry = timeline.entries[index];
if (!entry || entry.item.type !== "assistant_message") {
continue;
}
const text = entry.item.text.trim()
const text = entry.item.text.trim();
if (text.length > 0) {
return text
return text;
}
}
} catch {
// Leave empty; caller will surface a consistent structured-output failure message.
}
return null
return null;
}
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
return {
...agentRunSchema,
serialize: () => output,
}
};
}
export function resolveProviderAndModel(options: Pick<AgentRunOptions, 'provider' | 'model'>): ResolvedProviderModel {
const providerInput = options.provider?.trim() || 'claude'
const modelInput = options.model?.trim()
export function resolveProviderAndModel(
options: Pick<AgentRunOptions, "provider" | "model">,
): ResolvedProviderModel {
const providerInput = options.provider?.trim() || "claude";
const modelInput = options.model?.trim();
if (options.model !== undefined && !modelInput) {
const error: CommandError = {
code: 'INVALID_MODEL',
message: '--model cannot be empty',
}
throw error
code: "INVALID_MODEL",
message: "--model cannot be empty",
};
throw error;
}
const slashIndex = providerInput.indexOf('/')
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return {
provider: providerInput,
model: modelInput,
}
};
}
const provider = providerInput.slice(0, slashIndex).trim()
const modelFromProvider = providerInput.slice(slashIndex + 1).trim()
const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) {
const error: CommandError = {
code: 'INVALID_PROVIDER',
message: 'Invalid --provider value',
details: 'Use --provider <provider> or --provider <provider>/<model>',
}
throw error
code: "INVALID_PROVIDER",
message: "Invalid --provider value",
details: "Use --provider <provider> or --provider <provider>/<model>",
};
throw error;
}
if (modelInput && modelInput !== modelFromProvider) {
const error: CommandError = {
code: 'CONFLICTING_MODEL_OPTIONS',
message: 'Conflicting model values provided',
code: "CONFLICTING_MODEL_OPTIONS",
message: "Conflicting model values provided",
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
}
throw error
};
throw error;
}
return {
provider,
model: modelInput ?? modelFromProvider,
}
};
}
export async function runRunCommand(
prompt: string,
options: AgentRunOptions,
_command: Command
_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
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
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
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
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)
waitTimeoutMs = parseDuration(options.waitTimeout);
if (waitTimeoutMs <= 0) {
throw new Error('Timeout must be positive')
throw new Error("Timeout must be positive");
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INVALID_TIMEOUT',
message: 'Invalid wait timeout value',
code: "INVALID_TIMEOUT",
message: "Invalid wait timeout value",
details: message,
}
throw error
};
throw error;
}
}
const resolvedProviderModel = resolveProviderAndModel(options)
const resolvedProviderModel = resolveProviderAndModel(options);
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
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()
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',
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
};
throw error;
}
// Process images if provided
let images: Array<{ data: string; mimeType: string }> | undefined
let images: Array<{ data: string; mimeType: string }> | undefined;
if (options.image && options.image.length > 0) {
images = options.image.map((imagePath) => {
const resolvedPath = resolve(imagePath)
const resolvedPath = resolve(imagePath);
try {
const imageData = readFileSync(resolvedPath)
const mimeType = lookup(resolvedPath) || 'application/octet-stream'
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})`)
if (!mimeType.startsWith("image/")) {
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`);
}
return {
data: imageData.toString('base64'),
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}`)
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
@@ -335,34 +342,34 @@ export async function runRunCommand(
worktreeSlug: options.worktree,
baseBranch: options.base,
}
: undefined
: undefined;
// Build labels from --label flags
const labels: Record<string, string> = {}
const labels: Record<string, string> = {};
if (options.label) {
for (const labelStr of options.label) {
const eqIndex = labelStr.indexOf('=')
const eqIndex = labelStr.indexOf("=");
if (eqIndex === -1) {
const error: CommandError = {
code: 'INVALID_LABEL',
code: "INVALID_LABEL",
message: `Invalid label format: ${labelStr}`,
details: 'Labels must be in key=value format',
}
throw error
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 key = labelStr.slice(0, eqIndex);
const value = labelStr.slice(eqIndex + 1);
labels[key] = value;
}
}
if (outputSchema) {
let structuredAgent: AgentSnapshotPayload | null = null
let structuredAgent: AgentSnapshotPayload | null = null;
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
if (!structuredAgent) {
structuredAgent = await client.createAgent({
provider: resolvedProviderModel.provider as 'claude' | 'codex' | 'opencode',
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
cwd,
title: options.name,
modeId: options.mode,
@@ -374,94 +381,94 @@ export async function runRunCommand(
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
});
} else {
await client.sendMessage(structuredAgent.id, structuredPrompt)
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')
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') {
if (state.status === "permission") {
throw new StructuredRunStatusError(
'permission',
'Agent is waiting for permission before producing structured output'
)
"permission",
"Agent is waiting for permission before producing structured output",
);
}
if (state.status === 'error') {
if (state.status === "error") {
throw new StructuredRunStatusError(
'error',
state.error ?? 'Agent failed before producing structured output'
)
"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'
)
"empty",
"Agent finished without a structured output message",
);
}
return lastMessage
}
return lastMessage;
};
let output: Record<string, unknown>
let output: Record<string, unknown>;
try {
output = await getStructuredAgentResponse<Record<string, unknown>>({
caller: callStructuredTurn,
prompt,
schema: outputSchema,
schemaName: 'RunOutput',
schemaName: "RunOutput",
maxRetries: 2,
})
});
} catch (err) {
if (err instanceof StructuredRunStatusError) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
code: "OUTPUT_SCHEMA_FAILED",
message: err.message,
}
throw error
};
throw error;
}
if (err instanceof StructuredAgentResponseError) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response did not match the required output schema',
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
? err.validationErrors.join("\n")
: err.lastResponse || "No response",
};
throw error;
}
throw err
throw err;
}
if (!structuredAgent) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent finished without a structured output message',
}
throw error
code: "OUTPUT_SCHEMA_FAILED",
message: "Agent finished without a structured output message",
};
throw error;
}
await client.close()
await client.close();
return {
type: 'single',
data: toRunResult(structuredAgent, 'completed'),
type: "single",
data: toRunResult(structuredAgent, "completed"),
schema: structuredRunSchema(output),
}
};
}
// Create the agent
const agent = await client.createAgent({
provider: resolvedProviderModel.provider as 'claude' | 'codex' | 'opencode',
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
cwd,
title: options.name,
modeId: options.mode,
@@ -472,43 +479,42 @@ export async function runRunCommand(
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 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
const finalAgent = state.final ?? agent;
const status: AgentRunResult["status"] = state.status === "idle" ? "completed" : state.status;
return {
type: 'single',
type: "single",
data: toRunResult(finalAgent, status),
schema: agentRunSchema,
}
};
}
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: toRunResult(agent),
schema: agentRunSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'AGENT_CREATE_FAILED',
code: "AGENT_CREATE_FAILED",
message: `Failed to create agent: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,129 +1,137 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import { readFile } from 'node:fs/promises'
import { extname, resolve } from 'node:path'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { readFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
/** Result type for agent send command */
export interface AgentSendResult {
agentId: string
status: 'sent' | 'completed' | 'timeout' | 'permission' | 'error'
message: string
agentId: string;
status: "sent" | "completed" | "timeout" | "permission" | "error";
message: string;
}
/** Schema for agent send output */
export const agentSendSchema: OutputSchema<AgentSendResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'STATUS', field: 'status', width: 12 },
{ header: 'MESSAGE', field: 'message', width: 40 },
{ header: "AGENT ID", field: "agentId", width: 12 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "MESSAGE", field: "message", width: 40 },
],
}
};
export interface AgentSendOptions extends CommandOptions {
noWait?: boolean
image?: string[]
prompt?: string
promptFile?: string
noWait?: boolean;
image?: string[];
prompt?: string;
promptFile?: string;
}
/**
* Read image files and convert them to base64 data URIs
*/
async function readImageFiles(imagePaths: string[]): Promise<Array<{ data: string; mimeType: string }>> {
const images: Array<{ data: string; mimeType: string }> = []
async function readImageFiles(
imagePaths: string[],
): Promise<Array<{ data: string; mimeType: string }>> {
const images: Array<{ data: string; mimeType: string }> = [];
for (const path of imagePaths) {
try {
const buffer = await readFile(path)
const ext = extname(path).toLowerCase()
const buffer = await readFile(path);
const ext = extname(path).toLowerCase();
// Determine media type from extension
let mimeType = 'image/jpeg'
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
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'
mimeType = "image/jpeg";
}
const data = buffer.toString('base64')
const data = buffer.toString("base64");
images.push({
data,
mimeType,
})
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'IMAGE_READ_ERROR',
code: "IMAGE_READ_ERROR",
message: `Failed to read image file: ${path}`,
details: message,
}
throw error
};
throw error;
}
}
return images
return images;
}
async function resolvePromptInput(options: {
promptArgument: string | undefined
promptOption: string | undefined
promptFile: string | undefined
promptArgument: string | undefined;
promptOption: string | undefined;
promptFile: string | undefined;
}): Promise<string> {
const promptText = options.promptArgument?.trim()
const promptOptionText = options.promptOption?.trim()
const promptFilePath = options.promptFile?.trim()
const providedSourceCount = [promptText, promptOptionText, promptFilePath].filter(Boolean).length
const promptText = options.promptArgument?.trim();
const promptOptionText = options.promptOption?.trim();
const promptFilePath = options.promptFile?.trim();
const providedSourceCount = [promptText, promptOptionText, promptFilePath].filter(Boolean).length;
if (providedSourceCount > 1) {
const error: CommandError = {
code: 'CONFLICTING_PROMPT_INPUT',
message: 'Provide exactly one of prompt argument, --prompt, or --prompt-file',
}
throw error
code: "CONFLICTING_PROMPT_INPUT",
message: "Provide exactly one of prompt argument, --prompt, or --prompt-file",
};
throw error;
}
if (promptText) {
return options.promptArgument as string
return options.promptArgument as string;
}
if (promptOptionText) {
return options.promptOption as string
return options.promptOption as string;
}
if (!promptFilePath) {
const error: CommandError = {
code: 'MISSING_PROMPT',
message: 'A prompt is required',
details: 'Usage: paseo agent send [options] <id> [prompt] | --prompt <text> | --prompt-file <path>',
}
throw error
code: "MISSING_PROMPT",
message: "A prompt is required",
details:
"Usage: paseo agent send [options] <id> [prompt] | --prompt <text> | --prompt-file <path>",
};
throw error;
}
try {
return await readFile(resolve(promptFilePath), 'utf8')
return await readFile(resolve(promptFilePath), "utf8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'PROMPT_FILE_READ_ERROR',
code: "PROMPT_FILE_READ_ERROR",
message: `Failed to read prompt file: ${promptFilePath}`,
details: message,
}
throw error
};
throw error;
}
}
@@ -131,126 +139,125 @@ export async function runSendCommand(
agentIdArg: string,
prompt: string | undefined,
options: AgentSendOptions,
_command: Command
_command: Command,
): Promise<SingleResult<AgentSendResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent send [options] <id> [prompt]',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent send [options] <id> [prompt]",
};
throw error;
}
const promptInput = await resolvePromptInput({
promptArgument: prompt,
promptOption: options.prompt,
promptFile: options.promptFile,
})
});
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
// Read image files if provided
const images = options.image && options.image.length > 0
? await readImageFiles(options.image)
: undefined
const images =
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
// Send the message
await client.sendAgentMessage(agentIdArg, promptInput, { images })
await client.sendAgentMessage(agentIdArg, promptInput, { images });
// If --no-wait, return immediately
if (options.noWait) {
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
agentId: agentIdArg,
status: 'sent',
message: 'Message sent, not waiting for completion',
status: "sent",
message: "Message sent, not waiting for completion",
},
schema: agentSendSchema,
}
};
}
// Wait for agent to finish
const state = await client.waitForFinish(agentIdArg, 600000) // 10 minute timeout
const state = await client.waitForFinish(agentIdArg, 600000); // 10 minute timeout
await client.close()
await client.close();
if (state.status === 'timeout') {
if (state.status === "timeout") {
return {
type: 'single',
type: "single",
data: {
agentId: state.final?.id ?? agentIdArg,
status: 'timeout',
message: 'Timed out waiting for agent to finish',
status: "timeout",
message: "Timed out waiting for agent to finish",
},
schema: agentSendSchema,
}
};
}
if (state.status === 'permission') {
if (state.status === "permission") {
return {
type: 'single',
type: "single",
data: {
agentId: state.final?.id ?? agentIdArg,
status: 'permission',
message: 'Agent is waiting for permission',
status: "permission",
message: "Agent is waiting for permission",
},
schema: agentSendSchema,
}
};
}
if (state.status === 'error') {
if (state.status === "error") {
return {
type: 'single',
type: "single",
data: {
agentId: state.final?.id ?? agentIdArg,
status: 'error',
message: state.error ?? 'Agent finished with error',
status: "error",
message: state.error ?? "Agent finished with error",
},
schema: agentSendSchema,
}
};
}
return {
type: 'single',
type: "single",
data: {
agentId: state.final?.id ?? agentIdArg,
status: 'completed',
message: 'Agent completed processing the message',
status: "completed",
message: "Agent completed processing the message",
},
schema: agentSendSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'SEND_FAILED',
code: "SEND_FAILED",
message: `Failed to send message: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,123 +1,128 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for agent stop command */
export interface StopResult {
stoppedCount: number
agentIds: string[]
stoppedCount: number;
agentIds: string[];
}
/** Schema for stop command output */
export const stopSchema: OutputSchema<StopResult> = {
// For quiet mode, output the stopped agent IDs (one per line)
idField: (item) => item.agentIds.join('\n'),
columns: [{ header: 'INTERRUPTED', field: 'stoppedCount' }],
}
idField: (item) => item.agentIds.join("\n"),
columns: [{ header: "INTERRUPTED", field: "stoppedCount" }],
};
export interface AgentStopOptions extends CommandOptions {
all?: boolean
cwd?: string
all?: boolean;
cwd?: string;
}
export type AgentStopResult = SingleResult<StopResult>
export type AgentStopResult = SingleResult<StopResult>;
export async function runStopCommand(
id: string | undefined,
options: AgentStopOptions,
_command: Command
_command: Command,
): Promise<AgentStopResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments - need either an id, --all, or --cwd
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Agent ID required unless --all or --cwd is specified',
details: 'Usage: paseo agent stop <id> | --all | --cwd <path>',
}
throw error
code: "MISSING_ARGUMENT",
message: "Agent ID required unless --all or --cwd is specified",
details: "Usage: paseo agent stop <id> | --all | --cwd <path>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } })
let agents = fetchPayload.entries.map((entry) => entry.agent)
const stoppedIds: string[] = []
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
let agents = fetchPayload.entries.map((entry) => entry.agent);
const stoppedIds: string[] = [];
if (options.all) {
// Stop all agents (not archived)
agents = agents.filter((a) => !a.archivedAt)
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = filterCwd.replace(/\/$/, '')
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
// Stop specific agent
const fetchResult = await client.fetchAgent(id)
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
details: "Use `paseo ls` to list available agents",
};
throw error;
}
agents = [fetchResult.agent]
agents = [fetchResult.agent];
}
// Interrupt each running agent. Idle agents are a no-op.
for (const agent of agents) {
try {
if (agent.status === 'running') {
await client.cancelAgent(agent.id)
stoppedIds.push(agent.id)
if (agent.status === "running") {
await client.cancelAgent(agent.id);
stoppedIds.push(agent.id);
}
} 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}`)
const message = err instanceof Error ? err.message : String(err);
console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`);
}
}
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
stoppedCount: stoppedIds.length,
agentIds: stoppedIds,
},
schema: stopSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw if it's already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'STOP_AGENT_FAILED',
code: "STOP_AGENT_FAILED",
message: `Failed to stop agent(s): ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,177 +1,182 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for agent update command */
export interface AgentUpdateResult {
agentId: string
name: string | null
labels: string
agentId: string;
name: string | null;
labels: string;
}
/** Schema for update command output */
export const updateSchema: OutputSchema<AgentUpdateResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'NAME', field: 'name' },
{ header: 'LABELS', field: 'labels' },
{ header: "AGENT ID", field: "agentId" },
{ header: "NAME", field: "name" },
{ header: "LABELS", field: "labels" },
],
}
};
export interface AgentUpdateOptions extends CommandOptions {
name?: string
label?: string[]
host?: string
name?: string;
label?: string[];
host?: string;
}
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>;
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
const parsed: Record<string, string> = {}
const parsed: Record<string, string> = {};
if (!labels) {
return parsed
return parsed;
}
for (const rawLabel of labels) {
for (const segment of rawLabel.split(',')) {
const label = segment.trim()
for (const segment of rawLabel.split(",")) {
const label = segment.trim();
if (!label) {
continue
continue;
}
const eqIndex = label.indexOf('=')
const eqIndex = label.indexOf("=");
if (eqIndex === -1) {
const error: CommandError = {
code: 'INVALID_LABEL',
code: "INVALID_LABEL",
message: `Invalid label format: ${label}`,
details: 'Labels must be in key=value format',
}
throw error
details: "Labels must be in key=value format",
};
throw error;
}
const key = label.slice(0, eqIndex).trim()
const value = label.slice(eqIndex + 1)
const key = label.slice(0, eqIndex).trim();
const value = label.slice(eqIndex + 1);
if (!key) {
const error: CommandError = {
code: 'INVALID_LABEL',
code: "INVALID_LABEL",
message: `Invalid label format: ${label}`,
details: 'Labels must include a non-empty key in key=value format',
}
throw error
details: "Labels must include a non-empty key in key=value format",
};
throw error;
}
parsed[key] = value
parsed[key] = value;
}
}
return parsed
return parsed;
}
function formatLabels(labels: Record<string, string>): string {
const entries = Object.entries(labels)
const entries = Object.entries(labels);
if (entries.length === 0) {
return '-'
return "-";
}
return entries.map(([key, value]) => `${key}=${value}`).join(',')
return entries.map(([key, value]) => `${key}=${value}`).join(",");
}
export async function runUpdateCommand(
agentIdArg: string,
options: AgentUpdateOptions,
_command: Command
_command: Command,
): Promise<AgentUpdateCommandResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent update <id> [--name <name>] [--label <key=value>]',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent update <id> [--name <name>] [--label <key=value>]",
};
throw error;
}
const name = options.name?.trim()
const name = options.name?.trim();
if (options.name !== undefined && !name) {
const error: CommandError = {
code: 'INVALID_NAME',
message: 'Name cannot be empty',
details: 'Use --name <name> with a non-empty value',
}
throw error
code: "INVALID_NAME",
message: "Name cannot be empty",
details: "Use --name <name> with a non-empty value",
};
throw error;
}
const labels = parseLabelOptions(options.label)
const labels = parseLabelOptions(options.label);
if (!name && Object.keys(labels).length === 0) {
const error: CommandError = {
code: 'NO_CHANGES_PROVIDED',
message: 'Nothing to update',
details: 'Provide at least one of: --name <name>, --label <key=value>',
}
throw error
code: "NO_CHANGES_PROVIDED",
message: "Nothing to update",
details: "Provide at least one of: --name <name>, --label <key=value>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchResult = await client.fetchAgent(agentIdArg)
const fetchResult = await client.fetchAgent(agentIdArg);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
const agentId = fetchResult.agent.id
const agentId = fetchResult.agent.id;
await client.updateAgent(agentId, {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
})
});
const updatedResult = await client.fetchAgent(agentId)
const updatedResult = await client.fetchAgent(agentId);
if (!updatedResult) {
throw new Error(`Agent not found after update: ${agentId}`)
throw new Error(`Agent not found after update: ${agentId}`);
}
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
agentId,
name: updatedResult.agent.title,
labels: formatLabels(updatedResult.agent.labels),
},
schema: updateSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'UPDATE_FAILED',
code: "UPDATE_FAILED",
message: `Failed to update agent: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,193 +1,198 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import { fetchAgentTimelineItems, formatAgentActivityTranscript } from './logs.js'
import { parseDuration } from '../../utils/duration.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { fetchAgentTimelineItems, formatAgentActivityTranscript } from "./logs.js";
import { parseDuration } from "../../utils/duration.js";
/** Result type for agent wait command */
export interface AgentWaitResult {
agentId: string
status: 'idle' | 'timeout' | 'permission' | 'error'
message: string
agentId: string;
status: "idle" | "timeout" | "permission" | "error";
message: string;
}
/** Schema for agent wait output */
export const agentWaitSchema: OutputSchema<AgentWaitResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'STATUS', field: 'status', width: 12 },
{ header: 'MESSAGE', field: 'message', width: 40 },
{ header: "AGENT ID", field: "agentId", width: 12 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "MESSAGE", field: "message", width: 40 },
],
}
};
export interface AgentWaitOptions extends CommandOptions {
timeout?: string
host?: string
timeout?: string;
host?: string;
}
const WAIT_ACTIVITY_PREVIEW_COUNT = 5
const WAIT_ACTIVITY_PREVIEW_COUNT = 5;
function appendRecentActivity(message: string, transcript: string | null): string {
if (!transcript || transcript.trim().length === 0) {
return message
return message;
}
return `${message}\nLast ${WAIT_ACTIVITY_PREVIEW_COUNT} activity items:\n${transcript}`
return `${message}\nLast ${WAIT_ACTIVITY_PREVIEW_COUNT} activity items:\n${transcript}`;
}
async function getRecentActivityTranscript(
client: Awaited<ReturnType<typeof connectToDaemon>>,
agentId: string
agentId: string,
): Promise<string | null> {
try {
const timelineItems = await fetchAgentTimelineItems(client, agentId)
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT)
const timelineItems = await fetchAgentTimelineItems(client, agentId);
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT);
} catch {
return null
return null;
}
}
export async function runWaitCommand(
agentIdArg: string,
options: AgentWaitOptions,
_command: Command
_command: Command,
): Promise<SingleResult<AgentWaitResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent wait <id>',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent wait <id>",
};
throw error;
}
// Parse timeout (no limit unless explicitly provided)
let timeoutMs = 0
let timeoutLabel: string | null = null
let timeoutMs = 0;
let timeoutLabel: string | null = null;
if (options.timeout) {
try {
timeoutMs = parseDuration(options.timeout)
timeoutMs = parseDuration(options.timeout);
if (timeoutMs <= 0) {
throw new Error('Timeout must be positive')
throw new Error("Timeout must be positive");
}
const timeoutSeconds = Math.floor(timeoutMs / 1000)
timeoutLabel = `${timeoutSeconds} second${timeoutSeconds === 1 ? '' : 's'}`
const timeoutSeconds = Math.floor(timeoutMs / 1000);
timeoutLabel = `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`;
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INVALID_TIMEOUT',
message: 'Invalid timeout value',
code: "INVALID_TIMEOUT",
message: "Invalid timeout value",
details: message,
}
throw error
};
throw error;
}
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
try {
const state = await client.waitForFinish(agentIdArg, timeoutMs)
const resolvedAgentId = state.final?.id ?? agentIdArg
const state = await client.waitForFinish(agentIdArg, timeoutMs);
const resolvedAgentId = state.final?.id ?? agentIdArg;
const recentActivity =
state.status === 'timeout' || state.status === 'idle'
state.status === "timeout" || state.status === "idle"
? await getRecentActivityTranscript(client, resolvedAgentId)
: null
: null;
await client.close()
await client.close();
if (state.status === 'timeout') {
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.`
: `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`;
return {
type: 'single',
type: "single",
data: {
agentId: resolvedAgentId,
status: 'timeout',
status: "timeout",
message: appendRecentActivity(timeoutMessage, recentActivity),
},
schema: agentWaitSchema,
}
};
}
if (state.status === 'permission') {
const permission = state.final?.pendingPermissions?.[0]
if (state.status === "permission") {
const permission = state.final?.pendingPermissions?.[0];
return {
type: 'single',
type: "single",
data: {
agentId: resolvedAgentId,
status: 'permission',
status: "permission",
message: permission
? `Agent is waiting for permission: ${permission.kind}`
: 'Agent is waiting for permission',
: "Agent is waiting for permission",
},
schema: agentWaitSchema,
}
};
}
if (state.status === 'error') {
if (state.status === "error") {
return {
type: 'single',
type: "single",
data: {
agentId: resolvedAgentId,
status: 'error',
message: state.error ?? 'Agent finished with error',
status: "error",
message: state.error ?? "Agent finished with error",
},
schema: agentWaitSchema,
}
};
}
// Agent is idle
return {
type: 'single',
type: "single",
data: {
agentId: resolvedAgentId,
status: 'idle',
message: appendRecentActivity('Agent is idle.', recentActivity),
status: "idle",
message: appendRecentActivity("Agent is idle.", recentActivity),
},
schema: agentWaitSchema,
}
};
} catch (waitErr) {
await client.close().catch(() => {})
await client.close().catch(() => {});
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr)
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr);
// Other errors
const error: CommandError = {
code: 'WAIT_FAILED',
code: "WAIT_FAILED",
message: `Failed to wait for agent: ${waitMessage}`,
}
throw error
};
throw error;
}
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'WAIT_FAILED',
code: "WAIT_FAILED",
message: `Failed to wait for agent: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,53 +1,44 @@
import { Command } from 'commander'
import { startCommand } from './start.js'
import { runStatusCommand } from './status.js'
import { runStopCommand } from './stop.js'
import { runRestartCommand } from './restart.js'
import { pairCommand } from './pair.js'
import { withOutput } from '../../output/index.js'
import { addJsonOption } from '../../utils/command-options.js'
import { Command } from "commander";
import { startCommand } from "./start.js";
import { runStatusCommand } from "./status.js";
import { runStopCommand } from "./stop.js";
import { runRestartCommand } from "./restart.js";
import { pairCommand } from "./pair.js";
import { withOutput } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
export function createDaemonCommand(): Command {
const daemon = new Command('daemon').description('Manage the Paseo daemon')
const daemon = new Command("daemon").description("Manage the Paseo daemon");
daemon.addCommand(startCommand())
daemon.addCommand(pairCommand())
daemon.addCommand(startCommand());
daemon.addCommand(pairCommand());
addJsonOption(
daemon
.command('status')
.description('Show local daemon status')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(withOutput(runStatusCommand))
addJsonOption(daemon.command("status").description("Show local daemon status"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runStatusCommand));
addJsonOption(
daemon
.command('stop')
.description('Stop the local daemon')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before failing (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.action(withOutput(runStopCommand))
addJsonOption(daemon.command("stop").description("Stop the local daemon"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before failing (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.action(withOutput(runStopCommand));
addJsonOption(
daemon
.command('restart')
.description('Restart the local daemon')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before force step (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.option('--listen <listen>', 'Listen target for restarted daemon (host:port, port, or unix socket)')
.option('--port <port>', 'Port for restarted daemon listen target')
.option('--no-relay', 'Disable relay on restarted daemon')
.option('--no-mcp', 'Disable Agent MCP on restarted daemon')
addJsonOption(daemon.command("restart").description("Restart the local daemon"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
"--listen <listen>",
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.action(withOutput(runRestartCommand))
.option("--port <port>", "Port for restarted daemon listen target")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
)
.action(withOutput(runRestartCommand));
return daemon
return daemon;
}

View File

@@ -1,312 +1,313 @@
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
export interface DaemonStartOptions {
port?: string
listen?: string
home?: string
foreground?: boolean
relay?: boolean
mcp?: boolean
allowedHosts?: string
port?: string;
listen?: string;
home?: string;
foreground?: boolean;
relay?: boolean;
mcp?: boolean;
allowedHosts?: string;
}
export interface LocalDaemonPidInfo {
pid: number
startedAt?: string
hostname?: string
uid?: number
listen?: string
pid: number;
startedAt?: string;
hostname?: string;
uid?: number;
listen?: string;
}
export interface LocalDaemonState {
home: string
listen: string
logPath: string
pidPath: string
pidInfo: LocalDaemonPidInfo | null
running: boolean
stalePidFile: boolean
home: string;
listen: string;
logPath: string;
pidPath: string;
pidInfo: LocalDaemonPidInfo | null;
running: boolean;
stalePidFile: boolean;
}
export interface DetachedStartResult {
pid: number | null
logPath: string
pid: number | null;
logPath: string;
}
export interface StopLocalDaemonOptions {
home?: string
timeoutMs?: number
force?: boolean
home?: string;
timeoutMs?: number;
force?: boolean;
}
export interface StopLocalDaemonResult {
action: 'stopped' | 'not_running'
home: string
pid: number | null
forced: boolean
message: string
action: "stopped" | "not_running";
home: string;
pid: number | null;
forced: boolean;
message: string;
}
type ProcessExitDetails = {
code: number | null
signal: NodeJS.Signals | null
error?: Error
}
code: number | null;
signal: NodeJS.Signals | null;
error?: Error;
};
type DetachedStartupResult =
| { exitedEarly: false }
| ({ exitedEarly: true } & ProcessExitDetails)
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
const DETACHED_STARTUP_GRACE_MS = 1200
const PID_POLL_INTERVAL_MS = 100
const KILL_TIMEOUT_MS = 3000
const DAEMON_LOG_FILENAME = 'daemon.log'
const DAEMON_PID_FILENAME = 'paseo.pid'
const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const KILL_TIMEOUT_MS = 3000;
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
export const DEFAULT_STOP_TIMEOUT_MS = 15_000
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
const require = createRequire(import.meta.url)
const require = createRequire(import.meta.url);
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false })
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
exitedEarly: true,
...details,
})
});
function envWithHome(home?: string): NodeJS.ProcessEnv {
if (!home) {
return process.env
return process.env;
}
return { ...process.env, PASEO_HOME: home }
return { ...process.env, PASEO_HOME: home };
}
function buildRunnerArgs(options: DaemonStartOptions): string[] {
const args: string[] = []
const args: string[] = [];
if (options.relay === false) {
args.push('--no-relay')
args.push("--no-relay");
}
if (options.mcp === false) {
args.push('--no-mcp')
args.push("--no-mcp");
}
return args
return args;
}
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = { ...process.env }
const childEnv: NodeJS.ProcessEnv = { ...process.env };
if (options.home) {
childEnv.PASEO_HOME = options.home
childEnv.PASEO_HOME = options.home;
}
if (options.listen) {
childEnv.PASEO_LISTEN = options.listen
childEnv.PASEO_LISTEN = options.listen;
} else if (options.port) {
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
}
if (options.allowedHosts) {
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts;
}
return childEnv
return childEnv;
}
function resolveDaemonRunnerEntry(): string {
const serverExportPath = require.resolve('@getpaseo/server')
let currentDir = path.dirname(serverExportPath)
const serverExportPath = require.resolve("@getpaseo/server");
let currentDir = path.dirname(serverExportPath);
while (true) {
const packageJsonPath = path.join(currentDir, 'package.json')
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', 'daemon-runner.js')
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
if (packageJson.name === "@getpaseo/server") {
const distRunner = path.join(currentDir, "dist", "scripts", "daemon-runner.js");
if (existsSync(distRunner)) {
return distRunner
return distRunner;
}
return path.join(currentDir, 'scripts', 'daemon-runner.ts')
return path.join(currentDir, "scripts", "daemon-runner.ts");
}
} catch {
// Continue searching up if package.json exists but is invalid.
}
}
const parentDir = path.dirname(currentDir)
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break
break;
}
currentDir = parentDir
currentDir = parentDir;
}
throw new Error('Unable to resolve @getpaseo/server package root for daemon runner')
throw new Error("Unable to resolve @getpaseo/server package root for daemon runner");
}
function pidFilePath(paseoHome: string): string {
return path.join(paseoHome, DAEMON_PID_FILENAME)
return path.join(paseoHome, DAEMON_PID_FILENAME);
}
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
try {
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as Record<string, unknown>
const pidValue = parsed.pid
if (typeof pidValue !== 'number' || !Number.isInteger(pidValue) || pidValue <= 0) {
return null
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
const pidValue = parsed.pid;
if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) {
return null;
}
return {
pid: pidValue,
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,
}
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,
};
} catch {
return null
return null;
}
}
function tailFile(filePath: string, lines = 30): string | null {
try {
const content = readFileSync(filePath, 'utf-8')
return content.split('\n').filter(Boolean).slice(-lines).join('\n')
const content = readFileSync(filePath, "utf-8");
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
} catch {
return null
return null;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
setTimeout(resolve, ms);
});
}
function readNodeErrnoCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return undefined
if (typeof error !== "object" || error === null || !("code" in error)) {
return undefined;
}
return typeof error.code === 'string' ? error.code : undefined
return typeof error.code === "string" ? error.code : undefined;
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0)
return true
process.kill(pid, 0);
return true;
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'EPERM') {
return true
const code = readNodeErrnoCode(err);
if (code === "EPERM") {
return true;
}
return false
return false;
}
}
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal)
return true
process.kill(pid, signal);
return true;
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'ESRCH') {
return false
const code = readNodeErrnoCode(err);
if (code === "ESRCH") {
return false;
}
throw err
throw err;
}
}
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false
return false;
}
try {
return signalProcess(pid, signal)
return signalProcess(pid, signal);
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'EPERM') {
return true
const code = readNodeErrnoCode(err);
if (code === "EPERM") {
return true;
}
throw err
throw err;
}
}
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false
return false;
}
if (process.platform === 'win32') {
return signalProcessSafely(pid, signal)
if (process.platform === "win32") {
return signalProcessSafely(pid, signal);
}
try {
process.kill(-pid, signal)
return true
process.kill(-pid, signal);
return true;
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'ESRCH') {
return signalProcessSafely(pid, signal)
const code = readNodeErrnoCode(err);
if (code === "ESRCH") {
return signalProcessSafely(pid, signal);
}
if (code === 'EPERM') {
return true
if (code === "EPERM") {
return true;
}
throw err
throw err;
}
}
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
return true
return true;
}
await sleep(PID_POLL_INTERVAL_MS)
await sleep(PID_POLL_INTERVAL_MS);
}
return !isProcessRunning(pid)
return !isProcessRunning(pid);
}
type LifecycleShutdownAttempt =
| { requested: true }
| { requested: false; reason: string }
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
return error instanceof Error ? error.message : String(error);
}
export function resolveLocalPaseoHome(home?: string): string {
return resolvePaseoHome(envWithHome(home))
return resolvePaseoHome(envWithHome(home));
}
export function resolveTcpHostFromListen(listen: string): string | null {
const normalized = listen.trim()
const normalized = listen.trim();
if (!normalized) {
return null
return null;
}
if (
normalized.startsWith('/') ||
normalized.startsWith('unix://') ||
normalized.startsWith('pipe://') ||
normalized.startsWith('\\\\.\\pipe\\')
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\")
) {
return null
return null;
}
if (/^\d+$/.test(normalized)) {
return `127.0.0.1:${normalized}`
return `127.0.0.1:${normalized}`;
}
if (normalized.includes(':')) {
return normalized
if (normalized.includes(":")) {
return normalized;
}
return null
return null;
}
export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState {
@@ -315,14 +316,14 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
PASEO_LISTEN: undefined,
PASEO_ALLOWED_HOSTS: undefined,
}
const home = resolvePaseoHome(env)
const config = loadConfig(home, { env })
const pidPath = pidFilePath(home)
const logPath = path.join(home, DAEMON_LOG_FILENAME)
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false
const listen = pidInfo?.listen ?? config.listen
};
const home = resolvePaseoHome(env);
const config = loadConfig(home, { env });
const pidPath = pidFilePath(home);
const logPath = path.join(home, DAEMON_LOG_FILENAME);
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null;
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false;
const listen = pidInfo?.listen ?? config.listen;
return {
home,
@@ -332,199 +333,197 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
pidInfo,
running,
stalePidFile: Boolean(pidInfo) && !running,
}
};
}
export function tailDaemonLog(home?: string, lines = 30): string | null {
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME)
return tailFile(logPath, lines)
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME);
return tailFile(logPath, lines);
}
export async function startLocalDaemonDetached(
options: DaemonStartOptions
options: DaemonStartOptions,
): Promise<DetachedStartResult> {
if (options.listen && options.port) {
throw new Error('Cannot use --listen and --port together')
throw new Error("Cannot use --listen and --port together");
}
const childEnv = buildChildEnv(options)
const childEnv = buildChildEnv(options);
const paseoHome = resolvePaseoHome(childEnv)
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
const daemonRunnerEntry = resolveDaemonRunnerEntry()
const paseoHome = resolvePaseoHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const child = spawn(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
detached: true,
env: childEnv,
stdio: ['ignore', 'ignore', 'ignore'],
}
)
stdio: ["ignore", "ignore", "ignore"],
},
);
child.unref()
child.unref();
const startup = await new Promise<DetachedStartupResult>((resolve) => {
let settled = false
let settled = false;
const finish = (value: DetachedStartupResult) => {
if (settled) return
settled = true
resolve(value)
}
if (settled) return;
settled = true;
resolve(value);
};
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS)
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS);
child.once('error', (error) => {
clearTimeout(timer)
finish(startupExited({ code: null, signal: null, error }))
})
child.once("error", (error) => {
clearTimeout(timer);
finish(startupExited({ code: null, signal: null, error }));
});
child.once('exit', (code, signal) => {
clearTimeout(timer)
finish(startupExited({ code, signal }))
})
})
child.once("exit", (code, signal) => {
clearTimeout(timer);
finish(startupExited({ code, signal }));
});
});
if (startup.exitedEarly) {
const reason = startup.error
? startup.error.message
: `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}`
const recentLogs = tailFile(logPath)
: `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`;
const recentLogs = tailFile(logPath);
throw new Error(
[
`Daemon failed to start in background (${reason}).`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join('\n\n')
)
.join("\n\n"),
);
}
return {
pid: child.pid ?? null,
logPath,
}
};
}
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
if (options.listen && options.port) {
throw new Error('Cannot use --listen and --port together')
throw new Error("Cannot use --listen and --port together");
}
const childEnv = buildChildEnv(options)
const daemonRunnerEntry = resolveDaemonRunnerEntry()
const childEnv = buildChildEnv(options);
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const result = spawnSync(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
env: childEnv,
stdio: 'inherit',
}
)
stdio: "inherit",
},
);
if (result.error) {
throw result.error
throw result.error;
}
return result.status ?? 1
return result.status ?? 1;
}
async function requestLifecycleShutdown(
state: LocalDaemonState,
timeoutMs: number
timeoutMs: number,
): Promise<LifecycleShutdownAttempt> {
const host = resolveTcpHostFromListen(state.listen)
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return {
requested: false,
reason: 'daemon listen target is not TCP, falling back to owner PID signal',
}
reason: "daemon listen target is not TCP, falling back to owner PID signal",
};
}
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) })
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) });
if (!client) {
return {
requested: false,
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
}
};
}
try {
await client.shutdownServer()
return { requested: true }
await client.shutdownServer();
return { requested: true };
} catch (error) {
return {
requested: false,
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
error
error,
)}), falling back to owner PID signal`,
}
};
} finally {
await client.close().catch(() => undefined)
await client.close().catch(() => undefined);
}
}
export async function stopLocalDaemon(
options: StopLocalDaemonOptions = {}
options: StopLocalDaemonOptions = {},
): Promise<StopLocalDaemonResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
const state = resolveLocalDaemonState({ home: options.home })
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
if (!state.pidInfo || !state.running) {
const staleSuffix =
state.stalePidFile && state.pidInfo
? ` (stale PID file for ${state.pidInfo.pid})`
: ''
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return {
action: 'not_running',
action: "not_running",
home: state.home,
pid: state.pidInfo?.pid ?? null,
forced: false,
message: `Daemon is not running${staleSuffix}`,
}
};
}
const pid = state.pidInfo.pid
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs)
const lifecycleRequested = shutdownAttempt.requested
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason
let forced = false
const pid = state.pidInfo.pid;
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
const lifecycleRequested = shutdownAttempt.requested;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
let forced = false;
if (!lifecycleRequested) {
const signaled = signalProcessSafely(pid, 'SIGTERM')
const signaled = signalProcessSafely(pid, "SIGTERM");
if (!signaled) {
return {
action: 'not_running',
action: "not_running",
home: state.home,
pid,
forced: false,
message: 'Daemon process was already stopped',
}
message: "Daemon process was already stopped",
};
}
}
let stopped = await waitForPidExit(pid, timeoutMs)
let stopped = await waitForPidExit(pid, timeoutMs);
if (!stopped && options.force) {
forced = true
signalProcessGroupSafely(pid, 'SIGKILL')
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS)
forced = true;
signalProcessGroupSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS);
}
if (!stopped) {
throw new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`
)
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
);
}
return {
action: 'stopped',
action: "stopped",
home: state.home,
pid,
forced,
message: forced
? 'Daemon owner process was force-stopped'
? "Daemon owner process was force-stopped"
: lifecycleRequested
? 'Daemon stopped gracefully'
: fallbackMessage ?? 'Daemon stopped via owner PID signal',
}
? "Daemon stopped gracefully"
: (fallbackMessage ?? "Daemon stopped via owner PID signal"),
};
}

View File

@@ -1,31 +1,28 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpaseo/server'
import { addJsonOption } from '../../utils/command-options.js'
import { Command } from "commander";
import chalk from "chalk";
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server";
import { addJsonOption } from "../../utils/command-options.js";
interface PairOptions {
home?: string
json?: boolean
home?: string;
json?: boolean;
}
export function pairCommand(): Command {
return addJsonOption(
new Command('pair')
.description('Print the daemon pairing QR code and link')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(async (_options: PairOptions, command: Command) => {
await runPairCommand(command.optsWithGlobals() as PairOptions)
})
await runPairCommand(command.optsWithGlobals() as PairOptions);
});
}
export async function runPairCommand(options: PairOptions): Promise<void> {
if (options.home) {
process.env.PASEO_HOME = options.home
process.env.PASEO_HOME = options.home;
}
const paseoHome = resolvePaseoHome()
const config = loadConfig(paseoHome)
const paseoHome = resolvePaseoHome();
const config = loadConfig(paseoHome);
const pairing = await generateLocalPairingOffer({
paseoHome,
relayEnabled: config.relayEnabled,
@@ -33,12 +30,12 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
includeQr: true,
})
});
if (!pairing.relayEnabled || !pairing.url) {
console.error(chalk.red('Relay pairing is disabled for this daemon config.'))
console.error(chalk.yellow('Enable relay and run this command again.'))
process.exit(1)
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
console.error(chalk.yellow("Enable relay and run this command again."));
process.exit(1);
}
if (options.json) {
@@ -50,12 +47,12 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
qr: pairing.qr,
},
null,
2
)}\n`
)
return
2,
)}\n`,
);
return;
}
const qrBlock = pairing.qr ? `${pairing.qr}\n` : ''
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`)
const qrBlock = pairing.qr ? `${pairing.qr}\n` : "";
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`);
}

View File

@@ -1,123 +1,129 @@
import type { Command } from 'commander'
import type { Command } from "commander";
import {
startLocalDaemonDetached,
stopLocalDaemon,
DEFAULT_STOP_TIMEOUT_MS,
type DaemonStartOptions,
} from './local-daemon.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
} from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
interface RestartResult {
action: 'restarted'
home: string
pid: string
message: string
action: "restarted";
home: string;
pid: string;
message: string;
}
const restartResultSchema: OutputSchema<RestartResult> = {
idField: 'action',
idField: "action",
columns: [
{
header: 'STATUS',
field: 'action',
color: () => 'green',
header: "STATUS",
field: "action",
color: () => "green",
},
{ header: 'HOME', field: 'home' },
{ header: 'PID', field: 'pid' },
{ header: 'MESSAGE', field: 'message' },
{ header: "HOME", field: "home" },
{ header: "PID", field: "pid" },
{ header: "MESSAGE", field: "message" },
],
}
};
export type RestartCommandResult = SingleResult<RestartResult>
export type RestartCommandResult = SingleResult<RestartResult>;
function parseTimeoutMs(raw: unknown): number {
if (typeof raw !== 'string' || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS
if (typeof raw !== "string" || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS;
}
const seconds = Number(raw)
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: 'INVALID_TIMEOUT',
code: "INVALID_TIMEOUT",
message: `Invalid timeout value: ${raw}`,
details: 'Timeout must be a positive number of seconds',
}
throw error
details: "Timeout must be a positive number of seconds",
};
throw error;
}
return Math.ceil(seconds * 1000)
return Math.ceil(seconds * 1000);
}
function toStartOptions(options: CommandOptions): DaemonStartOptions {
const startOptions: DaemonStartOptions = {
home: typeof options.home === 'string' ? options.home : undefined,
listen: typeof options.listen === 'string' ? options.listen : undefined,
port: typeof options.port === 'string' ? options.port : undefined,
relay: typeof options.relay === 'boolean' ? options.relay : undefined,
mcp: typeof options.mcp === 'boolean' ? options.mcp : undefined,
allowedHosts: typeof options.allowedHosts === 'string' ? options.allowedHosts : undefined,
}
home: typeof options.home === "string" ? options.home : undefined,
listen: typeof options.listen === "string" ? options.listen : undefined,
port: typeof options.port === "string" ? options.port : undefined,
relay: typeof options.relay === "boolean" ? options.relay : undefined,
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
};
if (startOptions.listen && startOptions.port) {
const error: CommandError = {
code: 'INVALID_OPTIONS',
message: 'Cannot use --listen and --port together',
}
throw error
code: "INVALID_OPTIONS",
message: "Cannot use --listen and --port together",
};
throw error;
}
return startOptions
return startOptions;
}
export async function runRestartCommand(
options: CommandOptions,
_command: Command
_command: Command,
): Promise<RestartCommandResult> {
const timeoutMs = parseTimeoutMs(options.timeout)
const force = options.force === true
const startOptions = toStartOptions(options)
const timeoutMs = parseTimeoutMs(options.timeout);
const force = options.force === true;
const startOptions = toStartOptions(options);
try {
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>;
try {
stopResult = await stopLocalDaemon({
home: startOptions.home,
timeoutMs,
force,
})
});
} catch (err) {
const isTimeout = err instanceof Error && err.message.includes('Timed out waiting for daemon PID')
const isTimeout =
err instanceof Error && err.message.includes("Timed out waiting for daemon PID");
if (!force && isTimeout) {
stopResult = await stopLocalDaemon({
home: startOptions.home,
timeoutMs,
force: true,
})
});
} else {
throw err
throw err;
}
}
const startup = await startLocalDaemonDetached(startOptions)
const before = stopResult.pid === null ? 'not running' : `PID ${stopResult.pid}`
const after = startup.pid === null ? 'unknown PID' : `PID ${startup.pid}`
const startup = await startLocalDaemonDetached(startOptions);
const before = stopResult.pid === null ? "not running" : `PID ${stopResult.pid}`;
const after = startup.pid === null ? "unknown PID" : `PID ${startup.pid}`;
return {
type: 'single',
type: "single",
data: {
action: 'restarted',
action: "restarted",
home: stopResult.home,
pid: startup.pid === null ? '-' : String(startup.pid),
pid: startup.pid === null ? "-" : String(startup.pid),
message: `Local daemon restarted (${before} -> ${after})`,
},
schema: restartResultSchema,
}
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'RESTART_FAILED',
code: "RESTART_FAILED",
message: `Failed to restart local daemon: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,46 +1,45 @@
import { spawnSync } from 'node:child_process'
import { spawnSync } from "node:child_process";
export interface NodePathFromPidResult {
nodePath: string | null
error?: string
nodePath: string | null;
error?: string;
}
function normalizeError(error: unknown): string {
if (error instanceof Error) {
return error.message
return error.message;
}
return String(error)
return String(error);
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
const result = spawnSync('ps', ['-o', 'comm=', '-p', String(pid)], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return {
nodePath: null,
error: `ps failed: ${normalizeError(result.error)}`,
}
};
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim()
const details = result.stderr?.trim();
return {
nodePath: null,
error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`,
}
};
}
const resolved = result.stdout.trim()
const resolved = result.stdout.trim();
if (!resolved) {
return {
nodePath: null,
error: 'ps returned an empty command path',
}
error: "ps returned an empty command path",
};
}
return { nodePath: resolved }
return { nodePath: resolved };
}

View File

@@ -1,58 +1,58 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { Command } from "commander";
import chalk from "chalk";
import {
startLocalDaemonForeground,
startLocalDaemonDetached,
type DaemonStartOptions as StartOptions,
} from './local-daemon.js'
import { getErrorMessage } from '../../utils/errors.js'
} from "./local-daemon.js";
import { getErrorMessage } from "../../utils/errors.js";
export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
export function startCommand(): Command {
return new Command('start')
.description('Start the local Paseo daemon')
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--foreground', 'Run in foreground (don\'t daemonize)')
.option('--no-relay', 'Disable relay connection')
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
return new Command("start")
.description("Start the local Paseo daemon")
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--foreground", "Run in foreground (don't daemonize)")
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
)
.action(async (options: StartOptions) => {
await runStart(options)
})
await runStart(options);
});
}
export async function runStart(options: StartOptions): Promise<void> {
if (options.listen && options.port) {
console.error(chalk.red('Cannot use --listen and --port together'))
process.exit(1)
console.error(chalk.red("Cannot use --listen and --port together"));
process.exit(1);
}
if (!options.foreground) {
try {
const startup = await startLocalDaemonDetached(options)
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? 'unknown'}).`))
console.log(chalk.dim(`Logs: ${startup.logPath}`))
const startup = await startLocalDaemonDetached(options);
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? "unknown"}).`));
console.log(chalk.dim(`Logs: ${startup.logPath}`));
} catch (err) {
exitWithError(getErrorMessage(err))
exitWithError(getErrorMessage(err));
}
return
return;
}
try {
const status = startLocalDaemonForeground(options)
process.exit(status)
const status = startLocalDaemonForeground(options);
process.exit(status);
} catch (err) {
const message = getErrorMessage(err)
exitWithError(`Failed to start daemon: ${message}`)
const message = getErrorMessage(err);
exitWithError(`Failed to start daemon: ${message}`);
}
}
function exitWithError(message: string): never {
console.error(chalk.red(message))
process.exit(1)
console.error(chalk.red(message));
process.exit(1);
}

View File

@@ -1,260 +1,260 @@
import type { Command } from 'commander'
import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { getOrCreateServerId, findExecutable, applyProviderEnv } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
import { resolveNodePathFromPid } from './runtime-toolchain.js'
import type { Command } from "commander";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
interface ProviderBinaryStatus {
label: string
path: string | null
version: string | null
label: string;
path: string | null;
version: string | null;
}
interface DaemonStatus {
serverId: string | null
status: 'running' | 'stopped' | 'unresponsive'
home: string
listen: string
hostname: string | null
pid: number | null
startedAt: string | null
owner: string | null
logPath: string
runningAgents: number | null
idleAgents: number | null
daemonNode: string
cliNode: string
cliVersion: string
providers: ProviderBinaryStatus[]
note?: string
serverId: string | null;
status: "running" | "stopped" | "unresponsive";
home: string;
listen: string;
hostname: string | null;
pid: number | null;
startedAt: string | null;
owner: string | null;
logPath: string;
runningAgents: number | null;
idleAgents: number | null;
daemonNode: string;
cliNode: string;
cliVersion: string;
providers: ProviderBinaryStatus[];
note?: string;
}
interface StatusRow {
key: string
value: string
key: string;
value: string;
}
type CliPackageJson = {
version?: unknown
}
version?: unknown;
};
const require = createRequire(import.meta.url)
const require = createRequire(import.meta.url);
function normalizeError(error: unknown): string {
if (error instanceof Error) {
return error.message
return error.message;
}
return String(error)
return String(error);
}
function shortenMessage(message: string, max = 120): string {
const normalized = message.replace(/\s+/g, ' ').trim()
const normalized = message.replace(/\s+/g, " ").trim();
if (normalized.length <= max) {
return normalized
return normalized;
}
return `${normalized.slice(0, max - 3)}...`
return `${normalized.slice(0, max - 3)}...`;
}
function appendNote(current: string | undefined, next: string | undefined): string | undefined {
if (!next) return current
if (!current) return next
return `${current}; ${next}`
if (!next) return current;
if (!current) return next;
return `${current}; ${next}`;
}
function resolveCliVersion(): string {
try {
const packageJson = require('../../../package.json') as CliPackageJson
if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) {
return packageJson.version.trim()
const packageJson = require("../../../package.json") as CliPackageJson;
if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
return packageJson.version.trim();
}
} catch {
// Fall through.
}
return 'unknown'
return "unknown";
}
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
return {
idField: 'key',
idField: "key",
columns: [
{ header: 'KEY', field: 'key' },
{ header: "KEY", field: "key" },
{
header: 'VALUE',
field: 'value',
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === 'Status') {
if (item.value === 'running') return 'green'
if (item.value === 'unresponsive') return 'yellow'
return 'red'
if (item.key === "Status") {
if (item.value === "running") return "green";
if (item.value === "unresponsive") return "yellow";
return "red";
}
if (item.key.startsWith(' ')) {
if (item.value === 'not found') return 'red'
if (item.value.endsWith('(--version failed)')) return 'yellow'
return 'green'
if (item.key.startsWith(" ")) {
if (item.value === "not found") return "red";
if (item.value.endsWith("(--version failed)")) return "yellow";
return "green";
}
return undefined
return undefined;
},
},
],
serialize: () => status,
}
};
}
function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [
{ key: 'Server ID', value: status.serverId ?? '-' },
{ key: 'Status', value: status.status },
{ key: 'Home', value: status.home },
{ key: 'Listen', value: status.listen },
{ key: 'Hostname', value: status.hostname ?? '-' },
{ key: 'PID', value: status.pid === null ? '-' : String(status.pid) },
{ key: 'Started', value: status.startedAt ?? '-' },
{ key: 'Owner', value: status.owner ?? '-' },
{ key: 'Logs', value: status.logPath },
{ key: 'Daemon Node', value: status.daemonNode },
{ key: 'CLI Node', value: status.cliNode },
{ key: 'CLI', value: status.cliVersion },
]
{ key: "Server ID", value: status.serverId ?? "-" },
{ key: "Status", value: status.status },
{ key: "Home", value: status.home },
{ key: "Listen", value: status.listen },
{ key: "Hostname", value: status.hostname ?? "-" },
{ key: "PID", value: status.pid === null ? "-" : String(status.pid) },
{ key: "Started", value: status.startedAt ?? "-" },
{ key: "Owner", value: status.owner ?? "-" },
{ key: "Logs", value: status.logPath },
{ key: "Daemon Node", value: status.daemonNode },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
];
if (status.runningAgents !== null && status.idleAgents !== null) {
rows.push({
key: 'Agents',
key: "Agents",
value: `${status.runningAgents} running, ${status.idleAgents} idle`,
})
});
} else {
rows.push({
key: 'Agents',
value: 'Unavailable (daemon API not reachable)',
})
key: "Agents",
value: "Unavailable (daemon API not reachable)",
});
}
if (status.note) {
rows.push({ key: 'Note', value: status.note })
rows.push({ key: "Note", value: status.note });
}
rows.push({ key: '', value: '' })
rows.push({ key: 'Providers', value: '' })
rows.push({ key: "", value: "" });
rows.push({ key: "Providers", value: "" });
for (const provider of status.providers) {
if (!provider.path) {
rows.push({ key: ` ${provider.label}`, value: 'not found' })
rows.push({ key: ` ${provider.label}`, value: "not found" });
} else if (!provider.version) {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` })
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
} else {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` })
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` });
}
}
return rows
return rows;
}
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: 'Claude', binary: 'claude' },
{ label: 'Codex', binary: 'codex' },
{ label: 'OpenCode', binary: 'opencode' },
]
{ label: "Claude", binary: "claude" },
{ label: "Codex", binary: "codex" },
{ label: "OpenCode", binary: "opencode" },
];
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
const binaryPath = findExecutable(binary)
const binaryPath = findExecutable(binary);
if (!binaryPath) {
return { path: null, version: null }
return { path: null, version: null };
}
const env = applyProviderEnv(process.env)
const env = applyProviderEnv(process.env);
try {
const output = execFileSync(binaryPath, ['--version'], {
encoding: 'utf8',
const output = execFileSync(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5000,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
env,
}).trim()
return { path: binaryPath, version: output || null }
}).trim();
return { path: binaryPath, version: output || null };
} catch {
return { path: binaryPath, version: null }
return { path: binaryPath, version: null };
}
}
function checkProviderBinaries(): ProviderBinaryStatus[] {
return PROVIDER_BINARIES.map(({ label, binary }) => {
const result = checkProviderBinary(binary)
return { label, ...result }
})
const result = checkProviderBinary(binary);
return { label, ...result };
});
}
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
if (uid === undefined && !hostname) {
return null
return null;
}
const uidPart = uid === undefined ? '?' : String(uid)
const hostPart = hostname ?? 'unknown-host'
return `${uidPart}@${hostPart}`
const uidPart = uid === undefined ? "?" : String(uid);
const hostPart = hostname ?? "unknown-host";
return `${uidPart}@${hostPart}`;
}
export type StatusResult = ListResult<StatusRow>
export type StatusResult = ListResult<StatusRow>;
export async function runStatusCommand(
options: CommandOptions,
_command: Command
_command: Command,
): Promise<StatusResult> {
const home = typeof options.home === 'string' ? options.home : undefined
const state = resolveLocalDaemonState({ home })
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname)
let daemonNode: string
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode: string;
if (!state.running) {
daemonNode = '-'
daemonNode = "-";
} else if (state.pidInfo?.pid) {
const fromPid = resolveNodePathFromPid(state.pidInfo.pid)
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? 'could not resolve from PID'})`
const fromPid = resolveNodePathFromPid(state.pidInfo.pid);
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
} else {
daemonNode = 'unknown (no PID available)'
daemonNode = "unknown (no PID available)";
}
const cliNode = process.execPath
let status: DaemonStatus['status'] = state.running ? 'running' : 'stopped'
let runningAgents: number | null = null
let idleAgents: number | null = null
let note: string | undefined
const cliNode = process.execPath;
let status: DaemonStatus["status"] = state.running ? "running" : "stopped";
let runningAgents: number | null = null;
let idleAgents: number | null = null;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
note = `Stale PID file found for PID ${state.pidInfo.pid}`
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
}
if (state.running) {
const host = resolveTcpHostFromListen(state.listen)
const host = resolveTcpHostFromListen(state.listen);
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 })
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
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
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;
} catch {
status = 'unresponsive'
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`)
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`);
} finally {
await client.close().catch(() => {})
await client.close().catch(() => {});
}
} else {
status = 'unresponsive'
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`)
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`);
}
} else {
note = appendNote(note, 'Daemon is configured for unix socket listen; API probe skipped')
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
}
const cliVersion = resolveCliVersion()
const cliVersion = resolveCliVersion();
let serverId: string | null = null
let serverId: string | null = null;
try {
serverId = getOrCreateServerId(state.home)
serverId = getOrCreateServerId(state.home);
} catch (error) {
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`)
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
}
const providers = checkProviderBinaries()
const providers = checkProviderBinaries();
const daemonStatus: DaemonStatus = {
serverId,
@@ -273,11 +273,11 @@ export async function runStatusCommand(
cliVersion,
providers,
note,
}
};
return {
type: 'list',
type: "list",
data: toStatusRows(daemonStatus),
schema: createStatusSchema(daemonStatus),
}
};
}

View File

@@ -1,74 +1,79 @@
import type { Command } from 'commander'
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from './local-daemon.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
interface StopResult {
action: 'stopped' | 'not_running'
home: string
pid: string
message: string
action: "stopped" | "not_running";
home: string;
pid: string;
message: string;
}
const stopResultSchema: OutputSchema<StopResult> = {
idField: 'action',
idField: "action",
columns: [
{
header: 'STATUS',
field: 'action',
color: (value) => (value === 'stopped' ? 'green' : 'yellow'),
header: "STATUS",
field: "action",
color: (value) => (value === "stopped" ? "green" : "yellow"),
},
{ header: 'HOME', field: 'home' },
{ header: 'PID', field: 'pid' },
{ header: 'MESSAGE', field: 'message' },
{ header: "HOME", field: "home" },
{ header: "PID", field: "pid" },
{ header: "MESSAGE", field: "message" },
],
}
};
export type StopCommandResult = SingleResult<StopResult>
export type StopCommandResult = SingleResult<StopResult>;
function parseTimeoutMs(raw: unknown): number {
if (typeof raw !== 'string' || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS
if (typeof raw !== "string" || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS;
}
const seconds = Number(raw)
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: 'INVALID_TIMEOUT',
code: "INVALID_TIMEOUT",
message: `Invalid timeout value: ${raw}`,
details: 'Timeout must be a positive number of seconds',
}
throw error
details: "Timeout must be a positive number of seconds",
};
throw error;
}
return Math.ceil(seconds * 1000)
return Math.ceil(seconds * 1000);
}
export async function runStopCommand(
options: CommandOptions,
_command: Command
_command: Command,
): Promise<StopCommandResult> {
const home = typeof options.home === 'string' ? options.home : undefined
const force = options.force === true
const timeoutMs = parseTimeoutMs(options.timeout)
const home = typeof options.home === "string" ? options.home : undefined;
const force = options.force === true;
const timeoutMs = parseTimeoutMs(options.timeout);
try {
const result = await stopLocalDaemon({ home, force, timeoutMs })
const result = await stopLocalDaemon({ home, force, timeoutMs });
return {
type: 'single',
type: "single",
data: {
action: result.action,
home: result.home,
pid: result.pid === null ? '-' : String(result.pid),
pid: result.pid === null ? "-" : String(result.pid),
message: result.message,
},
schema: stopResultSchema,
}
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'STOP_FAILED',
code: "STOP_FAILED",
message: `Failed to stop local daemon: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,14 +1,14 @@
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts'
import { Command } from 'commander'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
import { Command } from "commander";
import { writeFileSync } from "node:fs";
import path from "node:path";
import {
generateLocalPairingOffer,
loadConfig,
loadPersistedConfig,
type CliConfigOverrides,
type PersistedConfig,
} from '@getpaseo/server'
} from "@getpaseo/server";
import {
resolveLocalPaseoHome,
resolveLocalDaemonState,
@@ -16,88 +16,94 @@ import {
startLocalDaemonDetached,
tailDaemonLog,
type DaemonStartOptions,
} from './daemon/local-daemon.js'
import { tryConnectToDaemon } from '../utils/client.js'
} from "./daemon/local-daemon.js";
import { tryConnectToDaemon } from "../utils/client.js";
interface OnboardOptions extends DaemonStartOptions {
timeout?: string
voice?: 'ask' | 'enable' | 'disable'
timeout?: string;
voice?: "ask" | "enable" | "disable";
}
type OnboardPersistedConfig = PersistedConfig & {
features?: PersistedConfig['features'] & {
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
features?: PersistedConfig["features"] & {
dictation?: PersistedConfig["features"] extends { dictation?: infer T }
? T & { enabled?: boolean }
: { enabled?: boolean }
voiceMode?: PersistedConfig['features'] extends { voiceMode?: infer T }
: { enabled?: boolean };
voiceMode?: PersistedConfig["features"] extends { voiceMode?: infer T }
? T & { enabled?: boolean }
: { enabled?: boolean }
}
}
: { enabled?: boolean };
};
};
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
class OnboardCancelledError extends Error {}
const plainNoteFormat = (line: string): string => line
const plainNoteFormat = (line: string): string => line;
function renderNote(message: string, title: string): void {
note(message, title, { format: plainNoteFormat })
note(message, title, { format: plainNoteFormat });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
setTimeout(resolve, ms);
});
}
function parseTimeoutMs(raw: string | undefined): number {
if (!raw || raw.trim().length === 0) {
return DEFAULT_READY_TIMEOUT_MS
return DEFAULT_READY_TIMEOUT_MS;
}
const seconds = Number(raw)
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
throw new Error(`Invalid timeout value: ${raw}`)
throw new Error(`Invalid timeout value: ${raw}`);
}
return Math.ceil(seconds * 1000)
return Math.ceil(seconds * 1000);
}
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {}
const cliOverrides: CliConfigOverrides = {};
if (options.listen) {
cliOverrides.listen = options.listen
cliOverrides.listen = options.listen;
} else if (options.port) {
cliOverrides.listen = `127.0.0.1:${options.port}`
cliOverrides.listen = `127.0.0.1:${options.port}`;
}
if (options.relay === false) {
cliOverrides.relayEnabled = false
cliOverrides.relayEnabled = false;
}
if (options.allowedHosts) {
const raw = options.allowedHosts.trim()
const raw = options.allowedHosts.trim();
cliOverrides.allowedHosts =
raw.toLowerCase() === 'true'
raw.toLowerCase() === "true"
? true
: raw.split(',').map(host => host.trim()).filter(Boolean)
: raw
.split(",")
.map((host) => host.trim())
.filter(Boolean);
}
if (options.mcp === false) {
cliOverrides.mcpEnabled = false
cliOverrides.mcpEnabled = false;
}
return cliOverrides
return cliOverrides;
}
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
const configPath = path.join(paseoHome, 'config.json')
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
const configPath = path.join(paseoHome, "config.json");
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
function applyVoiceSelection(
config: OnboardPersistedConfig,
enabled: boolean,
): OnboardPersistedConfig {
return {
...config,
features: {
@@ -111,321 +117,319 @@ function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean):
enabled,
},
},
}
};
}
function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null {
const voiceModeEnabled = config.features?.voiceMode?.enabled
if (typeof voiceModeEnabled === 'boolean') {
return voiceModeEnabled
const voiceModeEnabled = config.features?.voiceMode?.enabled;
if (typeof voiceModeEnabled === "boolean") {
return voiceModeEnabled;
}
const dictationEnabled = config.features?.dictation?.enabled
if (typeof dictationEnabled === 'boolean') {
return dictationEnabled
const dictationEnabled = config.features?.dictation?.enabled;
if (typeof dictationEnabled === "boolean") {
return dictationEnabled;
}
return null
return null;
}
async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise<boolean> {
if (mode === 'enable') {
return true
async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise<boolean> {
if (mode === "enable") {
return true;
}
if (mode === 'disable') {
return false
if (mode === "disable") {
return false;
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
log.message('Non-interactive terminal detected; voice setup defaults to disabled.')
return false
log.message("Non-interactive terminal detected; voice setup defaults to disabled.");
return false;
}
const answer = await confirm({
message: 'Enable voice features? (downloads local STT/TTS models in background)',
active: 'Yes',
inactive: 'No',
message: "Enable voice features? (downloads local STT/TTS models in background)",
active: "Yes",
inactive: "No",
initialValue: false,
})
});
if (isCancel(answer)) {
throw new OnboardCancelledError('Onboarding cancelled by user.')
throw new OnboardCancelledError("Onboarding cancelled by user.");
}
return answer
return answer;
}
type DownloadProgress = {
modelId: string | null
pct: number | null
}
modelId: string | null;
pct: number | null;
};
function parseDownloadProgress(logTail: string): DownloadProgress | null {
const lines = logTail.split('\n').filter(Boolean)
const lines = logTail.split("\n").filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]
if (!line || !line.includes('Downloading model artifact')) {
continue
const line = lines[index];
if (!line || !line.includes("Downloading model artifact")) {
continue;
}
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/)
const modelMatch = line.match(
/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/
)
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/);
const modelMatch = line.match(/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/);
return {
modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null,
pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null,
}
};
}
return null
return null;
}
function renderProgressLine(progress: DownloadProgress): string {
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ''
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : "";
if (progress.pct === null) {
return `Downloading speech model${modelSuffix}...`
return `Downloading speech model${modelSuffix}...`;
}
return `Downloading speech model${modelSuffix}: ${progress.pct}%`
return `Downloading speech model${modelSuffix}: ${progress.pct}%`;
}
async function waitForDaemonReady(args: {
home: string
timeoutMs: number
onStatus?: (message: string) => void
home: string;
timeoutMs: number;
onStatus?: (message: string) => void;
}): Promise<{ listen: string; host: string | null }> {
const deadline = Date.now() + args.timeoutMs
let lastStatus = ''
let lastPrintedAt = 0
const deadline = Date.now() + args.timeoutMs;
let lastStatus = "";
let lastPrintedAt = 0;
while (Date.now() < deadline) {
const state = resolveLocalDaemonState({ home: args.home })
const host = resolveTcpHostFromListen(state.listen)
const state = resolveLocalDaemonState({ home: args.home });
const host = resolveTcpHostFromListen(state.listen);
if (state.running && host) {
const client = await tryConnectToDaemon({ host, timeout: 1200 })
const client = await tryConnectToDaemon({ host, timeout: 1200 });
if (client) {
try {
await client.fetchAgents()
return { listen: state.listen, host }
await client.fetchAgents();
return { listen: state.listen, host };
} catch {
// Daemon process is alive but not API-ready yet.
} finally {
await client.close().catch(() => {})
await client.close().catch(() => {});
}
}
} else if (state.running && !host) {
return { listen: state.listen, host: null }
return { listen: state.listen, host: null };
}
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? '')
const progressLine = progress ? renderProgressLine(progress) : null
const statusMessage = progressLine ?? 'Waiting for daemon to become ready...'
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? "");
const progressLine = progress ? renderProgressLine(progress) : null;
const statusMessage = progressLine ?? "Waiting for daemon to become ready...";
if (statusMessage !== lastStatus) {
args.onStatus?.(statusMessage)
lastStatus = statusMessage
lastPrintedAt = Date.now()
args.onStatus?.(statusMessage);
lastStatus = statusMessage;
lastPrintedAt = Date.now();
} else if (!args.onStatus && Date.now() - lastPrintedAt >= 3000) {
console.log(statusMessage)
lastPrintedAt = Date.now()
console.log(statusMessage);
lastPrintedAt = Date.now();
}
await sleep(200)
await sleep(200);
}
const recentLogs = tailDaemonLog(args.home, 60)
const recentLogs = tailDaemonLog(args.home, 60);
throw new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join('\n\n')
)
.join("\n\n"),
);
}
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
const daemonLogPath = path.join(paseoHome, 'daemon.log')
const daemonLogPath = path.join(paseoHome, "daemon.log");
const nextStepsLines = [
pairingUrl
? '1. Open Paseo and scan the QR code above, or paste the pairing link.'
: '1. Open Paseo and connect to your daemon.',
'2. Web app: https://app.paseo.sh',
'3. Desktop app: https://github.com/getpaseo/paseo/releases/latest',
'4. Docs: https://paseo.sh/docs',
? "1. Open Paseo and scan the QR code above, or paste the pairing link."
: "1. Open Paseo and connect to your daemon.",
"2. Web app: https://app.paseo.sh",
"3. Desktop app: https://github.com/getpaseo/paseo/releases/latest",
"4. Docs: https://paseo.sh/docs",
'5. Example: paseo run --output-schema schema.json "extract fields"',
]
];
const quickReferenceLines = [
'1. paseo --help',
'2. paseo ls',
"1. paseo --help",
"2. paseo ls",
'3. paseo run "your prompt"',
'4. paseo status',
"4. paseo status",
`5. Daemon logs: ${daemonLogPath}`,
]
];
if (!richUi) {
console.log('')
console.log('Next steps:')
console.log("");
console.log("Next steps:");
for (const line of nextStepsLines) {
console.log(line)
console.log(line);
}
console.log('')
console.log('CLI quick reference:')
console.log("");
console.log("CLI quick reference:");
for (const line of quickReferenceLines) {
console.log(line)
console.log(line);
}
return
return;
}
renderNote(nextStepsLines.join('\n'), 'Next steps')
renderNote(quickReferenceLines.join('\n'), 'CLI quick reference')
renderNote(nextStepsLines.join("\n"), "Next steps");
renderNote(quickReferenceLines.join("\n"), "CLI quick reference");
}
export function onboardCommand(): Command {
return new Command('onboard')
.description('Run first-time setup, start daemon, and print pairing instructions')
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--no-relay', 'Disable relay connection')
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
return new Command("onboard")
.description("Run first-time setup, start daemon, and print pairing instructions")
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
)
.option('--timeout <seconds>', 'Max time to wait for daemon readiness (default: 600)')
.option('--voice <mode>', 'Voice setup mode: ask, enable, disable', 'ask')
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
.option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask")
.action(async (options: OnboardOptions) => {
await runOnboard(options)
})
await runOnboard(options);
});
}
export async function runOnboard(options: OnboardOptions): Promise<void> {
const richUi = process.stdin.isTTY && process.stdout.isTTY
const richUi = process.stdin.isTTY && process.stdout.isTTY;
if (richUi) {
intro('Welcome to Paseo')
intro("Welcome to Paseo");
}
if (options.listen && options.port) {
cancel('Cannot use --listen and --port together')
process.exit(1)
cancel("Cannot use --listen and --port together");
process.exit(1);
}
let timeoutMs = DEFAULT_READY_TIMEOUT_MS
let timeoutMs = DEFAULT_READY_TIMEOUT_MS;
try {
timeoutMs = parseTimeoutMs(options.timeout)
timeoutMs = parseTimeoutMs(options.timeout);
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
cancel(message)
process.exit(1)
const message = error instanceof Error ? error.message : String(error);
cancel(message);
process.exit(1);
}
const paseoHome = resolveLocalPaseoHome(options.home)
const paseoHome = resolveLocalPaseoHome(options.home);
if (richUi) {
renderNote(paseoHome, 'Paseo home')
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
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)
: await resolveVoiceSelection(options.voice);
} catch (error) {
if (error instanceof OnboardCancelledError) {
cancel('Onboarding cancelled.')
process.exit(0)
return
cancel("Onboarding cancelled.");
process.exit(0);
return;
}
throw error
throw error;
}
if (shouldPrompt && persistedVoiceSelection !== null) {
log.message(`Using saved voice setup from config (${voiceEnabled ? 'enabled' : 'disabled'}).`)
log.message(`Using saved voice setup from config (${voiceEnabled ? "enabled" : "disabled"}).`);
}
persisted = applyVoiceSelection(persisted, voiceEnabled)
savePersistedConfig(paseoHome, persisted)
persisted = applyVoiceSelection(persisted, voiceEnabled);
savePersistedConfig(paseoHome, persisted);
const config = loadConfig(paseoHome, { cli: toCliOverrides(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)
? "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);
const stateBeforeStart = resolveLocalDaemonState({ home: options.home })
const startSpinner = richUi ? spinner() : null
const stateBeforeStart = resolveLocalDaemonState({ home: options.home });
const startSpinner = richUi ? spinner() : null;
if (!stateBeforeStart.running) {
try {
if (startSpinner) {
startSpinner.start('Starting daemon...')
startSpinner.start("Starting daemon...");
} else {
log.message('Starting daemon...')
log.message("Starting daemon...");
}
const startup = await startLocalDaemonDetached(options)
const startup = await startLocalDaemonDetached(options);
if (startSpinner) {
startSpinner.stop(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`);
} else {
log.message(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`);
}
log.message(`Logs: ${startup.logPath}`)
log.message(`Logs: ${startup.logPath}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const message = error instanceof Error ? error.message : String(error);
if (startSpinner) {
startSpinner.error(message)
startSpinner.error(message);
} else {
log.error(message)
log.error(message);
}
process.exit(1)
process.exit(1);
}
} else {
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? 'unknown'}).`)
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`);
}
let readyState: { listen: string; host: string | null }
const readySpinner = richUi ? spinner() : null
let readyState: { listen: string; host: string | null };
const readySpinner = richUi ? spinner() : null;
try {
if (readySpinner) {
readySpinner.start('Waiting for daemon to become ready...')
readySpinner.start("Waiting for daemon to become ready...");
} else {
log.message('Waiting for daemon to become ready...')
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}`)
readySpinner.stop(`Daemon ready on ${readyState.listen}`);
} else {
log.message(`Daemon ready on ${readyState.listen}`)
log.message(`Daemon ready on ${readyState.listen}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const message = error instanceof Error ? error.message : String(error);
if (readySpinner) {
readySpinner.error(message)
readySpinner.error(message);
} else {
log.error(message)
log.error(message);
}
process.exit(1)
return
process.exit(1);
return;
}
if (config.relayEnabled === false) {
log.warn('Relay is disabled; pairing offer is unavailable for this daemon.')
printNextSteps(null, paseoHome, richUi)
log.warn("Relay is disabled; pairing offer is unavailable for this daemon.");
printNextSteps(null, paseoHome, richUi);
if (richUi) {
outro('Paseo daemon is running.')
outro("Paseo daemon is running.");
}
return
return;
}
const pairing = await generateLocalPairingOffer({
@@ -435,24 +439,24 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
includeQr: true,
})
});
if (!pairing.url) {
log.warn('Relay pairing URL is unavailable for this daemon configuration.')
printNextSteps(null, paseoHome, richUi)
log.warn("Relay pairing URL is unavailable for this daemon configuration.");
printNextSteps(null, paseoHome, richUi);
if (richUi) {
outro('Paseo daemon is running.')
outro("Paseo daemon is running.");
}
return
return;
}
renderNote(
pairing.qr ?? 'QR is unavailable in this terminal. Use the pairing link below.',
'Scan to pair'
)
renderNote(pairing.url, 'Pairing link')
printNextSteps(pairing.url, paseoHome, richUi)
pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.",
"Scan to pair",
);
renderNote(pairing.url, "Pairing link");
printNextSteps(pairing.url, paseoHome, richUi);
if (richUi) {
outro('Paseo is ready!')
outro("Paseo is ready!");
}
}

View File

@@ -1,165 +1,163 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Permission response item for display */
export interface PermissionResponseItem {
requestId: string
agentId: string
agentShortId: string
name: string
result: string
requestId: string;
agentId: string;
agentShortId: string;
name: string;
result: string;
}
/** Schema for permit allow/deny output */
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
idField: 'requestId',
idField: "requestId",
columns: [
{ header: 'REQUEST ID', field: 'requestId', width: 12 },
{ header: 'AGENT', field: 'agentShortId', width: 10 },
{ header: 'TOOL', field: 'name', width: 20 },
{ header: "REQUEST ID", field: "requestId", width: 12 },
{ header: "AGENT", field: "agentShortId", width: 10 },
{ header: "TOOL", field: "name", width: 20 },
{
header: 'RESULT',
field: 'result',
header: "RESULT",
field: "result",
width: 10,
color: (value) => {
if (value === 'allowed') return 'green'
if (value === 'denied') return 'red'
return undefined
if (value === "allowed") return "green";
if (value === "denied") return "red";
return undefined;
},
},
],
}
};
export type PermitAllowResult = ListResult<PermissionResponseItem>
export type PermitAllowResult = ListResult<PermissionResponseItem>;
export interface PermitAllowOptions extends CommandOptions {
all?: boolean
input?: string
host?: string
all?: boolean;
input?: string;
host?: string;
}
export async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command
_command: Command,
): Promise<PermitAllowResult> {
const host = getDaemonHost({ host: options.host })
const host = getDaemonHost({ host: options.host });
// No validation needed - if no reqId provided, allow all by default
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined
let updatedInput: Record<string, unknown> | undefined;
if (options.input) {
try {
updatedInput = JSON.parse(options.input)
updatedInput = JSON.parse(options.input);
} catch (err) {
const error: CommandError = {
code: 'INVALID_JSON',
code: "INVALID_JSON",
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
}
throw error
};
throw error;
}
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchResult = await client.fetchAgent(agentIdOrPrefix)
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
if (!fetchResult) {
await client.close()
await client.close();
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
const agent = fetchResult.agent
const resolvedAgentId = agent.id
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || []
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close()
await client.close();
const error: CommandError = {
code: 'NO_PENDING_PERMISSIONS',
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
}
throw error
};
throw error;
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[]
let permissionsToAllow: AgentPermissionRequest[];
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions
permissionsToAllow = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!)
)
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
if (!permission) {
await client.close()
await client.close();
const error: CommandError = {
code: 'PERMISSION_NOT_FOUND',
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
}
throw error
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToAllow = [permission]
permissionsToAllow = [permission];
}
// Allow permissions
const results: PermissionResponseItem[] = []
const results: PermissionResponseItem[] = [];
for (const permission of permissionsToAllow) {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: 'allow',
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
})
});
results.push({
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: 'allowed',
})
result: "allowed",
});
}
await client.close()
await client.close();
return {
type: 'list',
type: "list",
data: results,
schema: permitResponseSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'ALLOW_PERMISSION_FAILED',
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,130 +1,128 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, CommandError } from '../../output/index.js'
import { permitResponseSchema, type PermissionResponseItem } from './allow.js'
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>
export type PermitDenyResult = ListResult<PermissionResponseItem>;
export interface PermitDenyOptions extends CommandOptions {
all?: boolean
message?: string
interrupt?: boolean
host?: string
all?: boolean;
message?: string;
interrupt?: boolean;
host?: string;
}
export async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command
_command: Command,
): Promise<PermitDenyResult> {
const host = getDaemonHost({ host: options.host })
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Request ID is required unless --all is specified',
details: 'Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all',
}
throw error
code: "MISSING_ARGUMENT",
message: "Request ID is required unless --all is specified",
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchResult = await client.fetchAgent(agentIdOrPrefix)
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
if (!fetchResult) {
await client.close()
await client.close();
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
const agent = fetchResult.agent
const resolvedAgentId = agent.id
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || []
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close()
await client.close();
const error: CommandError = {
code: 'NO_PENDING_PERMISSIONS',
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
}
throw error
};
throw error;
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[]
let permissionsToDeny: AgentPermissionRequest[];
if (options.all) {
permissionsToDeny = pendingPermissions
permissionsToDeny = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!)
)
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
if (!permission) {
await client.close()
await client.close();
const error: CommandError = {
code: 'PERMISSION_NOT_FOUND',
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
}
throw error
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToDeny = [permission]
permissionsToDeny = [permission];
}
// Deny permissions
const results: PermissionResponseItem[] = []
const results: PermissionResponseItem[] = [];
for (const permission of permissionsToDeny) {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: 'deny',
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',
})
result: "denied",
});
}
await client.close()
await client.close();
return {
type: 'list',
type: "list",
data: results,
schema: permitResponseSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DENY_PERMISSION_FAILED',
code: "DENY_PERMISSION_FAILED",
message: `Failed to deny permission: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,39 +1,37 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runAllowCommand } from './allow.js'
import { runDenyCommand } from './deny.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runAllowCommand } from "./allow.js";
import { runDenyCommand } from "./deny.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createPermitCommand(): Command {
const permit = new Command('permit').description('Manage permission requests')
const permit = new Command("permit").description("Manage permission requests");
addJsonAndDaemonHostOptions(
permit.command("ls").description("List all pending permissions"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
permit
.command('ls')
.description('List all pending permissions')
).action(withOutput(runLsCommand))
.command("allow")
.description("Allow a permission request")
.argument("<agent>", "Agent ID (or prefix)")
.argument("[req_id]", "Permission request ID (optional if --all)")
.option("--all", "Allow all pending permissions for this agent")
.option("--input <json>", "Modified input parameters (JSON)"),
).action(withOutput(runAllowCommand));
addJsonAndDaemonHostOptions(
permit
.command('allow')
.description('Allow a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)')
).action(withOutput(runAllowCommand))
.command("deny")
.description("Deny a permission request")
.argument("<agent>", "Agent ID (or prefix)")
.argument("[req_id]", "Permission request ID (optional if --all)")
.option("--all", "Deny all pending permissions for this agent")
.option("--message <msg>", "Denial reason message")
.option("--interrupt", "Stop agent after denial"),
).action(withOutput(runDenyCommand));
addJsonAndDaemonHostOptions(
permit
.command('deny')
.description('Deny a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Deny all pending permissions for this agent')
.option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
).action(withOutput(runDenyCommand))
return permit
return permit;
}

View File

@@ -1,88 +1,94 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest, AgentSnapshotPayload } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import type { AgentPermissionRequest, AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Permission list item for display */
export interface PermissionListItem {
id: string
agentId: string
agentShortId: string
name: string
description: string
id: string;
agentId: string;
agentShortId: string;
name: string;
description: string;
}
/** Schema for permit ls output */
export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'AGENT', field: 'agentShortId', width: 12 },
{ header: 'REQ_ID', field: 'id', width: 12 },
{ header: 'TOOL', field: 'name', width: 20 },
{ header: 'DESCRIPTION', field: 'description', width: 50 },
{ header: "AGENT", field: "agentShortId", width: 12 },
{ header: "REQ_ID", field: "id", width: 12 },
{ header: "TOOL", field: "name", width: 20 },
{ header: "DESCRIPTION", field: "description", width: 50 },
],
}
};
/** Transform agent snapshot + permission to list item */
function toListItem(agent: AgentSnapshotPayload, permission: AgentPermissionRequest): PermissionListItem {
function toListItem(
agent: AgentSnapshotPayload,
permission: AgentPermissionRequest,
): PermissionListItem {
return {
id: permission.id.slice(0, 8),
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
name: permission.name,
description: permission.description ?? '-',
}
description: permission.description ?? "-",
};
}
export type PermitLsResult = ListResult<PermissionListItem>
export type PermitLsResult = ListResult<PermissionListItem>;
export interface PermitLsOptions extends CommandOptions {
host?: string
host?: string;
}
export async function runLsCommand(options: PermitLsOptions, _command: Command): Promise<PermitLsResult> {
const host = getDaemonHost({ host: options.host })
export async function runLsCommand(
options: PermitLsOptions,
_command: Command,
): Promise<PermitLsResult> {
const host = getDaemonHost({ host: options.host });
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
await client.close()
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
await client.close();
// Collect all pending permissions from all agents
const items: PermissionListItem[] = []
const items: PermissionListItem[] = [];
for (const agent of agents) {
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
for (const permission of agent.pendingPermissions) {
items.push(toListItem(agent, permission))
items.push(toListItem(agent, permission));
}
}
}
return {
type: 'list',
type: "list",
data: items,
schema: permitLsSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'LIST_PERMISSIONS_FAILED',
code: "LIST_PERMISSIONS_FAILED",
message: `Failed to list permissions: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,25 +1,23 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runModelsCommand } from './models.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runModelsCommand } from "./models.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createProviderCommand(): Command {
const provider = new Command('provider').description('Manage agent providers')
const provider = new Command("provider").description("Manage agent providers");
addJsonAndDaemonHostOptions(
provider.command("ls").description("List available providers and status"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
provider
.command('ls')
.description('List available providers and status')
).action(withOutput(runLsCommand))
.command("models")
.description("List models for a provider")
.argument("<provider>", "Provider name (claude, codex, opencode)")
.option("--thinking", "Include thinking option IDs for each model"),
).action(withOutput(runModelsCommand));
addJsonAndDaemonHostOptions(
provider
.command('models')
.description('List models for a provider')
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--thinking', 'Include thinking option IDs for each model')
).action(withOutput(runModelsCommand))
return provider
return provider;
}

View File

@@ -1,70 +1,70 @@
import type { Command } from 'commander'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
/** Provider list item for display */
export interface ProviderListItem {
provider: string
status: string
defaultMode: string
modes: string
provider: string;
status: string;
defaultMode: string;
modes: string;
}
/** Static provider data - providers are built-in and don't require daemon */
const PROVIDERS: ProviderListItem[] = [
{
provider: 'claude',
status: 'available',
defaultMode: 'default',
modes: 'plan, default, bypass',
provider: "claude",
status: "available",
defaultMode: "default",
modes: "plan, default, bypass",
},
{
provider: 'codex',
status: 'available',
defaultMode: 'auto',
modes: 'read-only, auto, full-access',
provider: "codex",
status: "available",
defaultMode: "auto",
modes: "read-only, auto, full-access",
},
{
provider: 'opencode',
status: 'available',
defaultMode: 'default',
modes: 'plan, default, bypass',
provider: "opencode",
status: "available",
defaultMode: "default",
modes: "plan, default, bypass",
},
]
];
/** Schema for provider ls output */
export const providerLsSchema: OutputSchema<ProviderListItem> = {
idField: 'provider',
idField: "provider",
columns: [
{ header: 'PROVIDER', field: 'provider', width: 12 },
{ header: "PROVIDER", field: "provider", width: 12 },
{
header: 'STATUS',
field: 'status',
header: "STATUS",
field: "status",
width: 12,
color: (value) => {
if (value === 'available') return 'green'
if (value === 'unavailable') return 'red'
return undefined
if (value === "available") return "green";
if (value === "unavailable") return "red";
return undefined;
},
},
{ header: 'DEFAULT MODE', field: 'defaultMode', width: 14 },
{ header: 'MODES', field: 'modes', width: 30 },
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
{ header: "MODES", field: "modes", width: 30 },
],
}
};
export type ProviderLsResult = ListResult<ProviderListItem>
export type ProviderLsResult = ListResult<ProviderListItem>;
export interface ProviderLsOptions extends CommandOptions {
host?: string
host?: string;
}
export async function runLsCommand(
_options: ProviderLsOptions,
_command: Command
_command: Command,
): Promise<ProviderLsResult> {
// Provider data is static - no daemon connection needed
return {
type: 'list',
type: "list",
data: PROVIDERS,
schema: providerLsSchema,
}
};
}

View File

@@ -1,82 +1,81 @@
import type { Command } from 'commander'
import { connectToDaemon } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
/** Model list item for display */
export interface ModelListItem {
model: string
id: string
description: string
thinkingOptionIds: string[]
defaultThinkingOptionId: string | null
thinkingOptions: string
model: string;
id: string;
description: string;
thinkingOptionIds: string[];
defaultThinkingOptionId: string | null;
thinkingOptions: string;
}
/** Schema for provider models output */
export const providerModelsSchema: OutputSchema<ModelListItem> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'ID', field: 'id', width: 30 },
{ header: 'MODEL', field: 'model', width: 30 },
{ header: 'DESCRIPTION', field: 'description', width: 40 },
{ header: "ID", field: "id", width: 30 },
{ header: "MODEL", field: "model", width: 30 },
{ header: "DESCRIPTION", field: "description", width: 40 },
],
}
};
const providerModelsWithThinkingSchema: OutputSchema<ModelListItem> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'ID', field: 'id', width: 30 },
{ header: 'MODEL', field: 'model', width: 30 },
{ header: 'THINKING IDS', field: 'thinkingOptions', width: 40 },
{ header: "ID", field: "id", width: 30 },
{ header: "MODEL", field: "model", width: 30 },
{ header: "THINKING IDS", field: "thinkingOptions", width: 40 },
{
header: 'DEFAULT THINKING',
field: (item) => item.defaultThinkingOptionId ?? 'auto',
header: "DEFAULT THINKING",
field: (item) => item.defaultThinkingOptionId ?? "auto",
width: 18,
},
],
}
};
export type ProviderModelsResult = ListResult<ModelListItem>
export type ProviderModelsResult = ListResult<ModelListItem>;
export interface ProviderModelsOptions extends CommandOptions {
host?: string
thinking?: boolean
host?: string;
thinking?: boolean;
}
export async function runModelsCommand(
provider: string,
options: ProviderModelsOptions,
_command: Command
_command: Command,
): Promise<ProviderModelsResult> {
const normalizedProvider = provider.toLowerCase()
const normalizedProvider = provider.toLowerCase();
const client = await connectToDaemon({ host: options.host })
const client = await connectToDaemon({ host: options.host });
try {
const result = await client.listProviderModels(normalizedProvider)
const result = await client.listProviderModels(normalizedProvider);
if (result.error) {
throw {
code: 'PROVIDER_ERROR',
code: "PROVIDER_ERROR",
message: `Failed to fetch models for ${provider}: ${result.error}`,
}
};
}
const models: ModelListItem[] = (result.models ?? []).map((m) => ({
model: m.label,
id: m.id,
description: m.description ?? '',
description: m.description ?? "",
thinkingOptionIds: (m.thinkingOptions ?? []).map((option) => option.id),
defaultThinkingOptionId: m.defaultThinkingOptionId ?? null,
thinkingOptions:
(m.thinkingOptions ?? []).map((option) => option.id).join(', ') || 'none',
}))
thinkingOptions: (m.thinkingOptions ?? []).map((option) => option.id).join(", ") || "none",
}));
return {
type: 'list',
type: "list",
data: models,
schema: options.thinking ? providerModelsWithThinkingSchema : providerModelsSchema,
}
};
} finally {
await client.close()
await client.close();
}
}

View File

@@ -1,10 +1,5 @@
import type { Command } from "commander";
import type {
CommandError,
CommandOptions,
ListResult,
OutputSchema,
} from "../../output/index.js";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechDownloadRow {
@@ -29,7 +24,7 @@ export interface SpeechDownloadOptions extends CommandOptions {
export async function runSpeechDownloadCommand(
options: SpeechDownloadOptions,
_command: Command
_command: Command,
): Promise<SpeechDownloadResult> {
const client = await connectToDaemon({ host: options.host });
try {

View File

@@ -1,24 +1,22 @@
import { Command } from 'commander'
import { withOutput } from '../../output/index.js'
import { runSpeechModelsCommand } from './models.js'
import { runSpeechDownloadCommand } from './download.js'
import { addJsonAndDaemonHostOptions, collectMultiple } from '../../utils/command-options.js'
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { runSpeechModelsCommand } from "./models.js";
import { runSpeechDownloadCommand } from "./download.js";
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
export function createSpeechCommand(): Command {
const speech = new Command('speech').description('Manage local speech models')
const speech = new Command("speech").description("Manage local speech models");
addJsonAndDaemonHostOptions(
speech.command("models").description("List local speech model download status"),
).action(withOutput(runSpeechModelsCommand));
addJsonAndDaemonHostOptions(
speech
.command('models')
.description('List local speech model download status')
).action(withOutput(runSpeechModelsCommand))
.command("download")
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, []),
).action(withOutput(runSpeechDownloadCommand));
addJsonAndDaemonHostOptions(
speech
.command('download')
.description('Download local speech models')
.option('--model <id>', 'Model ID to download (repeatable)', collectMultiple, [])
).action(withOutput(runSpeechDownloadCommand))
return speech
return speech;
}

View File

@@ -1,10 +1,5 @@
import type { Command } from "commander";
import type {
CommandError,
CommandOptions,
ListResult,
OutputSchema,
} from "../../output/index.js";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechModelListItem {
@@ -39,7 +34,7 @@ export interface SpeechModelsOptions extends CommandOptions {
export async function runSpeechModelsCommand(
options: SpeechModelsOptions,
_command: Command
_command: Command,
): Promise<SpeechModelsResult> {
const client = await connectToDaemon({ host: options.host });
try {

View File

@@ -1,129 +1,134 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for worktree archive command */
export interface WorktreeArchiveResult {
name: string
status: 'archived'
removedAgents: string[]
name: string;
status: "archived";
removedAgents: string[];
}
/** Schema for archive command output */
export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
idField: 'name',
idField: "name",
columns: [
{ header: 'NAME', field: 'name' },
{ header: 'STATUS', field: 'status' },
{ header: "NAME", field: "name" },
{ header: "STATUS", field: "status" },
{
header: 'REMOVED AGENTS',
field: (item) => item.removedAgents.length > 0 ? item.removedAgents.join(', ') : '-',
header: "REMOVED AGENTS",
field: (item) => (item.removedAgents.length > 0 ? item.removedAgents.join(", ") : "-"),
},
],
}
};
export interface WorktreeArchiveOptions extends CommandOptions {
host?: string
host?: string;
}
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>;
export async function runArchiveCommand(
nameArg: string,
options: WorktreeArchiveOptions,
_command: Command
_command: Command,
): Promise<WorktreeArchiveCommandResult> {
const host = getDaemonHost({ host: options.host })
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!nameArg || nameArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_WORKTREE_NAME',
message: 'Worktree name is required',
details: 'Usage: paseo worktree archive <name>',
}
throw error
code: "MISSING_WORKTREE_NAME",
message: "Worktree name is required",
details: "Usage: paseo worktree archive <name>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
// Get the list of worktrees first to resolve the name
const listResponse = await client.getPaseoWorktreeList({})
const listResponse = await client.getPaseoWorktreeList({});
if (listResponse.error) {
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
code: "WORKTREE_LIST_FAILED",
message: `Failed to list worktrees: ${listResponse.error.message}`,
}
throw error
};
throw error;
}
// Find the worktree by name or branch
const worktree = listResponse.worktrees.find((wt) => {
const name = wt.worktreePath.split('/').pop()
return name === nameArg || wt.branchName === nameArg
})
const name = wt.worktreePath.split("/").pop();
return name === nameArg || wt.branchName === nameArg;
});
if (!worktree) {
const error: CommandError = {
code: 'WORKTREE_NOT_FOUND',
code: "WORKTREE_NOT_FOUND",
message: `Worktree not found: ${nameArg}`,
details: 'Use "paseo worktree ls" to list available worktrees',
}
throw error
};
throw error;
}
// Archive the worktree
const response = await client.archivePaseoWorktree({
worktreePath: worktree.worktreePath,
})
});
await client.close()
await client.close();
if (response.error) {
const error: CommandError = {
code: 'WORKTREE_ARCHIVE_FAILED',
code: "WORKTREE_ARCHIVE_FAILED",
message: `Failed to archive worktree: ${response.error.message}`,
}
throw error
};
throw error;
}
const worktreeName = worktree.worktreePath.split('/').pop() ?? nameArg
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
return {
type: 'single',
type: "single",
data: {
name: worktreeName,
status: 'archived',
status: "archived",
removedAgents: response.removedAgents ?? [],
},
schema: archiveSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'WORKTREE_ARCHIVE_FAILED',
code: "WORKTREE_ARCHIVE_FAILED",
message: `Failed to archive worktree: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,24 +1,22 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runArchiveCommand } from './archive.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runArchiveCommand } from "./archive.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createWorktreeCommand(): Command {
const worktree = new Command('worktree').description('Manage Paseo-managed git worktrees')
const worktree = new Command("worktree").description("Manage Paseo-managed git worktrees");
addJsonAndDaemonHostOptions(
worktree.command("ls").description("List Paseo-managed git worktrees"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
worktree
.command('ls')
.description('List Paseo-managed git worktrees')
).action(withOutput(runLsCommand))
.command("archive")
.description("Archive a worktree (removes worktree and associated branch)")
.argument("<name>", "Worktree name or branch name"),
).action(withOutput(runArchiveCommand));
addJsonAndDaemonHostOptions(
worktree
.command('archive')
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
).action(withOutput(runArchiveCommand))
return worktree
return worktree;
}

View File

@@ -1,130 +1,130 @@
import type { Command } from 'commander'
import { homedir } from 'node:os'
import { basename, join, sep } from 'node:path'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { homedir } from "node:os";
import { basename, join, sep } from "node:path";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Worktree list item for display */
export interface WorktreeListItem {
name: string
branch: string
cwd: string
agent: string
name: string;
branch: string;
cwd: string;
agent: string;
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
const home = process.env.HOME;
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
return "~" + path.slice(home.length);
}
return path
return path;
}
/** Extract worktree name from path */
function extractWorktreeName(path: string): string {
return basename(path)
return basename(path);
}
export function resolvePaseoHomePath(): string {
return process.env.PASEO_HOME ?? join(homedir(), '.paseo')
return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
}
export function resolvePaseoWorktreesDir(): string {
return join(resolvePaseoHomePath(), 'worktrees')
return join(resolvePaseoHomePath(), "worktrees");
}
function isAgentInManagedWorktree(agentCwd: string): boolean {
const worktreesDir = resolvePaseoWorktreesDir()
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep)
const worktreesDir = resolvePaseoWorktreesDir();
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep);
}
/** Schema for worktree ls output */
export const worktreeLsSchema: OutputSchema<WorktreeListItem> = {
idField: 'name',
idField: "name",
columns: [
{ header: 'NAME', field: 'name', width: 20 },
{ header: 'BRANCH', field: 'branch', width: 25 },
{ header: 'CWD', field: 'cwd', width: 45 },
{ header: 'AGENT', field: 'agent', width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "BRANCH", field: "branch", width: 25 },
{ header: "CWD", field: "cwd", width: 45 },
{ header: "AGENT", field: "agent", width: 10 },
],
}
};
export type WorktreeLsResult = ListResult<WorktreeListItem>
export type WorktreeLsResult = ListResult<WorktreeListItem>;
export interface WorktreeLsOptions extends CommandOptions {
host?: string
host?: string;
}
export async function runLsCommand(
options: WorktreeLsOptions,
_command: Command
_command: Command,
): Promise<WorktreeLsResult> {
const host = getDaemonHost({ host: options.host })
const host = getDaemonHost({ host: options.host });
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
// Get worktree list from daemon
const response = await client.getPaseoWorktreeList({})
const response = await client.getPaseoWorktreeList({});
await client.close()
await client.close();
if (response.error) {
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
code: "WORKTREE_LIST_FAILED",
message: `Failed to list worktrees: ${response.error.message}`,
}
throw error
};
throw error;
}
// Build a map of worktree paths to agent IDs
const worktreeAgentMap = new Map<string, string>()
const worktreeAgentMap = new Map<string, string>();
for (const agent of agents) {
if (isAgentInManagedWorktree(agent.cwd)) {
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7))
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7));
}
}
const items: WorktreeListItem[] = response.worktrees.map((wt) => ({
name: extractWorktreeName(wt.worktreePath),
branch: wt.branchName ?? '-',
branch: wt.branchName ?? "-",
cwd: shortenPath(wt.worktreePath),
agent: worktreeAgentMap.get(wt.worktreePath) ?? '-',
}))
agent: worktreeAgentMap.get(wt.worktreePath) ?? "-",
}));
return {
type: 'list',
type: "list",
data: items,
schema: worktreeLsSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
code: "WORKTREE_LIST_FAILED",
message: `Failed to list worktrees: ${message}`,
}
throw error
};
throw error;
}
}

View File

@@ -1,7 +1,7 @@
import { createCli } from './cli.js'
import { createCli } from "./cli.js";
const program = createCli()
const program = createCli();
if (process.argv.length <= 2) {
process.argv.push('onboard')
process.argv.push("onboard");
}
program.parse()
program.parse();

View File

@@ -47,16 +47,16 @@ export type {
ListResult,
AnyCommandResult,
CommandError,
} from './types.js'
} from "./types.js";
// Renderers
export { renderTable, renderTableHeader, renderTableRow } from './table.js'
export { renderJson, renderJsonLine } from './json.js'
export { renderYaml, renderYamlDoc } from './yaml.js'
export { renderQuiet } from './quiet.js'
export { renderTable, renderTableHeader, renderTableRow } from "./table.js";
export { renderJson, renderJsonLine } from "./json.js";
export { renderYaml, renderYamlDoc } from "./yaml.js";
export { renderQuiet } from "./quiet.js";
// Main render function
export { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
export { render, renderError, toCommandError, defaultOutputOptions } from "./render.js";
// Command wrapper
export { withOutput, createOutputOptions, type CommandOptions } from './with-output.js'
export { withOutput, createOutputOptions, type CommandOptions } from "./with-output.js";

View File

@@ -4,41 +4,38 @@
* Renders structured data as formatted JSON for machine consumption.
*/
import type { AnyCommandResult, OutputOptions } from './types.js'
import type { AnyCommandResult, OutputOptions } from "./types.js";
/** Render command result as JSON */
export function renderJson<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
const { schema } = result
export function renderJson<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
const { schema } = result;
// Apply custom serializer if provided
if (schema.serialize) {
if (result.type === 'list') {
if (result.type === "list") {
// If all items serialize to the same object, return just one
// This handles the case where a list of key-value rows should serialize
// to a single structured object
const serialized = result.data.map((item) => schema.serialize!(item))
const serialized = result.data.map((item) => schema.serialize!(item));
if (serialized.length > 0) {
const first = JSON.stringify(serialized[0])
const allSame = serialized.every((s) => JSON.stringify(s) === first)
const first = JSON.stringify(serialized[0]);
const allSame = serialized.every((s) => JSON.stringify(s) === first);
if (allSame) {
return JSON.stringify(serialized[0], null, 2)
return JSON.stringify(serialized[0], null, 2);
}
}
return JSON.stringify(serialized, null, 2)
return JSON.stringify(serialized, null, 2);
} else {
const serialized = schema.serialize(result.data)
return JSON.stringify(serialized, null, 2)
const serialized = schema.serialize(result.data);
return JSON.stringify(serialized, null, 2);
}
}
return JSON.stringify(result.data, null, 2)
return JSON.stringify(result.data, null, 2);
}
/** Render a single item as JSON line (for NDJSON streaming) */
export function renderJsonLine<T>(item: T, serialize?: (data: T) => unknown): string {
const output = serialize ? serialize(item) : item
return JSON.stringify(output)
const output = serialize ? serialize(item) : item;
return JSON.stringify(output);
}

View File

@@ -4,24 +4,21 @@
* Outputs only ID fields, one per line. Useful for scripting and pipelines.
*/
import type { AnyCommandResult, OutputOptions } from './types.js'
import type { AnyCommandResult, OutputOptions } from "./types.js";
/** Extract ID from item using schema definition */
function getId<T>(item: T, idField: keyof T | ((item: T) => string)): string {
if (typeof idField === 'function') {
return idField(item)
if (typeof idField === "function") {
return idField(item);
}
return String(item[idField])
return String(item[idField]);
}
/** Render command result in quiet mode (IDs only) */
export function renderQuiet<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
if (result.type === 'single') {
return getId(result.data, result.schema.idField)
export function renderQuiet<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
if (result.type === "single") {
return getId(result.data, result.schema.idField);
} else {
return result.data.map((item) => getId(item, result.schema.idField)).join('\n')
return result.data.map((item) => getId(item, result.schema.idField)).join("\n");
}
}

View File

@@ -4,100 +4,97 @@
* Selects the appropriate renderer based on output options.
*/
import chalk from 'chalk'
import YAML from 'yaml'
import type { AnyCommandResult, CommandError, OutputOptions } from './types.js'
import { renderTable } from './table.js'
import { renderJson } from './json.js'
import { renderYaml } from './yaml.js'
import { renderQuiet } from './quiet.js'
import chalk from "chalk";
import YAML from "yaml";
import type { AnyCommandResult, CommandError, OutputOptions } from "./types.js";
import { renderTable } from "./table.js";
import { renderJson } from "./json.js";
import { renderYaml } from "./yaml.js";
import { renderQuiet } from "./quiet.js";
/** Default output options */
export const defaultOutputOptions: OutputOptions = {
format: 'table',
format: "table",
quiet: false,
noHeaders: false,
noColor: false,
}
};
/** Render command result to string based on output options */
export function render<T>(
result: AnyCommandResult<T>,
options: Partial<OutputOptions> = {}
options: Partial<OutputOptions> = {},
): string {
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
const opts: OutputOptions = { ...defaultOutputOptions, ...options };
// Quiet mode takes precedence
if (opts.quiet) {
return renderQuiet(result, opts)
return renderQuiet(result, opts);
}
// Dispatch to format-specific renderer
switch (opts.format) {
case 'json':
return renderJson(result, opts)
case 'yaml':
return renderYaml(result, opts)
case 'table':
case "json":
return renderJson(result, opts);
case "yaml":
return renderYaml(result, opts);
case "table":
default:
return renderTable(result, opts)
return renderTable(result, opts);
}
}
/** Convert an unknown error to a CommandError */
export function toCommandError(error: unknown): CommandError {
if (isCommandError(error)) {
return error
return error;
}
if (error instanceof Error) {
return {
code: 'UNKNOWN_ERROR',
code: "UNKNOWN_ERROR",
message: error.message,
details: error.stack,
}
};
}
return {
code: 'UNKNOWN_ERROR',
code: "UNKNOWN_ERROR",
message: String(error),
}
};
}
/** Type guard for CommandError */
function isCommandError(error: unknown): error is CommandError {
return (
typeof error === 'object' &&
typeof error === "object" &&
error !== null &&
'code' in error &&
'message' in error &&
typeof (error as CommandError).code === 'string' &&
typeof (error as CommandError).message === 'string'
)
"code" in error &&
"message" in error &&
typeof (error as CommandError).code === "string" &&
typeof (error as CommandError).message === "string"
);
}
/** Render an error to string based on output options */
export function renderError(
error: CommandError,
options: Partial<OutputOptions> = {}
): string {
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
export function renderError(error: CommandError, options: Partial<OutputOptions> = {}): string {
const opts: OutputOptions = { ...defaultOutputOptions, ...options };
if (opts.format === 'json') {
return JSON.stringify({ error }, null, 2)
if (opts.format === "json") {
return JSON.stringify({ error }, null, 2);
}
if (opts.format === 'yaml') {
return YAML.stringify({ error })
if (opts.format === "yaml") {
return YAML.stringify({ error });
}
// Table/default format: human-readable error
const prefix = opts.noColor ? 'Error: ' : chalk.red('Error: ')
const message = error.message
const prefix = opts.noColor ? "Error: " : chalk.red("Error: ");
const message = error.message;
if (error.details && typeof error.details === 'string') {
return `${prefix}${message}\n${error.details}`
if (error.details && typeof error.details === "string") {
return `${prefix}${message}\n${error.details}`;
}
return `${prefix}${message}`
return `${prefix}${message}`;
}

View File

@@ -4,49 +4,40 @@
* Renders structured data as aligned ASCII tables with optional color support.
*/
import chalk, { type ChalkInstance } from 'chalk'
import type {
AnyCommandResult,
ColumnDef,
OutputOptions,
OutputSchema,
} from './types.js'
import chalk, { type ChalkInstance } from "chalk";
import type { AnyCommandResult, ColumnDef, OutputOptions, OutputSchema } from "./types.js";
// ANSI escape code regex for stripping colors when measuring width
const ANSI_REGEX =
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
/** Strip ANSI escape codes from a string */
function stripAnsi(str: string): string {
return str.replace(ANSI_REGEX, '')
return str.replace(ANSI_REGEX, "");
}
/** Get visible string length (excluding ANSI codes) */
function visibleLength(str: string): number {
return stripAnsi(str).length
return stripAnsi(str).length;
}
/** Pad a cell to the specified width with alignment */
function padCell(
cell: string,
width: number,
align: 'left' | 'right' | 'center'
): string {
const visible = visibleLength(cell)
const padding = Math.max(0, width - visible)
function padCell(cell: string, width: number, align: "left" | "right" | "center"): string {
const visible = visibleLength(cell);
const padding = Math.max(0, width - visible);
switch (align) {
case 'right':
return ' '.repeat(padding) + cell
case 'center': {
const left = Math.floor(padding / 2)
const right = padding - left
return ' '.repeat(left) + cell + ' '.repeat(right)
case "right":
return " ".repeat(padding) + cell;
case "center": {
const left = Math.floor(padding / 2);
const right = padding - left;
return " ".repeat(left) + cell + " ".repeat(right);
}
case 'left':
case "left":
default:
return cell + ' '.repeat(padding)
return cell + " ".repeat(padding);
}
}
@@ -65,15 +56,15 @@ function applyColor(str: string, colorName: string): string {
grey: chalk.grey,
dim: chalk.dim,
bold: chalk.bold,
}
};
const colorFn = colorMap[colorName]
return colorFn ? colorFn(str) : str
const colorFn = colorMap[colorName];
return colorFn ? colorFn(str) : str;
}
/** Extract value from item using field definition */
function getValue<T>(item: T, field: keyof T | ((item: T) => unknown)): unknown {
return typeof field === 'function' ? field(item) : item[field]
return typeof field === "function" ? field(item) : item[field];
}
/** Render a single table row */
@@ -81,106 +72,99 @@ function renderRow<T>(
item: T,
columns: ColumnDef<T>[],
widths: number[],
options: OutputOptions
options: OutputOptions,
): string {
return columns
.map((col, colIndex) => {
const value = getValue(item, col.field)
let cell = String(value ?? '')
const width = widths[colIndex]
const value = getValue(item, col.field);
let cell = String(value ?? "");
const width = widths[colIndex];
// Apply color if enabled
if (col.color && !options.noColor) {
const colorName = col.color(value, item)
const colorName = col.color(value, item);
if (colorName) {
cell = applyColor(cell, colorName)
cell = applyColor(cell, colorName);
}
}
return padCell(cell, width ?? 0, col.align ?? 'left')
return padCell(cell, width ?? 0, col.align ?? "left");
})
.join(' ')
.join(" ");
}
/** Render header row */
function renderHeader<T>(
columns: ColumnDef<T>[],
widths: number[],
options: OutputOptions
options: OutputOptions,
): string {
const headerRow = columns
.map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? 'left'))
.join(' ')
.map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? "left"))
.join(" ");
return options.noColor ? headerRow : chalk.bold(headerRow)
return options.noColor ? headerRow : chalk.bold(headerRow);
}
/** Calculate column widths based on content and hints */
function calculateWidths<T>(
data: T[],
columns: ColumnDef<T>[],
includeHeaders: boolean
): number[] {
function calculateWidths<T>(data: T[], columns: ColumnDef<T>[], includeHeaders: boolean): number[] {
return columns.map((col) => {
// Start with header width if including headers
let maxWidth = includeHeaders ? col.header.length : 0
let maxWidth = includeHeaders ? col.header.length : 0;
// Check all data values
for (const item of data) {
const value = getValue(item, col.field)
const str = String(value ?? '')
maxWidth = Math.max(maxWidth, visibleLength(str))
const value = getValue(item, col.field);
const str = String(value ?? "");
maxWidth = Math.max(maxWidth, visibleLength(str));
}
// Apply width hint if specified (minimum width)
if (col.width) {
maxWidth = Math.max(maxWidth, col.width)
maxWidth = Math.max(maxWidth, col.width);
}
return maxWidth
})
return maxWidth;
});
}
/** Render a list result as a table */
export function renderTable<T>(
result: AnyCommandResult<T>,
options: OutputOptions
): string {
const { schema } = result
const data = result.type === 'list' ? result.data : [result.data]
export function renderTable<T>(result: AnyCommandResult<T>, options: OutputOptions): string {
const { schema } = result;
const data = result.type === "list" ? result.data : [result.data];
if (data.length === 0) {
return ''
return "";
}
const columns = schema.columns as ColumnDef<T>[]
const includeHeaders = !options.noHeaders
const widths = calculateWidths(data, columns, includeHeaders)
const columns = schema.columns as ColumnDef<T>[];
const includeHeaders = !options.noHeaders;
const widths = calculateWidths(data, columns, includeHeaders);
const lines: string[] = []
const lines: string[] = [];
// Add header row
if (includeHeaders) {
lines.push(renderHeader(columns, widths, options))
lines.push(renderHeader(columns, widths, options));
}
// Add data rows
for (const item of data) {
lines.push(renderRow(item, columns, widths, options))
lines.push(renderRow(item, columns, widths, options));
}
return lines.join('\n')
return lines.join("\n");
}
/** Render just a table header (for streaming) */
export function renderTableHeader<T>(
schema: OutputSchema<T>,
options: OutputOptions,
widths?: number[]
widths?: number[],
): string {
const columns = schema.columns
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
return renderHeader(columns, actualWidths, options)
const columns = schema.columns;
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length);
return renderHeader(columns, actualWidths, options);
}
/** Render just a table row (for streaming) */
@@ -188,9 +172,9 @@ export function renderTableRow<T>(
item: T,
schema: OutputSchema<T>,
options: OutputOptions,
widths?: number[]
widths?: number[],
): string {
const columns = schema.columns
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
return renderRow(item, columns, actualWidths, options)
const columns = schema.columns;
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length);
return renderRow(item, columns, actualWidths, options);
}

View File

@@ -6,74 +6,74 @@
*/
/** Supported output formats */
export type OutputFormat = 'table' | 'json' | 'yaml'
export type OutputFormat = "table" | "json" | "yaml";
/** Options controlling output rendering */
export interface OutputOptions {
/** Output format (table, json, yaml) */
format: OutputFormat
format: OutputFormat;
/** Minimal output - IDs only */
quiet: boolean
quiet: boolean;
/** Omit table headers */
noHeaders: boolean
noHeaders: boolean;
/** Disable color output */
noColor: boolean
noColor: boolean;
}
/** Column definition for table output */
export interface ColumnDef<T> {
/** Header text for the column */
header: string
header: string;
/** Field key or accessor function */
field: keyof T | ((item: T) => unknown)
field: keyof T | ((item: T) => unknown);
/** Optional width hint (characters) */
width?: number
width?: number;
/** Optional alignment */
align?: 'left' | 'right' | 'center'
align?: "left" | "right" | "center";
/** Optional color function - returns chalk color name */
color?: (value: unknown, item: T) => string | undefined
color?: (value: unknown, item: T) => string | undefined;
}
/** Schema describing how to render command output */
export interface OutputSchema<T> {
/** Field to use for quiet mode (--quiet outputs just this) */
idField: keyof T | ((item: T) => string)
idField: keyof T | ((item: T) => string);
/** Column definitions for table output */
columns: ColumnDef<T>[]
columns: ColumnDef<T>[];
/** Optional: transform data before JSON/YAML output */
serialize?: (data: T) => unknown
serialize?: (data: T) => unknown;
}
/** Result type for commands returning a single item */
export interface SingleResult<T> {
type: 'single'
type: "single";
/** The structured data to render */
data: T
data: T;
/** Schema describing how to render this data (for item type T) */
schema: OutputSchema<T>
schema: OutputSchema<T>;
}
/** Result type for commands returning a list */
export interface ListResult<T> {
type: 'list'
type: "list";
/** The structured data to render */
data: T[]
data: T[];
/** Schema describing how to render this data (for item type T) */
schema: OutputSchema<T>
schema: OutputSchema<T>;
}
/** Union type for all command results */
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>;
/** Base interface for command results (deprecated, use SingleResult or ListResult) */
export type CommandResult<T> = SingleResult<T> | ListResult<T>
export type CommandResult<T> = SingleResult<T> | ListResult<T>;
/** Structured error for command failures */
export interface CommandError {
/** Machine-readable error code */
code: string
code: string;
/** Human-readable message */
message: string
message: string;
/** Additional context */
details?: unknown
details?: unknown;
}

View File

@@ -4,51 +4,51 @@
* Wraps command handlers to automatically render results and handle errors.
*/
import type { Command } from 'commander'
import type { AnyCommandResult, CommandError, OutputOptions } from './types.js'
import { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
import type { Command } from "commander";
import type { AnyCommandResult, CommandError, OutputOptions } from "./types.js";
import { render, renderError, toCommandError, defaultOutputOptions } from "./render.js";
/** Options that include output settings from global options */
export interface CommandOptions extends Partial<OutputOptions> {
[key: string]: unknown
[key: string]: unknown;
}
function normalizeFormat(raw: unknown): OutputOptions['format'] {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
function normalizeFormat(raw: unknown): OutputOptions["format"] {
const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
// Common user expectation: "cli" means "table/human"
if (value === 'cli') return 'table'
if (value === "cli") return "table";
if (value === 'table' || value === 'json' || value === 'yaml') return value
if (value === "table" || value === "json" || value === "yaml") return value;
const error: CommandError = {
code: 'INVALID_FORMAT',
code: "INVALID_FORMAT",
message: `Unsupported output format: ${String(raw)}`,
details: 'Supported formats: table, json, yaml',
}
throw error
details: "Supported formats: table, json, yaml",
};
throw error;
}
/** Extract output options from command options */
function extractOutputOptions(options: CommandOptions): OutputOptions {
const hasStructuredOutputSchema =
typeof options.outputSchema === 'string' && options.outputSchema.trim().length > 0
typeof options.outputSchema === "string" && options.outputSchema.trim().length > 0;
if (hasStructuredOutputSchema) {
return {
format: 'json',
format: "json",
quiet: false,
noHeaders: options.headers === false,
noColor: options.color === false,
}
};
}
return {
format: options.json ? 'json' : normalizeFormat(options.format ?? defaultOutputOptions.format),
format: options.json ? "json" : normalizeFormat(options.format ?? defaultOutputOptions.format),
quiet: options.quiet ?? defaultOutputOptions.quiet,
noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false
noColor: options.color === false, // Commander uses --no-color -> color: false
}
};
}
/**
@@ -71,37 +71,35 @@ function extractOutputOptions(options: CommandOptions): OutputOptions {
* ```
*/
export function withOutput<T, Args extends unknown[]>(
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>,
): (...args: [...Args, CommandOptions, Command]) => Promise<void> {
return async (...args) => {
// Last two args are options and command
const command = args[args.length - 1] as Command
const command = args[args.length - 1] as Command;
// Use optsWithGlobals() to get both local and global options
const options = command.optsWithGlobals() as CommandOptions
const outputOptions = extractOutputOptions(options)
const options = command.optsWithGlobals() as CommandOptions;
const outputOptions = extractOutputOptions(options);
try {
const result = await handler(...args)
const output = render(result, outputOptions)
const result = await handler(...args);
const output = render(result, outputOptions);
if (output) {
process.stdout.write(output + '\n')
process.stdout.write(output + "\n");
}
} catch (error) {
const commandError = toCommandError(error)
const errorOutput = renderError(commandError, outputOptions)
process.stderr.write(errorOutput + '\n')
process.exit(1)
const commandError = toCommandError(error);
const errorOutput = renderError(commandError, outputOptions);
process.stderr.write(errorOutput + "\n");
process.exit(1);
}
}
};
}
/**
* Helper to create output options from partial input.
* Useful for testing or manual rendering.
*/
export function createOutputOptions(
partial: Partial<OutputOptions> = {}
): OutputOptions {
return { ...defaultOutputOptions, ...partial }
export function createOutputOptions(partial: Partial<OutputOptions> = {}): OutputOptions {
return { ...defaultOutputOptions, ...partial };
}

View File

@@ -4,42 +4,39 @@
* Renders structured data as YAML for machine consumption and human readability.
*/
import YAML from 'yaml'
import type { AnyCommandResult, OutputOptions } from './types.js'
import YAML from "yaml";
import type { AnyCommandResult, OutputOptions } from "./types.js";
/** Render command result as YAML */
export function renderYaml<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
const { schema } = result
export function renderYaml<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
const { schema } = result;
// Apply custom serializer if provided
if (schema.serialize) {
if (result.type === 'list') {
if (result.type === "list") {
// If all items serialize to the same object, return just one
// This handles the case where a list of key-value rows should serialize
// to a single structured object
const serialized = result.data.map((item) => schema.serialize!(item))
const serialized = result.data.map((item) => schema.serialize!(item));
if (serialized.length > 0) {
const first = JSON.stringify(serialized[0])
const allSame = serialized.every((s) => JSON.stringify(s) === first)
const first = JSON.stringify(serialized[0]);
const allSame = serialized.every((s) => JSON.stringify(s) === first);
if (allSame) {
return YAML.stringify(serialized[0])
return YAML.stringify(serialized[0]);
}
}
return YAML.stringify(serialized)
return YAML.stringify(serialized);
} else {
const serialized = schema.serialize(result.data)
return YAML.stringify(serialized)
const serialized = schema.serialize(result.data);
return YAML.stringify(serialized);
}
}
return YAML.stringify(result.data)
return YAML.stringify(result.data);
}
/** Render a single item as YAML document (for streaming) */
export function renderYamlDoc<T>(item: T, serialize?: (data: T) => unknown): string {
const output = serialize ? serialize(item) : item
return YAML.stringify(output)
const output = serialize ? serialize(item) : item;
return YAML.stringify(output);
}

View File

@@ -5,7 +5,7 @@ import { homedir } from "node:os";
const CLIENT_SESSION_KEY_FILE = join(
process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
"cli-client-id"
"cli-client-id",
);
let cachedClientId: string | null = null;
@@ -25,9 +25,7 @@ export async function getOrCreateCliClientId(): Promise<string> {
}
try {
const existing = normalizeClientId(
await readFile(CLIENT_SESSION_KEY_FILE, "utf8")
);
const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8"));
if (existing) {
cachedClientId = existing;
return existing;

View File

@@ -1,186 +1,188 @@
import { existsSync, readFileSync } from 'node:fs'
import { loadConfig, resolvePaseoHome, DaemonClient } from '@getpaseo/server'
import path from 'node:path'
import WebSocket from 'ws'
import { getOrCreateCliClientId } from './client-id.js'
import { existsSync, readFileSync } from "node:fs";
import { loadConfig, resolvePaseoHome, DaemonClient } from "@getpaseo/server";
import path from "node:path";
import WebSocket from "ws";
import { getOrCreateCliClientId } from "./client-id.js";
export interface ConnectOptions {
host?: string
timeout?: number
host?: string;
timeout?: number;
}
const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000
const PID_FILENAME = 'paseo.pid'
const DEFAULT_HOST = "localhost:6767";
const DEFAULT_TIMEOUT = 5000;
const PID_FILENAME = "paseo.pid";
type DaemonTarget =
| {
type: 'tcp'
url: string
type: "tcp";
url: string;
}
| {
type: 'ipc'
url: string
socketPath: string
}
type: "ipc";
url: string;
socketPath: string;
};
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST;
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim()
const trimmed = raw.trim();
if (!trimmed) {
return null
return null;
}
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
trimmed.startsWith("unix://") ||
trimmed.startsWith("pipe://") ||
trimmed.startsWith("\\\\.\\pipe\\")
) {
return trimmed.startsWith('\\\\.\\pipe\\') ? `pipe://${trimmed}` : trimmed
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
}
if (path.isAbsolute(trimmed)) {
return `unix://${trimmed}`
return `unix://${trimmed}`;
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`
return `127.0.0.1:${trimmed}`;
}
return trimmed.includes(':') ? trimmed : null
return trimmed.includes(":") ? trimmed : null;
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST;
}
function isIpcDaemonHost(host: string | null): host is string {
return host !== null && (host.startsWith('unix://') || host.startsWith('pipe://'))
return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://"));
}
function isTcpDaemonHost(host: string | null): host is string {
return host !== null && !isIpcDaemonHost(host)
return host !== null && !isIpcDaemonHost(host);
}
function readPidSocketTarget(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, PID_FILENAME)
const pidPath = path.join(paseoHome, PID_FILENAME);
if (!existsSync(pidPath)) {
return null
return null;
}
try {
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { listen?: unknown; sockPath?: unknown }
return typeof parsed.listen === 'string' ? parsed.listen : typeof parsed.sockPath === 'string' ? parsed.sockPath : null
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as {
listen?: unknown;
sockPath?: unknown;
};
return typeof parsed.listen === "string"
? parsed.listen
: typeof parsed.sockPath === "string"
? parsed.sockPath
: null;
} catch {
return null
return null;
}
}
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? '')
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
if (isIpcDaemonHost(directEnvHost)) {
return directEnvHost
return directEnvHost;
}
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? '')
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? "");
if (isIpcDaemonHost(pidHost)) {
return pidHost
return pidHost;
}
const config = loadConfig(paseoHome, { env })
const configuredHost = normalizeDaemonHost(config.listen)
return isIpcDaemonHost(configuredHost) ? configuredHost : null
const config = loadConfig(paseoHome, { env });
const configuredHost = normalizeDaemonHost(config.listen);
return isIpcDaemonHost(configuredHost) ? configuredHost : null;
}
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen)
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen);
if (!isTcpDaemonHost(configuredHost)) {
return null
return null;
}
return configuredHost === '127.0.0.1:6767' ? null : configuredHost
return configuredHost === "127.0.0.1:6767" ? null : configuredHost;
}
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const paseoHome = resolvePaseoHome(env)
const candidates: string[] = []
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome)
const paseoHome = resolvePaseoHome(env);
const candidates: string[] = [];
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome);
if (configuredIpcHost) {
candidates.push(configuredIpcHost)
candidates.push(configuredIpcHost);
}
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome)
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome);
if (configuredTcpHost) {
candidates.push(configuredTcpHost)
candidates.push(configuredTcpHost);
}
candidates.push(DEFAULT_HOST)
return Array.from(new Set(candidates))
candidates.push(DEFAULT_HOST);
return Array.from(new Set(candidates));
}
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
const explicitHost = options?.host ?? process.env.PASEO_HOST
const explicitHost = options?.host ?? process.env.PASEO_HOST;
if (explicitHost) {
return [explicitHost]
return [explicitHost];
}
return resolveDefaultDaemonHosts()
return resolveDefaultDaemonHosts();
}
export function resolveDaemonTarget(host: string): DaemonTarget {
const trimmed = host.trim()
const trimmed = host.trim();
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
trimmed.startsWith("unix://") ||
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 = trimmed.startsWith("unix://")
? trimmed.slice("unix://".length).trim()
: trimmed.startsWith("pipe://")
? trimmed.slice("pipe://".length).trim()
: trimmed;
if (!socketPath) {
throw new Error('Invalid IPC daemon target: missing socket path')
throw new Error("Invalid IPC daemon target: missing socket path");
}
const isUnixSocket = trimmed.startsWith('unix://')
const isUnixSocket = trimmed.startsWith("unix://");
return {
type: 'ipc',
url: isUnixSocket
? `ws+unix://${socketPath}:/ws`
: 'ws://localhost/ws',
type: "ipc",
url: isUnixSocket ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws",
socketPath,
}
};
}
return {
type: 'tcp',
type: "tcp",
url: `ws://${trimmed}/ws`,
}
};
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (
url: string,
options?: { headers?: Record<string, string>; socketPath?: string }
) => {
return (url: string, options?: { headers?: Record<string, string>; socketPath?: string }) => {
return new WebSocket(url, {
headers: options?.headers,
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
}) as unknown as {
readyState: number
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
binaryType?: string
on: (event: string, listener: (...args: unknown[]) => void) => void
off: (event: string, listener: (...args: unknown[]) => void) => void
}
}
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on: (event: string, listener: (...args: unknown[]) => void) => void;
off: (event: string, listener: (...args: unknown[]) => void) => void;
};
};
}
/**
@@ -188,56 +190,54 @@ function createNodeWebSocketFactory() {
* Returns the connected client or throws if connection fails
*/
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const clientId = await getOrCreateCliClientId()
const hosts = resolveDaemonHostCandidates(options)
const nodeWebSocketFactory = createNodeWebSocketFactory()
let lastError: unknown = null
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
const clientId = await getOrCreateCliClientId();
const hosts = resolveDaemonHostCandidates(options);
const nodeWebSocketFactory = createNodeWebSocketFactory();
let lastError: unknown = null;
for (const host of hosts) {
const target = resolveDaemonTarget(host)
const client = new DaemonClient(
{
url: target.url,
clientId,
clientType: 'cli',
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
const target = resolveDaemonTarget(host);
const client = new DaemonClient({
url: target.url,
clientId,
clientType: "cli",
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]);
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
const connectPromise = client.connect();
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
reject(new Error(`Connection timeout after ${timeout}ms`));
}, timeout);
});
try {
await Promise.race([connectPromise, timeoutPromise])
await Promise.race([connectPromise, timeoutPromise]);
if (timeoutHandle) {
clearTimeout(timeoutHandle)
clearTimeout(timeoutHandle);
}
return client
return client;
} catch (err) {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
clearTimeout(timeoutHandle);
}
lastError = err
await client.close().catch(() => {})
lastError = err;
await client.close().catch(() => {});
}
}
if (lastError instanceof Error) {
throw lastError
throw lastError;
}
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(', ')}`)
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
}
/**
@@ -245,16 +245,16 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
*/
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
try {
return await connectToDaemon(options)
return await connectToDaemon(options);
} catch {
return null
return null;
}
}
/** Minimal agent type for ID resolution */
interface AgentLike {
id: string
title?: string | null
id: string;
title?: string | null;
}
/**
@@ -268,40 +268,40 @@ interface AgentLike {
*/
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
if (!idOrName || agents.length === 0) {
return null
return null;
}
const query = idOrName.toLowerCase()
const query = idOrName.toLowerCase();
// Try exact ID match first
const exactMatch = agents.find((a) => a.id === idOrName)
const exactMatch = agents.find((a) => a.id === idOrName);
if (exactMatch) {
return exactMatch.id
return exactMatch.id;
}
// Try ID prefix match
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query))
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query));
if (prefixMatches.length === 1 && prefixMatches[0]) {
return prefixMatches[0].id
return prefixMatches[0].id;
}
// Try title/name match (case-insensitive)
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query)
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query);
if (titleMatches.length === 1 && titleMatches[0]) {
return titleMatches[0].id
return titleMatches[0].id;
}
// Try partial title match
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query))
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query));
if (partialTitleMatches.length === 1 && partialTitleMatches[0]) {
return partialTitleMatches[0].id
return partialTitleMatches[0].id;
}
// If we have multiple prefix matches and no unique title match, return first prefix match
const firstPrefixMatch = prefixMatches[0]
const firstPrefixMatch = prefixMatches[0];
if (firstPrefixMatch) {
return firstPrefixMatch.id
return firstPrefixMatch.id;
}
return null
return null;
}

View File

@@ -1,23 +1,23 @@
import type { Command } from 'commander'
import type { Command } from "commander";
const JSON_OPTION_DESCRIPTION = 'Output in JSON format'
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
const DAEMON_HOST_OPTION_DESCRIPTION =
'Daemon host target (default: local socket/pipe, then localhost:6767)'
"Daemon host target (default: local socket/pipe, then localhost:6767)";
export function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value])
return previous.concat([value]);
}
export function addJsonOption<T extends Command>(command: T): T {
command.option('--json', JSON_OPTION_DESCRIPTION)
return command
command.option("--json", JSON_OPTION_DESCRIPTION);
return command;
}
export function addDaemonHostOption<T extends Command>(command: T): T {
command.option('--host <host>', DAEMON_HOST_OPTION_DESCRIPTION)
return command
command.option("--host <host>", DAEMON_HOST_OPTION_DESCRIPTION);
return command;
}
export function addJsonAndDaemonHostOptions<T extends Command>(command: T): T {
return addDaemonHostOption(addJsonOption(command))
return addDaemonHostOption(addJsonOption(command));
}

View File

@@ -4,40 +4,40 @@
* If no unit is specified, assumes seconds.
*/
export function parseDuration(input: string): number {
const trimmed = input.trim()
const trimmed = input.trim();
// If it's just a number, treat as seconds
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10) * 1000
return parseInt(trimmed, 10) * 1000;
}
// Parse duration with units
let totalMs = 0
const regex = /(\d+)([smh])/g
let match
let hasMatch = false
let totalMs = 0;
const regex = /(\d+)([smh])/g;
let match;
let hasMatch = false;
while ((match = regex.exec(trimmed)) !== null) {
hasMatch = true
const value = parseInt(match[1], 10)
const unit = match[2]
hasMatch = true;
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's':
totalMs += value * 1000
break
case 'm':
totalMs += value * 60 * 1000
break
case 'h':
totalMs += value * 60 * 60 * 1000
break
case "s":
totalMs += value * 1000;
break;
case "m":
totalMs += value * 60 * 1000;
break;
case "h":
totalMs += value * 60 * 60 * 1000;
break;
}
}
if (!hasMatch) {
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`)
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
}
return totalMs
return totalMs;
}

View File

@@ -3,8 +3,8 @@
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
return error.message;
}
return String(error)
return String(error);
}

View File

@@ -1,17 +1,17 @@
import type { AgentTimelineItem, DaemonClient } from '@getpaseo/server'
import type { AgentTimelineItem, DaemonClient } from "@getpaseo/server";
type FetchProjectedTimelineItemsInput = {
client: DaemonClient
agentId: string
}
client: DaemonClient;
agentId: string;
};
export async function fetchProjectedTimelineItems(
input: FetchProjectedTimelineItemsInput
input: FetchProjectedTimelineItemsInput,
): Promise<AgentTimelineItem[]> {
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
direction: 'tail',
direction: "tail",
limit: 0,
projection: 'projected',
})
return timeline.entries.map((entry) => entry.item)
projection: "projected",
});
return timeline.entries.map((entry) => entry.item);
}