From 857ee4a0573b22ed8f01c948f931b8f148c738c7 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 1 Jun 2026 15:19:36 +0200 Subject: [PATCH 1/4] Resolve conflict --- .../2026-06-01-feature-agent-console-kill.md | 72 ++++++++++++++ .../2026-06-01-feature-agent-console-kill.md | 93 +++++++++++++++++++ .../2026-06-01-feature-agent-console-kill.md | 57 ++++++++++++ .../2026-06-01-feature-agent-console-kill.md | 56 +++++++++++ .../2026-06-01-feature-agent-console-kill.md | 61 ++++++++++++ packages/cli/src/tui/console/ConsoleApp.tsx | 49 ++++++++++ .../cli/src/tui/console/KillConfirmDialog.tsx | 21 +++++ packages/cli/src/tui/console/StatusFooter.tsx | 2 +- .../cli/src/tui/console/actions/runAction.ts | 2 + packages/cli/src/tui/console/actions/types.ts | 3 +- 10 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 docs/ai/design/2026-06-01-feature-agent-console-kill.md create mode 100644 docs/ai/implementation/2026-06-01-feature-agent-console-kill.md create mode 100644 docs/ai/planning/2026-06-01-feature-agent-console-kill.md create mode 100644 docs/ai/requirements/2026-06-01-feature-agent-console-kill.md create mode 100644 docs/ai/testing/2026-06-01-feature-agent-console-kill.md create mode 100644 packages/cli/src/tui/console/KillConfirmDialog.tsx diff --git a/docs/ai/design/2026-06-01-feature-agent-console-kill.md b/docs/ai/design/2026-06-01-feature-agent-console-kill.md new file mode 100644 index 0000000..eaea637 --- /dev/null +++ b/docs/ai/design/2026-06-01-feature-agent-console-kill.md @@ -0,0 +1,72 @@ +--- +phase: design +title: Agent Console Kill Design +description: Technical design for confirmed kill support in agent console +--- + +# System Design & Architecture + +## Architecture Overview + +```mermaid +graph TD + User["User presses K"] --> ConsoleApp["ConsoleAppShell"] + ConsoleApp --> Confirm["Kill confirmation overlay"] + Confirm -->|Esc/n| Cancel["Close overlay"] + Confirm -->|Enter/y| RunAction["runAction({ type: kill })"] + RunAction --> KillCmd["agent kill "] + KillCmd --> AgentManager["AgentManager.listAgents/resolveAgent"] + KillCmd --> ProcessKill["process.kill(pid, SIGTERM)"] + KillCmd --> Registry["AgentRegistry.lookup(name)"] + Registry --> TmuxSession{"tmuxSession?"} + TmuxSession -->|yes| TmuxManager["TmuxManager.killSession"] + TmuxSession -->|no| Done["Success"] +``` + +Kill support follows the existing console action pattern: the TUI handles key input and presentation, while the destructive operation runs through a CLI subprocess. The CLI receives the agent name, resolves it against the current live agent list, sends `SIGTERM` to the PID, and cleans up the registry-backed tmux session when available. + +## Data Models + +- `ConsoleAction` + - Add `{ type: 'kill'; agentName: string }`. +- `AgentInfo` + - Existing selected agent data supplies `name`, `pid`, and optionally `tmuxSession`. +- `RegistryEntry` + - `AgentRegistry.lookup(name)` supplies a preserved `tmuxSession` when the live adapter did not populate it. + +## API Design + +- Add CLI command: `ai-devkit agent kill ` + - Resolves `` like `open`/`send`. + - Rejects no-match and ambiguous matches with existing agent-list messaging patterns. + - Calls a reusable service function for process/tmux cleanup. +- Add service function: `killAgent(agent, deps)` + - Inputs: selected `AgentInfo`, `AgentRegistry`, `TmuxManager`, optional signal. + - Behavior: `process.kill(agent.pid, 'SIGTERM')`, then `tmux.killSession(session)` when a session name exists. + +## Component Breakdown + +| Component | Change | +|-----------|--------| +| `ConsoleAppShell` | Track pending kill confirmation and handle `K`, `y`, `Enter`, `n`, `Esc` | +| New `KillConfirmDialog` | Render centered absolute-positioned confirmation overlay | +| `StatusFooter` | Include `K kill` in key hints | +| `runAction` | Add `kill` action that spawns `agent kill ` | +| `agent.service.ts` | Add kill service with tmux cleanup | +| `commands/agent.ts` | Add `agent kill` command | + +## Design Decisions + +- Keep lowercase `k` as up navigation and use uppercase `K` for kill. +- Require confirmation before invoking the subprocess. +- Render the confirmation as an absolute-positioned overlay so the list, preview, input, and footer keep their existing dimensions. +- Use subprocess actions consistently with existing console `open` and `send`. +- Resolve tmux session from the registry as a fallback because `AgentInfo` may be adapter-derived and not always contain registry metadata. +- Treat missing/already-ended process as non-fatal enough to continue tmux cleanup; the user action intent is to ensure the selected agent is stopped. + +## Non-Functional Requirements + +- The confirmation overlay must be keyboard-only, absolute-positioned, and not resize the main console panes. +- TUI errors should appear as transient messages. +- Kill must not take over stdin/stdout from Ink. +- Tests should cover both process-only and tmux-backed agents. diff --git a/docs/ai/implementation/2026-06-01-feature-agent-console-kill.md b/docs/ai/implementation/2026-06-01-feature-agent-console-kill.md new file mode 100644 index 0000000..3354085 --- /dev/null +++ b/docs/ai/implementation/2026-06-01-feature-agent-console-kill.md @@ -0,0 +1,93 @@ +--- +phase: implementation +title: Agent Console Kill Implementation +description: Implementation notes for confirmed kill support in agent console +--- + +# Implementation Guide + +## Development Setup + +- Feature worktree: `.worktrees/feature-agent-console-kill` +- Branch: `feature-agent-console-kill` +- Dependency bootstrap: `npm ci` completed. Husky prepare could not lock the main repo git config from the sandbox, but dependency install exited successfully. + +## Code Structure + +- `packages/cli/src/services/agent/agent.service.ts` + - Added `killAgent()` service. +- `packages/cli/src/commands/agent.ts` + - Added `agent kill `. +- `packages/cli/src/tui/console/actions/types.ts` + - Added `kill` console action. +- `packages/cli/src/tui/console/actions/runAction.ts` + - Dispatches kill through a CLI subprocess. +- `packages/cli/src/tui/console/ConsoleApp.tsx` + - Handles uppercase `K`, confirmation state, and kill result messages. +- `packages/cli/src/tui/console/KillConfirmDialog.tsx` + - New Ink confirmation dialog. +- `packages/cli/src/tui/console/StatusFooter.tsx` + - Documents `K kill`. + +## Implementation Notes + +### Core Features + +- `killAgent()` sends `SIGTERM` to the selected agent PID. +- `killAgent()` looks up the registry entry by agent name and kills the stored `tmuxSession` when present. +- `ESRCH` process-kill errors are treated as already stopped so tmux cleanup still runs. +- `agent kill ` uses existing list/resolve behavior and reuses the no-match/ambiguous-match messaging style from existing commands. +- `agent console` keeps lowercase `k` navigation and uses uppercase `K` to open a confirmation dialog. +- Confirming with `Enter` or `y` dispatches `agent kill ` through `runAction`; cancelling with `Esc` or `n` closes the dialog without side effects. +- `KillConfirmDialog` is rendered inside an absolute-positioned `Box` centered from terminal dimensions, so opening it does not shrink the agent list, preview pane, input box, or footer. + +### Patterns & Best Practices + +- Console actions remain subprocess-based so the Ink TUI keeps terminal control. +- Tmux cleanup is centralized in the service layer, not the TUI. +- Process killing is dependency-injected in tests via `killProcess`. + +## Error Handling + +- CLI command resolution failures return visible `ui.error`/`ui.info` messages and do not call `killAgent`. +- Non-`ESRCH` process kill errors propagate through the existing `withErrorHandler` path. +- TUI action failures are shown as transient footer errors. + +## Verification Evidence + +- `npx ai-devkit@latest lint --feature agent-console-kill` exited 0. +- `npm run build` in `packages/agent-manager` exited 0. +- `npm run build` in `packages/cli` exited 0. +- `npm run lint` in `packages/cli` exited 0. +- `npx vitest run src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts src/__tests__/tui/console/actions/runAction.test.ts src/__tests__/tui/console/computeLayout.test.ts` exited 0 with 90 passing tests. + +## Phase 6 Implementation Check + +### Alignment Status + +- Requirements alignment: pass. + - Uppercase `K` opens kill confirmation. + - Lowercase `k` navigation remains unchanged. + - Confirmed kill dispatches through `agent kill `. + - The kill service sends `SIGTERM` and kills registry-backed `tmuxSession` when present. + - Console errors are surfaced as transient messages. +- Design alignment: pass. + - `ConsoleAction` includes `kill`. + - `runAction` spawns `agent kill ` with piped stdio. + - `agent kill` resolves agents through `AgentManager.listAgents()` and `resolveAgent()`. + - `killAgent()` centralizes process and tmux cleanup. + - `KillConfirmDialog` is absolute-positioned so it does not resize the main UI panes. + +### File-by-File Notes + +- `packages/cli/src/services/agent/agent.service.ts`: matches design. `ESRCH` is handled as already stopped, while unexpected process errors still propagate. +- `packages/cli/src/commands/agent.ts`: matches existing command resolution patterns for no agents, no match, ambiguous match, and success. +- `packages/cli/src/tui/console/ConsoleApp.tsx`: matches the keyboard flow. Confirmation handling has priority over normal shortcuts while pending. +- `packages/cli/src/tui/console/KillConfirmDialog.tsx`: matches the overlay design and does not own kill behavior. +- `packages/cli/src/tui/console/actions/*`: matches existing subprocess action pattern. +- `packages/cli/src/tui/console/StatusFooter.tsx`: documents `K kill`. + +### Deviations and Follow-Ups + +- No blocking deviations found. +- Follow-up for Phase 7: add or manually execute coverage for live Ink key handling (`k`, `K`, `n`/`Esc`, `Enter`/`y`) and the managed tmux smoke test. diff --git a/docs/ai/planning/2026-06-01-feature-agent-console-kill.md b/docs/ai/planning/2026-06-01-feature-agent-console-kill.md new file mode 100644 index 0000000..f0c1724 --- /dev/null +++ b/docs/ai/planning/2026-06-01-feature-agent-console-kill.md @@ -0,0 +1,57 @@ +--- +phase: planning +title: Agent Console Kill Plan +description: Task breakdown for confirmed kill support in agent console +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] Milestone 1: CLI kill service and command implemented. +- [x] Milestone 2: Console confirmation UI and `K` keybinding implemented. +- [x] Milestone 3: Tests, docs, and verification complete. + +## Task Breakdown + +### Phase 1: CLI Kill Path +- [x] Task 1.1: Add `killAgent` service that sends `SIGTERM` and kills tmux session when available. +- [x] Task 1.2: Add `agent kill ` command with no-match and ambiguous-match handling. +- [x] Task 1.3: Add command/service tests for process kill and tmux cleanup. + +### Phase 2: Console Integration +- [x] Task 2.1: Extend console action types and `runAction` with `kill`. +- [x] Task 2.2: Add confirmation overlay component. +- [x] Task 2.3: Wire uppercase `K` to open confirmation while preserving lowercase `k` navigation. +- [x] Task 2.4: Show kill result/error through transient footer messages. + +### Phase 3: Verification +- [x] Task 3.1: Update footer/docs hints. +- [x] Task 3.2: Run focused tests. +- [x] Task 3.3: Run build/typecheck for touched packages. + +## Dependencies + +- Existing `AgentManager.resolveAgent` behavior for name resolution. +- Existing `TmuxManager.killSession` behavior for tmux cleanup. +- Existing console subprocess action pattern. + +## Timeline & Estimates + +- CLI kill path: 30-45 minutes. +- Console integration: 45-60 minutes. +- Tests and verification: 30-45 minutes. + +## Risks & Mitigation + +- Risk: agent process exits before kill is invoked. + - Mitigation: tolerate `ESRCH`/already-gone process errors and still attempt tmux cleanup. +- Risk: adapter-derived agent lacks `tmuxSession`. + - Mitigation: fallback to registry lookup by agent name. +- Risk: confirmation input conflicts with chat input. + - Mitigation: ignore `K` while input focus is active and give dialog input handling priority. + +## Resources Needed + +- Existing CLI and Ink test patterns. +- `TmuxManager` and `AgentRegistry` utilities from `@ai-devkit/agent-manager`. diff --git a/docs/ai/requirements/2026-06-01-feature-agent-console-kill.md b/docs/ai/requirements/2026-06-01-feature-agent-console-kill.md new file mode 100644 index 0000000..a5c0f88 --- /dev/null +++ b/docs/ai/requirements/2026-06-01-feature-agent-console-kill.md @@ -0,0 +1,56 @@ +--- +phase: requirements +title: Agent Console Kill +description: Add confirmed kill support to the interactive agent console +--- + +# Requirements & Problem Understanding + +## Problem Statement + +Developers can monitor, open, and message agents from `ai-devkit agent console`, but cannot stop a selected agent from the same interface. Stopping a managed agent currently requires leaving the console and using external process or tmux commands. This is slower and risks leaving the managed tmux session behind. + +## Goals & Objectives + +- Add a keyboard-driven kill action to `agent console`. +- Use uppercase `K` for kill so lowercase `k` remains upward navigation. +- Show a confirmation pop-up before killing the selected agent. +- Stop the selected agent process. +- If the selected agent has a non-empty `tmuxSession`, kill that tmux session as part of the same action. +- Refresh console state after a successful kill so the stopped agent disappears once discovery/registry pruning observes it. + +## Non-Goals + +- Do not change lowercase `j/k` navigation. +- Do not add bulk kill support. +- Do not add force/timeout tuning in this feature. +- Do not expose kill for historical sessions. + +## User Stories & Use Cases + +- As a developer using `agent console`, I want to press `K` on the selected agent and confirm the action so I can stop the agent without leaving the console. +- As a developer using managed tmux agents, I want the tmux session cleaned up when the agent is killed so that no detached tmux session remains. +- As a developer, I want accidental `K` presses to be reversible at the confirmation prompt. + +## Success Criteria + +- Pressing lowercase `k` still moves the selection up. +- Pressing uppercase `K` with a selected agent opens a confirmation pop-up. +- Confirming the pop-up invokes a kill action for the selected agent. +- Cancelling the pop-up leaves the agent and tmux session untouched. +- The kill action sends a termination signal to the agent PID. +- The kill action kills `tmuxSession` when the selected agent has one. +- The console footer documents `K kill`. +- Errors are shown as transient console errors rather than crashing the TUI. + +## Constraints & Assumptions + +- The console is an Ink TUI; the confirmation should be implemented with existing Ink components, not an external terminal prompt. +- Existing console actions are subprocess-based through `runAction`; kill should follow the same pattern so the TUI keeps control of the terminal. +- `tmuxSession` is available from `AgentInfo` for registry-backed managed agents. +- A regular `SIGTERM` is the default process stop signal for this feature. +- Memory search was unavailable due to a local `better-sqlite3` Node ABI mismatch, so this scope is based on existing repo docs/code and user clarification. + +## Questions & Open Items + +- Resolved: shortcut is uppercase `K`, not lowercase `k`. diff --git a/docs/ai/testing/2026-06-01-feature-agent-console-kill.md b/docs/ai/testing/2026-06-01-feature-agent-console-kill.md new file mode 100644 index 0000000..56cb7aa --- /dev/null +++ b/docs/ai/testing/2026-06-01-feature-agent-console-kill.md @@ -0,0 +1,61 @@ +--- +phase: testing +title: Agent Console Kill Testing +description: Test plan for confirmed kill support in agent console +--- + +# Testing Strategy + +## Test Coverage Goals + +- Unit coverage for new kill service branches. +- Command coverage for `agent kill` resolution behavior. +- Console/action coverage for kill action dispatch and confirmation keyboard behavior where practical. +- Manual TUI smoke test for the confirmation overlay. + +## Unit Tests + +### Agent Kill Service +- [x] Kills the selected agent PID with `SIGTERM`. +- [x] Kills the registry `tmuxSession` when present. +- [x] Falls back to `AgentRegistry.lookup(name).tmuxSession`. +- [x] Continues tmux cleanup when the process is already gone. +- [x] Does not call tmux when no tmux session exists. + +### Console Actions +- [x] `runAction({ type: 'kill' })` spawns `agent kill `. +- [x] Existing open/send action behavior is unchanged. + +### Console UI +- [ ] Pressing lowercase `k` navigates up. +- [ ] Pressing uppercase `K` opens confirmation for the selected agent. +- [x] Confirmation renders as an overlay without changing computed pane dimensions. +- [ ] `Esc`/`n` cancels the pending kill. +- [ ] `Enter`/`y` confirms and dispatches the kill action. + +## Integration Tests + +- [x] `agent kill ` resolves an exact or unique partial agent and calls the kill service. +- [x] `agent kill ` reports no-match and ambiguous-match cases without killing anything. +- [x] `agent console` footer includes `K kill`. + +## Manual Testing + +- [ ] Start a managed tmux agent with `ai-devkit agent start`. +- [ ] Open `ai-devkit agent console`. +- [ ] Select the agent and press lowercase `k`; verify selection moves up. +- [ ] Press uppercase `K`; verify confirmation appears. +- [ ] Press `n`; verify the agent remains. +- [ ] Press uppercase `K` again, then `Enter`; verify the agent process exits and the tmux session is gone. + +## Test Reporting & Coverage + +- Run focused package tests for CLI and agent-manager changes. +- Run build or typecheck for touched packages before completion. + +## Verification Results + +- `npx vitest run src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts src/__tests__/tui/console/actions/runAction.test.ts src/__tests__/tui/console/computeLayout.test.ts`: exit 0, 90 tests passed. +- `npm run build` in `packages/agent-manager`: exit 0. +- `npm run build` in `packages/cli`: exit 0. +- `npm run lint` in `packages/cli`: exit 0. diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 82b7abd..5195a1f 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -11,6 +11,7 @@ import { ChatInput } from './ChatInput.js'; import { HeaderBar } from './HeaderBar.js'; import { runAction } from './actions/runAction.js'; import { StartAgentPane } from './StartAgentPane.js'; +import { KillConfirmDialog } from './KillConfirmDialog.js'; interface ConsoleAppProps { manager: AgentManager; @@ -56,6 +57,7 @@ const ConsoleAppShell: React.FC<{ const [focus, setFocus] = useState('list'); const [inputLines, setInputLines] = useState(1); const [inputValue, setInputValue] = useState(''); + const [pendingKillName, setPendingKillName] = useState(null); const [transient, setTransient] = useState(null); const [rightPaneMode, setRightPaneMode] = useState({ type: 'preview' }); const startPaneActive = rightPaneMode.type === 'start-agent'; @@ -79,6 +81,16 @@ const ConsoleAppShell: React.FC<{ const agentsRef = useRef(agents); agentsRef.current = agents; + useEffect(() => { + if (!agents.length) { + setSelectedName(null); + return; + } + if (!selectedName || !agents.some(agent => agent.name === selectedName)) { + setSelectedName(agents[0].name); + } + }, [agents, selectedName]); + const getSelectedAgent = useCallback(() => { const name = selectedNameRef.current; return name ? agentsRef.current.find(agent => agent.name === name) ?? null : null; @@ -115,7 +127,30 @@ const ConsoleAppShell: React.FC<{ setFocus('list'); }, []); + const confirmKill = useCallback((agentName: string) => { + setPendingKillName(null); + void runAction({ type: 'kill', agentName }).then(result => { + if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { + setTransient({ kind: 'error', text: result.error ?? `kill exited ${result.exitCode}` }); + } else { + setTransient({ kind: 'info', text: `Killed ${agentName}` }); + } + }); + }, []); + useInput((input, key) => { + if (pendingKillName) { + if (key.escape || input === 'n') { + setPendingKillName(null); + return; + } + if (key.return || input === 'y') { + confirmKill(pendingKillName); + return; + } + return; + } + if (startPaneActive) return; if (focus === 'input') { @@ -128,6 +163,12 @@ const ConsoleAppShell: React.FC<{ if (input === 'q') { exit(); return; } + if (input === 'K') { + const agent = getSelectedAgent(); + if (agent) setPendingKillName(agent.name); + return; + } + if (input === 'o') { const agent = getSelectedAgent(); if (!agent) return; @@ -169,6 +210,9 @@ const ConsoleAppShell: React.FC<{ const { cols, rows } = useTerminalSize(); const narrow = cols < NARROW_THRESHOLD_COLS; const { inputBoxHeight, contentHeight, previewHeight, listPaneWidth, rightColWidth, inputInnerWidth } = computeLayout(cols, rows, inputLines, narrow); + const dialogWidth = Math.min(56, Math.max(24, cols - 6)); + const dialogLeft = Math.max(0, Math.floor((cols - dialogWidth) / 2)); + const dialogTop = Math.max(1, Math.floor(rows / 2) - 3); const startPane = ( )} + {pendingKillName ? ( + + + + ) : null} = ({ agentName, width }) => ( + + Kill agent "{agentName}"? + Enter/y confirm · Esc/n cancel + +); diff --git a/packages/cli/src/tui/console/StatusFooter.tsx b/packages/cli/src/tui/console/StatusFooter.tsx index 4013fda..bce7783 100644 --- a/packages/cli/src/tui/console/StatusFooter.tsx +++ b/packages/cli/src/tui/console/StatusFooter.tsx @@ -40,7 +40,7 @@ const StatusFooterInner: React.FC = ({ - {summary}{' · '}{updated}{' · '}j/k nav · s start · o open · i message · q quit + {summary}{' · '}{updated}{' · '}j/k nav · s start · o open · i message · K kill · q quit {narrowNote ? ( diff --git a/packages/cli/src/tui/console/actions/runAction.ts b/packages/cli/src/tui/console/actions/runAction.ts index 6aac76d..161f6a6 100644 --- a/packages/cli/src/tui/console/actions/runAction.ts +++ b/packages/cli/src/tui/console/actions/runAction.ts @@ -20,6 +20,8 @@ export async function runAction(action: ConsoleAction): Promise { return [...baseArgs, 'agent', 'send', action.message, '--id', action.agentName]; case 'start': return [...baseArgs, 'agent', 'start', '--type', action.agentType, '--name', action.name, '--cwd', action.cwd]; + case 'kill': + return [...baseArgs, 'agent', 'kill', action.agentName]; } })(); diff --git a/packages/cli/src/tui/console/actions/types.ts b/packages/cli/src/tui/console/actions/types.ts index 01e74da..e1075f0 100644 --- a/packages/cli/src/tui/console/actions/types.ts +++ b/packages/cli/src/tui/console/actions/types.ts @@ -3,4 +3,5 @@ import type { StartableAgentType } from '@ai-devkit/agent-manager'; export type ConsoleAction = | { type: 'open'; agentName: string } | { type: 'send'; agentName: string; message: string } - | { type: 'start'; agentType: StartableAgentType; name: string; cwd: string }; + | { type: 'start'; agentType: StartableAgentType; name: string; cwd: string } + | { type: 'kill'; agentName: string }; From d23539cd75c13ca251d72f6015148b4e89d6cca8 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 1 Jun 2026 15:35:39 +0200 Subject: [PATCH 2/4] feat(cli): add agent kill in agent console --- .../cli/src/__tests__/commands/agent.test.ts | 63 +++++++++++++ .../services/agent/agent.service.test.ts | 91 +++++++++++++++++++ .../tui/console/actions/runAction.test.ts | 7 ++ .../tui/console/computeLayout.test.ts | 18 +++- packages/cli/src/commands/agent.ts | 37 ++++++++ .../cli/src/services/agent/agent.service.ts | 46 ++++++++++ packages/cli/src/tui/console/ConsoleApp.tsx | 50 +++++----- .../cli/src/tui/console/KillConfirmDialog.tsx | 1 + .../tui/console/hooks/useKillAgentAction.ts | 57 ++++++++++++ 9 files changed, 340 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/tui/console/hooks/useKillAgentAction.ts diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 1be0305..8272357 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -32,6 +32,7 @@ const mockPrompt: any = vi.fn(); const mockTtyWriterSend = vi.fn<(location: any, message: string) => Promise>().mockResolvedValue(undefined); const mockWaitForAgentResponse = vi.fn<(...args: any[]) => Promise>(); +const mockKillAgent = vi.fn<(...args: any[]) => Promise>(); let restoreStdin: (() => void) | undefined; const mockRegistry: any = { @@ -120,6 +121,7 @@ vi.mock('../../util/terminal-ui.js', () => ({ vi.mock('../../services/agent/agent.service.js', () => ({ waitForAgentResponse: (...args: any[]) => mockWaitForAgentResponse(...args), + killAgent: (...args: any[]) => mockKillAgent(...args), })); describe('agent command', () => { @@ -311,6 +313,67 @@ Waiting on user input`, expect(mockSpinner.succeed).toHaveBeenCalledWith('Focused repo-a!'); }); + it('kills a resolved agent and reports tmux cleanup', async () => { + const agent = { + name: 'repo-a', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'A', + lastActive: new Date(), + pid: 10, + }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockKillAgent.mockResolvedValue({ + agentName: 'repo-a', + pid: 10, + tmuxSession: 'repo-a', + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'kill', 'repo-a']); + + expect(mockManager.resolveAgent).toHaveBeenCalledWith('repo-a', [agent]); + expect(mockKillAgent).toHaveBeenCalledWith(agent, expect.objectContaining({ + tmux: expect.any(Object), + registry: mockRegistry, + })); + expect(ui.success).toHaveBeenCalledWith('Stopped agent "repo-a" (PID 10) and tmux session "repo-a".'); + }); + + it('does not kill when target is ambiguous', async () => { + const agents = [ + { name: 'repo-a', status: AgentStatus.RUNNING, summary: 'A', lastActive: new Date(), pid: 10 }, + { name: 'repo-b', status: AgentStatus.WAITING, summary: 'B', lastActive: new Date(), pid: 11 }, + ]; + mockManager.listAgents.mockResolvedValue(agents); + mockManager.resolveAgent.mockReturnValue(agents); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'kill', 'repo']); + + expect(ui.error).toHaveBeenCalledWith('Multiple agents match "repo":'); + expect(mockKillAgent).not.toHaveBeenCalled(); + }); + + it('does not kill when target is not found', async () => { + const agents = [ + { name: 'repo-a', status: AgentStatus.RUNNING, summary: 'A', lastActive: new Date(), pid: 10 }, + ]; + mockManager.listAgents.mockResolvedValue(agents); + mockManager.resolveAgent.mockReturnValue(null); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'kill', 'missing']); + + expect(ui.error).toHaveBeenCalledWith('No agent found matching "missing".'); + expect(ui.info).toHaveBeenCalledWith('Available agents:'); + expect(mockKillAgent).not.toHaveBeenCalled(); + }); + it('sends message to a resolved agent', async () => { const agent = { name: 'repo-a', diff --git a/packages/cli/src/__tests__/services/agent/agent.service.test.ts b/packages/cli/src/__tests__/services/agent/agent.service.test.ts index 0249dac..4958334 100644 --- a/packages/cli/src/__tests__/services/agent/agent.service.test.ts +++ b/packages/cli/src/__tests__/services/agent/agent.service.test.ts @@ -10,6 +10,7 @@ import { import { waitForAgentResponse, startAgent, + killAgent, AgentNameInUseError, AgentPidPollTimeoutError, TmuxUnavailableError, @@ -489,6 +490,96 @@ const startOpts = { pollTimeoutMs: 50, }; +describe('killAgent', () => { + it('sends SIGTERM to the agent PID', async () => { + const tmux = makeTmux(); + const registry = makeRegistry(); + const killProcess = vi.fn(); + + const result = await killAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + tmux, + registry, + killProcess, + }); + + expect(killProcess).toHaveBeenCalledWith(123, 'SIGTERM'); + expect(tmux.killSession).not.toHaveBeenCalled(); + expect(result).toEqual({ + agentName: 'repo-a', + pid: 123, + tmuxSession: null, + }); + }); + + it('kills the registry tmux session when present', async () => { + const tmux = makeTmux(); + const registry = makeRegistry({ + lookup: vi.fn().mockReturnValue({ + name: 'repo-a', + type: 'claude', + pid: 123, + tmuxSession: 'repo-a', + cwd: '/repo', + startedAt: '2026-06-01T00:00:00.000Z', + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + } satisfies RegistryEntry), + } as Partial); + const killProcess = vi.fn(); + + const result = await killAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + tmux, + registry, + killProcess, + }); + + expect(killProcess).toHaveBeenCalledWith(123, 'SIGTERM'); + expect(tmux.killSession).toHaveBeenCalledWith('repo-a'); + expect(result.tmuxSession).toBe('repo-a'); + }); + + it('still kills tmux session when the process is already gone', async () => { + const tmux = makeTmux(); + const registry = makeRegistry({ + lookup: vi.fn().mockReturnValue({ + name: 'repo-a', + type: 'claude', + pid: 123, + tmuxSession: 'repo-a', + cwd: '/repo', + startedAt: '2026-06-01T00:00:00.000Z', + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + } satisfies RegistryEntry), + } as Partial); + const error = Object.assign(new Error('gone'), { code: 'ESRCH' }); + const killProcess = vi.fn(() => { throw error; }); + + await killAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + tmux, + registry, + killProcess, + }); + + expect(tmux.killSession).toHaveBeenCalledWith('repo-a'); + }); + + it('rethrows unexpected process kill errors', async () => { + const tmux = makeTmux(); + const registry = makeRegistry(); + const error = Object.assign(new Error('permission denied'), { code: 'EPERM' }); + const killProcess = vi.fn(() => { throw error; }); + + await expect(killAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + tmux, + registry, + killProcess, + })).rejects.toThrow('permission denied'); + + expect(tmux.killSession).not.toHaveBeenCalled(); + }); +}); + describe('startAgent', () => { it('happy path: creates session, sends command, polls, registers, returns entry', async () => { const tmux = makeTmux(); diff --git a/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts index 6624fe2..e5de362 100644 --- a/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts +++ b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts @@ -92,6 +92,13 @@ describe('runAction', () => { ])); }); + it('passes correct argv for kill action', async () => { + vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); + await runAction({ type: 'kill', agentName: 'my-agent' }); + const [, argv] = vi.mocked(spawn).mock.calls[0]; + expect(argv).toEqual(expect.arrayContaining(['agent', 'kill', 'my-agent'])); + }); + it('spawns with stdio pipe to avoid seizing the TUI terminal', async () => { vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); await runAction({ type: 'start', agentType: 'claude', name: 'x', cwd: '/tmp/project' }); diff --git a/packages/cli/src/__tests__/tui/console/computeLayout.test.ts b/packages/cli/src/__tests__/tui/console/computeLayout.test.ts index ef45f2a..f65738f 100644 --- a/packages/cli/src/__tests__/tui/console/computeLayout.test.ts +++ b/packages/cli/src/__tests__/tui/console/computeLayout.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; // computeLayout is a pure function exported from ConsoleApp — import only the function, // not the React component tree, to avoid JSX in the test environment. -import { computeLayout } from '../../../tui/console/ConsoleApp.js'; +import { computeCenteredDialog, computeLayout } from '../../../tui/console/ConsoleApp.js'; // Constants mirrored from ConsoleApp.tsx for assertions const LIST_PANE_WIDTH = 48; @@ -73,3 +73,19 @@ describe('computeLayout', () => { }); }); }); + +describe('computeCenteredDialog', () => { + it('centers the dialog and caps width on wide terminals', () => { + const dialog = computeCenteredDialog(160, 40); + expect(dialog.width).toBe(56); + expect(dialog.left).toBe(52); + expect(dialog.top).toBe(17); + }); + + it('keeps the dialog usable on narrow terminals', () => { + const dialog = computeCenteredDialog(28, 10); + expect(dialog.width).toBe(24); + expect(dialog.left).toBe(2); + expect(dialog.top).toBe(2); + }); +}); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 8415a36..fa1d7bf 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -38,6 +38,7 @@ import { import { waitForAgentResponse, startAgent, + killAgent, TmuxUnavailableError, AgentNameInUseError, AgentPidPollTimeoutError, @@ -668,6 +669,42 @@ export function registerAgentCommand(program: Command): void { } })); + agentCommand + .command('kill ') + .description('Stop a running agent and clean up its managed tmux session') + .action(withErrorHandler('kill agent', async (name: string) => { + const manager = createAgentManager(); + const agents = await manager.listAgents(); + if (agents.length === 0) { + ui.error('No running agents found.'); + return; + } + + const resolved = manager.resolveAgent(name, agents); + + if (!resolved) { + ui.error(`No agent found matching "${name}".`); + ui.info('Available agents:'); + agents.forEach(a => ui.text(` - ${a.name}`)); + return; + } + + if (Array.isArray(resolved)) { + ui.error(`Multiple agents match "${name}":`); + resolved.forEach(a => ui.text(` - ${a.name} (${formatStatus(a.status)})`)); + ui.info('Please use a more specific name.'); + return; + } + + const result = await killAgent(resolved, { + tmux: new TmuxManager(), + registry: AgentRegistry.default(), + }); + + const suffix = result.tmuxSession ? ` and tmux session "${result.tmuxSession}"` : ''; + ui.success(`Stopped agent "${result.agentName}" (PID ${result.pid})${suffix}.`); + })); + agentCommand .command('detail') .description('Show detailed information about a running agent') diff --git a/packages/cli/src/services/agent/agent.service.ts b/packages/cli/src/services/agent/agent.service.ts index 682a13b..f1d1532 100644 --- a/packages/cli/src/services/agent/agent.service.ts +++ b/packages/cli/src/services/agent/agent.service.ts @@ -147,6 +147,18 @@ export interface StartAgentDeps { onWarning?: (message: string) => void; } +export interface KillAgentDeps { + tmux: Pick; + registry: Pick; + killProcess?: (pid: number, signal: NodeJS.Signals) => void; +} + +export interface KillAgentResult { + agentName: string; + pid: number; + tmuxSession: string | null; +} + export class TmuxUnavailableError extends Error { constructor() { super('tmux is not installed or not in PATH.'); @@ -168,6 +180,40 @@ export class AgentPidPollTimeoutError extends Error { } } +function isProcessAlreadyGone(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && (error as NodeJS.ErrnoException).code === 'ESRCH'; +} + +export async function killAgent( + agent: Pick, + deps: KillAgentDeps, +): Promise { + const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal)); + const registryEntry = deps.registry.lookup(agent.name); + const tmuxSession = registryEntry?.tmuxSession || null; + + try { + killProcess(agent.pid, 'SIGTERM'); + } catch (error) { + if (!isProcessAlreadyGone(error)) { + throw error; + } + } + + if (tmuxSession) { + await deps.tmux.killSession(tmuxSession); + } + + return { + agentName: agent.name, + pid: agent.pid, + tmuxSession, + }; +} + /** * Orchestrate `agent start`: ensure tmux is available, drop stale state, * create the session, send the launch command, poll for the real agent PID, diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 5195a1f..7329a91 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -4,6 +4,7 @@ import type { AgentManager } from '@ai-devkit/agent-manager'; import { ConsoleProvider, useConsoleContext } from './state/ConsoleContext.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useStartAgentPane } from './hooks/useStartAgentPane.js'; +import { useKillAgentAction } from './hooks/useKillAgentAction.js'; import { AgentListPane } from './AgentListPane.js'; import { PreviewSection } from './PreviewSection.js'; import { StatusFooter } from './StatusFooter.js'; @@ -29,6 +30,15 @@ type Focus = 'list' | 'input'; type RightPaneMode = { type: 'preview' } | { type: 'start-agent' }; type Transient = { kind: 'info' | 'error'; text: string }; +export function computeCenteredDialog(cols: number, rows: number) { + const width = Math.min(56, Math.max(24, cols - 6)); + return { + width, + left: Math.max(0, Math.floor((cols - width) / 2)), + top: Math.max(1, Math.floor(rows / 2) - 3), + }; +} + export function computeLayout(cols: number, rows: number, inputLines: number, narrow: boolean) { const inputBoxHeight = inputLines + INPUT_BOX_CHROME_ROWS; const totalHeight = Math.max( @@ -57,7 +67,6 @@ const ConsoleAppShell: React.FC<{ const [focus, setFocus] = useState('list'); const [inputLines, setInputLines] = useState(1); const [inputValue, setInputValue] = useState(''); - const [pendingKillName, setPendingKillName] = useState(null); const [transient, setTransient] = useState(null); const [rightPaneMode, setRightPaneMode] = useState({ type: 'preview' }); const startPaneActive = rightPaneMode.type === 'start-agent'; @@ -110,6 +119,12 @@ const ConsoleAppShell: React.FC<{ setTransient, }); + const { + pendingKillName, + openKillConfirm, + handleKillInput, + } = useKillAgentAction({ setTransient }); + const handleInputSubmit = useCallback((text: string) => { setFocus('list'); const agent = getSelectedAgent(); @@ -127,29 +142,8 @@ const ConsoleAppShell: React.FC<{ setFocus('list'); }, []); - const confirmKill = useCallback((agentName: string) => { - setPendingKillName(null); - void runAction({ type: 'kill', agentName }).then(result => { - if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { - setTransient({ kind: 'error', text: result.error ?? `kill exited ${result.exitCode}` }); - } else { - setTransient({ kind: 'info', text: `Killed ${agentName}` }); - } - }); - }, []); - useInput((input, key) => { - if (pendingKillName) { - if (key.escape || input === 'n') { - setPendingKillName(null); - return; - } - if (key.return || input === 'y') { - confirmKill(pendingKillName); - return; - } - return; - } + if (handleKillInput(input, key)) return; if (startPaneActive) return; @@ -165,7 +159,7 @@ const ConsoleAppShell: React.FC<{ if (input === 'K') { const agent = getSelectedAgent(); - if (agent) setPendingKillName(agent.name); + if (agent) openKillConfirm(agent.name); return; } @@ -210,9 +204,7 @@ const ConsoleAppShell: React.FC<{ const { cols, rows } = useTerminalSize(); const narrow = cols < NARROW_THRESHOLD_COLS; const { inputBoxHeight, contentHeight, previewHeight, listPaneWidth, rightColWidth, inputInnerWidth } = computeLayout(cols, rows, inputLines, narrow); - const dialogWidth = Math.min(56, Math.max(24, cols - 6)); - const dialogLeft = Math.max(0, Math.floor((cols - dialogWidth) / 2)); - const dialogTop = Math.max(1, Math.floor(rows / 2) - 3); + const dialog = computeCenteredDialog(cols, rows); const startPane = ( {pendingKillName ? ( - - + + ) : null} = ({ agentName, >; +} + +export function useKillAgentAction({ setTransient }: UseKillAgentActionOptions) { + const [pendingKillName, setPendingKillName] = useState(null); + + const openKillConfirm = useCallback((agentName: string) => { + setPendingKillName(agentName); + }, []); + + const cancelKill = useCallback(() => { + setPendingKillName(null); + }, []); + + const confirmPendingKill = useCallback(() => { + if (!pendingKillName) return; + const agentName = pendingKillName; + setPendingKillName(null); + void runAction({ type: 'kill', agentName }).then(result => { + if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { + setTransient({ kind: 'error', text: result.error ?? `kill exited ${result.exitCode}` }); + } else { + setTransient({ kind: 'info', text: `Killed ${agentName}` }); + } + }); + }, [pendingKillName, setTransient]); + + const handleKillInput = useCallback((input: string, key: ConsoleInputKey): boolean => { + if (!pendingKillName) return false; + if (key.escape || input === 'n') { + cancelKill(); + return true; + } + if (key.return || input === 'y') { + confirmPendingKill(); + return true; + } + return true; + }, [cancelKill, confirmPendingKill, pendingKillName]); + + return { + pendingKillName, + openKillConfirm, + handleKillInput, + }; +} From 5271a81b052b90964bfc35c00d45ba793438060f Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 1 Jun 2026 15:43:05 +0200 Subject: [PATCH 3/4] update hook --- .../console/hooks/useKillAgentAction.test.ts | 22 +++++++++++++ .../tui/console/hooks/useKillAgentAction.ts | 33 ++++++++++++++----- 2 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/__tests__/tui/console/hooks/useKillAgentAction.test.ts diff --git a/packages/cli/src/__tests__/tui/console/hooks/useKillAgentAction.test.ts b/packages/cli/src/__tests__/tui/console/hooks/useKillAgentAction.test.ts new file mode 100644 index 0000000..e8bdfdb --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/hooks/useKillAgentAction.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { getKillInputDecision } from '../../../../tui/console/hooks/useKillAgentAction.js'; + +describe('getKillInputDecision', () => { + it('does not consume input when no kill is pending', () => { + expect(getKillInputDecision(null, 'K', {})).toBe('none'); + }); + + it('cancels pending kill on Escape or n', () => { + expect(getKillInputDecision('repo-a', '', { escape: true })).toBe('cancel'); + expect(getKillInputDecision('repo-a', 'n', {})).toBe('cancel'); + }); + + it('confirms pending kill on Enter or y', () => { + expect(getKillInputDecision('repo-a', '', { return: true })).toBe('confirm'); + expect(getKillInputDecision('repo-a', 'y', {})).toBe('confirm'); + }); + + it('consumes unrelated input while confirmation is open', () => { + expect(getKillInputDecision('repo-a', 'j', {})).toBe('consume'); + }); +}); diff --git a/packages/cli/src/tui/console/hooks/useKillAgentAction.ts b/packages/cli/src/tui/console/hooks/useKillAgentAction.ts index 77f4dec..fe174c4 100644 --- a/packages/cli/src/tui/console/hooks/useKillAgentAction.ts +++ b/packages/cli/src/tui/console/hooks/useKillAgentAction.ts @@ -12,6 +12,19 @@ interface UseKillAgentActionOptions { setTransient: Dispatch>; } +export type KillInputDecision = 'none' | 'cancel' | 'confirm' | 'consume'; + +export function getKillInputDecision( + pendingKillName: string | null, + input: string, + key: ConsoleInputKey, +): KillInputDecision { + if (!pendingKillName) return 'none'; + if (key.escape || input === 'n') return 'cancel'; + if (key.return || input === 'y') return 'confirm'; + return 'consume'; +} + export function useKillAgentAction({ setTransient }: UseKillAgentActionOptions) { const [pendingKillName, setPendingKillName] = useState(null); @@ -37,16 +50,18 @@ export function useKillAgentAction({ setTransient }: UseKillAgentActionOptions) }, [pendingKillName, setTransient]); const handleKillInput = useCallback((input: string, key: ConsoleInputKey): boolean => { - if (!pendingKillName) return false; - if (key.escape || input === 'n') { - cancelKill(); - return true; - } - if (key.return || input === 'y') { - confirmPendingKill(); - return true; + switch (getKillInputDecision(pendingKillName, input, key)) { + case 'none': + return false; + case 'cancel': + cancelKill(); + return true; + case 'confirm': + confirmPendingKill(); + return true; + case 'consume': + return true; } - return true; }, [cancelKill, confirmPendingKill, pendingKillName]); return { From a090877fc1e0248a5cf890045c17e41aaf6cd48b Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 1 Jun 2026 15:43:20 +0200 Subject: [PATCH 4/4] update docs --- .../2026-06-01-feature-agent-console-kill.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/ai/testing/2026-06-01-feature-agent-console-kill.md b/docs/ai/testing/2026-06-01-feature-agent-console-kill.md index 56cb7aa..fdbd7c4 100644 --- a/docs/ai/testing/2026-06-01-feature-agent-console-kill.md +++ b/docs/ai/testing/2026-06-01-feature-agent-console-kill.md @@ -30,8 +30,9 @@ description: Test plan for confirmed kill support in agent console - [ ] Pressing lowercase `k` navigates up. - [ ] Pressing uppercase `K` opens confirmation for the selected agent. - [x] Confirmation renders as an overlay without changing computed pane dimensions. -- [ ] `Esc`/`n` cancels the pending kill. -- [ ] `Enter`/`y` confirms and dispatches the kill action. +- [x] `Esc`/`n` maps to cancel while kill confirmation is pending. +- [x] `Enter`/`y` maps to confirm while kill confirmation is pending. +- [ ] Full live Ink key handling (`k`, `K`, cancel, confirm) is manually smoke-tested in a TTY. ## Integration Tests @@ -55,7 +56,14 @@ description: Test plan for confirmed kill support in agent console ## Verification Results -- `npx vitest run src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts src/__tests__/tui/console/actions/runAction.test.ts src/__tests__/tui/console/computeLayout.test.ts`: exit 0, 90 tests passed. +- `npx vitest run src/__tests__/tui/console/hooks/useKillAgentAction.test.ts src/__tests__/tui/console/actions/runAction.test.ts src/__tests__/tui/console/computeLayout.test.ts src/__tests__/commands/agent.test.ts src/__tests__/services/agent/agent.service.test.ts`: exit 0, 97 tests passed. +- `npx vitest run src/__tests__/tui/console/hooks/useKillAgentAction.test.ts src/__tests__/tui/console/actions/runAction.test.ts src/__tests__/services/agent/agent.service.test.ts --coverage --coverage.include=src/tui/console/hooks/useKillAgentAction.ts --coverage.include=src/tui/console/actions/runAction.ts --coverage.include=src/services/agent/agent.service.ts`: exit 0, 36 tests passed, touched-module coverage 84.58% statements / 94.73% branches / 92.85% functions / 84.58% lines. - `npm run build` in `packages/agent-manager`: exit 0. - `npm run build` in `packages/cli`: exit 0. - `npm run lint` in `packages/cli`: exit 0. + +## Coverage Notes + +- Full CLI coverage with a focused test subset exits non-zero because Vitest includes the entire CLI source tree and enforces global 60% thresholds against unrelated unexecuted modules. +- Scoped coverage for touched kill/action/service modules exits zero. +- The package does not currently include an Ink or React hook test renderer; full keypress behavior remains covered by manual TTY smoke testing.