Files
paseo/packages/cli/src/commands/agent/attach.ts
T

220 lines
5.6 KiB
TypeScript

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 agent list
client.requestAgentList()
// Wait for agent list 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)
}
}