diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e1ab0905..44b9bc0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased - Minor] + +### Added + +- `relay node agent list --pretty` now provides a compact agent view with each agent's name, CLI/model, state, and relative last activity time. + ## [Unreleased] ## [11.0.2] - 2026-07-22 diff --git a/packages/cli/src/cli/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index 69a2a8c20..a0dbf85aa 100644 --- a/packages/cli/src/cli/commands/local-agent.test.ts +++ b/packages/cli/src/cli/commands/local-agent.test.ts @@ -9,7 +9,11 @@ vi.mock('@agent-relay/harness-driver', () => ({ }, })); -import { registerLocalAgentCommands, type LocalAgentDependencies } from './local-agent.js'; +import { + formatPrettyAgentList, + registerLocalAgentCommands, + type LocalAgentDependencies, +} from './local-agent.js'; function harness(overrides: Partial = {}) { const client = { @@ -74,10 +78,78 @@ describe('local agent subtree', () => { expect(exit).toHaveBeenCalledWith(1); }); - it('list queries the broker', async () => { - const { program, client } = harness(); + it('list queries the broker and keeps JSON as the default output', async () => { + const { program, client, log } = harness(); await program.parseAsync(['local', 'agent', 'list'], { from: 'user' }); expect(client.listAgents).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith('[\n {\n "name": "lead"\n }\n]'); + }); + + it('list --pretty shows the compact human-readable view', async () => { + const { program, log } = harness({ + connect: vi.fn( + async () => + ({ + listAgents: vi.fn(async () => [ + { + name: 'Lead', + runtime: 'pty' as const, + cli: 'codex', + model: 'gpt-5.4', + channels: [], + current_state: 'working' as const, + last_activity_at: '2026-07-22T16:59:57.000Z', + }, + ]), + }) as never + ), + now: () => new Date('2026-07-22T17:00:00.000Z'), + }); + + await program.parseAsync(['local', 'agent', 'list', '--pretty'], { from: 'user' }); + + expect(log).toHaveBeenCalledWith(expect.stringContaining('Lead codex / gpt-5.4 ● working now')); + }); + + it('formats agent state and activity time for the pretty list', () => { + expect( + formatPrettyAgentList( + [ + { + name: 'Review', + runtime: 'pty', + cli: 'claude', + channels: [], + current_state: 'blocked_on_send', + last_activity_at: '2026-07-22T16:58:00.000Z', + }, + { name: 'Worker', runtime: 'headless', channels: [], current_state: 'idle' }, + ], + new Date('2026-07-22T17:00:00.000Z') + ) + ).toMatch(/Review\s+claude\s+◐ waiting\s+2 minutes ago/); + expect(formatPrettyAgentList([], new Date())).toBe('No agents running.'); + }); + + it('removes terminal control sequences from broker-provided cells', () => { + const [, , row] = formatPrettyAgentList( + [ + { + name: 'Lead\x1b[2J\nAgent', + runtime: 'pty', + cli: 'codex\nunsafe', + model: 'gpt-5\x1b[0m', + channels: [], + current_state: 'working', + last_activity_at: '2026-07-22T17:00:00.000Z', + }, + ], + new Date('2026-07-22T17:00:00.000Z') + ).split('\n'); + + expect(row).toContain('Lead�Agent'); + expect(row).toContain('codex�unsafe / gpt-5'); + expect(row).not.toContain('\x1b'); }); it('spawn forwards task-exit lifecycle options', async () => { diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index 89d9fcb7a..44d7d2808 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -1,7 +1,9 @@ import type { Command } from 'commander'; import { HarnessDriverClient } from '@agent-relay/harness-driver'; +import type { ListAgent } from '@agent-relay/harness-driver'; import type { HarnessRuntime } from '@agent-relay/harnesses'; +import { stripAnsiFast } from '@agent-relay/utils'; import { classifyTask, composeTeam, buildDirectorPrompt } from '../../auto/index.js'; import { createBrokerClient } from '../lib/attach-broker.js'; @@ -86,6 +88,7 @@ export interface LocalAgentDependencies { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void; exit: ExitFn; + now: () => Date; } function withDefaults(overrides: Partial = {}): LocalAgentDependencies { @@ -100,6 +103,7 @@ function withDefaults(overrides: Partial = {}): LocalAge log: (...args: unknown[]) => console.log(...args), error: (...args: unknown[]) => console.error(...args), exit: defaultExit, + now: () => new Date(), ...overrides, } as LocalAgentDependencies; deps.connectLocal ??= async (_cwd: string, options: LocalAgentMessageBrokerOptions) => { @@ -119,6 +123,77 @@ function withDefaults(overrides: Partial = {}): LocalAge return deps; } +const AGENT_STATE_DISPLAY: Record< + NonNullable, + { symbol: string; label: string } +> = { + working: { symbol: '●', label: 'working' }, + idle: { symbol: '○', label: 'idle' }, + blocked_on_send: { symbol: '◐', label: 'waiting' }, +}; + +function formatRelativeTime(value: string | undefined, now: Date): string { + if (!value) return 'unknown'; + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return 'unknown'; + + const seconds = Math.max(0, Math.floor((now.getTime() - timestamp) / 1_000)); + if (seconds < 5) return 'now'; + if (seconds < 60) return `${seconds} seconds ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`; + + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +} + +/** Keep broker-provided text from escaping its table cell or controlling the terminal. */ +function sanitizeTerminalCell(value: string): string { + // eslint-disable-next-line no-control-regex + return stripAnsiFast(value).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '�'); +} + +/** Render a compact terminal view while retaining JSON as the script-friendly default. */ +export function formatPrettyAgentList(agents: ListAgent[], now: Date): string { + if (agents.length === 0) return 'No agents running.'; + + const rows = agents.map((agent) => { + const state = agent.current_state ? AGENT_STATE_DISPLAY[agent.current_state] : undefined; + return { + name: sanitizeTerminalCell(agent.name), + cliModel: sanitizeTerminalCell( + [agent.cli ?? agent.provider ?? agent.runtime, agent.model].filter(Boolean).join(' / ') + ), + state: sanitizeTerminalCell(state ? `${state.symbol} ${state.label}` : '· unknown'), + lastActive: sanitizeTerminalCell(formatRelativeTime(agent.last_activity_at, now)), + }; + }); + const columns = [ + { header: 'NAME', values: rows.map((row) => row.name) }, + { header: 'CLI / MODEL', values: rows.map((row) => row.cliModel) }, + { header: 'STATE', values: rows.map((row) => row.state) }, + { header: 'LAST ACTIVE', values: rows.map((row) => row.lastActive) }, + ]; + const widths = columns.map((column) => + Math.max(column.header.length, ...column.values.map((value) => value.length)) + ); + const formatRow = (values: string[]) => + values + .map((value, index) => value.padEnd(widths[index]!)) + .join(' ') + .trimEnd(); + + return [ + formatRow(columns.map((column) => column.header)), + formatRow(columns.map((_, index) => '-'.repeat(widths[index]!))), + ...rows.map((row) => formatRow([row.name, row.cliModel, row.state, row.lastActive])), + ].join('\n'); +} + async function run( deps: LocalAgentDependencies, fn: (client: HarnessDriverClient) => Promise @@ -241,9 +316,11 @@ export function registerLocalAgentCommands( agent .command('list') .description('List agents running on the local broker') - .action(async () => { + .option('--pretty', 'Show a compact human-readable list') + .action(async (opts: { pretty?: boolean }) => { await run(deps, async (client) => { - deps.log(JSON.stringify(await client.listAgents(), null, 2)); + const agents = await client.listAgents(); + deps.log(opts.pretty ? formatPrettyAgentList(agents, deps.now()) : JSON.stringify(agents, null, 2)); }); });