mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user