feat: wait returns on permission request and improve CLI logging
This commit is contained in:
@@ -84,14 +84,8 @@ export function createCli(): Command {
|
||||
.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)
|
||||
})
|
||||
.action(runLogsCommand)
|
||||
|
||||
program
|
||||
.command('stop')
|
||||
|
||||
@@ -64,8 +64,9 @@ export function createAgentCommand(): Command {
|
||||
.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('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runLogsCommand))
|
||||
.action(runLogsCommand)
|
||||
|
||||
agent
|
||||
.command('stop')
|
||||
|
||||
@@ -3,37 +3,39 @@ 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 inspect data for display */
|
||||
/** Agent inspect data for display (matches CLI spec format) */
|
||||
interface AgentInspect {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
model: string
|
||||
status: string
|
||||
mode: string
|
||||
cwd: string
|
||||
createdAt: string
|
||||
archivedAt: string | null
|
||||
lastUsage: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedInputTokens: number
|
||||
totalCostUsd: number
|
||||
Id: string
|
||||
Name: string
|
||||
Provider: string
|
||||
Model: string
|
||||
Status: string
|
||||
Mode: string
|
||||
Cwd: string
|
||||
CreatedAt: string
|
||||
UpdatedAt: string
|
||||
LastUsage: {
|
||||
InputTokens: number
|
||||
OutputTokens: number
|
||||
CachedTokens: number
|
||||
CostUsd: number
|
||||
} | null
|
||||
capabilities: {
|
||||
streaming: boolean
|
||||
persistence: boolean
|
||||
dynamicModes: boolean
|
||||
mcpServers: boolean
|
||||
Capabilities: {
|
||||
Streaming: boolean
|
||||
Persistence: boolean
|
||||
DynamicModes: boolean
|
||||
McpServers: boolean
|
||||
} | null
|
||||
availableModes: Array<{
|
||||
AvailableModes: Array<{
|
||||
id: string
|
||||
label: string
|
||||
}> | null
|
||||
pendingPermissions: Array<{
|
||||
PendingPermissions: Array<{
|
||||
id: string
|
||||
tool: string
|
||||
}>
|
||||
Worktree: string | null
|
||||
ParentAgentId: string | null
|
||||
}
|
||||
|
||||
/** Key-value row for table display */
|
||||
@@ -86,94 +88,94 @@ function formatCost(costUsd: number): string {
|
||||
function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
const lastUsage = snapshot.lastUsage
|
||||
? {
|
||||
inputTokens: snapshot.lastUsage.inputTokens ?? 0,
|
||||
outputTokens: snapshot.lastUsage.outputTokens ?? 0,
|
||||
cachedInputTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
|
||||
totalCostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
|
||||
InputTokens: snapshot.lastUsage.inputTokens ?? 0,
|
||||
OutputTokens: snapshot.lastUsage.outputTokens ?? 0,
|
||||
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
|
||||
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
|
||||
}
|
||||
: null
|
||||
|
||||
const capabilities = snapshot.capabilities
|
||||
? {
|
||||
streaming: snapshot.capabilities.supportsStreaming ?? false,
|
||||
persistence: snapshot.capabilities.supportsSessionPersistence ?? false,
|
||||
dynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
|
||||
mcpServers: snapshot.capabilities.supportsMcpServers ?? false,
|
||||
Streaming: snapshot.capabilities.supportsStreaming ?? false,
|
||||
Persistence: snapshot.capabilities.supportsSessionPersistence ?? false,
|
||||
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
|
||||
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
|
||||
}
|
||||
: null
|
||||
|
||||
// Extract worktree and parentAgentId from labels if they exist
|
||||
const worktree = snapshot.labels?.['paseo.worktree'] ?? null
|
||||
const parentAgentId = snapshot.labels?.['paseo.parent-agent-id'] ?? null
|
||||
|
||||
return {
|
||||
id: snapshot.id,
|
||||
name: snapshot.title ?? '-',
|
||||
provider: snapshot.provider,
|
||||
model: snapshot.model ?? '-',
|
||||
status: snapshot.status,
|
||||
mode: snapshot.currentModeId ?? 'default',
|
||||
cwd: snapshot.cwd,
|
||||
createdAt: snapshot.createdAt,
|
||||
archivedAt: snapshot.archivedAt ?? null,
|
||||
lastUsage,
|
||||
capabilities,
|
||||
availableModes: snapshot.availableModes
|
||||
Id: snapshot.id,
|
||||
Name: snapshot.title ?? '-',
|
||||
Provider: snapshot.provider,
|
||||
Model: snapshot.model ?? '-',
|
||||
Status: snapshot.status,
|
||||
Mode: snapshot.currentModeId ?? 'default',
|
||||
Cwd: snapshot.cwd,
|
||||
CreatedAt: snapshot.createdAt,
|
||||
UpdatedAt: snapshot.updatedAt,
|
||||
LastUsage: lastUsage,
|
||||
Capabilities: capabilities,
|
||||
AvailableModes: snapshot.availableModes
|
||||
? snapshot.availableModes.map((m) => ({ id: m.id, label: m.label }))
|
||||
: null,
|
||||
pendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
|
||||
PendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
|
||||
id: p.id,
|
||||
tool: p.name ?? 'unknown',
|
||||
})),
|
||||
Worktree: worktree,
|
||||
ParentAgentId: parentAgentId,
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert agent to key-value rows for table display */
|
||||
function toInspectRows(agent: AgentInspect): InspectRow[] {
|
||||
const rows: InspectRow[] = [
|
||||
{ key: 'Id', value: agent.id },
|
||||
{ key: 'Name', value: agent.name },
|
||||
{ key: 'Provider', value: agent.provider },
|
||||
{ key: 'Model', value: agent.model },
|
||||
{ key: 'Status', value: agent.status },
|
||||
{ key: 'Mode', value: agent.mode },
|
||||
{ key: 'Cwd', value: shortenPath(agent.cwd) },
|
||||
{ key: 'CreatedAt', value: agent.createdAt },
|
||||
{ key: 'Id', value: agent.Id },
|
||||
{ key: 'Name', value: agent.Name },
|
||||
{ key: 'Provider', value: agent.Provider },
|
||||
{ key: 'Model', value: agent.Model },
|
||||
{ key: 'Status', value: agent.Status },
|
||||
{ key: 'Mode', value: agent.Mode },
|
||||
{ key: 'Cwd', value: shortenPath(agent.Cwd) },
|
||||
{ key: 'CreatedAt', value: agent.CreatedAt },
|
||||
{ key: 'UpdatedAt', value: agent.UpdatedAt },
|
||||
]
|
||||
|
||||
if (agent.archivedAt) {
|
||||
rows.push({ key: 'ArchivedAt', value: agent.archivedAt })
|
||||
}
|
||||
|
||||
if (agent.lastUsage) {
|
||||
if (agent.LastUsage) {
|
||||
rows.push({
|
||||
key: 'LastUsage',
|
||||
value: `${agent.lastUsage.inputTokens} in, ${agent.lastUsage.outputTokens} out, ${formatCost(agent.lastUsage.totalCostUsd)}`,
|
||||
value: `InputTokens: ${agent.LastUsage.InputTokens}, OutputTokens: ${agent.LastUsage.OutputTokens}, CachedTokens: ${agent.LastUsage.CachedTokens}, CostUsd: ${formatCost(agent.LastUsage.CostUsd)}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (agent.capabilities) {
|
||||
const caps = agent.capabilities
|
||||
const capsList = [
|
||||
caps.streaming ? 'Streaming' : null,
|
||||
caps.persistence ? 'Persistence' : null,
|
||||
caps.dynamicModes ? 'DynamicModes' : null,
|
||||
caps.mcpServers ? 'McpServers' : null,
|
||||
].filter(Boolean)
|
||||
rows.push({ key: 'Capabilities', value: capsList.join(', ') || 'none' })
|
||||
if (agent.Capabilities) {
|
||||
rows.push({
|
||||
key: 'Capabilities',
|
||||
value: `Streaming: ${agent.Capabilities.Streaming}, Persistence: ${agent.Capabilities.Persistence}, DynamicModes: ${agent.Capabilities.DynamicModes}, McpServers: ${agent.Capabilities.McpServers}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (agent.availableModes && agent.availableModes.length > 0) {
|
||||
if (agent.AvailableModes && agent.AvailableModes.length > 0) {
|
||||
rows.push({
|
||||
key: 'AvailableModes',
|
||||
value: agent.availableModes.map((m) => m.id).join(', '),
|
||||
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
if (agent.pendingPermissions.length > 0) {
|
||||
rows.push({
|
||||
key: 'PendingPermissions',
|
||||
value: agent.pendingPermissions.length.toString(),
|
||||
})
|
||||
} else {
|
||||
rows.push({ key: 'PendingPermissions', value: '[]' })
|
||||
}
|
||||
rows.push({
|
||||
key: 'PendingPermissions',
|
||||
value: agent.PendingPermissions.length > 0
|
||||
? agent.PendingPermissions.map(p => `${p.id} (${p.tool})`).join(', ')
|
||||
: '[]'
|
||||
})
|
||||
|
||||
rows.push({ key: 'Worktree', value: agent.Worktree ?? 'null' })
|
||||
rows.push({ key: 'ParentAgentId', value: agent.ParentAgentId ?? 'null' })
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
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 { CommandOptions } from '../../output/index.js'
|
||||
import type {
|
||||
DaemonClientV2,
|
||||
AgentStreamMessage,
|
||||
AgentStreamSnapshotMessage,
|
||||
AgentTimelineItem,
|
||||
} from '@paseo/server'
|
||||
|
||||
/** 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 },
|
||||
],
|
||||
}
|
||||
import { curateAgentActivity } from '@paseo/server'
|
||||
|
||||
export interface AgentLogsOptions extends CommandOptions {
|
||||
follow?: boolean
|
||||
@@ -32,74 +16,9 @@ export interface AgentLogsOptions extends CommandOptions {
|
||||
since?: string
|
||||
}
|
||||
|
||||
export type AgentLogsResult = ListResult<LogEntry>
|
||||
// Logs command returns void - it outputs directly to console
|
||||
export type AgentLogsResult = void
|
||||
|
||||
/** Format a timeline item into a log entry */
|
||||
function formatTimelineItem(item: AgentTimelineItem): 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
|
||||
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 completed = item.items.filter((i) => i.completed).length
|
||||
return {
|
||||
timestamp: now,
|
||||
type: 'todo',
|
||||
summary: `${completed}/${item.items.length} completed`,
|
||||
}
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
timestamp: now,
|
||||
type: 'error',
|
||||
summary: truncate(item.message, 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) + '...'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a timeline item matches the filter type
|
||||
@@ -126,43 +45,6 @@ function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
@@ -194,12 +76,9 @@ export async function runLogsCommand(
|
||||
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
|
||||
console.error('Error: Agent ID required')
|
||||
console.error('Usage: paseo agent logs <id>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let client: DaemonClientV2
|
||||
@@ -207,12 +86,9 @@ export async function runLogsCommand(
|
||||
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
|
||||
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
|
||||
console.error('Start the daemon with: paseo daemon start')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -226,22 +102,19 @@ export async function runLogsCommand(
|
||||
const resolvedId = resolveAgentId(id, agents)
|
||||
|
||||
if (!resolvedId) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
message: `No agent found matching: ${id}`,
|
||||
details: 'Use `paseo ls` to list available agents',
|
||||
}
|
||||
throw error
|
||||
console.error(`Error: No agent found matching: ${id}`)
|
||||
console.error('Use `paseo ls` to list available agents')
|
||||
await client.close()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// For follow mode, we stream events continuously
|
||||
if (options.follow) {
|
||||
return await runFollowMode(client, resolvedId, options)
|
||||
await runFollowMode(client, resolvedId, options)
|
||||
return
|
||||
}
|
||||
|
||||
// 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<AgentTimelineItem[]>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve([]), 10000)
|
||||
@@ -265,7 +138,7 @@ export async function runLogsCommand(
|
||||
}
|
||||
|
||||
// Get timeline from snapshot
|
||||
const timelineItems = await snapshotPromise
|
||||
let timelineItems = await snapshotPromise
|
||||
|
||||
// Also check message queue for any stream events
|
||||
const queue = client.getMessageQueue()
|
||||
@@ -281,64 +154,29 @@ export async function runLogsCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Apply filter
|
||||
if (options.filter) {
|
||||
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
|
||||
}
|
||||
|
||||
// Convert to log entries with filtering
|
||||
for (const item of timelineItems) {
|
||||
// Apply filter
|
||||
if (!matchesFilter(item, options.filter)) {
|
||||
continue
|
||||
// Apply tail limit
|
||||
if (options.tail) {
|
||||
const tailCount = parseInt(options.tail, 10)
|
||||
if (!isNaN(tailCount) && tailCount > 0) {
|
||||
timelineItems = timelineItems.slice(-tailCount)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// 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,
|
||||
}
|
||||
// Use curateAgentActivity to format the transcript
|
||||
const transcript = curateAgentActivity(timelineItems)
|
||||
console.log(transcript)
|
||||
} 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
|
||||
console.error(`Error: Failed to get logs: ${message}`)
|
||||
await client.close().catch(() => {})
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,9 +187,7 @@ async function runFollowMode(
|
||||
client: DaemonClientV2,
|
||||
agentId: string,
|
||||
options: AgentLogsOptions
|
||||
): Promise<AgentLogsResult> {
|
||||
const logEntries: LogEntry[] = []
|
||||
|
||||
): Promise<void> {
|
||||
// First, get existing timeline
|
||||
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve([]), 10000)
|
||||
@@ -375,24 +211,25 @@ async function runFollowMode(
|
||||
}
|
||||
|
||||
// Get existing timeline
|
||||
const existingItems = await snapshotPromise
|
||||
let existingItems = await snapshotPromise
|
||||
|
||||
// Apply filter to existing items
|
||||
let itemsToShow = existingItems.filter((item) => matchesFilter(item, options.filter))
|
||||
if (options.filter) {
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))
|
||||
}
|
||||
|
||||
// Apply tail to existing items
|
||||
if (options.tail) {
|
||||
const tailCount = parseInt(options.tail, 10)
|
||||
if (!isNaN(tailCount) && tailCount > 0) {
|
||||
itemsToShow = itemsToShow.slice(-tailCount)
|
||||
existingItems = existingItems.slice(-tailCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Print existing entries
|
||||
for (const item of itemsToShow) {
|
||||
const entry = formatTimelineItem(item)
|
||||
logEntries.push(entry)
|
||||
printLogEntry(entry)
|
||||
// Print existing transcript
|
||||
const existingTranscript = curateAgentActivity(existingItems)
|
||||
if (existingTranscript !== 'No activity to display.') {
|
||||
console.log(existingTranscript)
|
||||
}
|
||||
|
||||
// Subscribe to new events
|
||||
@@ -406,12 +243,14 @@ async function runFollowMode(
|
||||
if (message.payload.event.type === 'timeline') {
|
||||
const item = message.payload.event.item
|
||||
// Apply filter
|
||||
if (!matchesFilter(item, options.filter)) {
|
||||
if (options.filter && !matchesFilter(item, options.filter)) {
|
||||
return
|
||||
}
|
||||
const entry = formatTimelineItem(item)
|
||||
logEntries.push(entry)
|
||||
printLogEntry(entry)
|
||||
// Print each timeline item as it arrives using the curator format
|
||||
const transcript = curateAgentActivity([item])
|
||||
if (transcript !== 'No activity to display.') {
|
||||
console.log(transcript)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -427,17 +266,4 @@ async function runFollowMode(
|
||||
})
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '.
|
||||
/** Result type for agent wait command */
|
||||
export interface AgentWaitResult {
|
||||
agentId: string
|
||||
status: 'idle' | 'timeout'
|
||||
status: 'idle' | 'timeout' | 'permission'
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -140,12 +140,29 @@ export async function runWaitCommand(
|
||||
throw error
|
||||
}
|
||||
|
||||
// Wait for agent to become idle
|
||||
// Wait for agent to become idle OR request permission (whichever comes first)
|
||||
try {
|
||||
await client.waitForAgentIdle(agentId, timeoutMs)
|
||||
const idlePromise = client.waitForAgentIdle(agentId, timeoutMs).then(() => ({ type: 'idle' as const }))
|
||||
const permissionPromise = client
|
||||
.waitForPermission(agentId, timeoutMs)
|
||||
.then((request) => ({ type: 'permission' as const, request }))
|
||||
|
||||
const result = await Promise.race([idlePromise, permissionPromise])
|
||||
|
||||
await client.close()
|
||||
|
||||
if (result.type === 'permission') {
|
||||
return {
|
||||
type: 'single',
|
||||
data: {
|
||||
agentId,
|
||||
status: 'permission',
|
||||
message: `Agent is waiting for permission: ${result.request.kind}`,
|
||||
},
|
||||
schema: agentWaitSchema,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: {
|
||||
|
||||
@@ -48,15 +48,7 @@ export async function runAllowCommand(
|
||||
): 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
|
||||
}
|
||||
// No validation needed - if no reqId provided, allow all by default
|
||||
|
||||
// Parse input JSON if provided
|
||||
let updatedInput: Record<string, unknown> | undefined
|
||||
@@ -129,7 +121,9 @@ export async function runAllowCommand(
|
||||
|
||||
// Determine which permissions to allow
|
||||
let permissionsToAllow: AgentPermissionRequest[]
|
||||
if (options.all) {
|
||||
if (!reqId || options.all) {
|
||||
// Default: allow all pending permissions if no req_id specified
|
||||
// --all flag is kept as an explicit alias for clarity
|
||||
permissionsToAllow = pendingPermissions
|
||||
} else {
|
||||
// Find permission by ID prefix
|
||||
|
||||
@@ -8,10 +8,7 @@ export interface PermissionListItem {
|
||||
id: string
|
||||
agentId: string
|
||||
agentShortId: string
|
||||
agentName: string
|
||||
name: string
|
||||
kind: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -19,21 +16,10 @@ export interface PermissionListItem {
|
||||
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: 'AGENT', field: 'agentShortId', width: 12 },
|
||||
{ header: 'REQ_ID', field: 'id', width: 12 },
|
||||
{ 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 },
|
||||
{ header: 'DESCRIPTION', field: 'description', width: 50 },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -43,10 +29,7 @@ function toListItem(agent: AgentSnapshotPayload, permission: AgentPermissionRequ
|
||||
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 ?? '-',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1902,6 +1902,10 @@ export class DaemonClientV2 {
|
||||
sawRunningInQueue = true;
|
||||
queuedIdle = null; // Reset: any previous idle was before this run
|
||||
}
|
||||
// Return immediately if we have pending permissions (even if still running)
|
||||
if (sawRunningInQueue && hasPendingPermissions) {
|
||||
return msg.payload;
|
||||
}
|
||||
if (
|
||||
sawRunningInQueue &&
|
||||
(status === "idle" || status === "error") &&
|
||||
@@ -1934,6 +1938,11 @@ export class DaemonClientV2 {
|
||||
if (status === "running" || hasPendingPermissions) {
|
||||
sawRunning = true;
|
||||
}
|
||||
// Return if we have pending permissions (even if still running)
|
||||
// OR if agent is idle/error with no pending permissions (after having run)
|
||||
if (sawRunning && hasPendingPermissions) {
|
||||
return msg.payload;
|
||||
}
|
||||
if (
|
||||
sawRunning &&
|
||||
(status === "idle" || status === "error") &&
|
||||
|
||||
@@ -384,7 +384,8 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
return this.registerSession(session, overrides, agentId);
|
||||
// Preserve existing labels during refresh
|
||||
return this.registerSession(session, overrides, agentId, { labels: existing.labels });
|
||||
}
|
||||
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
|
||||
@@ -14,7 +14,7 @@ Core rules:
|
||||
- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself.
|
||||
- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
|
||||
- Set cwd to the repository root and choose the most permissive mode available.
|
||||
- If an agent control call fails, list agents before launching another; it may just be a wait timeout.
|
||||
- If an agent control tool call fails, list agents before launching another; it may just be a wait timeout.
|
||||
|
||||
Context management:
|
||||
- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area).
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../test-utils/index.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "wait-perm-e2e-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests for wait returning on permission request.
|
||||
*
|
||||
* The `paseo wait` command should return when:
|
||||
* 1. Agent completes (goes idle)
|
||||
* 2. Agent requests permission
|
||||
*
|
||||
* This test verifies that waitForAgentIdle correctly returns when
|
||||
* an agent requests permission, not just when it goes idle.
|
||||
*/
|
||||
describe("wait returns on permission request", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
describe("Claude provider", () => {
|
||||
test(
|
||||
"waitForAgentIdle returns when permission is requested (not just when idle)",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const testFilePath = path.join(cwd, "test-wait-perm.txt");
|
||||
|
||||
// Create Claude agent with default mode (always ask for permissions)
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
cwd,
|
||||
title: "Wait Permission Test",
|
||||
modeId: "default",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Clear message queue before sending prompt
|
||||
ctx.client.clearMessageQueue();
|
||||
|
||||
// Send a prompt that requires file write permission
|
||||
const prompt = [
|
||||
`You must use the Write tool to create a file at "${testFilePath}" with content "hello".`,
|
||||
"Do not respond before attempting to write the file.",
|
||||
].join(" ");
|
||||
|
||||
await ctx.client.sendMessage(agent.id, prompt);
|
||||
|
||||
// CRITICAL: This is the behavior we're testing
|
||||
// waitForAgentIdle should return when permission is requested,
|
||||
// NOT wait until the agent is fully idle (which would timeout or
|
||||
// require us to approve the permission first)
|
||||
const startTime = Date.now();
|
||||
const state = await ctx.client.waitForAgentIdle(agent.id, 60000);
|
||||
const waitDuration = Date.now() - startTime;
|
||||
|
||||
// If wait returns because of permission request, we should have:
|
||||
// 1. Agent status still "running" (not idle yet - waiting for permission)
|
||||
// 2. Pending permissions in the state
|
||||
// 3. Wait should return quickly (under 30 seconds, not timeout)
|
||||
|
||||
// Check that we have a pending permission
|
||||
const hasPendingPermission =
|
||||
(state.pendingPermissions && state.pendingPermissions.length > 0);
|
||||
|
||||
// Log for debugging
|
||||
console.log("Wait returned after", waitDuration, "ms");
|
||||
console.log("Agent status:", state.status);
|
||||
console.log("Pending permissions:", state.pendingPermissions?.length ?? 0);
|
||||
|
||||
// THE ASSERTION:
|
||||
// If waitForAgentIdle correctly yields on permission request,
|
||||
// we should either:
|
||||
// - Get a state with pending permissions (status might be "running")
|
||||
// - Or the state should be from right when permission was requested
|
||||
//
|
||||
// If waitForAgentIdle does NOT yield on permission request,
|
||||
// this test will either:
|
||||
// - Timeout (60s)
|
||||
// - Return only after we never approve permission and agent errors/gives up
|
||||
|
||||
// This test will FAIL if waitForAgentIdle waits for full idle
|
||||
// instead of returning on permission request
|
||||
expect(hasPendingPermission).toBe(true);
|
||||
|
||||
// Also verify we can get the permission via waitForPermission
|
||||
// (This should return immediately since permission is already pending)
|
||||
const permission = await ctx.client.waitForPermission(agent.id, 5000);
|
||||
expect(permission).toBeTruthy();
|
||||
expect(permission.kind).toBe("tool");
|
||||
|
||||
// Clean up: deny the permission so agent can finish
|
||||
await ctx.client.respondToPermission(agent.id, permission.id, {
|
||||
behavior: "deny",
|
||||
message: "Test complete",
|
||||
});
|
||||
|
||||
// Wait for agent to finish processing the denial
|
||||
await ctx.client.waitForAgentIdle(agent.id, 30000);
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
120000
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,9 @@ export type {
|
||||
AgentTimelineItem,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
|
||||
// Agent activity curator for CLI logs
|
||||
export { curateAgentActivity } from "./agent/activity-curator.js";
|
||||
|
||||
// WebSocket message types for CLI streaming
|
||||
export type {
|
||||
AgentSnapshotPayload,
|
||||
|
||||
@@ -623,6 +623,7 @@ export class Session {
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
labels: record.labels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -654,7 +655,7 @@ export class Session {
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
snapshot = await this.agentManager.createAgent(config, agentId);
|
||||
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
|
||||
}
|
||||
|
||||
await this.agentManager.primeAgentHistory(agentId);
|
||||
|
||||
Reference in New Issue
Block a user