feat(cli): add agent logs command

Add `paseo agent logs <id>` command to view agent activity/timeline.

Features:
- View agent timeline entries (messages, tool calls, errors)
- `-f, --follow` flag for real-time streaming
- `--tail <n>` to limit entries shown
- Proper formatting of different timeline item types
This commit is contained in:
Mohamed Boudra
2026-01-28 23:46:13 +07:00
parent 74fceed9b5
commit 7d4636ecff
3 changed files with 556 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ 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 { runInspectCommand } from './inspect.js'
import { withOutput } from '../../output/index.js'
@@ -58,6 +59,15 @@ export function createAgentCommand(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runModeCommand))
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('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runLogsCommand))
agent
.command('inspect')
.description('Show detailed information about an agent')

View File

@@ -0,0 +1,398 @@
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
}
}
/** Timeline item for display */
export interface LogEntry {
timestamp: string
type: string
summary: string
}
/** Schema for logs output */
export const logsSchema: OutputSchema<LogEntry> = {
idField: 'timestamp',
columns: [
{ header: 'TIME', field: 'timestamp', width: 12 },
{ header: 'TYPE', field: 'type', width: 15 },
{ header: 'SUMMARY', field: 'summary', width: 60 },
],
}
export interface AgentLogsOptions extends CommandOptions {
follow?: boolean
tail?: 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 {
const now = new Date().toISOString().slice(11, 19) // HH:MM:SS
switch (item.type) {
case 'user_message':
return {
timestamp: now,
type: 'user',
summary: truncate(item.text ?? '', 60),
}
case 'assistant_message':
return {
timestamp: now,
type: 'assistant',
summary: truncate(item.text ?? '', 60),
}
case 'reasoning':
return {
timestamp: now,
type: 'reasoning',
summary: truncate(item.text ?? '', 60),
}
case 'tool_call': {
const toolName = item.name ?? 'unknown'
const status = item.status ?? ''
let inputSummary = ''
if (item.input && typeof item.input === 'object') {
const inp = item.input as Record<string, unknown>
// Common input fields for summarization
if (inp.command) {
inputSummary = truncate(String(inp.command), 40)
} else if (inp.file_path) {
inputSummary = truncate(String(inp.file_path), 40)
} else if (inp.pattern) {
inputSummary = truncate(String(inp.pattern), 40)
}
}
return {
timestamp: now,
type: `tool:${toolName}`,
summary: inputSummary ? `${status} ${inputSummary}`.trim() : status,
}
}
case 'todo': {
const items = item.items ?? []
const completed = items.filter((i) => i.completed).length
return {
timestamp: now,
type: 'todo',
summary: `${completed}/${items.length} completed`,
}
}
case 'error':
return {
timestamp: now,
type: 'error',
summary: truncate(item.message ?? '', 60),
}
default:
return {
timestamp: now,
type: item.type,
summary: '',
}
}
}
function truncate(str: string, maxLen: number): string {
const cleaned = str.replace(/\n/g, ' ').trim()
if (cleaned.length <= maxLen) return cleaned
return cleaned.slice(0, maxLen - 3) + '...'
}
/**
* Extract timeline items from an agent_stream_snapshot message
*/
function extractTimelineFromSnapshot(
message: { type: string; payload: unknown }
): Array<{ type: string; [key: string]: unknown }> {
if (message.type !== 'agent_stream_snapshot') return []
const payload = message.payload as {
agentId: string
events: Array<{ event: { type: string; item?: unknown }; timestamp: string }>
}
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 })
}
}
return items
}
/**
* 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 }
}
return null
}
export async function runLogsCommand(
id: string,
options: AgentLogsOptions,
_command: Command
): Promise<AgentLogsResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
if (!id) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Agent ID required',
details: 'Usage: paseo agent logs <id>',
}
throw error
}
let client: DaemonClientV2
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 for session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
const resolvedId = resolveAgentId(id, agents)
if (!resolvedId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `No agent found matching: ${id}`,
details: 'Use `paseo agent ps` to list available agents',
}
throw error
}
// For follow mode, we stream events continuously
if (options.follow) {
return await runFollowMode(client, resolvedId, options)
}
// For non-follow mode, initialize the agent to get timeline snapshot
const logEntries: LogEntry[] = []
// Set up handler for timeline events before initializing
const snapshotPromise = new Promise<Array<{ type: string; [key: string]: unknown }>>((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
clearTimeout(timeout)
unsubscribe()
resolve(extractTimelineFromSnapshot(message))
})
})
// Initialize agent to trigger timeline snapshot
try {
await client.initializeAgent(resolvedId)
} catch {
// Agent might already be initialized, continue to collect from queue
}
// Get timeline from snapshot
const timelineItems = await snapshotPromise
// Also check message queue for any stream events
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)
if (item) {
timelineItems.push(item)
}
}
}
}
// Convert to log entries
for (const item of timelineItems) {
logEntries.push(formatTimelineItem(item))
}
await client.close()
// Apply tail limit
let entries = logEntries
if (options.tail) {
const tailCount = parseInt(options.tail, 10)
if (!isNaN(tailCount) && tailCount > 0) {
entries = entries.slice(-tailCount)
}
}
return {
type: 'list',
data: entries,
schema: logsSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw if already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'LOGS_FAILED',
message: `Failed to get logs: ${message}`,
}
throw error
}
}
/**
* Follow mode: stream logs in real-time until interrupted
*/
async function runFollowMode(
client: DaemonClientV2,
agentId: string,
options: AgentLogsOptions
): Promise<AgentLogsResult> {
const logEntries: LogEntry[] = []
// First, get existing timeline
const snapshotPromise = new Promise<Array<{ type: string; [key: string]: unknown }>>((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
clearTimeout(timeout)
unsubscribe()
resolve(extractTimelineFromSnapshot(message))
})
})
// Initialize agent to trigger timeline snapshot
try {
await client.initializeAgent(agentId)
} catch {
// Agent might already be initialized
}
// Get existing timeline
const existingItems = await snapshotPromise
// Apply tail to existing items
let itemsToShow = existingItems
if (options.tail) {
const tailCount = parseInt(options.tail, 10)
if (!isNaN(tailCount) && tailCount > 0) {
itemsToShow = itemsToShow.slice(-tailCount)
}
}
// Print existing entries
for (const item of itemsToShow) {
const entry = formatTimelineItem(item)
logEntries.push(entry)
printLogEntry(entry)
}
// Subscribe to new events
console.log('\n--- Following logs (Ctrl+C to stop) ---\n')
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 (payload.event.type === 'timeline' && payload.event.item) {
const entry = formatTimelineItem(payload.event.item as { type: string; [key: string]: unknown })
logEntries.push(entry)
printLogEntry(entry)
}
})
// Wait for interrupt
await new Promise<void>((resolve) => {
const cleanup = () => {
unsubscribe()
resolve()
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
})
await client.close()
return {
type: 'list',
data: logEntries,
schema: logsSchema,
}
}
function printLogEntry(entry: LogEntry): void {
// Simple format for streaming output
const typeWidth = 15
const paddedType = entry.type.padEnd(typeWidth)
console.log(`${entry.timestamp} ${paddedType} ${entry.summary}`)
}

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env npx tsx
/**
* Phase 7: Agent Logs Command Tests
*
* Tests the agent logs command - viewing agent activity/timeline.
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - agent logs --help shows options
* - agent logs requires ID argument
* - agent logs handles daemon not running
* - agent logs -f (follow) flag is accepted
* - agent logs --tail flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Agent Logs Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: agent logs --help shows options
{
console.log('Test 1: agent logs --help shows options')
const result = await $`npx paseo agent logs --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent logs --help should exit 0')
assert(result.stdout.includes('-f') || result.stdout.includes('--follow'), 'help should mention -f/--follow flag')
assert(result.stdout.includes('--tail'), 'help should mention --tail option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
console.log('✓ agent logs --help shows options\n')
}
// Test 2: agent logs requires ID argument
{
console.log('Test 2: agent logs requires ID argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ agent logs requires ID argument\n')
}
// Test 3: agent logs handles daemon not running
{
console.log('Test 3: agent logs handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs abc123`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ agent logs handles daemon not running\n')
}
// Test 4: agent logs -f (follow) flag is accepted
{
console.log('Test 4: agent logs -f (follow) flag is accepted')
// Use timeout to avoid hanging on follow mode
const result =
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs -f abc123' || true`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -f flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent logs -f (follow) flag is accepted\n')
}
// Test 5: agent logs --follow flag is accepted
{
console.log('Test 5: agent logs --follow flag is accepted')
const result =
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs --follow abc123' || true`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --follow flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent logs --follow flag is accepted\n')
}
// Test 6: agent logs --tail flag is accepted
{
console.log('Test 6: agent logs --tail flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs --tail 50 abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --tail flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent logs --tail flag is accepted\n')
}
// Test 7: agent logs with ID and --host flag is accepted
{
console.log('Test 7: agent logs with ID and --host flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent logs abc123 --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent logs with ID and --host flag is accepted\n')
}
// Test 8: agent shows logs in subcommands
{
console.log('Test 8: agent --help shows logs subcommand')
const result = await $`npx paseo agent --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
assert(result.stdout.includes('logs'), 'help should mention logs subcommand')
console.log('✓ agent --help shows logs subcommand\n')
}
// Test 9: -q (quiet) flag is accepted with agent logs
{
console.log('Test 9: -q (quiet) flag is accepted with agent logs')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent logs abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with agent logs\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All agent logs tests passed ===')