From b040e2fc6d253741fd04ece12a14fd3b351c88c9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 28 Jan 2026 23:26:53 +0700 Subject: [PATCH] feat(cli): add agent stop command Implements the `agent stop` command for stopping agents. Features: - Stop a specific agent by ID (with prefix matching) - --all flag to stop all running agents - --cwd flag to stop all agents in a specific directory - Cancels running agents before terminating them - Graceful error handling for partially failed stops Usage: paseo agent stop paseo agent stop --all paseo agent stop --cwd ~/dev/paseo --- packages/cli/src/commands/agent/stop.ts | 131 +++++++++++++++++++++ packages/cli/tests/07-agent-stop.test.mts | 136 ++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 packages/cli/src/commands/agent/stop.ts create mode 100644 packages/cli/tests/07-agent-stop.test.mts diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts new file mode 100644 index 000000000..24e9fff15 --- /dev/null +++ b/packages/cli/src/commands/agent/stop.ts @@ -0,0 +1,131 @@ +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 stop command */ +export interface StopResult { + stoppedCount: number + agentIds: string[] +} + +/** Schema for stop command output */ +export const stopSchema: OutputSchema = { + // For quiet mode, output the stopped agent IDs (one per line) + idField: (item) => item.agentIds.join('\n'), + columns: [{ header: 'STOPPED', field: 'stoppedCount' }], +} + +export interface AgentStopOptions extends CommandOptions { + all?: boolean + cwd?: string +} + +export type AgentStopResult = SingleResult + +export async function runStopCommand( + id: string | undefined, + options: AgentStopOptions, + _command: Command +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }) + + // Validate arguments - need either an id, --all, or --cwd + if (!id && !options.all && !options.cwd) { + const error: CommandError = { + code: 'MISSING_ARGUMENT', + message: 'Agent ID required unless --all or --cwd is specified', + details: 'Usage: paseo agent stop | --all | --cwd ', + } + 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)) + + let agents = client.listAgents() + const stoppedIds: string[] = [] + + if (options.all) { + // Stop all agents (not archived) + agents = agents.filter((a) => !a.archivedAt) + } else if (options.cwd) { + // Stop agents in directory + const filterCwd = options.cwd + agents = agents.filter((a) => { + if (a.archivedAt) return false + const agentCwd = a.cwd.replace(/\/$/, '') + const targetCwd = filterCwd.replace(/\/$/, '') + return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/') + }) + } else if (id) { + // Stop specific agent + 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 + } + agents = agents.filter((a) => a.id === resolvedId) + } + + // Stop each agent + for (const agent of agents) { + try { + // Cancel if running + if (agent.status === 'running') { + await client.cancelAgent(agent.id) + } + // Delete the agent + await client.deleteAgent(agent.id) + stoppedIds.push(agent.id) + } catch (err) { + // Continue stopping other agents even if one fails + const message = err instanceof Error ? err.message : String(err) + console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`) + } + } + + await client.close() + + return { + type: 'single', + data: { + stoppedCount: stoppedIds.length, + agentIds: stoppedIds, + }, + schema: stopSchema, + } + } catch (err) { + await client.close().catch(() => {}) + // Re-throw if it's 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: 'STOP_AGENT_FAILED', + message: `Failed to stop agent(s): ${message}`, + } + throw error + } +} diff --git a/packages/cli/tests/07-agent-stop.test.mts b/packages/cli/tests/07-agent-stop.test.mts new file mode 100644 index 000000000..3cbdac1ec --- /dev/null +++ b/packages/cli/tests/07-agent-stop.test.mts @@ -0,0 +1,136 @@ +#!/usr/bin/env npx tsx + +/** + * Phase 6: Agent Stop Command Tests + * + * Tests the agent stop command - stopping agents (cancel if running, then terminate). + * 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 stop --help shows options + * - agent stop requires ID, --all, or --cwd + * - agent stop handles daemon not running + * - agent stop --all flag is accepted + * - agent stop --cwd 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 Stop 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 stop --help shows options + { + console.log('Test 1: agent stop --help shows options') + const result = await $`npx paseo agent stop --help`.nothrow() + assert.strictEqual(result.exitCode, 0, 'agent stop --help should exit 0') + assert(result.stdout.includes('--all'), 'help should mention --all flag') + assert(result.stdout.includes('--cwd'), 'help should mention --cwd option') + assert(result.stdout.includes('--host'), 'help should mention --host option') + assert(result.stdout.includes('[id]'), 'help should mention optional id argument') + console.log('✓ agent stop --help shows options\n') + } + + // Test 2: agent stop requires ID, --all, or --cwd + { + console.log('Test 2: agent stop requires ID, --all, or --cwd') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent stop`.nothrow() + assert.notStrictEqual(result.exitCode, 0, 'should fail without id, --all, or --cwd') + 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 stop requires ID, --all, or --cwd\n') + } + + // Test 3: agent stop handles daemon not running + { + console.log('Test 3: agent stop handles daemon not running') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent stop 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 stop handles daemon not running\n') + } + + // Test 4: agent stop --all flag is accepted + { + console.log('Test 4: agent stop --all flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent stop --all`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --all flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ agent stop --all flag is accepted\n') + } + + // Test 5: agent stop --cwd flag is accepted + { + console.log('Test 5: agent stop --cwd flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent stop --cwd /tmp`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --cwd flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ agent stop --cwd flag is accepted\n') + } + + // Test 6: agent stop with ID and --host flag is accepted + { + console.log('Test 6: agent stop with ID and --host flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent stop 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 stop with ID and --host flag is accepted\n') + } + + // Test 7: agent shows stop in subcommands + { + console.log('Test 7: agent --help shows stop subcommand') + const result = await $`npx paseo agent --help`.nothrow() + assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0') + assert(result.stdout.includes('stop'), 'help should mention stop subcommand') + console.log('✓ agent --help shows stop subcommand\n') + } + + // Test 8: -q (quiet) flag is accepted with agent stop + { + console.log('Test 8: -q (quiet) flag is accepted with agent stop') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent stop 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 stop\n') + } +} finally { + // Clean up temp directory + await rm(paseoHome, { recursive: true, force: true }) +} + +console.log('=== All agent stop tests passed ===')