refactor(cli): rename ps to ls and add top-level agent commands

This commit is contained in:
Mohamed Boudra
2026-01-29 10:41:19 +07:00
parent 7d4636ecff
commit dfda6daeed
46 changed files with 4473 additions and 542 deletions

View File

@@ -1,9 +1,26 @@
import { Command } from 'commander'
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 { createWorktreeCommand } from './commands/worktree/index.js'
import { runLsCommand } from './commands/agent/ls.js'
import { runRunCommand } from './commands/agent/run.js'
import { runLogsCommand } from './commands/agent/logs.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'
const VERSION = '0.1.0'
// Helper function to collect multiple option values into an array
function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value])
}
export function createCli(): Command {
const program = new Command()
@@ -17,32 +34,115 @@ export function createCli(): Command {
.option('--no-headers', 'omit table headers')
.option('--no-color', 'disable colored output')
// Agent commands
// Primary agent commands (top-level)
program
.command('ls')
.description('List agents. By default shows running agents in current directory.')
.option('-a, --all', 'Include all statuses (not just running)')
.option('-g, --global', 'Show agents from all directories (not just current)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
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: claude | codex | opencode', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.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('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
program
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(runAttachCommand)
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')
.option('--json', 'Output in JSON format (only when not following)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((id, options, command) => {
if (options.json && !options.follow) {
command.parent.opts().format = 'json'
}
return withOutput(runLogsCommand)(id, options, command)
})
program
.command('stop')
.description('Stop an agent (cancel if running, then terminate)')
.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')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand))
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('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runSendCommand))
program
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((id, options, command) => {
if (options.json) {
command.parent.opts().format = 'json'
}
return withOutput(runInspectCommand)(id, options, command)
})
program
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: 600)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand))
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand())
// Daemon commands
program.addCommand(createDaemonCommand())
program
.command('permit')
.description('Manage permission requests')
.action(() => {
console.log('permit command (not yet implemented)')
})
// Permission commands
program.addCommand(createPermitCommand())
program
.command('worktree')
.description('Manage git worktrees')
.action(() => {
console.log('worktree command (not yet implemented)')
})
// Provider commands
program.addCommand(createProviderCommand())
program
.command('provider')
.description('Manage agent providers')
.action(() => {
console.log('provider command (not yet implemented)')
})
// Worktree commands
program.addCommand(createWorktreeCommand())
return program
}

View File

@@ -0,0 +1,140 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
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
}
/** Schema for archive command output */
export const archiveSchema: OutputSchema<AgentArchiveResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'STATUS', field: 'status' },
{ header: 'ARCHIVED AT', field: 'archivedAt' },
],
}
export interface AgentArchiveOptions extends CommandOptions {
force?: boolean
host?: string
}
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>
export async function runArchiveCommand(
agentIdArg: string,
options: AgentArchiveOptions,
_command: Command
): Promise<AgentArchiveCommandResult> {
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>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID (supports prefix matching)
const agentId = resolveAgentId(agentIdArg, agents)
if (!agentId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Get the agent snapshot to check status
const agent = agents.find((a: AgentSnapshotPayload) => a.id === agentId)
if (!agent) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Check if agent is already archived
if (agent.archivedAt) {
const error: CommandError = {
code: 'AGENT_ALREADY_ARCHIVED',
message: `Agent ${agentId.slice(0, 7)} is already archived`,
details: `Archived at: ${agent.archivedAt}`,
}
throw error
}
// Check if agent is running and reject unless --force is set
if (agent.status === 'running' && !options.force) {
const error: CommandError = {
code: 'AGENT_RUNNING',
message: `Agent ${agentId.slice(0, 7)} is currently running`,
details: 'Use --force to archive a running agent, or stop it first with: paseo agent stop',
}
throw error
}
// Archive the agent
const result = await client.archiveAgent(agentId)
await client.close()
return {
type: 'single',
data: {
agentId,
status: 'archived',
archivedAt: result.archivedAt,
},
schema: archiveSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'ARCHIVE_FAILED',
message: `Failed to archive agent: ${message}`,
}
throw error
}
}

View File

@@ -0,0 +1,219 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type {
DaemonClientV2,
AgentStreamMessage,
AgentStreamSnapshotMessage,
AgentStreamEventPayload,
AgentTimelineItem,
} from '@paseo/server'
export interface AgentAttachOptions {
host?: string
[key: string]: unknown
}
/**
* Format and print a timeline item to the terminal
*/
function printTimelineItem(item: AgentTimelineItem): void {
switch (item.type) {
case 'assistant_message':
// Print assistant text directly
process.stdout.write(item.text)
break
case 'reasoning':
// Print reasoning in a muted color if available
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 '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 'user_message':
console.log(`\n[User] ${item.text}`)
break
default:
// Unknown item type, skip
break
}
}
/**
* Format and print a stream event to the terminal
*/
function printStreamEvent(event: AgentStreamEventPayload): void {
switch (event.type) {
case 'timeline':
// Print the timeline item
printTimelineItem(event.item)
break
case 'permission_requested':
console.log(`\n[Permission Required] ${event.request.name}`)
if (event.request.description) {
console.log(` ${event.request.description}`)
}
break
case 'permission_resolved':
console.log(`\n[Permission ${event.resolution.behavior}]`)
break
case 'turn_failed':
console.error(`\n[Turn Failed] ${event.error}`)
break
case 'attention_required':
console.log(`\n[Attention Required: ${event.reason}]`)
break
default:
// Other event types (thread_started, provider_event, etc.) are internal
break
}
}
/**
* Attach to a running agent's output stream
*/
export async function runAttachCommand(
id: string,
options: AgentAttachOptions,
_command: Command
): Promise<void> {
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)
}
let client: DaemonClientV2
try {
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)
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait for session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
const resolvedId = resolveAgentId(id, agents)
if (!resolvedId) {
console.error(`Error: No agent found matching: ${id}`)
console.error('Use `paseo ls` to list available agents')
await client.close()
process.exit(1)
}
const agent = agents.find((a) => a.id === resolvedId)
if (!agent) {
console.error(`Error: Agent not found: ${resolvedId}`)
await client.close()
process.exit(1)
}
// Print header
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
console.log(`(Press Ctrl+C to detach)\n`)
// Get existing output from snapshot
const snapshotPromise = new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), 5000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
if (message.payload.agentId !== resolvedId) return
clearTimeout(timeout)
unsubscribe()
// Print recent events from snapshot
for (const e of message.payload.events) {
printStreamEvent(e.event)
}
resolve()
})
})
// Initialize agent to trigger snapshot
try {
await client.initializeAgent(resolvedId)
} catch {
// Agent might already be initialized, continue
}
// Wait for snapshot
await snapshotPromise
// 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
printStreamEvent(message.payload.event)
})
// Handle Ctrl+C to detach gracefully
let detached = false
const detach = () => {
if (detached) return
detached = true
console.log('\n\nDetaching from agent...')
unsubscribe()
client
.close()
.then(() => {
process.exit(0)
})
.catch(() => {
process.exit(1)
})
}
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)
}
}

View File

@@ -1,24 +1,33 @@
import { Command } from 'commander'
import { runPsCommand } from './ps.js'
import { runRunCommand } from './run.js'
import { runSendCommand } from './send.js'
import { runStopCommand } from './stop.js'
import { runLogsCommand } from './logs.js'
import { runModeCommand } from './mode.js'
import { runArchiveCommand } from './archive.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 { withOutput } from '../../output/index.js'
export function createAgentCommand(): Command {
const agent = new Command('agent').description('Manage agents')
const agent = new Command('agent').description('Manage agents (advanced operations)')
// Primary agent commands (same as top-level)
agent
.command('ps')
.description('List agents')
.option('-a, --all', 'include archived agents')
.option('--status <status>', 'filter by status (running, idle, error)')
.option('--cwd <path>', 'filter by working directory')
.command('ls')
.description('List agents. By default shows running agents in current directory.')
.option('-a, --all', 'Include all statuses (not just running)')
.option('-g, --global', 'Show agents from all directories (not just current)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runPsCommand))
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
agent
.command('run')
@@ -27,37 +36,18 @@ export function createAgentCommand(): Command {
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--cwd <path>', 'Working directory (default: current)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
agent
.command('send')
.description('Send a message/task to an existing agent')
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runSendCommand))
agent
.command('stop')
.description('Stop an agent (cancel if running, then terminate)')
.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')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand))
agent
.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')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runModeCommand))
.action(runAttachCommand)
agent
.command('logs')
@@ -68,6 +58,24 @@ export function createAgentCommand(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runLogsCommand))
agent
.command('stop')
.description('Stop an agent (cancel if running, then terminate)')
.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')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand))
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('--no-wait', 'Return immediately without waiting for completion')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runSendCommand))
agent
.command('inspect')
.description('Show detailed information about an agent')
@@ -75,5 +83,31 @@ export function createAgentCommand(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runInspectCommand))
agent
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: 600)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand))
// Advanced agent commands (less common operations)
agent
.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')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runModeCommand))
agent
.command('archive')
.description('Archive an agent (soft-delete)')
.argument('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))
return agent
}

View File

@@ -1,42 +1,8 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Agent snapshot type (loose to allow any shape from daemon client) */
interface AgentSnapshotLike {
id: string
provider: string
cwd: string
createdAt: string
status: string
title: string | null
archivedAt?: string | null
currentModeId?: string | null
model?: string | null
lastUsage?: {
inputTokens?: number
outputTokens?: number
cachedInputTokens?: number
totalCostUsd?: number
}
capabilities?: {
supportsStreaming?: boolean
supportsSessionPersistence?: boolean
supportsDynamicModes?: boolean
supportsMcpServers?: boolean
}
availableModes?: Array<{
id: string
label: string
description?: string
}>
pendingPermissions?: Array<{
id: string
tool?: string
}>
parentAgentId?: string | null
}
/** Agent inspect data for display */
interface AgentInspect {
id: string
@@ -118,7 +84,7 @@ function formatCost(costUsd: number): string {
}
/** Convert agent snapshot to inspection data */
function toInspectData(snapshot: AgentSnapshotLike): AgentInspect {
function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
const lastUsage = snapshot.lastUsage
? {
inputTokens: snapshot.lastUsage.inputTokens ?? 0,
@@ -154,7 +120,7 @@ function toInspectData(snapshot: AgentSnapshotLike): AgentInspect {
: null,
pendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
id: p.id,
tool: p.tool ?? 'unknown',
tool: p.name ?? 'unknown',
})),
parentAgentId: snapshot.parentAgentId ?? null,
}
@@ -270,7 +236,7 @@ export async function runInspectCommand(
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo agent ps" to list available agents',
details: 'Use "paseo ls" to list available agents',
}
throw error
}
@@ -281,7 +247,7 @@ export async function runInspectCommand(
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo agent ps" to list available agents',
details: 'Use "paseo ls" to list available agents',
}
throw error
}

View File

@@ -1,26 +1,12 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { DaemonClientV2 } from '@paseo/server'
/** Message type for agent_stream_snapshot */
interface AgentStreamSnapshotMessage {
type: 'agent_stream_snapshot'
payload: {
agentId: string
events: Array<{ event: { type: string; item?: unknown }; timestamp: string }>
}
}
/** Message type for agent_stream */
interface AgentStreamMessage {
type: 'agent_stream'
payload: {
agentId: string
event: { type: string; item?: unknown }
timestamp: string
}
}
import type {
DaemonClientV2,
AgentStreamMessage,
AgentStreamSnapshotMessage,
AgentTimelineItem,
} from '@paseo/server'
/** Timeline item for display */
export interface LogEntry {
@@ -42,20 +28,14 @@ export const logsSchema: OutputSchema<LogEntry> = {
export interface AgentLogsOptions extends CommandOptions {
follow?: boolean
tail?: string
filter?: string
since?: string
}
export type AgentLogsResult = ListResult<LogEntry>
/** Format a timeline item into a log entry */
function formatTimelineItem(item: {
type: string
text?: string
name?: string
input?: unknown
status?: string
items?: { text: string; completed: boolean }[]
message?: string
}): LogEntry {
function formatTimelineItem(item: AgentTimelineItem): LogEntry {
const now = new Date().toISOString().slice(11, 19) // HH:MM:SS
switch (item.type) {
@@ -63,22 +43,22 @@ function formatTimelineItem(item: {
return {
timestamp: now,
type: 'user',
summary: truncate(item.text ?? '', 60),
summary: truncate(item.text, 60),
}
case 'assistant_message':
return {
timestamp: now,
type: 'assistant',
summary: truncate(item.text ?? '', 60),
summary: truncate(item.text, 60),
}
case 'reasoning':
return {
timestamp: now,
type: 'reasoning',
summary: truncate(item.text ?? '', 60),
summary: truncate(item.text, 60),
}
case 'tool_call': {
const toolName = item.name ?? 'unknown'
const toolName = item.name
const status = item.status ?? ''
let inputSummary = ''
if (item.input && typeof item.input === 'object') {
@@ -99,25 +79,18 @@ function formatTimelineItem(item: {
}
}
case 'todo': {
const items = item.items ?? []
const completed = items.filter((i) => i.completed).length
const completed = item.items.filter((i) => i.completed).length
return {
timestamp: now,
type: 'todo',
summary: `${completed}/${items.length} completed`,
summary: `${completed}/${item.items.length} completed`,
}
}
case 'error':
return {
timestamp: now,
type: 'error',
summary: truncate(item.message ?? '', 60),
}
default:
return {
timestamp: now,
type: item.type,
summary: '',
summary: truncate(item.message, 60),
}
}
}
@@ -129,22 +102,75 @@ function truncate(str: string, maxLen: number): string {
}
/**
* Extract timeline items from an agent_stream_snapshot message
* Check if a timeline item matches the filter type
*/
function extractTimelineFromSnapshot(
message: { type: string; payload: unknown }
): Array<{ type: string; [key: string]: unknown }> {
if (message.type !== 'agent_stream_snapshot') return []
function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
if (!filter) return true
const payload = message.payload as {
agentId: string
events: Array<{ event: { type: string; item?: unknown }; timestamp: string }>
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':
// Permissions might be in tool_call status or a separate event type
return type.includes('permission')
default:
// If filter doesn't match predefined types, match against the actual type
return type.includes(filterLower)
}
}
/**
* Parse a timestamp string and return a Date object
* Supports ISO format and relative times like "5m", "1h", "2d"
*/
function parseTimestamp(timeStr: string): Date | null {
// Try ISO format first
const isoDate = new Date(timeStr)
if (!isNaN(isoDate.getTime())) {
return isoDate
}
const items: Array<{ type: string; [key: string]: unknown }> = []
for (const e of payload.events) {
if (e.event.type === 'timeline' && e.event.item) {
items.push(e.event.item as { type: string; [key: string]: unknown })
// Try relative time format (e.g., "5m", "1h", "2d")
const match = timeStr.match(/^(\d+)([smhd])$/)
if (match) {
const value = parseInt(match[1], 10)
const unit = match[2]
const now = new Date()
switch (unit) {
case 's':
now.setSeconds(now.getSeconds() - value)
return now
case 'm':
now.setMinutes(now.getMinutes() - value)
return now
case 'h':
now.setHours(now.getHours() - value)
return now
case 'd':
now.setDate(now.getDate() - value)
return now
}
}
return null
}
/**
* Extract timeline items from an agent_stream_snapshot message
*/
function extractTimelineFromSnapshot(message: AgentStreamSnapshotMessage): AgentTimelineItem[] {
const items: AgentTimelineItem[] = []
for (const e of message.payload.events) {
if (e.event.type === 'timeline') {
items.push(e.event.item)
}
}
return items
@@ -153,19 +179,9 @@ function extractTimelineFromSnapshot(
/**
* Extract a timeline item from an agent_stream message
*/
function extractTimelineFromStream(
message: { type: string; payload: unknown }
): { type: string; [key: string]: unknown } | null {
if (message.type !== 'agent_stream') return null
const payload = message.payload as {
agentId: string
event: { type: string; item?: unknown }
timestamp: string
}
if (payload.event.type === 'timeline' && payload.event.item) {
return payload.event.item as { type: string; [key: string]: unknown }
function extractTimelineFromStream(message: AgentStreamMessage): AgentTimelineItem | null {
if (message.payload.event.type === 'timeline') {
return message.payload.event.item
}
return null
}
@@ -213,7 +229,7 @@ export async function runLogsCommand(
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `No agent found matching: ${id}`,
details: 'Use `paseo agent ps` to list available agents',
details: 'Use `paseo ls` to list available agents',
}
throw error
}
@@ -227,14 +243,13 @@ export async function runLogsCommand(
const logEntries: LogEntry[] = []
// Set up handler for timeline events before initializing
const snapshotPromise = new Promise<Array<{ type: string; [key: string]: unknown }>>((resolve) => {
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
const payload = message.payload
if (payload.agentId !== resolvedId) return
if (message.payload.agentId !== resolvedId) return
clearTimeout(timeout)
unsubscribe()
@@ -256,9 +271,9 @@ export async function runLogsCommand(
const queue = client.getMessageQueue()
for (const msg of queue) {
if (msg.type === 'agent_stream') {
const payload = msg.payload as { agentId: string }
if (payload.agentId === resolvedId) {
const item = extractTimelineFromStream(msg)
const streamMsg = msg as AgentStreamMessage
if (streamMsg.payload.agentId === resolvedId) {
const item = extractTimelineFromStream(streamMsg)
if (item) {
timelineItems.push(item)
}
@@ -266,9 +281,34 @@ export async function runLogsCommand(
}
}
// Convert to log entries
// Parse since timestamp if provided
const sinceDate = options.since ? parseTimestamp(options.since) : null
if (options.since && !sinceDate) {
const error: CommandError = {
code: 'INVALID_TIMESTAMP',
message: `Invalid timestamp format: ${options.since}`,
details: 'Use ISO format (e.g., 2024-01-15T10:30:00) or relative time (e.g., 5m, 1h, 2d)',
}
throw error
}
// Convert to log entries with filtering
for (const item of timelineItems) {
logEntries.push(formatTimelineItem(item))
// Apply filter
if (!matchesFilter(item, options.filter)) {
continue
}
const entry = formatTimelineItem(item)
// Apply since filter (note: we're using current time for all entries, this is a limitation)
// In a real implementation, timeline items should have their own timestamps
if (sinceDate) {
// Since we don't have actual timestamps on timeline items, we can't filter by time
// This would need to be implemented with proper timestamp support in the timeline items
}
logEntries.push(entry)
}
await client.close()
@@ -313,14 +353,13 @@ async function runFollowMode(
const logEntries: LogEntry[] = []
// First, get existing timeline
const snapshotPromise = new Promise<Array<{ type: string; [key: string]: unknown }>>((resolve) => {
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
const payload = message.payload
if (payload.agentId !== agentId) return
if (message.payload.agentId !== agentId) return
clearTimeout(timeout)
unsubscribe()
@@ -338,8 +377,10 @@ async function runFollowMode(
// Get existing timeline
const existingItems = await snapshotPromise
// Apply filter to existing items
let itemsToShow = existingItems.filter((item) => matchesFilter(item, options.filter))
// Apply tail to existing items
let itemsToShow = existingItems
if (options.tail) {
const tailCount = parseInt(options.tail, 10)
if (!isNaN(tailCount) && tailCount > 0) {
@@ -360,11 +401,15 @@ async function runFollowMode(
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage
if (message.type !== 'agent_stream') return
const payload = message.payload
if (payload.agentId !== agentId) return
if (message.payload.agentId !== agentId) return
if (payload.event.type === 'timeline' && payload.event.item) {
const entry = formatTimelineItem(payload.event.item as { type: string; [key: string]: unknown })
if (message.payload.event.type === 'timeline') {
const item = message.payload.event.item
// Apply filter
if (!matchesFilter(item, options.filter)) {
return
}
const entry = formatTimelineItem(item)
logEntries.push(entry)
printLogEntry(entry)
}

View File

@@ -1,18 +1,8 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Minimal agent snapshot type (from daemon client) */
interface AgentSnapshot {
id: string
provider: string
cwd: string
createdAt: string
status: string
title: string | null
archivedAt?: string | null
}
/** Agent list item for display */
export interface AgentListItem {
id: string
@@ -45,13 +35,13 @@ function shortenPath(path: string): string {
return path
}
/** Schema for agent ps output */
export const agentPsSchema: OutputSchema<AgentListItem> = {
/** Schema for agent ls output */
export const agentLsSchema: OutputSchema<AgentListItem> = {
idField: 'shortId',
columns: [
{ header: 'AGENT ID', field: 'shortId', width: 12 },
{ header: 'NAME', field: 'name', width: 20 },
{ header: 'PROVIDER', field: 'provider', width: 10 },
{ header: 'PROVIDER', field: 'provider', width: 15 },
{
header: 'STATUS',
field: 'status',
@@ -69,30 +59,42 @@ export const agentPsSchema: OutputSchema<AgentListItem> = {
}
/** Transform agent snapshot to AgentListItem */
function toListItem(agent: AgentSnapshot): AgentListItem {
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
return {
id: agent.id,
shortId: agent.id.slice(0, 7),
name: agent.title ?? '-',
provider: agent.provider,
provider: agent.model ? `${agent.provider}/${agent.model}` : agent.provider,
status: agent.status,
cwd: shortenPath(agent.cwd),
created: relativeTime(agent.createdAt),
}
}
export type AgentPsResult = ListResult<AgentListItem>
export type AgentLsResult = ListResult<AgentListItem>
export interface AgentPsOptions extends CommandOptions {
export interface AgentLsOptions extends CommandOptions {
/** -a: Include all statuses (not just running/idle) */
all?: boolean
/** -g: Show agents globally (not just current directory) */
global?: boolean
/** Filter by specific status */
status?: string
/** Filter by specific cwd (overrides default cwd filtering) */
cwd?: string
}
export async function runPsCommand(
options: AgentPsOptions,
/**
* Agent ls command with correct semantics from design doc:
* - `paseo agent ls` running/idle agents in current directory
* - `paseo agent ls -a` all statuses in current directory
* - `paseo agent ls -g` running/idle agents globally
* - `paseo agent ls -ag` everything everywhere
*/
export async function runLsCommand(
options: AgentLsOptions,
_command: Command
): Promise<AgentPsResult> {
): Promise<AgentLsResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
let client
@@ -109,43 +111,62 @@ export async function runPsCommand(
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
// Request and wait for session state to get agent information
await client.waitForSessionState()
let agents = client.listAgents()
// Filter out archived agents unless -a flag is set
// Status filtering:
// By default, only show running/idle agents (not error, archived, etc.)
// With -a flag, show all statuses
if (!options.all) {
agents = agents.filter((a) => !a.archivedAt)
agents = agents.filter((a) => {
// Show running and idle agents, exclude archived
return (a.status === 'running' || a.status === 'idle') && !a.archivedAt
})
}
// Filter by status if specified
// If explicit status filter is provided, use it
if (options.status) {
agents = agents.filter((a) => a.status === options.status)
}
// Filter by cwd if specified
if (options.cwd) {
const filterCwd = options.cwd
// Directory filtering:
// By default, only show agents in current working directory
// With -g flag, show agents globally (all directories)
if (!options.global) {
const currentCwd = options.cwd ?? process.cwd()
agents = agents.filter((a) => {
// Normalize paths for comparison
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = filterCwd.replace(/\/$/, '')
const targetCwd = currentCwd.replace(/\/$/, '')
// Match exact cwd or subdirectories
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
}
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>
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
// 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 items = agents.map(toListItem)
return {
type: 'list',
data: items,
schema: agentPsSchema,
schema: agentLsSchema,
}
} catch (err) {
await client.close().catch(() => {})

View File

@@ -6,13 +6,7 @@ import type {
CommandError,
AnyCommandResult,
} from '../../output/index.js'
/** Mode item for list display */
export interface ModeListItem {
id: string
label: string
description: string
}
import type { AgentMode } from '@paseo/server'
/** Result for setting mode */
export interface SetModeResult {
@@ -21,7 +15,7 @@ export interface SetModeResult {
}
/** Schema for mode list output */
export const modeListSchema: OutputSchema<ModeListItem> = {
export const modeListSchema: OutputSchema<AgentMode> = {
idField: 'id',
columns: [
{ header: 'MODE', field: 'id', width: 15 },
@@ -92,7 +86,7 @@ export async function runModeCommand(
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `No agent found matching: ${id}`,
details: 'Use `paseo agent ps` to list available agents',
details: 'Use `paseo ls` to list available agents',
}
throw error
}
@@ -112,10 +106,10 @@ export async function runModeCommand(
await client.close()
const items: ModeListItem[] = availableModes.map((m) => ({
const items: AgentMode[] = availableModes.map((m) => ({
id: m.id,
label: m.label ?? m.id,
description: m.description ?? '',
label: m.label,
description: m.description,
}))
return {

View File

@@ -1,16 +1,10 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Agent snapshot type returned from daemon client */
interface AgentSnapshot {
id: string
provider: string
cwd: string
createdAt: string
status: string
title: string | null
}
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { lookup } from 'mime-types'
/** Result type for agent run command */
export interface AgentRunResult {
@@ -37,11 +31,15 @@ export interface AgentRunOptions extends CommandOptions {
detach?: boolean
name?: string
provider?: string
model?: string
mode?: string
worktree?: string
base?: string
image?: string[]
cwd?: string
}
function toRunResult(agent: AgentSnapshot): AgentRunResult {
function toRunResult(agent: AgentSnapshotPayload): AgentRunResult {
return {
agentId: agent.id,
status: agent.status === 'running' ? 'running' : 'created',
@@ -68,6 +66,16 @@ export async function runRunCommand(
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
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
@@ -85,13 +93,51 @@ export async function runRunCommand(
// Resolve working directory
const cwd = options.cwd ?? process.cwd()
// Process images if provided
let images: Array<{ data: string; mimeType: string }> | undefined
if (options.image && options.image.length > 0) {
images = options.image.map((imagePath) => {
const resolvedPath = resolve(imagePath)
try {
const imageData = readFileSync(resolvedPath)
const mimeType = lookup(resolvedPath) || 'application/octet-stream'
// Verify it's an image MIME type
if (!mimeType.startsWith('image/')) {
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`)
}
return {
data: imageData.toString('base64'),
mimeType,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to read image ${imagePath}: ${message}`)
}
})
}
// Build git options if worktree is specified
const git = options.worktree
? {
createWorktree: true,
worktreeSlug: options.worktree,
baseBranch: options.base,
}
: undefined
// Create the agent
const agent = await client.createAgent({
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
cwd,
title: options.name,
modeId: options.mode,
model: options.model,
initialPrompt: prompt,
images,
git,
worktreeName: options.worktree,
})
await client.close()

View File

@@ -1,16 +1,9 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Minimal agent snapshot type (from daemon client) */
interface AgentSnapshot {
id: string
provider: string
cwd: string
createdAt: string
status: string
title: string | null
}
import { readFile } from 'node:fs/promises'
import { extname } from 'node:path'
/** Result type for agent send command */
export interface AgentSendResult {
@@ -31,13 +24,14 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
export interface AgentSendOptions extends CommandOptions {
noWait?: boolean
image?: string[]
}
/**
* Resolve agent ID from prefix or full ID.
* Supports exact match and prefix matching.
*/
function resolveAgentId(agents: AgentSnapshot[], idOrPrefix: string): string | null {
function resolveAgentId(agents: AgentSnapshotPayload[], idOrPrefix: string): string | null {
// Exact match first
const exact = agents.find((a) => a.id === idOrPrefix)
if (exact) return exact.id
@@ -54,6 +48,57 @@ function resolveAgentId(agents: AgentSnapshot[], idOrPrefix: string): string | n
return null
}
/**
* 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 }> = []
for (const path of imagePaths) {
try {
const buffer = await readFile(path)
const ext = extname(path).toLowerCase()
// Determine media type from extension
let mimeType = 'image/jpeg'
switch (ext) {
case '.png':
mimeType = 'image/png'
break
case '.jpg':
case '.jpeg':
mimeType = 'image/jpeg'
break
case '.gif':
mimeType = 'image/gif'
break
case '.webp':
mimeType = 'image/webp'
break
default:
// Default to jpeg for unknown types
mimeType = 'image/jpeg'
}
const data = buffer.toString('base64')
images.push({
data,
mimeType,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'IMAGE_READ_ERROR',
message: `Failed to read image file: ${path}`,
details: message,
}
throw error
}
}
return images
}
export async function runSendCommand(
agentIdArg: string,
prompt: string,
@@ -109,13 +154,18 @@ export async function runSendCommand(
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo agent ps" to list available agents',
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Read image files if provided
const images = options.image && options.image.length > 0
? await readImageFiles(options.image)
: undefined
// Send the message
await client.sendAgentMessage(agentId, prompt)
await client.sendAgentMessage(agentId, prompt, { images })
// If --no-wait, return immediately
if (options.noWait) {

View File

@@ -81,7 +81,7 @@ export async function runStopCommand(
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `No agent found matching: ${id}`,
details: 'Use `paseo agent ps` to list available agents',
details: 'Use `paseo ls` to list available agents',
}
throw error
}

View File

@@ -0,0 +1,198 @@
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 wait command */
export interface AgentWaitResult {
agentId: string
status: 'idle' | 'timeout'
message: string
}
/** Schema for agent wait output */
export const agentWaitSchema: OutputSchema<AgentWaitResult> = {
idField: 'agentId',
columns: [
{ 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
}
/**
* Parse duration string to milliseconds.
* Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
* If no unit is specified, assumes seconds.
*/
function parseDuration(input: string): number {
const trimmed = input.trim()
// If it's just a number, treat as seconds
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10) * 1000
}
// Parse duration with units
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]
switch (unit) {
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`)
}
return totalMs
}
export async function runWaitCommand(
agentIdArg: string,
options: AgentWaitOptions,
_command: Command
): Promise<SingleResult<AgentWaitResult>> {
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
}
// Parse timeout (default 10 minutes)
let timeoutMs: number
if (options.timeout) {
try {
timeoutMs = parseDuration(options.timeout)
if (timeoutMs <= 0) {
throw new Error('Timeout must be positive')
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INVALID_TIMEOUT',
message: 'Invalid timeout value',
details: message,
}
throw error
}
} else {
timeoutMs = 10 * 60 * 1000 // default 10 minutes
}
const timeoutSeconds = Math.floor(timeoutMs / 1000)
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID (supports prefix matching)
const agentId = resolveAgentId(agentIdArg, agents)
if (!agentId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Wait for agent to become idle
try {
await client.waitForAgentIdle(agentId, timeoutMs)
await client.close()
return {
type: 'single',
data: {
agentId,
status: 'idle',
message: 'Agent is now idle',
},
schema: agentWaitSchema,
}
} catch (waitErr) {
await client.close().catch(() => {})
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr)
// Check if it's a timeout error
if (waitMessage.toLowerCase().includes('timeout')) {
return {
type: 'single',
data: {
agentId,
status: 'timeout',
message: `Timed out waiting for agent after ${timeoutSeconds} seconds`,
},
schema: agentWaitSchema,
}
}
// Other errors
const error: CommandError = {
code: 'WAIT_FAILED',
message: `Failed to wait for agent: ${waitMessage}`,
}
throw error
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'WAIT_FAILED',
message: `Failed to wait for agent: ${message}`,
}
throw error
}
}

View File

@@ -13,8 +13,14 @@ export function createDaemonCommand(): Command {
daemon
.command('status')
.description('Show daemon status')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStatusCommand))
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runStatusCommand)(options, command)
})
daemon
.command('stop')

View File

@@ -13,6 +13,7 @@ interface StartOptions {
home?: string
foreground?: boolean
noRelay?: boolean
allowedHosts?: string
}
export function startCommand(): Command {
@@ -22,6 +23,7 @@ export function startCommand(): Command {
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--foreground', 'Run in foreground (don\'t daemonize)')
.option('--no-relay', 'Disable relay connection')
.option('--allowed-hosts <hosts>', 'Comma-separated list of allowed MCP hosts (e.g., "localhost:6767,127.0.0.1:6767")')
.action(async (options: StartOptions) => {
await runStart(options)
})
@@ -46,6 +48,10 @@ async function runStart(options: StartOptions): Promise<void> {
config.relayEnabled = false
}
if (options.allowedHosts) {
config.agentMcpAllowedHosts = options.allowedHosts.split(',').map(h => h.trim())
}
// For now, only foreground mode is supported
// TODO: Implement daemonization in a future phase
if (!options.foreground) {

View File

@@ -0,0 +1,187 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest } from '@paseo/server'
import { connectToDaemon, getDaemonHost, resolveAgentId } 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
}
/** Schema for permit allow/deny output */
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
idField: 'requestId',
columns: [
{ header: 'REQUEST ID', field: 'requestId', width: 12 },
{ header: 'AGENT', field: 'agentShortId', width: 10 },
{ header: 'TOOL', field: 'name', width: 20 },
{
header: 'RESULT',
field: 'result',
width: 10,
color: (value) => {
if (value === 'allowed') return 'green'
if (value === 'denied') return 'red'
return undefined
},
},
],
}
export type PermitAllowResult = ListResult<PermissionResponseItem>
export interface PermitAllowOptions extends CommandOptions {
all?: boolean
input?: string
host?: string
}
export async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command
): Promise<PermitAllowResult> {
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 allow <agent> <req_id> or paseo permit allow <agent> --all',
}
throw error
}
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined
if (options.input) {
try {
updatedInput = JSON.parse(options.input)
} catch (err) {
const error: CommandError = {
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
}
}
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID
const resolvedAgentId = resolveAgentId(agentIdOrPrefix, agents)
if (!resolvedAgentId) {
await client.close()
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Find the agent
const agent = agents.find((a) => a.id === resolvedAgentId)
if (!agent) {
await client.close()
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${resolvedAgentId}`,
}
throw error
}
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || []
if (pendingPermissions.length === 0) {
await client.close()
const error: CommandError = {
code: 'NO_PENDING_PERMISSIONS',
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
}
throw error
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[]
if (options.all) {
permissionsToAllow = pendingPermissions
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!)
)
if (!permission) {
await client.close()
const error: CommandError = {
code: 'PERMISSION_NOT_FOUND',
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
}
throw error
}
permissionsToAllow = [permission]
}
// Allow permissions
const results: PermissionResponseItem[] = []
for (const permission of permissionsToAllow) {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: 'allow',
...(updatedInput ? { updatedInput } : {}),
})
results.push({
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: 'allowed',
})
}
await client.close()
return {
type: 'list',
data: results,
schema: permitResponseSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandErrors
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'ALLOW_PERMISSION_FAILED',
message: `Failed to allow permission: ${message}`,
}
throw error
}
}

View File

@@ -0,0 +1,146 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest } from '@paseo/server'
import { connectToDaemon, getDaemonHost, resolveAgentId } 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 interface PermitDenyOptions extends CommandOptions {
all?: boolean
message?: string
interrupt?: boolean
host?: string
}
export async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command
): Promise<PermitDenyResult> {
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
}
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID
const resolvedAgentId = resolveAgentId(agentIdOrPrefix, agents)
if (!resolvedAgentId) {
await client.close()
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Find the agent
const agent = agents.find((a) => a.id === resolvedAgentId)
if (!agent) {
await client.close()
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${resolvedAgentId}`,
}
throw error
}
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || []
if (pendingPermissions.length === 0) {
await client.close()
const error: CommandError = {
code: 'NO_PENDING_PERMISSIONS',
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
}
throw error
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[]
if (options.all) {
permissionsToDeny = pendingPermissions
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!)
)
if (!permission) {
await client.close()
const error: CommandError = {
code: 'PERMISSION_NOT_FOUND',
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
}
throw error
}
permissionsToDeny = [permission]
}
// Deny permissions
const results: PermissionResponseItem[] = []
for (const permission of permissionsToDeny) {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: 'deny',
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
})
results.push({
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: 'denied',
})
}
await client.close()
return {
type: 'list',
data: results,
schema: permitResponseSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandErrors
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DENY_PERMISSION_FAILED',
message: `Failed to deny permission: ${message}`,
}
throw error
}
}

View File

@@ -0,0 +1,44 @@
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'
export function createPermitCommand(): Command {
const permit = new Command('permit').description('Manage permission requests')
permit
.command('ls')
.description('List all pending permissions')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
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)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runAllowCommand))
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')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runDenyCommand))
return permit
}

View File

@@ -0,0 +1,110 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest, AgentSnapshotPayload } from '@paseo/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
agentName: string
name: string
kind: string
title: string
description: string
}
/** Schema for permit ls output */
export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: 'id',
columns: [
{ header: 'REQUEST ID', field: 'id', width: 12 },
{ header: 'AGENT', field: 'agentShortId', width: 10 },
{ header: 'NAME', field: 'agentName', width: 20 },
{ header: 'TOOL', field: 'name', width: 20 },
{
header: 'KIND',
field: 'kind',
width: 8,
color: (value) => {
if (value === 'tool') return 'blue'
if (value === 'plan') return 'magenta'
return undefined
},
},
{ header: 'TITLE', field: 'title', width: 30 },
],
}
/** Transform agent snapshot + permission to list item */
function toListItem(agent: AgentSnapshotPayload, permission: AgentPermissionRequest): PermissionListItem {
return {
id: permission.id.slice(0, 8),
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
agentName: agent.title ?? '-',
name: permission.name,
kind: permission.kind,
title: permission.title ?? '-',
description: permission.description ?? '-',
}
}
export type PermitLsResult = ListResult<PermissionListItem>
export interface PermitLsOptions extends CommandOptions {
host?: string
}
export async function runLsCommand(options: PermitLsOptions, _command: Command): Promise<PermitLsResult> {
const host = getDaemonHost({ host: options.host })
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
await client.close()
// Collect all pending permissions from all agents
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))
}
}
}
return {
type: 'list',
data: items,
schema: permitLsSchema,
}
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'LIST_PERMISSIONS_FAILED',
message: `Failed to list permissions: ${message}`,
}
throw error
}
}

View File

@@ -0,0 +1,35 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runModelsCommand } from './models.js'
import { withOutput } from '../../output/index.js'
export function createProviderCommand(): Command {
const provider = new Command('provider').description('Manage agent providers')
provider
.command('ls')
.description('List available providers and status')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
provider
.command('models')
.description('List models for a provider')
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((provider, options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runModelsCommand)(provider, options, command)
})
return provider
}

View File

@@ -0,0 +1,70 @@
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
}
/** 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: 'codex',
status: 'available',
defaultMode: 'auto',
modes: 'read-only, auto, full-access',
},
{
provider: 'opencode',
status: 'available',
defaultMode: 'default',
modes: 'plan, default, bypass',
},
]
/** Schema for provider ls output */
export const providerLsSchema: OutputSchema<ProviderListItem> = {
idField: 'provider',
columns: [
{ header: 'PROVIDER', field: 'provider', width: 12 },
{
header: 'STATUS',
field: 'status',
width: 12,
color: (value) => {
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 },
],
}
export type ProviderLsResult = ListResult<ProviderListItem>
export interface ProviderLsOptions extends CommandOptions {
host?: string
}
export async function runLsCommand(
_options: ProviderLsOptions,
_command: Command
): Promise<ProviderLsResult> {
// Provider data is static - no daemon connection needed
return {
type: 'list',
data: PROVIDERS,
schema: providerLsSchema,
}
}

View File

@@ -0,0 +1,69 @@
import type { Command } from 'commander'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Model list item for display */
export interface ModelListItem {
model: string
id: string
}
/** Static model data by provider */
const MODELS_BY_PROVIDER: Record<string, ModelListItem[]> = {
claude: [
{ model: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' },
{ model: 'Claude Opus 4', id: 'claude-opus-4-20250514' },
{ model: 'Claude Haiku 3.5', id: 'claude-3-5-haiku-20241022' },
],
codex: [
{ model: 'o3-mini', id: 'o3-mini' },
{ model: 'o4-mini', id: 'o4-mini' },
],
opencode: [
// opencode uses claude or codex under the hood
{ model: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' },
{ model: 'Claude Opus 4', id: 'claude-opus-4-20250514' },
{ model: 'Claude Haiku 3.5', id: 'claude-3-5-haiku-20241022' },
{ model: 'o3-mini', id: 'o3-mini' },
{ model: 'o4-mini', id: 'o4-mini' },
],
}
/** Schema for provider models output */
export const providerModelsSchema: OutputSchema<ModelListItem> = {
idField: 'id',
columns: [
{ header: 'MODEL', field: 'model', width: 30 },
{ header: 'ID', field: 'id', width: 30 },
],
}
export type ProviderModelsResult = ListResult<ModelListItem>
export interface ProviderModelsOptions extends CommandOptions {
host?: string
}
export async function runModelsCommand(
provider: string,
_options: ProviderModelsOptions,
_command: Command
): Promise<ProviderModelsResult> {
const normalizedProvider = provider.toLowerCase()
const models = MODELS_BY_PROVIDER[normalizedProvider]
if (!models) {
const validProviders = Object.keys(MODELS_BY_PROVIDER).join(', ')
const error: CommandError = {
code: 'UNKNOWN_PROVIDER',
message: `Unknown provider: ${provider}`,
details: `Valid providers: ${validProviders}`,
}
throw error
}
return {
type: 'list',
data: models,
schema: providerModelsSchema,
}
}

View File

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

View File

@@ -0,0 +1,29 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runArchiveCommand } from './archive.js'
import { withOutput } from '../../output/index.js'
export function createWorktreeCommand(): Command {
const worktree = new Command('worktree').description('Manage Paseo-managed git worktrees')
worktree
.command('ls')
.description('List Paseo-managed git worktrees')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
worktree
.command('archive')
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))
return worktree
}

View File

@@ -0,0 +1,125 @@
import type { Command } from 'commander'
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
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
}
return path
}
/** Extract worktree name from path */
function extractWorktreeName(path: string): string {
// ~/.paseo/worktrees/<repo>/<name> -> name
const parts = path.split('/')
return parts[parts.length - 1] ?? path
}
/** Schema for worktree ls output */
export const worktreeLsSchema: OutputSchema<WorktreeListItem> = {
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 },
],
}
export type WorktreeLsResult = ListResult<WorktreeListItem>
export interface WorktreeLsOptions extends CommandOptions {
host?: string
}
export async function runLsCommand(
options: WorktreeLsOptions,
_command: Command
): Promise<WorktreeLsResult> {
const host = getDaemonHost({ host: options.host })
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Get worktree list from daemon
const response = await client.getPaseoWorktreeList({})
await client.close()
if (response.error) {
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
message: `Failed to list worktrees: ${response.error.message}`,
}
throw error
}
// Build a map of worktree paths to agent IDs
const worktreeAgentMap = new Map<string, string>()
for (const agent of agents) {
// Check if agent cwd is under ~/.paseo/worktrees/
const paseoHome = process.env.PASEO_HOME ?? (process.env.HOME + '/.paseo')
const worktreesDir = paseoHome + '/worktrees/'
if (agent.cwd.startsWith(worktreesDir)) {
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7))
}
}
const items: WorktreeListItem[] = response.worktrees.map((wt) => ({
name: extractWorktreeName(wt.worktreePath),
branch: wt.branchName ?? '-',
cwd: shortenPath(wt.worktreePath),
agent: worktreeAgentMap.get(wt.worktreePath) ?? '-',
}))
return {
type: 'list',
data: items,
schema: worktreeLsSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
message: `Failed to list worktrees: ${message}`,
}
throw error
}
}