diff --git a/docs/ai/design/2026-08-14-feature-console-in-process-actions.md b/docs/ai/design/2026-08-14-feature-console-in-process-actions.md new file mode 100644 index 00000000..0d78fe2d --- /dev/null +++ b/docs/ai/design/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,70 @@ +--- +phase: design +title: Console In-Process Actions Design +description: Shared application-service boundary for Commander and the agent console +--- + +# Console In-Process Actions Design + +## Architecture + +```mermaid +flowchart LR + Commander[Commander handlers] --> AgentPackage[@ai-devkit/agent-manager services] + Commander --> ChannelPackage[@ai-devkit/channel-connector services] + Console[Console hooks and shell] --> Runner[In-process console action runner] + Runner --> AgentPackage + Runner --> ChannelPackage + AgentPackage --> AgentManager[Agent manager / terminal / tmux / registry] + ChannelPackage --> Channel[ConfigStore / bridge registry] + Channel --> Daemon[Detached channel daemon] + Commander --> CliUI[CLI reporter + exit adapter] + Console --> Pending[Immediate pending state + duplicate guard] +``` + +Commander remains responsible for parsing command-only input such as stdin, debug flags, interactive selection, CLI rendering, and applying an exit directive. Application services live in the packages that own their dependencies: agent lifecycle and terminal operations in `@ai-devkit/agent-manager`, and channel configuration/bridge process state in `@ai-devkit/channel-connector`. The console imports those public package services directly. The CLI alone resolves its source/build-specific channel-daemon entrypoint and passes that launch descriptor into the channel service. + +## Service Contract + +```ts +interface ApplicationActionResult { + ok: boolean; + message?: string; + cliExitCode?: number; +} +``` + +Services accept explicit dependencies (manager, focus manager, registry, tmux manager, config store, bridge service, reporter, and optional interactive selector). Defaults construct package-native production dependencies. Tests inject boundary doubles. Package services must not import CLI UI, debug, path-layout, group-storage, or process-exit modules. + +`cliExitCode` is independent from `ok`: existing open/kill lookup failures produce command errors without forcing exit 1, while invalid start/rename and typed start failures retain exit 1. Commander applies only the explicit directive. Console uses `ok` and `message` for inline feedback. + +## Pending State + +The console uses a synchronous keyed pending gate. `begin(key, label)` records pending before invoking the async service and rejects a duplicate key until the promise settles. State notifications drive transient UI text. Required labels are `Sending`, `Opening`, and `Stopping channel`; other actions retain their existing pane submitting/confirmation behavior while gaining duplicate protection. + +Tests assert the notification and pending snapshot immediately after submission and before resolving a deferred promise. No elapsed-time assertion is used, so the test deterministically proves the UI acknowledgement occurs in the same call stack and therefore satisfies the 50 ms target independent of machine load. + +## Compatibility and Security + +- Services reuse `sendToAgent`, `startAgent`, `killAgent`, `TerminalFocusManager`, `AgentRegistry`, `TmuxManager`, `ConfigStore`, and `ChannelService` rather than reimplementing lower-level behavior. +- The reusable services and their lower-level operations are exported by their owning packages; `packages/cli` does not duplicate them. +- CLI reporters preserve exact existing output strings and spinner behavior. +- Commander retains stdin/debug/group/print/wait parsing and presentation paths; only the shared interactive-agent action path moves behind services. +- Channel start still launches the dedicated daemon with `spawn` inside `ChannelService`; console actions no longer reinvoke the whole CLI. +- No shell is introduced. User values remain ordinary method inputs and daemon argv elements. +- Config and registry access stays behind existing stores/services; channel secrets are never returned to the TUI. + +## Alternatives Considered + +- Reuse Commander handlers directly: rejected because they couple services to parsing, `process.exit`, prompts, and terminal output. +- Keep subprocesses behind a generic executor: rejected because it retains startup latency and duplicate orchestration. +- Create TUI-only direct implementations: rejected because it leaves two behavior sources and risks validation/security drift. +- Extract all command modes into one large service: rejected because group, print, stdin, and wait modes are not console actions; retaining them in thin command orchestration reduces scope while sharing the requested interactive paths. +- Keep reusable services under `packages/cli`: rejected because it makes the console consume a CLI-owned reimplementation and prevents other package consumers from using the same behavior. + +## Risks + +- Output or exit drift during extraction: keep command tests and add wrapper/service tests. +- React state closure allows rapid duplicate input: use a synchronous mutable gate, not state alone. +- Service result loses useful error text: reporter captures the first/most relevant error while services also return a message. +- Channel daemon launch path differs between source and build: retain the resolver in the CLI adapter and pass a structured launch descriptor into the package service. diff --git a/docs/ai/implementation/2026-08-14-feature-console-in-process-actions.md b/docs/ai/implementation/2026-08-14-feature-console-in-process-actions.md new file mode 100644 index 00000000..3e93c484 --- /dev/null +++ b/docs/ai/implementation/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,40 @@ +--- +phase: implementation +title: Console In-Process Actions Implementation +description: Implementation log for shared action services +--- + +# Console In-Process Actions Implementation + +## Status + +Implementation, validation, and publication for review are complete. PR: https://github.com/codeaholicguy/ai-devkit/pull/160 + +## Intended Changes + +- `@ai-devkit/agent-manager` exports the reusable start/open/send/kill/rename service and its existing lower-level operations. The CLI keeps only a dependency-composition adapter for prompts, groups, debug logging, and terminal reporting. +- `@ai-devkit/channel-connector` exports bridge registry and daemon start/stop services. The CLI keeps foreground execution and source/build daemon-entrypoint resolution as CLI-specific adapters. +- Commander resolves paths and message input, calls the service, and applies only an explicit service exit directive. +- `runAction.ts` dispatches all seven console actions directly to injectable service methods; it no longer imports `child_process`. +- `pendingAction.ts` provides synchronous action identity/label notification and a keyed in-flight gate shared by all console flows. +- `ConsoleApp` publishes pending labels through the existing transient message surface. Required labels are `Sending`, `Opening`, and `Stopping channel`. + +## Decisions and Deviations + +- All seven actions fit coherently in the shared boundary; no action migration was deferred. +- Group, print, and wait orchestration moved into the agent package service. Foreground-channel execution remains in the CLI adapter because it owns the long-running Commander process, while daemon start/stop is package-owned and shared with the console. +- The configured Vercel React best-practices skill was unavailable in the active skill catalog. The implementation follows existing hook extraction, stable setter, memoized executor, and synchronous mutable-gate patterns; no render-time side effects or timing assertions were added. +- The channel daemon remains an intentional detached child process. Only the per-action full CLI respawn was removed. +- Remaining compatibility modules under `packages/cli/src/services` are export-only shims; service behavior has one implementation in the owning packages. The console imports the package APIs directly. + +## Validation Evidence + +- Red: focused action tests failed with seven zero-call assertions against the subprocess runner; pending tests failed because the pending module/mapping did not exist. +- Green/refactor: `npm test --workspace packages/cli -- src/__tests__/commands/agent.test.ts src/__tests__/commands/channel.test.ts src/__tests__/tui/console/actions/runAction.test.ts src/__tests__/tui/console/actions/pendingAction.test.ts` — 4 files, 114 tests passed. +- Focused console actions/hooks: 5 files, 33 tests passed. +- Full CLI: `npm test --workspace packages/cli` — 82 files, 975 tests passed after rebasing onto `origin/main`. +- CLI lint: `npm run lint --workspace packages/cli` — exit 0, five existing warnings and no errors. +- CLI build: `npm run build --workspace packages/cli` — exit 0, 199 files compiled after rebase. +- Feature docs: `npx ai-devkit@latest lint --feature console-in-process-actions` — all checks passed. +- Output isolation: default console services receive a silent reporter; a red-to-green test proves CLI spinners/text cannot write into the Ink terminal. +- Owning packages: agent-manager 25 files/504 tests and channel-connector 8 files/105 tests passed; both package builds and lints passed. diff --git a/docs/ai/planning/2026-08-14-feature-console-in-process-actions.md b/docs/ai/planning/2026-08-14-feature-console-in-process-actions.md new file mode 100644 index 00000000..741b3da7 --- /dev/null +++ b/docs/ai/planning/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,35 @@ +--- +phase: planning +title: Console In-Process Actions Plan +description: TDD task queue for shared console action services +--- + +# Console In-Process Actions Plan + +## Task Queue + +- [x] Add red tests for direct service dispatch for all seven console actions. +- [x] Add red deterministic tests for immediate pending notification and duplicate suppression. +- [x] Add red tests for successful results, service errors, and retry after settlement. +- [x] Extract agent action services and convert start/open/send/kill/rename Commander handlers to thin wrappers. +- [x] Extract channel start/stop action services and convert Commander handlers to thin wrappers. +- [x] Replace the console subprocess runner with direct in-process dispatch and injected defaults. +- [x] Wire immediate `Sending`, `Opening`, and `Stopping channel` feedback and pending guards into console flows. +- [x] Refactor after green and update implementation/testing documents. +- [x] Run focused tests, full CLI tests, CLI lint, CLI build, and feature-doc lint. +- [x] Review diff, commit conventionally, rebase on `origin/main`, revalidate, push, and open a PR to `main`. + +## Package Ownership Revision + +- [x] Add red package-level tests for exported agent and channel application services. +- [x] Move reusable agent lifecycle orchestration into `@ai-devkit/agent-manager`. +- [x] Move reusable channel bridge registry and daemon orchestration into `@ai-devkit/channel-connector`. +- [x] Remove CLI-owned service reimplementations and import package services from Commander and the console. +- [x] Re-run package tests/builds plus the full CLI validation matrix. +- [x] Commit and update PR #160. + +## Scope Decision + +All seven actions share the same dispatch/result boundary and are included. Command-only group, print, stdin, wait, and foreground-channel modes remain in Commander orchestration, using existing lower-level services, because the console does not invoke them. + +Implementation refined this boundary by moving group, print, wait, and foreground-channel orchestration into the application services as well. Commander retains only parsing/input acquisition, output/exit adaptation, and registration. diff --git a/docs/ai/requirements/2026-08-14-feature-console-in-process-actions.md b/docs/ai/requirements/2026-08-14-feature-console-in-process-actions.md new file mode 100644 index 00000000..44256713 --- /dev/null +++ b/docs/ai/requirements/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,43 @@ +--- +phase: requirements +title: Console In-Process Actions Requirements +description: Remove per-action CLI subprocesses from the agent console +--- + +# Console In-Process Actions Requirements + +## Problem Statement + +`packages/cli/src/tui/console/actions/runAction.ts` starts a new CLI process for every console action. Startup and module-loading latency delays feedback for send, open, start, kill, rename, channel start, and channel stop, while duplicating orchestration between Commander and the TUI. + +## Goals + +- Invoke reusable application services directly from both Commander handlers and the console. +- Cover send, open, start, kill, rename, channel start, and channel stop in one coherent boundary. +- Preserve existing command output, exit behavior, validation, dependency/security boundaries, and test seams. +- Show `Sending`, `Opening`, and `Stopping channel` immediately when those actions begin. +- Suppress duplicate submission of an action while that action is pending. +- Keep acknowledgement under the 50 ms target by making the state transition synchronous and testing it without wall-clock timing. +- Add tests before production changes for direct invocation, immediate feedback, duplicate suppression, success, and errors. + +## Non-Goals + +- Removing the channel daemon child process; the daemon is the long-lived workload and remains intentionally detached. +- Changing command syntax, output wording, terminal resolution, tmux behavior, registry formats, or channel authorization/configuration. +- Adding new console actions or redesigning the console UI. + +## Acceptance Criteria + +- Console action execution no longer imports or calls `child_process.spawn` to reinvoke the CLI. +- Commander actions are thin adapters over the same application services used by the console. +- User-controlled values remain structured arguments/data and are never interpolated into a shell command. +- Existing CLI behavior and focused command tests remain green. +- Pending feedback is observable synchronously before the action promise settles. +- A second submission with the same pending key is ignored until settlement; retry is possible afterward. +- Focused tests, full CLI tests, CLI lint, and CLI build pass. + +## Assumptions + +- The user-approved objective is the authoritative requirements source for this feature. +- Existing feature documents for agent console start/kill/rename/channel and agent send define compatibility behavior. +- The configured Vercel React best-practices skill is unavailable in this runtime; repository React conventions and deterministic state tests are used instead. diff --git a/docs/ai/testing/2026-08-14-feature-console-in-process-actions.md b/docs/ai/testing/2026-08-14-feature-console-in-process-actions.md new file mode 100644 index 00000000..d11b7bcb --- /dev/null +++ b/docs/ai/testing/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,43 @@ +--- +phase: testing +title: Console In-Process Actions Testing +description: TDD coverage and validation evidence +--- + +# Console In-Process Actions Testing + +## Required Scenarios + +- [x] Each console action invokes its injected application service directly. +- [x] No console action runner starts a fresh CLI process. +- [x] Direct services cannot write CLI reporter output into the Ink terminal. +- [x] Pending feedback is emitted synchronously before a deferred action settles. +- [x] Sending renders `Sending`; opening renders `Opening`; channel stop renders `Stopping channel`. +- [x] Duplicate submission for a pending action is suppressed. +- [x] Submission can retry after success or error settlement. +- [x] Successful actions preserve existing console feedback and refresh behavior. +- [x] Service errors preserve useful messages and existing CLI output/exit behavior. +- [x] User-controlled values remain structured and reach the expected service dependency. + +## Validation Commands + +- Focused action/service/pending tests. +- Package service tests, lint, and builds for `agent-manager` and `channel-connector`. +- `npm test --workspace packages/cli` +- `npm run lint --workspace packages/cli` +- `npm run build --workspace packages/cli` +- `npx ai-devkit@latest lint --feature console-in-process-actions` + +## Evidence + +- TDD red run: 2 files failed; all seven direct-dispatch assertions observed zero service calls, and pending behavior was absent. +- Pending/action unit tests: 2 files, 20 tests passed. +- Focused command/action regression: 4 files, 114 tests passed. +- Focused console action/hook regression: 5 files, 33 tests passed. +- Full CLI suite: 82 files, 975 tests passed after rebase, exit 0. +- CLI lint: exit 0 with five pre-existing warnings, zero errors. +- CLI build: exit 0; SWC compiled 199 files after rebase and declaration generation completed. +- Feature-doc lint: exit 0; all required feature documents and worktree checks passed. +- Package boundary tests: agent open and channel daemon start invoke injected package dependencies directly. +- Package builds and lint: both owning packages compile and lint successfully. +- Agent-manager suite: 25 files and 504 tests passed with process inspection enabled; channel-connector suite: 8 files and 105 tests passed. diff --git a/packages/agent-manager/src/__tests__/services/AgentActionService.test.ts b/packages/agent-manager/src/__tests__/services/AgentActionService.test.ts new file mode 100644 index 00000000..82afe05f --- /dev/null +++ b/packages/agent-manager/src/__tests__/services/AgentActionService.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AgentStatus } from '../../adapters/AgentAdapter.js'; +import { createAgentActionService } from '../../services/AgentActionService.js'; + +describe('AgentActionService', () => { + it('owns terminal opening in the agent-manager package', async () => { + const agent = { + name: 'jarvis', + pid: 42, + status: AgentStatus.WAITING, + projectPath: '/tmp/project', + lastActive: new Date(), + type: 'codex' as const, + }; + const focusManager = { + findTerminal: vi.fn().mockResolvedValue({ type: 'tmux', identifier: 'jarvis' }), + focusTerminal: vi.fn().mockResolvedValue(true), + }; + const service = createAgentActionService({ + manager: { + listAgents: vi.fn().mockResolvedValue([agent]), + resolveAgent: vi.fn().mockReturnValue(agent), + getAdapter: vi.fn(), + }, + createFocusManager: () => focusManager, + }); + + await expect(service.open({ agentName: 'jarvis' })).resolves.toMatchObject({ ok: true }); + expect(focusManager.findTerminal).toHaveBeenCalledWith(42); + expect(focusManager.focusTerminal).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index ced400ec..936294a3 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -73,3 +73,46 @@ export type { ClaudePrintAgentServiceOptions, ClaudePrintSendResult, } from './print/ClaudePrintAgentService.js'; + +export { + actionFailed, + actionSucceeded, +} from './services/ActionResult.js'; +export type { ApplicationActionResult } from './services/ActionResult.js'; +export { + createAgentActionService, + createAgentManager, +} from './services/AgentActionService.js'; +export type { + AgentActionReporter, + AgentActionService, + AgentActionServiceDependencies, + KillAgentActionInput, + OpenAgentActionInput, + RenameAgentActionInput, + SendAgentActionInput, + StartAgentActionInput, +} from './services/AgentActionService.js'; +export { + AgentNameInUseError, + AgentPidPollTimeoutError, + DEFAULT_PID_POLL_INTERVAL_MS, + DEFAULT_PID_POLL_TIMEOUT_MS, + TmuxUnavailableError, + assertSendTargetOptions, + killAgent, + sendToAgent, + sendToAgentGroup, + startAgent, + waitForAgentResponse, +} from './services/AgentService.js'; +export type { + AgentGroup, + AgentSendWaitOptions, + AgentSendWaitResult, + AgentSendWaitTarget, + SendReporter, + SendToAgentGroupOptions, + SendToAgentOptions, + WaitForAgentResponseParams, +} from './services/AgentService.js'; diff --git a/packages/agent-manager/src/services/ActionResult.ts b/packages/agent-manager/src/services/ActionResult.ts new file mode 100644 index 00000000..8bd995e0 --- /dev/null +++ b/packages/agent-manager/src/services/ActionResult.ts @@ -0,0 +1,12 @@ +export interface ApplicationActionResult { + ok: boolean; + message?: string; + cliExitCode?: number; +} + +export const actionSucceeded = (): ApplicationActionResult => ({ ok: true }); + +export const actionFailed = ( + message: string, + cliExitCode?: number, +): ApplicationActionResult => ({ ok: false, message, cliExitCode }); diff --git a/packages/agent-manager/src/services/AgentActionService.ts b/packages/agent-manager/src/services/AgentActionService.ts new file mode 100644 index 00000000..d284e461 --- /dev/null +++ b/packages/agent-manager/src/services/AgentActionService.ts @@ -0,0 +1,457 @@ +import fs from 'fs'; +import os from 'os'; +import { AgentManager } from '../AgentManager.js'; +import { ClaudeCodeAdapter } from '../adapters/ClaudeCodeAdapter.js'; +import { CodexAdapter } from '../adapters/CodexAdapter.js'; +import { CopilotAdapter } from '../adapters/CopilotAdapter.js'; +import { GeminiCliAdapter } from '../adapters/GeminiCliAdapter.js'; +import { GrokCliAdapter } from '../adapters/GrokCliAdapter.js'; +import { OpenCodeAdapter } from '../adapters/OpenCodeAdapter.js'; +import { PiAdapter } from '../adapters/PiAdapter.js'; +import { AgentStatus, type AgentInfo } from '../adapters/AgentAdapter.js'; +import { ClaudePrintAgentService } from '../print/ClaudePrintAgentService.js'; +import { PrintAgentStore } from '../print/PrintAgentStore.js'; +import { TerminalFocusManager } from '../terminal/TerminalFocusManager.js'; +import { TmuxManager } from '../terminal/TmuxManager.js'; +import { AgentRegistry, RenameConflictError, RenameNotFoundError } from '../utils/AgentRegistry.js'; +import { AGENTS, type StartableAgentType } from '../utils/agents.js'; +import { + AgentNameInUseError, + AgentPidPollTimeoutError, + TmuxUnavailableError, + assertSendTargetOptions, + killAgent, + sendToAgent, + sendToAgentGroup, + startAgent, + type SendReporter, +} from './AgentService.js'; +import { actionFailed, actionSucceeded, type ApplicationActionResult } from './ActionResult.js'; + +const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; + +export interface AgentActionReporter { + text(message: string, options?: unknown): void; + info(message: string): void; + success(message: string): void; + warning(message: string): void; + error(message: string): void; + spinner(message: string): { + start(): unknown; + succeed(message?: string): unknown; + fail(message?: string): unknown; + }; +} +type AgentManagerLike = Pick; +type FocusManagerLike = Pick; +type RegistryLike = Pick; +type TmuxLike = TmuxManager; +type PrintServiceLike = Pick; +interface GroupServiceLike { + get(name: string): { name: string; members: string[] } | undefined; +} + +interface SelectAgentOptions { + message: string; + choices: Array<{ name: string; value: AgentInfo }>; +} + +export interface StartAgentActionInput { + agentType: string; + mode: string; + name: string; + cwd: string; + debug?: boolean; +} + +export interface OpenAgentActionInput { + agentName: string; + debug?: boolean; +} + +export interface SendAgentActionInput { + agentName?: string; + groupName?: string; + message: string; + wait?: boolean; + timeout?: string; + json?: boolean; +} + +export interface KillAgentActionInput { + agentName: string; +} + +export interface RenameAgentActionInput { + currentName: string; + newName: string; +} + +export interface AgentActionService { + start(input: StartAgentActionInput): Promise; + open(input: OpenAgentActionInput): Promise; + send(input: SendAgentActionInput): Promise; + kill(input: KillAgentActionInput): Promise; + rename(input: RenameAgentActionInput): Promise; +} + +export interface AgentActionServiceDependencies { + manager?: AgentManagerLike; + createFocusManager?: (logger?: (message: string) => void) => FocusManagerLike; + registry?: RegistryLike; + tmux?: TmuxLike; + printService?: PrintServiceLike; + groupService?: GroupServiceLike; + reporter?: AgentActionReporter; + selectAgent?: (options: SelectAgentOptions) => Promise; + writeWaitStatus?: (message: string) => void; + writeProviderOutput?: (message: string) => void; + writeJson?: (value: object) => void; + startAgent?: typeof startAgent; + killAgent?: typeof killAgent; + sendToAgent?: typeof sendToAgent; + sendToAgentGroup?: typeof sendToAgentGroup; +} + +export function createAgentManager(): AgentManager { + const manager = new AgentManager(AgentRegistry.default()); + manager.registerAdapter(new ClaudeCodeAdapter()); + manager.registerAdapter(new CodexAdapter()); + manager.registerAdapter(new CopilotAdapter()); + manager.registerAdapter(new GeminiCliAdapter()); + manager.registerAdapter(new GrokCliAdapter()); + manager.registerAdapter(new OpenCodeAdapter()); + manager.registerAdapter(new PiAdapter()); + return manager; +} + +function createPrintAgentService(): ClaudePrintAgentService { + return new ClaudePrintAgentService({ store: new PrintAgentStore() }); +} + +function formatCwd(projectPath?: string): string { + if (!projectPath) return ''; + const home = os.homedir(); + return projectPath.startsWith(home) ? `~${projectPath.slice(home.length)}` : projectPath; +} + +function formatStatus(status: AgentStatus): string { + const labels: Record = { + [AgentStatus.RUNNING]: '🟢 run', + [AgentStatus.WAITING]: '🟡 wait', + [AgentStatus.IDLE]: '⚪ idle', + [AgentStatus.UNKNOWN]: '❓ unknown', + }; + return labels[status] ?? labels[AgentStatus.UNKNOWN]; +} + +function sanitizeProviderOutput(value: string): string { + // eslint-disable-next-line no-control-regex + const withoutOsc = value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, ''); + return Array.from(withoutOsc, (character) => { + const code = character.charCodeAt(0); + return (code < 32 && code !== 9 && code !== 10) || code === 127 ? '' : character; + }).join(''); +} + +function createTrackedSendReporter( + reporter: AgentActionReporter, +): { reporter: SendReporter; getError: () => string | undefined } { + let error: string | undefined; + return { + reporter: { + info: (text) => text.startsWith(' - ') ? reporter.text(text) : reporter.info(text), + warning: (text) => reporter.warning(text), + success: (text) => reporter.success(text), + error: (text) => { + error ??= text; + reporter.error(text); + }, + }, + getError: () => error, + }; +} + +function createSilentReporter(): AgentActionReporter { + const noOutput = () => undefined; + return { + text: noOutput, + info: noOutput, + success: noOutput, + warning: noOutput, + error: noOutput, + spinner: () => ({ start: noOutput, succeed: noOutput, fail: noOutput }), + }; +} + +export function createAgentActionService( + dependencies: AgentActionServiceDependencies = {}, +): AgentActionService { + const reporter = dependencies.reporter ?? createSilentReporter(); + const manager = dependencies.manager ?? createAgentManager(); + const registry = dependencies.registry ?? AgentRegistry.default(); + const tmux = dependencies.tmux ?? new TmuxManager(); + const printService = dependencies.printService ?? createPrintAgentService(); + const groupService = dependencies.groupService; + const createFocusManager = dependencies.createFocusManager + ?? ((logger?: (message: string) => void) => new TerminalFocusManager(logger)); + const selectAgent = dependencies.selectAgent; + const writeWaitStatus = dependencies.writeWaitStatus + ?? ((message: string) => process.stderr.write(`${message.replace(ANSI_ESCAPE_PATTERN, '')}\n`)); + const writeProviderOutput = dependencies.writeProviderOutput + ?? ((message: string) => reporter.text(message)); + const writeJson = dependencies.writeJson ?? ((value: object) => console.log(JSON.stringify(value, null, 2))); + const startAgentOperation = dependencies.startAgent ?? startAgent; + const killAgentOperation = dependencies.killAgent ?? killAgent; + const sendToAgentOperation = dependencies.sendToAgent ?? sendToAgent; + const sendToAgentGroupOperation = dependencies.sendToAgentGroup ?? sendToAgentGroup; + + return { + async start(input) { + if (!(input.agentType in AGENTS)) { + const message = `Unsupported agent type "${input.agentType}". Supported: ${Object.keys(AGENTS).join(', ')}.`; + reporter.error(message); + return actionFailed(message, 1); + } + if (!['interactive', 'print'].includes(input.mode)) { + throw new Error(`Unsupported agent mode "${input.mode}". Supported: interactive, print.`); + } + if (input.mode === 'print' && input.agentType !== 'claude') { + throw new Error('Print mode currently supports only --type claude.'); + } + if (!NAME_REGEX.test(input.name)) { + const message = `Invalid name "${input.name}". Use lowercase letters, digits, and hyphens only. ` + + 'Must start and end with a letter or digit, 2–64 characters.'; + reporter.error(message); + return actionFailed(message, 1); + } + if (!fs.existsSync(input.cwd)) { + const message = `Directory "${input.cwd}" does not exist.`; + reporter.error(message); + return actionFailed(message, 1); + } + + try { + if (input.mode === 'print') { + const entry = await printService.create({ name: input.name, cwd: input.cwd }); + reporter.success(`Print agent "${entry.name}" started (${entry.provider}, ID ${entry.id})`); + reporter.text(`Working directory: ${formatCwd(entry.cwd)}`); + reporter.text('State: ready (Claude session not started)'); + return actionSucceeded(); + } + + const entry = await startAgentOperation( + { type: input.agentType as StartableAgentType, name: input.name, cwd: input.cwd }, + { tmux, registry: registry as AgentRegistry, onWarning: (message) => reporter.warning(message) }, + ); + reporter.success(`Agent "${entry.name}" started (${entry.type}, PID ${entry.pid})`); + reporter.text(`Working directory: ${formatCwd(entry.cwd)}`); + reporter.text(`Attach: tmux attach -t ${entry.tmuxSession}`); + return actionSucceeded(); + } catch (error) { + let message: string; + if (error instanceof TmuxUnavailableError || (error instanceof Error && error.name === 'TmuxUnavailableError')) { + message = 'tmux is not installed or not in PATH. Install it first (e.g., brew install tmux).'; + } else if (error instanceof AgentNameInUseError) { + message = `Agent "${error.agentName}" is already running (PID ${error.pid}). Choose a different name.`; + } else if (error instanceof AgentPidPollTimeoutError) { + message = `Agent process not found after ${error.timeoutMs / 1000}s. ` + + `Verify that "${error.command}" is in PATH inside the tmux environment.`; + } else { + throw error; + } + reporter.error(message); + return actionFailed(message, 1); + } + }, + + async open(input) { + const focusManager = createFocusManager(); + const agents = await manager.listAgents(); + if (agents.length === 0) { + const message = 'No running agents found.'; + reporter.error(message); + return actionFailed(message); + } + + const resolved = manager.resolveAgent(input.agentName, agents); + if (!resolved) { + const message = `No agent found matching "${input.agentName}".`; + reporter.error(message); + reporter.info('Available agents:'); + agents.forEach((agent) => reporter.text(` - ${agent.name}`)); + return actionFailed(message); + } + + let targetAgent = resolved; + if (Array.isArray(resolved)) { + reporter.warning(`Multiple agents match "${input.agentName}":`); + if (!selectAgent) { + const message = `Multiple agents match "${input.agentName}".`; + reporter.error(message); + return actionFailed(message); + } + targetAgent = await selectAgent({ + message: 'Select an agent to open:', + choices: resolved.map((agent) => ({ + name: `${agent.name} (${formatStatus(agent.status)}) - ${agent.summary}`, + value: agent, + })), + }); + } + + const agent = targetAgent as AgentInfo; + if (!agent.pid) { + const message = `Cannot focus agent "${agent.name}" (No PID found).`; + reporter.error(message); + return actionFailed(message); + } + + const spinner = reporter.spinner(`Switching focus to ${agent.name}...`); + spinner.start(); + const location = await focusManager.findTerminal(agent.pid); + if (!location) { + const message = `Could not find terminal window for agent "${agent.name}" (PID: ${agent.pid}).`; + spinner.fail(message); + return actionFailed(message); + } + const success = await focusManager.focusTerminal(location); + if (!success) { + const message = `Failed to switch focus to ${agent.name}.`; + spinner.fail(message); + return actionFailed(message); + } + spinner.succeed(`Focused ${agent.name}!`); + return actionSucceeded(); + }, + + async send(input) { + assertSendTargetOptions({ + id: input.agentName, + group: input.groupName, + wait: input.wait, + timeout: input.timeout, + json: input.json, + }); + const focusManager = createFocusManager(); + + if (input.groupName) { + if (!groupService) throw new Error('Agent group service is required for group sends.'); + const group = groupService.get(input.groupName); + if (!group) throw new Error(`Agent group "${input.groupName}" not found.`); + await sendToAgentGroupOperation({ group, prompt: input.message, manager, focusManager, reporter }); + return process.exitCode === 1 + ? actionFailed(`Failed to send message to agent group "${input.groupName}".`) + : actionSucceeded(); + } + + const printResolved = await printService.store.resolve(input.agentName!); + if (Array.isArray(printResolved)) { + throw new Error(`Multiple print agents match "${input.agentName}".`); + } + if (printResolved) { + if (input.timeout !== undefined) { + throw new Error('--timeout is not supported for synchronous print agents.'); + } + if (input.agentName !== printResolved.id) { + const liveAgents = await manager.listAgents(); + const liveExact = liveAgents.filter((agent) => ( + agent.name.toLowerCase() === String(input.agentName).toLowerCase() + )); + if (liveExact.length > 0) { + throw new Error(`Agent name "${input.agentName}" is ambiguous across interactive and print modes. Use the print agent ID.`); + } + } + const result = await printService.send(input.agentName!, input.message); + if (input.json) { + writeJson({ + target: { id: result.agentId, name: result.agentName, provider: 'claude', mode: 'print' }, + response: result.result, + exitCode: result.exitCode, + sessionId: result.sessionId, + }); + } else { + writeProviderOutput(sanitizeProviderOutput(result.result)); + } + return actionSucceeded(); + } + + const tracked = createTrackedSendReporter(reporter); + await sendToAgentOperation({ + id: input.agentName!, + prompt: input.message, + manager, + focusManager, + wait: input.wait, + timeout: input.timeout, + json: input.json, + reporter: tracked.reporter, + writeWaitStatus, + writeJson, + }); + const error = tracked.getError(); + return error ? actionFailed(error) : actionSucceeded(); + }, + + async kill(input) { + const agents = await manager.listAgents(); + if (agents.length === 0) { + const message = 'No running agents found.'; + reporter.error(message); + return actionFailed(message); + } + const resolved = manager.resolveAgent(input.agentName, agents); + if (!resolved) { + const message = `No agent found matching "${input.agentName}".`; + reporter.error(message); + reporter.info('Available agents:'); + agents.forEach((agent) => reporter.text(` - ${agent.name}`)); + return actionFailed(message); + } + if (Array.isArray(resolved)) { + const message = `Multiple agents match "${input.agentName}":`; + reporter.error(message); + resolved.forEach((agent) => reporter.text(` - ${agent.name} (${formatStatus(agent.status)})`)); + reporter.info('Please use a more specific name.'); + return actionFailed(message); + } + + const result = await killAgentOperation(resolved, { tmux, registry: registry as AgentRegistry }); + const suffix = result.tmuxSession ? ` and tmux session "${result.tmuxSession}"` : ''; + reporter.success(`Stopped agent "${result.agentName}" (PID ${result.pid})${suffix}.`); + return actionSucceeded(); + }, + + async rename(input) { + if (!NAME_REGEX.test(input.newName)) { + const message = `Invalid name "${input.newName}". Use lowercase letters, digits, and hyphens only. ` + + 'Must start and end with a letter or digit, 2–64 characters.'; + reporter.error(message); + return actionFailed(message, 1); + } + if (input.currentName === input.newName) { + reporter.info(`Agent "${input.currentName}" already has that name.`); + return actionSucceeded(); + } + try { + registry.rename(input.currentName, input.newName); + reporter.success(`Agent "${input.currentName}" renamed to "${input.newName}".`); + return actionSucceeded(); + } catch (error) { + let message: string; + if (error instanceof RenameNotFoundError || (error instanceof Error && error.name === 'RenameNotFoundError')) { + message = error.message; + } else if (error instanceof RenameConflictError || (error instanceof Error && error.name === 'RenameConflictError')) { + const agentName = 'agentName' in error ? String(error.agentName) : input.newName; + message = `Agent "${agentName}" is already in use. Choose a different name.`; + } else { + throw error; + } + reporter.error(message); + return actionFailed(message, 1); + } + }, + }; +} diff --git a/packages/agent-manager/src/services/AgentService.ts b/packages/agent-manager/src/services/AgentService.ts new file mode 100644 index 00000000..960eed08 --- /dev/null +++ b/packages/agent-manager/src/services/AgentService.ts @@ -0,0 +1,700 @@ +import { AgentStatus, type AgentAdapter, type AgentInfo, type AgentType, type ConversationMessage } from '../adapters/AgentAdapter.js'; +import type { AgentManager } from '../AgentManager.js'; +import { TtyWriter } from '../terminal/TtyWriter.js'; +import type { TerminalFocusManager, TerminalLocation } from '../terminal/TerminalFocusManager.js'; +import type { TmuxManager } from '../terminal/TmuxManager.js'; +import type { AgentRegistry, RegistryEntry } from '../utils/AgentRegistry.js'; +import { AGENTS, type StartableAgentType } from '../utils/agents.js'; + +const debug = (...messages: unknown[]): void => { + void messages; +}; + +export interface AgentGroup { + name: string; + members: string[]; +} + +function sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseMilliseconds(value: string | undefined, defaultMs: number): { milliseconds: number; label?: string } { + if (value === undefined) return { milliseconds: defaultMs }; + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) throw new Error('Expected positive integer milliseconds.'); + const milliseconds = Number(trimmed); + if (!Number.isFinite(milliseconds) || milliseconds <= 0) { + throw new Error('Expected positive integer milliseconds.'); + } + return { milliseconds, label: `${milliseconds}ms` }; +} + +const silentReporter: SendReporter = { + info: () => undefined, + warning: () => undefined, + success: () => undefined, + error: () => undefined, +}; + +export interface AgentSendWaitTarget { + id: string; + name: string; + type: AgentType; + pid: number; + sessionId: string; + sessionFilePath: string; +} + +export interface AgentSendWaitOptions { + pollIntervalMs: number; + maxWaitMs: number; + timeoutLabel?: string; +} + +export interface AgentSendWaitResult { + agentName: string; + agentType: AgentType; + pid: number; + sessionId: string; + sessionFilePath: string; + messages: ConversationMessage[]; + finalStatus: AgentStatus; + elapsedMs: number; +} + +export interface WaitForAgentResponseParams { + manager: Pick; + adapter: Pick; + target: AgentSendWaitTarget; + initialMessageCount: number; + options: AgentSendWaitOptions; + onAssistantMessage: (message: ConversationMessage) => void; + onStatus?: (message: string) => void; +} + +export interface SendReporter { + info(message: string): void; + warning(message: string): void; + success(message: string): void; + error(message: string): void; +} + +interface GroupTarget { + member: string; + agent: AgentInfo; +} + +export interface SendToAgentOptions { + id: string; + prompt: string; + manager: Pick; + focusManager: Pick; + wait?: boolean; + timeout?: string; + json?: boolean; + reporter?: SendReporter; + writer?: typeof TtyWriter.send; + writeWaitStatus?: (message: string) => void; + writeAssistantMessage?: (message: ConversationMessage) => void; + writeJson?: (value: object) => void; +} + +export interface SendToAgentGroupOptions { + group: AgentGroup; + prompt: string; + manager: Pick; + focusManager: Pick; + reporter?: SendReporter; + writer?: typeof TtyWriter.send; +} + +export function assertSendTargetOptions(options: { id?: string; group?: string; wait?: boolean; timeout?: string; json?: boolean }): void { + const targetCount = Number(Boolean(options.id)) + Number(Boolean(options.group)); + if (targetCount !== 1) { + throw new Error('Use exactly one of --id or --group.'); + } + if (options.group && options.wait) { + throw new Error('Use --wait only with --id; group wait mode is not supported.'); + } + if (options.group && options.timeout !== undefined) { + throw new Error('Use --timeout only with --id --wait; group wait mode is not supported.'); + } + if (options.group && options.json) { + throw new Error('Use --json only with --id --wait; group JSON output is not supported.'); + } + if (options.timeout !== undefined && !options.wait) { + throw new Error('Use --timeout only with --wait.'); + } + if (options.timeout !== undefined) { + parseSendWaitTimeout(options.timeout); + } +} + +function findSameAgent(target: AgentSendWaitTarget, agents: AgentInfo[]): AgentInfo | undefined { + return agents.find((agent) => agent.pid === target.pid) + ?? agents.find((agent) => agent.sessionId === target.sessionId && agent.type === target.type); +} + +function readNewAssistantMessages( + adapter: Pick, + sessionFilePath: string, + lastSeenCount: number, +): { messages: ConversationMessage[]; nextSeenCount: number } { + const conversation = adapter.getConversation(sessionFilePath, { verbose: false }); + const newMessages = conversation.slice(lastSeenCount); + const assistantMessages = newMessages.filter((message) => ( + message.role === 'assistant' && Boolean(message.content) + )); + + return { + messages: assistantMessages, + nextSeenCount: conversation.length, + }; +} + +export async function waitForAgentResponse(params: WaitForAgentResponseParams): Promise { + const { manager, adapter, target, initialMessageCount, options, onAssistantMessage, onStatus } = params; + const startedAt = Date.now(); + let lastSeenCount = initialMessageCount; + const messages: ConversationMessage[] = []; + + while (Date.now() - startedAt < options.maxWaitMs) { + let transcriptReadSucceeded = false; + try { + const read = readNewAssistantMessages(adapter, target.sessionFilePath, lastSeenCount); + lastSeenCount = read.nextSeenCount; + transcriptReadSucceeded = true; + + for (const message of read.messages) { + messages.push(message); + onAssistantMessage(message); + } + } catch { + // Transcript files can be observed mid-write. Treat read failures as + // transient while the status loop still has time to prove completion. + } + + const agents = await manager.listAgents(); + const agent = findSameAgent(target, agents); + if (!agent) { + throw new Error(`Agent "${target.name}" is no longer running.`); + } + + const hasAssistantOutput = messages.length > 0; + const canCompleteOnStatus = + agent.status === AgentStatus.WAITING || + (agent.status === AgentStatus.IDLE && hasAssistantOutput); + + if (canCompleteOnStatus && transcriptReadSucceeded) { + if (messages.length === 0) { + onStatus?.(`Agent "${target.name}" returned to waiting without assistant output.`); + } + + return { + agentName: target.name, + agentType: target.type, + pid: target.pid, + sessionId: target.sessionId, + sessionFilePath: target.sessionFilePath, + messages, + finalStatus: agent.status, + elapsedMs: Date.now() - startedAt, + }; + } + + const elapsedMs = Date.now() - startedAt; + const remainingMs = options.maxWaitMs - elapsedMs; + await sleep(Math.min(options.pollIntervalMs, remainingMs)); + } + + throw new Error(`Timed out waiting for agent "${target.name}" after ${options.timeoutLabel ?? `${options.maxWaitMs}ms`}.`); +} + +export async function sendToAgent({ + id, + prompt, + manager, + focusManager, + wait = false, + timeout, + json = false, + reporter = silentReporter, + writer = TtyWriter.send, + writeWaitStatus = (message) => process.stderr.write(`${message}\n`), + writeAssistantMessage = (message) => process.stdout.write(`${message.content}\n`), + writeJson = (value) => console.log(JSON.stringify(value, null, 2)), +}: SendToAgentOptions): Promise { + const waitTimeout = parseSendWaitTimeout(timeout); + const agents = await manager.listAgents(); + if (agents.length === 0) { + reporter.error('No running agents found.'); + return; + } + + const resolved = manager.resolveAgent(id, agents); + if (!resolved) { + reporter.error(`No agent found matching "${id}".`); + reporter.info('Available agents:'); + agents.forEach((agent) => reporter.info(` - ${agent.name}`)); + return; + } + + if (Array.isArray(resolved)) { + reporter.error(`Multiple agents match "${id}":`); + resolved.forEach((agent) => reporter.info(` - ${agent.name} (${formatStatus(agent.status)})`)); + reporter.info('Please use a more specific identifier.'); + return; + } + + const agent = resolved; + if (![AgentStatus.WAITING, AgentStatus.IDLE].includes(agent.status)) { + const warning = `Agent "${agent.name}" is not waiting for input (status: ${agent.status}). Sending anyway.`; + if (wait) { + writeWaitStatus(warning); + } else { + reporter.warning(warning); + } + } + + const waitContext = wait ? prepareWaitMode(manager, agent) : undefined; + const location = await focusManager.findTerminal(agent.pid); + if (!location) { + if (wait) { + throw new Error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); + } + reporter.error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); + return; + } + + await writer(location, prompt); + + if (!wait) { + reporter.success(`Sent message to ${agent.name}.`); + return; + } + + if (!waitContext) { + throw new Error('Wait mode was not prepared.'); + } + + const waitResult = await waitForAgentResponse({ + manager, + adapter: waitContext.adapter, + target: { + id, + name: agent.name, + type: agent.type, + pid: agent.pid, + sessionId: agent.sessionId, + sessionFilePath: waitContext.sessionFilePath, + }, + initialMessageCount: waitContext.initialMessageCount, + options: { + pollIntervalMs: AGENT_SEND_WAIT_POLL_INTERVAL_MS, + maxWaitMs: waitTimeout.maxWaitMs, + timeoutLabel: waitTimeout.label, + }, + onAssistantMessage: (message) => { + if (!json) writeAssistantMessage(message); + }, + onStatus: writeWaitStatus, + }); + + if (json) { + writeJson(toAgentSendWaitJson(waitResult, agent, prompt, id)); + } +} + +export async function sendToAgentGroup({ + group, + prompt, + manager, + focusManager, + reporter = silentReporter, + writer = TtyWriter.send, +}: SendToAgentGroupOptions): Promise { + if (group.members.length === 0) { + throw new Error(`Agent group "${group.name}" has no members.`); + } + + const agents = await manager.listAgents(); + if (agents.length === 0) { + reporter.error('No running agents found.'); + process.exitCode = 1; + return; + } + + const resolution = resolveGroupTargets(group, agents, manager); + if (resolution.errors.length > 0) { + reportResolutionErrors(group.name, resolution.errors, reporter); + process.exitCode = 1; + return; + } + + const targets = dedupeTargets(resolution.targets, reporter); + await deliverGroupMessage({ + groupName: group.name, + targets, + prompt, + focusManager, + reporter, + writer, + }); +} + +function parseSendWaitTimeout(value: string | undefined): { maxWaitMs: number; label?: string } { + try { + const parsed = parseMilliseconds(value, AGENT_SEND_WAIT_MAX_WAIT_MS); + return { maxWaitMs: parsed.milliseconds, label: parsed.label }; + } catch (error) { + throw new Error(`Invalid --timeout. ${(error as Error).message} Example: 30000.`); + } +} + +function prepareWaitMode(manager: Pick, agent: AgentInfo): { + adapter: AgentAdapter; + sessionFilePath: string; + initialMessageCount: number; +} { + if (!agent.sessionFilePath) { + throw new Error(`No session file found for agent "${agent.name}"; cannot wait for response.`); + } + + const adapter = manager.getAdapter(agent.type); + if (!adapter) { + throw new Error(`Unsupported agent type: ${agent.type}`); + } + + return { + adapter, + sessionFilePath: agent.sessionFilePath, + initialMessageCount: adapter.getConversation(agent.sessionFilePath, { verbose: false }).length, + }; +} + +function toAgentSendWaitJson(result: AgentSendWaitResult, agent: AgentInfo, prompt: string, targetId: string): object { + return { + target: { + id: targetId, + name: agent.name, + type: agent.type, + pid: agent.pid, + status: agent.status, + summary: agent.summary, + projectPath: agent.projectPath, + sessionId: agent.sessionId, + sessionFilePath: result.sessionFilePath, + lastActive: agent.lastActive, + }, + prompt, + responseMessages: result.messages, + elapsedMs: result.elapsedMs, + finalStatus: result.finalStatus, + }; +} + +function formatStatus(status: AgentStatus): string { + const label = { + [AgentStatus.RUNNING]: 'run', + [AgentStatus.WAITING]: 'wait', + [AgentStatus.IDLE]: 'idle', + [AgentStatus.UNKNOWN]: 'unknown', + }[status] ?? 'unknown'; + return `${statusEmoji(status)} ${label}`; +} + +function statusEmoji(status: AgentStatus): string { + return { + [AgentStatus.RUNNING]: '\u{1F7E2}', + [AgentStatus.WAITING]: '\u{1F7E1}', + [AgentStatus.IDLE]: '\u{26AA}', + [AgentStatus.UNKNOWN]: '\u{2753}', + }[status] ?? '\u{2753}'; +} + +function resolveGroupTargets( + group: AgentGroup, + agents: AgentInfo[], + manager: Pick, +): { targets: GroupTarget[]; errors: string[] } { + const targets: GroupTarget[] = []; + const errors: string[] = []; + + for (const member of group.members) { + const resolved = manager.resolveAgent(member, agents); + if (!resolved) { + errors.push(` - ${member}: no running agent matched`); + continue; + } + if (Array.isArray(resolved)) { + errors.push(` - ${member}: matched multiple agents (${resolved.map((agent) => agent.name).join(', ')})`); + continue; + } + targets.push({ member, agent: resolved }); + } + + return { targets, errors }; +} + +function reportResolutionErrors(groupName: string, errors: string[], reporter: SendReporter): void { + reporter.error(`Cannot send to group "${groupName}" because some members could not be resolved.`); + for (const error of errors) { + reporter.error(error); + } +} + +function dedupeTargets(targets: GroupTarget[], reporter: SendReporter): GroupTarget[] { + const uniqueTargets: GroupTarget[] = []; + const seen = new Set(); + + for (const target of targets) { + const key = targetKey(target.agent); + if (seen.has(key)) { + reporter.info(`Skipped duplicate target "${target.agent.name}" from group member "${target.member}".`); + continue; + } + seen.add(key); + uniqueTargets.push(target); + } + + return uniqueTargets; +} + +async function deliverGroupMessage(options: { + groupName: string; + targets: GroupTarget[]; + prompt: string; + focusManager: Pick; + reporter: SendReporter; + writer: (location: TerminalLocation, message: string) => Promise; +}): Promise { + let successCount = 0; + let failureCount = 0; + + for (const { agent } of options.targets) { + warnIfAgentIsBusy(agent, options.reporter); + + try { + const location = await options.focusManager.findTerminal(agent.pid); + if (!location) { + throw new Error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); + } + await options.writer(location, options.prompt); + successCount += 1; + options.reporter.success(`Sent message to ${agent.name}.`); + } catch (error) { + failureCount += 1; + options.reporter.error(`Failed to send to ${agent.name}: ${(error as Error).message}`); + } + } + + reportDeliverySummary(options.groupName, successCount, failureCount, options.reporter); +} + +function warnIfAgentIsBusy(agent: AgentInfo, reporter: SendReporter): void { + if (![AgentStatus.WAITING, AgentStatus.IDLE].includes(agent.status)) { + reporter.warning(`Agent "${agent.name}" is not waiting for input (status: ${agent.status}). Sending anyway.`); + } +} + +function reportDeliverySummary(groupName: string, successCount: number, failureCount: number, reporter: SendReporter): void { + if (failureCount > 0) { + reporter.error(`Sent message to ${successCount} agent(s), failed for ${failureCount} agent(s) in group "${groupName}".`); + process.exitCode = 1; + return; + } + + reporter.success(`Sent message to ${successCount} agent(s) in group "${groupName}".`); +} + +function targetKey(agent: AgentInfo): string { + return agent.pid ? `pid:${agent.pid}` : `name:${agent.name}`; +} + +const AGENT_SEND_WAIT_POLL_INTERVAL_MS = 2000; +const AGENT_SEND_WAIT_MAX_WAIT_MS = 10 * 60 * 1000; + +export const DEFAULT_PID_POLL_INTERVAL_MS = 500; +export const DEFAULT_PID_POLL_TIMEOUT_MS = 15_000; +const REQUIRED_STABLE_PID_POLLS = 5; + +export interface StartAgentOptions { + type: StartableAgentType; + name: string; + cwd: string; + pollIntervalMs?: number; + pollTimeoutMs?: number; +} + +export interface StartAgentDeps { + tmux: TmuxManager; + registry: AgentRegistry; + /** Called for non-fatal events (e.g., replacing an orphan tmux session). */ + 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.'); + this.name = 'TmuxUnavailableError'; + } +} + +export class AgentNameInUseError extends Error { + constructor(public agentName: string, public pid: number) { + super(`Agent "${agentName}" is already running (PID ${pid}).`); + this.name = 'AgentNameInUseError'; + } +} + +export class AgentPidPollTimeoutError extends Error { + constructor(public agentName: string, public command: string, public timeoutMs: number) { + super(`Agent process not found after ${timeoutMs / 1000}s.`); + this.name = 'AgentPidPollTimeoutError'; + } +} + +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, + * and register the entry. On poll timeout the tmux session is torn down so no + * orphan is left behind. + * + * Callers are responsible for input-format validation (name regex, cwd existence) + * before invoking this service. + */ +export async function startAgent( + opts: StartAgentOptions, + deps: StartAgentDeps, +): Promise { + const { tmux, registry, onWarning } = deps; + const agent = AGENTS[opts.type]; + const intervalMs = opts.pollIntervalMs ?? DEFAULT_PID_POLL_INTERVAL_MS; + const timeoutMs = opts.pollTimeoutMs ?? DEFAULT_PID_POLL_TIMEOUT_MS; + + debug(`startAgent: type=${opts.type}, name=${opts.name}, cwd=${opts.cwd}, pollTimeoutMs=${timeoutMs}`); + + if (!await tmux.isAvailable()) { + debug('startAgent: tmux unavailable'); + throw new TmuxUnavailableError(); + } + + registry.prune(); + const existing = registry.lookup(opts.name); + if (existing) { + debug(`startAgent: name already in use pid=${existing.pid}`); + throw new AgentNameInUseError(opts.name, existing.pid); + } + + if (await tmux.sessionExists(opts.name)) { + onWarning?.( + `tmux session "${opts.name}" already exists but has no live registry entry — it will be replaced.`, + ); + await tmux.killSession(opts.name); + } + + debug(`startAgent: creating tmux session ${opts.name}`); + await tmux.createSession(opts.name, opts.cwd); + debug(`startAgent: sending launch command "${agent.command}"`); + await tmux.sendKeys(opts.name, agent.command); + + const agentPid = await pollForPid(tmux, opts.name, agent.matches, intervalMs, timeoutMs); + if (agentPid === null) { + debug(`startAgent: PID poll timed out after ${timeoutMs}ms`); + await tmux.killSession(opts.name); + throw new AgentPidPollTimeoutError(opts.name, agent.command, timeoutMs); + } + debug(`startAgent: detected stable PID ${agentPid}`); + + const entry: RegistryEntry = { + name: opts.name, + type: opts.type, + pid: agentPid, + tmuxSession: opts.name, + cwd: opts.cwd, + startedAt: new Date().toISOString(), + sessionId: '', + sessionFilePath: '', + }; + registry.register(entry); + debug(`startAgent: registered ${entry.name}`); + return entry; +} + +async function pollForPid( + tmux: TmuxManager, + session: string, + matches: (psCommand: string) => boolean, + intervalMs: number, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let candidatePid: number | null = null; + let stablePolls = 0; + + while (Date.now() < deadline) { + const pid = await tmux.findAgentPid(session, matches); + if (pid !== null) { + if (pid === candidatePid) { + stablePolls += 1; + } else { + candidatePid = pid; + stablePolls = 1; + } + + debug(`pollForPid: candidatePid=${pid}, stablePolls=${stablePolls}`); + if (stablePolls >= REQUIRED_STABLE_PID_POLLS) return pid; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + + return null; +} diff --git a/packages/channel-connector/src/__tests__/services/ChannelActionService.test.ts b/packages/channel-connector/src/__tests__/services/ChannelActionService.test.ts new file mode 100644 index 00000000..1776cadc --- /dev/null +++ b/packages/channel-connector/src/__tests__/services/ChannelActionService.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createChannelActionService } from '../../services/ChannelActionService.js'; + +describe('ChannelActionService', () => { + it('owns daemon bridge startup in the channel-connector package', async () => { + const startDaemonBridge = vi.fn().mockResolvedValue({ + channelName: 'work', + channelType: 'telegram', + agentName: 'jarvis', + agentPid: 0, + bridgePid: 99, + startedAt: '2026-08-14T00:00:00.000Z', + }); + const service = createChannelActionService({ + configStore: { + getConfig: vi.fn().mockResolvedValue({ + channels: { + work: { type: 'telegram', enabled: true, createdAt: '', config: {} }, + }, + }), + }, + bridgeService: { + resolveStartChannelName: vi.fn().mockReturnValue('work'), + getLiveBridgeByChannel: vi.fn().mockResolvedValue(undefined), + startDaemonBridge, + stopBridge: vi.fn(), + }, + }); + + await expect(service.startDaemon({ + channelName: 'work', + agentName: 'jarvis', + launch: { command: 'node', args: ['daemon.js'], cwd: '/tmp/project' }, + })).resolves.toMatchObject({ ok: true }); + expect(startDaemonBridge).toHaveBeenCalledWith(expect.objectContaining({ + channelName: 'work', + agentName: 'jarvis', + command: 'node', + args: ['daemon.js', '--channel', 'work', '--agent', 'jarvis'], + cwd: '/tmp/project', + })); + }); +}); diff --git a/packages/channel-connector/src/index.ts b/packages/channel-connector/src/index.ts index 9d9701e7..4bc472d6 100644 --- a/packages/channel-connector/src/index.ts +++ b/packages/channel-connector/src/index.ts @@ -17,6 +17,23 @@ export { export { SlackDeliveryQueue } from './utils/SlackDeliveryQueue.js'; export type { TelegramAdapterOptions } from './adapters/TelegramAdapter.js'; +export { ChannelService } from './services/ChannelService.js'; +export type { + ChannelBridgeProcess, + StartDaemonBridgeInput, + StopBridgeResult, +} from './services/ChannelService.js'; +export { createChannelActionService } from './services/ChannelActionService.js'; +export type { + ChannelActionReporter, + ChannelActionResult, + ChannelActionService, + ChannelActionServiceDependencies, + DaemonLaunch, + StartDaemonChannelInput, + StopChannelInput, +} from './services/ChannelActionService.js'; + export { isInteractiveChannelAdapter } from './adapters/ChannelAdapter.js'; export type { ChannelAdapter, InteractiveChannelAdapter } from './adapters/ChannelAdapter.js'; diff --git a/packages/channel-connector/src/services/ChannelActionService.ts b/packages/channel-connector/src/services/ChannelActionService.ts new file mode 100644 index 00000000..a28178e9 --- /dev/null +++ b/packages/channel-connector/src/services/ChannelActionService.ts @@ -0,0 +1,112 @@ +import { ConfigStore } from '../ConfigStore.js'; +import { ChannelService } from './ChannelService.js'; + +export interface ChannelActionResult { + ok: boolean; + message?: string; +} + +export interface ChannelActionReporter { + info(message: string): void; + success(message: string): void; + error(message: string): void; +} + +export interface DaemonLaunch { + command: string; + args: string[]; + cwd: string; +} + +export interface StartDaemonChannelInput { + channelName?: string; + agentName: string; + launch: DaemonLaunch; + debug?: boolean; +} + +export interface StopChannelInput { + channelName?: string; +} + +export interface ChannelActionService { + startDaemon(input: StartDaemonChannelInput): Promise; + stop(input: StopChannelInput): Promise; +} + +type ConfigStoreLike = Pick; +type BridgeServiceLike = Pick< + ChannelService, + 'resolveStartChannelName' | 'getLiveBridgeByChannel' | 'startDaemonBridge' | 'stopBridge' +>; + +export interface ChannelActionServiceDependencies { + configStore?: ConfigStoreLike; + bridgeService?: BridgeServiceLike; + reporter?: ChannelActionReporter; +} + +function createSilentReporter(): ChannelActionReporter { + const noOutput = () => undefined; + return { info: noOutput, success: noOutput, error: noOutput }; +} + +export function createChannelActionService( + dependencies: ChannelActionServiceDependencies = {}, +): ChannelActionService { + const configStore = dependencies.configStore ?? new ConfigStore(); + const bridgeService = dependencies.bridgeService ?? new ChannelService(); + const reporter = dependencies.reporter ?? createSilentReporter(); + + return { + async startDaemon(input) { + const config = await configStore.getConfig(); + const channelName = bridgeService.resolveStartChannelName(config, input.channelName); + const channelEntry = config.channels[channelName]; + await bridgeService.getLiveBridgeByChannel(channelName); + + if (!channelEntry) { + const message = `No channel configured with name "${channelName}".`; + reporter.error(message); + const availableChannels = Object.keys(config.channels); + if (availableChannels.length > 0) { + reporter.info(`Available channels: ${availableChannels.join(', ')}`); + } + return { ok: false, message }; + } + + const args = [ + ...input.launch.args, + '--channel', + channelName, + '--agent', + input.agentName, + ]; + if (input.debug) args.push('--debug'); + + const bridge = await bridgeService.startDaemonBridge({ + channelName, + channelType: channelEntry.type, + agentName: input.agentName, + command: input.launch.command, + args, + cwd: input.launch.cwd, + }); + reporter.success(`Channel bridge daemon started for "${channelName}" (PID: ${bridge.bridgePid}).`); + if (bridge.logPath) reporter.info(`Logs: ${bridge.logPath}`); + reporter.info(`Run "ai-devkit channel stop ${channelName}" to stop it.`); + return { ok: true }; + }, + + async stop(input) { + const result = await bridgeService.stopBridge(input.channelName); + if (!result.stopped || !result.bridge) { + const message = 'No running channel bridge found.'; + reporter.info(message); + return { ok: false, message }; + } + reporter.success(`Channel bridge stopped: ${result.bridge.channelName} (PID: ${result.bridge.bridgePid}).`); + return { ok: true }; + }, + }; +} diff --git a/packages/channel-connector/src/services/ChannelService.ts b/packages/channel-connector/src/services/ChannelService.ts new file mode 100644 index 00000000..0e401fb7 --- /dev/null +++ b/packages/channel-connector/src/services/ChannelService.ts @@ -0,0 +1,215 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import type { ChannelConfig, TelegramConfig } from '../types.js'; + +const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'channel-bridges.json'); +const DEFAULT_TELEGRAM_CHANNEL_NAME = 'telegram'; +const CHANNEL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +export interface ChannelBridgeProcess { + channelName: string; + channelType: string; + agentName: string; + agentPid: number; + bridgePid: number; + startedAt: string; + logPath?: string; +} + +interface ChannelBridgeFile { + bridges: Record; +} + +type PidChecker = (pid: number) => boolean; +type DetachedSpawner = ( + command: string, + args: string[], + options: { cwd: string; detached: true; stdio: ['ignore', number, number] }, +) => { pid?: number; unref: () => void }; +type ProcessKiller = (pid: number, signal: NodeJS.Signals) => void; + +export interface StartDaemonBridgeInput { + channelName: string; + channelType: string; + agentName: string; + command: string; + args: string[]; + cwd: string; +} + +export interface StopBridgeResult { + stopped: boolean; + bridge?: ChannelBridgeProcess; +} + +function defaultPidChecker(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export class ChannelService { + constructor( + private readonly registryPath = DEFAULT_REGISTRY_PATH, + private readonly isPidAlive: PidChecker = defaultPidChecker, + private readonly spawnDetached: DetachedSpawner = (command, args, options) => spawn(command, args, options), + private readonly killProcess: ProcessKiller = (pid, signal) => { + process.kill(pid, signal); + }, + ) {} + + resolveConnectChannelName(name: string | undefined): string { + const channelName = (name ?? DEFAULT_TELEGRAM_CHANNEL_NAME).trim(); + if (!CHANNEL_NAME_PATTERN.test(channelName)) { + throw new Error('Channel name must be kebab-case using lowercase letters, numbers, and hyphens.'); + } + return channelName; + } + + assertUniqueTelegramToken(config: ChannelConfig, targetName: string, botToken: string): void { + for (const [name, entry] of Object.entries(config.channels)) { + if (name === targetName || entry.type !== DEFAULT_TELEGRAM_CHANNEL_NAME) continue; + const telegramConfig = entry.config as TelegramConfig; + if (telegramConfig.botToken === botToken) { + throw new Error(`Telegram bot token is already configured for channel "${name}".`); + } + } + } + + resolveStartChannelName(config: ChannelConfig, name: string | undefined): string { + if (name !== undefined) return this.resolveConnectChannelName(name); + + const telegramChannels = Object.entries(config.channels) + .filter(([, entry]) => entry.type === DEFAULT_TELEGRAM_CHANNEL_NAME) + .map(([channelName]) => channelName); + + if (telegramChannels.length === 1) return telegramChannels[0]; + if (telegramChannels.length > 1) { + throw new Error(`Multiple Telegram channels configured. Specify one: ${telegramChannels.join(', ')}`); + } + throw new Error('No Telegram channel configured. Run "ai-devkit channel connect telegram" first.'); + } + + async getLiveBridges(): Promise { + const registry = await this.readBridgeRegistry(); + const liveEntries = Object.entries(registry.bridges) + .filter(([, bridge]) => this.isPidAlive(bridge.bridgePid)); + + const next: ChannelBridgeFile = { bridges: Object.fromEntries(liveEntries) }; + await this.writeBridgeRegistry(next); + return Object.values(next.bridges); + } + + async getLiveBridgeByChannel(channelName: string): Promise { + const liveBridges = await this.getLiveBridges(); + return liveBridges.find(bridge => bridge.channelName === channelName); + } + + async registerBridge(processInfo: ChannelBridgeProcess): Promise { + const registry = await this.readBridgeRegistry(); + registry.bridges[processInfo.channelName] = processInfo; + await this.writeBridgeRegistry(registry); + } + + async startDaemonBridge(input: StartDaemonBridgeInput): Promise { + const runningBridge = await this.getLiveBridgeByChannel(input.channelName); + if (runningBridge) { + throw new Error(`Channel "${input.channelName}" bridge is already running (PID: ${runningBridge.bridgePid}).`); + } + + const logPath = this.getBridgeLogPath(input.channelName); + const logFd = this.openBridgeLog(input, logPath); + let child: { pid?: number; unref: () => void }; + try { + child = this.spawnDetached(input.command, input.args, { + cwd: input.cwd, + detached: true, + stdio: ['ignore', logFd, logFd], + }); + } finally { + fs.closeSync(logFd); + } + + if (!child.pid) { + throw new Error('Failed to start channel bridge daemon: child process did not report a PID.'); + } + + child.unref(); + + const bridge: ChannelBridgeProcess = { + channelName: input.channelName, + channelType: input.channelType, + agentName: input.agentName, + agentPid: 0, + bridgePid: child.pid, + startedAt: new Date().toISOString(), + logPath, + }; + + await this.registerBridge(bridge); + return bridge; + } + + async stopBridge(channelName?: string): Promise { + const liveBridges = await this.getLiveBridges(); + + if (liveBridges.length === 0) { + return { stopped: false }; + } + + let bridge: ChannelBridgeProcess | undefined; + if (channelName) { + bridge = liveBridges.find(candidate => candidate.channelName === this.resolveConnectChannelName(channelName)); + if (!bridge) { + return { stopped: false }; + } + } else if (liveBridges.length === 1) { + bridge = liveBridges[0]; + } else { + throw new Error(`Multiple channel bridges are running. Specify one: ${liveBridges.map(candidate => candidate.channelName).join(', ')}`); + } + + this.killProcess(bridge.bridgePid, 'SIGTERM'); + await this.unregisterBridge(bridge.channelName); + + return { stopped: true, bridge }; + } + + async unregisterBridge(channelName: string): Promise { + const registry = await this.readBridgeRegistry(); + delete registry.bridges[channelName]; + await this.writeBridgeRegistry(registry); + } + + private async readBridgeRegistry(): Promise { + try { + const raw = fs.readFileSync(this.registryPath, 'utf-8'); + return JSON.parse(raw) as ChannelBridgeFile; + } catch { + return { bridges: {} }; + } + } + + private async writeBridgeRegistry(registry: ChannelBridgeFile): Promise { + const dir = path.dirname(this.registryPath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(this.registryPath, JSON.stringify(registry, null, 2), { mode: 0o600 }); + } + + private getBridgeLogPath(channelName: string): string { + return path.join(path.dirname(this.registryPath), 'channel-logs', `${channelName}.log`); + } + + private openBridgeLog(input: StartDaemonBridgeInput, logPath: string): number { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + const logFd = fs.openSync(logPath, 'a', 0o600); + fs.writeSync(logFd, `[${new Date().toISOString()}] Starting channel daemon: ${input.channelName} -> ${input.agentName}\n`); + fs.writeSync(logFd, `[command] ${input.command} ${input.args.join(' ')}\n`); + return logFd; + } +} diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index eb18840f..3db5f9a9 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -88,7 +88,8 @@ const { RenameNotFoundError, RenameConflictError } = vi.hoisted(() => { return { RenameNotFoundError, RenameConflictError }; }); -vi.mock('@ai-devkit/agent-manager', () => ({ +vi.mock('@ai-devkit/agent-manager', async (importOriginal) => ({ + ...await importOriginal(), AgentManager: vi.fn(function () { return mockManager; }), ClaudeCodeAdapter: vi.fn(), CodexAdapter: vi.fn(), @@ -129,7 +130,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ }, RenameNotFoundError: RenameNotFoundError, RenameConflictError: RenameConflictError, -}), { virtual: true }); +})); vi.mock('@inquirer/prompts', () => ({ select: (...args: unknown[]) => mockSelect(...args), diff --git a/packages/cli/src/__tests__/commands/channel.test.ts b/packages/cli/src/__tests__/commands/channel.test.ts index a462daf3..d793eea7 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -64,7 +64,8 @@ const mockChannelService = { stopBridge: vi.fn<(channelName?: string) => Promise>(), }; -vi.mock('@ai-devkit/channel-connector', () => ({ +vi.mock('@ai-devkit/channel-connector', async (importOriginal) => ({ + ...await importOriginal(), ChannelManager: vi.fn(function () { return mockChannelManager; }), ConfigStore: vi.fn(function () { return mockConfigStore; }), TelegramAdapter: vi.fn(function () { return mockTelegramAdapter; }), @@ -72,7 +73,7 @@ vi.mock('@ai-devkit/channel-connector', () => ({ SLACK_CHANNEL_TYPE: 'slack', validateSlackCredentials: (...args: unknown[]) => mockValidateSlackCredentials(...args), validateSlackAppToken: (...args: unknown[]) => mockValidateSlackAppToken(...args), -}), { virtual: true }); +})); vi.mock('@ai-devkit/agent-manager', () => ({ AgentStatus: { diff --git a/packages/cli/src/__tests__/tui/console/actions/pendingAction.test.ts b/packages/cli/src/__tests__/tui/console/actions/pendingAction.test.ts new file mode 100644 index 00000000..af3c5d45 --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/actions/pendingAction.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createConsoleActionExecutor, + createPendingActionRunner, + getPendingActionIdentity, +} from '../../../../tui/console/actions/pendingAction.js'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe('createPendingActionRunner', () => { + it.each(['Sending', 'Opening', 'Stopping channel'])('publishes %s synchronously', (label) => { + const events: string[] = []; + const action = deferred(); + const runner = createPendingActionRunner((pendingLabel) => events.push(pendingLabel)); + + const execution = runner.run('action-key', label, () => action.promise); + + expect(execution.started).toBe(true); + expect(events).toEqual([label]); + expect(runner.isPending('action-key')).toBe(true); + action.resolve(); + return execution.promise; + }); + + it('suppresses a duplicate while the first action is pending', async () => { + const action = deferred(); + const invoke = vi.fn(() => action.promise); + const runner = createPendingActionRunner(() => undefined); + + const first = runner.run('send:jarvis', 'Sending', invoke); + const duplicate = runner.run('send:jarvis', 'Sending', invoke); + + expect(first.started).toBe(true); + expect(duplicate).toEqual({ started: false }); + expect(invoke).toHaveBeenCalledOnce(); + action.resolve(); + await first.promise; + }); + + it.each(['success', 'error'] as const)('allows retry after %s settlement', async (settlement) => { + const firstAction = deferred(); + const runner = createPendingActionRunner(() => undefined); + const first = runner.run('open:jarvis', 'Opening', () => firstAction.promise); + + if (settlement === 'success') firstAction.resolve(); + else firstAction.reject(new Error('failed')); + await first.promise?.catch(() => undefined); + + const retry = runner.run('open:jarvis', 'Opening', async () => undefined); + expect(retry.started).toBe(true); + await retry.promise; + }); +}); + +describe('getPendingActionIdentity', () => { + it.each([ + [{ type: 'send', agentName: 'jarvis', message: 'hello' } as const, 'send:jarvis', 'Sending'], + [{ type: 'open', agentName: 'jarvis' } as const, 'open:jarvis', 'Opening'], + [{ type: 'channel-stop', channelName: 'work' } as const, 'channel-stop:work', 'Stopping channel'], + ])('maps %s to an immediate UI pending state', (action, key, label) => { + expect(getPendingActionIdentity(action)).toEqual({ key, label }); + }); +}); + +describe('createConsoleActionExecutor', () => { + it('notifies the UI before invoking the service and suppresses duplicate submissions', async () => { + const events: string[] = []; + const action = deferred<{ exitCode: number }>(); + const invoke = vi.fn(() => { + events.push('service'); + return action.promise; + }); + const execute = createConsoleActionExecutor(invoke, (label) => events.push(label)); + const request = { type: 'send', agentName: 'jarvis', message: 'hello' } as const; + + const first = execute(request); + const duplicate = execute(request); + + expect(events).toEqual(['Sending', 'service']); + expect(duplicate).toBeNull(); + expect(invoke).toHaveBeenCalledOnce(); + action.resolve({ exitCode: 0 }); + await first; + }); +}); 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 3cdd87dc..d73bfc5e 100644 --- a/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts +++ b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts @@ -1,136 +1,133 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { EventEmitter } from 'events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -// Mock child_process before importing runAction -vi.mock('child_process', () => ({ - spawn: vi.fn(), +const { mockCreateAgentActionService, mockCreateChannelActionService } = vi.hoisted(() => ({ + mockCreateAgentActionService: vi.fn(() => ({})), + mockCreateChannelActionService: vi.fn(() => ({})), })); -import { spawn } from 'child_process'; -import { runAction } from '../../../../tui/console/actions/runAction.js'; +vi.mock('@ai-devkit/agent-manager', async (importOriginal) => ({ + ...await importOriginal(), + createAgentActionService: mockCreateAgentActionService, +})); +vi.mock('@ai-devkit/channel-connector', async (importOriginal) => ({ + ...await importOriginal(), + createChannelActionService: mockCreateChannelActionService, +})); -function makeChild(exitCode: number | null, stderr = '') { - const child = new EventEmitter() as EventEmitter & { - stderr: EventEmitter; - once: (event: string, cb: (...args: unknown[]) => void) => typeof child; +import { + runAction, + type ConsoleActionServices, +} from '../../../../tui/console/actions/runAction.js'; + +function createServices(): ConsoleActionServices { + return { + open: vi.fn().mockResolvedValue({ ok: true }), + send: vi.fn().mockResolvedValue({ ok: true }), + start: vi.fn().mockResolvedValue({ ok: true }), + kill: vi.fn().mockResolvedValue({ ok: true }), + rename: vi.fn().mockResolvedValue({ ok: true }), + startChannel: vi.fn().mockResolvedValue({ ok: true }), + stopChannel: vi.fn().mockResolvedValue({ ok: true }), }; - child.stderr = new EventEmitter(); - - // Emit stderr data then exit asynchronously - setTimeout(() => { - if (stderr) child.stderr.emit('data', Buffer.from(stderr)); - child.emit('exit', exitCode); - }, 0); - - return child; } describe('runAction', () => { - beforeEach(() => { - vi.mocked(spawn).mockReset(); - }); + let services: ConsoleActionServices; - it('resolves with exitCode 0 on success', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); - const result = await runAction({ type: 'open', agentName: 'jarvis' }); - expect(result.exitCode).toBe(0); - expect(result.error).toBeUndefined(); - }); - - it('includes stderr in error when exit code is non-zero', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(1, 'agent not found') as ReturnType); - const result = await runAction({ type: 'open', agentName: 'jarvis' }); - expect(result.exitCode).toBe(1); - expect(result.error).toBe('agent not found'); - }); - - it('does not set error when exit code is non-zero but stderr is empty', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(1, '') as ReturnType); - const result = await runAction({ type: 'open', agentName: 'jarvis' }); - expect(result.exitCode).toBe(1); - expect(result.error).toBeUndefined(); - }); - - it('resolves with null exitCode and error message on spawn error', async () => { - const child = new EventEmitter() as EventEmitter & { stderr: EventEmitter }; - child.stderr = new EventEmitter(); - setTimeout(() => child.emit('error', new Error('ENOENT')), 0); - vi.mocked(spawn).mockReturnValue(child as ReturnType); - - const result = await runAction({ type: 'send', agentName: 'jarvis', message: 'hello' }); - expect(result.exitCode).toBeNull(); - expect(result.error).toBe('ENOENT'); - }); - - it('passes correct argv for open action', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); - await runAction({ type: 'open', agentName: 'my-agent' }); - const [, argv] = vi.mocked(spawn).mock.calls[0]; - expect(argv).toEqual(expect.arrayContaining(['agent', 'open', 'my-agent'])); - }); - - it('passes correct argv for send action', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); - await runAction({ type: 'send', agentName: 'my-agent', message: 'hello world' }); - const [, argv] = vi.mocked(spawn).mock.calls[0]; - expect(argv).toEqual(expect.arrayContaining(['agent', 'send', 'hello world', '--id', 'my-agent'])); - }); - - it('passes correct argv for start action', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); - await runAction({ type: 'start', agentType: 'codex', name: 'my-agent', cwd: '/tmp/project' }); - const [, argv] = vi.mocked(spawn).mock.calls[0]; - expect(argv).toEqual(expect.arrayContaining([ - 'agent', - 'start', - '--type', - 'codex', - '--name', - 'my-agent', - '--cwd', - '/tmp/project', - ])); + beforeEach(() => { + services = createServices(); + mockCreateAgentActionService.mockClear(); + mockCreateChannelActionService.mockClear(); }); - 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.each([ + { + action: { type: 'open', agentName: 'jarvis' } as const, + method: 'open' as const, + input: { agentName: 'jarvis' }, + }, + { + action: { type: 'send', agentName: 'jarvis', message: 'hello' } as const, + method: 'send' as const, + input: { agentName: 'jarvis', message: 'hello' }, + }, + { + action: { type: 'start', agentType: 'codex', name: 'jarvis', cwd: '/tmp/project' } as const, + method: 'start' as const, + input: { agentType: 'codex', name: 'jarvis', cwd: '/tmp/project' }, + }, + { + action: { type: 'kill', agentName: 'jarvis' } as const, + method: 'kill' as const, + input: { agentName: 'jarvis' }, + }, + { + action: { type: 'rename', currentName: 'jarvis', newName: 'friday' } as const, + method: 'rename' as const, + input: { currentName: 'jarvis', newName: 'friday' }, + }, + { + action: { type: 'channel-start', channelName: 'work', agentName: 'jarvis' } as const, + method: 'startChannel' as const, + input: { channelName: 'work', agentName: 'jarvis' }, + }, + { + action: { type: 'channel-stop', channelName: 'work' } as const, + method: 'stopChannel' as const, + input: { channelName: 'work' }, + }, + ])('invokes $method directly in-process', async ({ action, method, input }) => { + const result = await runAction(action, services); + + expect(services[method]).toHaveBeenCalledOnce(); + expect(services[method]).toHaveBeenCalledWith(input); + expect(result).toEqual({ exitCode: 0 }); }); - it('passes correct argv for rename action', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); - await runAction({ type: 'rename', currentName: 'old-agent', newName: 'new-agent' }); - const [, argv] = vi.mocked(spawn).mock.calls[0]; - expect(argv).toEqual(expect.arrayContaining(['agent', 'rename', 'old-agent', 'new-agent'])); + it('returns a service error without throwing', async () => { + vi.mocked(services.send).mockResolvedValue({ + ok: false, + message: 'Cannot find terminal for agent "jarvis".', + }); + + await expect(runAction({ + type: 'send', + agentName: 'jarvis', + message: 'hello', + }, services)).resolves.toEqual({ + exitCode: 1, + error: 'Cannot find terminal for agent "jarvis".', + }); }); - it('passes correct argv for channel start action with selected channel name', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); - await runAction({ type: 'channel-start', channelName: 'work-telegram', agentName: 'my-agent' }); - const [, argv] = vi.mocked(spawn).mock.calls[0]; - expect(argv).toEqual(expect.arrayContaining([ - 'channel', - 'start', - 'work-telegram', - '--agent', - 'my-agent', - '--daemon', - ])); - }); + it('returns a thrown service error as a non-exit failure', async () => { + vi.mocked(services.open).mockRejectedValue(new Error('terminal lookup failed')); - it('passes correct argv for channel stop action with selected channel name', async () => { - vi.mocked(spawn).mockReturnValue(makeChild(0) as ReturnType); - await runAction({ type: 'channel-stop', channelName: 'work-telegram' }); - const [, argv] = vi.mocked(spawn).mock.calls[0]; - expect(argv).toEqual(expect.arrayContaining(['channel', 'stop', 'work-telegram'])); + await expect(runAction({ type: 'open', agentName: 'jarvis' }, services)).resolves.toEqual({ + exitCode: null, + error: 'terminal lookup failed', + }); }); - 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' }); - const [, , opts] = vi.mocked(spawn).mock.calls[0]; - expect(opts?.stdio).toEqual(['ignore', 'pipe', 'pipe']); + it('keeps service output away from the Ink terminal', async () => { + await runAction({ type: 'open', agentName: 'jarvis' }); + + expect(mockCreateAgentActionService).toHaveBeenCalledWith({ + reporter: expect.objectContaining({ + text: expect.any(Function), + info: expect.any(Function), + success: expect.any(Function), + warning: expect.any(Function), + error: expect.any(Function), + spinner: expect.any(Function), + }), + }); + expect(mockCreateChannelActionService).toHaveBeenCalledWith({ + reporter: expect.objectContaining({ + info: expect.any(Function), + success: expect.any(Function), + error: expect.any(Function), + }), + }); }); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index e1caf5a3..b1864799 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -1,4 +1,3 @@ -import fs from 'fs'; import os from 'os'; import path from 'path'; import { createElement } from 'react'; @@ -6,32 +5,18 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { render } from 'ink'; import { - AgentManager, - ClaudeCodeAdapter, - CodexAdapter, - CopilotAdapter, - GeminiCliAdapter, - GrokCliAdapter, - OpenCodeAdapter, - PiAdapter, ClaudePrintAgentService, PrintAgentStore, AgentStatus, - TerminalFocusManager, - AgentRegistry, - RenameNotFoundError, - RenameConflictError, - TmuxManager, AGENTS, - type StartableAgentType, type AgentInfo, type AgentType, type ConversationMessage, type SessionSummary, + type ApplicationActionResult, } from '@ai-devkit/agent-manager'; import { ui } from '../util/terminal-ui.js'; import { withErrorHandler } from '../util/errors.js'; -import { enableDebug, createLogger } from '../util/debug.js'; import { formatFirstMessage, parseLimit, @@ -39,27 +24,12 @@ import { toJsonSession, } from '../util/sessions.js'; import { - startAgent, - killAgent, - assertSendTargetOptions, - type SendReporter, - sendToAgent, - sendToAgentGroup, - TmuxUnavailableError, - AgentNameInUseError, - AgentPidPollTimeoutError, -} from '../services/agent/agent.service.js'; -import { - AgentGroupNotFoundError, - createDefaultAgentGroupService, -} from '../services/agent/agent-group.service.js'; + createCliAgentActionService, + createCliAgentManager as createAgentManager, +} from '../services/agent/cli-agent-action-service.js'; import { registerAgentGroupCommand } from './agent/group.command.js'; import { AGENT_CONSOLE_RENDER_OPTIONS, ConsoleApp } from '../tui/console/ConsoleApp.js'; import { generateAgentName } from '../util/agent.js'; -import { select } from '@inquirer/prompts'; - -// eslint-disable-next-line no-control-regex -const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; const STATUS_DISPLAY: Record = { [AgentStatus.RUNNING]: { emoji: '🟢', label: 'run' }, @@ -73,16 +43,6 @@ function formatStatus(status: AgentStatus): string { return `${config.emoji} ${config.label}`; } -function sanitizeProviderOutput(value: string): string { - // Strip OSC controls as a unit, then remove remaining terminal control bytes except newline/tab. - // eslint-disable-next-line no-control-regex - const withoutOsc = value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, ''); - return Array.from(withoutOsc, (character) => { - const code = character.charCodeAt(0); - return (code < 32 && code !== 9 && code !== 10) || code === 127 ? '' : character; - }).join(''); -} - function formatRelativeTime(timestamp: Date): string { const diffMs = Date.now() - new Date(timestamp).getTime(); const diffMinutes = Math.floor(diffMs / 60000); @@ -184,28 +144,10 @@ function findSessionById(sessions: SessionSummary[], sessionId: string): Session return matches; } -function createAgentManager(): AgentManager { - const manager = new AgentManager(AgentRegistry.default()); - manager.registerAdapter(new ClaudeCodeAdapter()); - manager.registerAdapter(new CodexAdapter()); - manager.registerAdapter(new CopilotAdapter()); - manager.registerAdapter(new GeminiCliAdapter()); - manager.registerAdapter(new GrokCliAdapter()); - manager.registerAdapter(new OpenCodeAdapter()); - manager.registerAdapter(new PiAdapter()); - return manager; -} - function createPrintAgentService(): ClaudePrintAgentService { return new ClaudePrintAgentService({ store: new PrintAgentStore() }); } -const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; - -function writeWaitStatus(message: string): void { - process.stderr.write(`${message.replace(ANSI_ESCAPE_PATTERN, '')}\n`); -} - function readStdin(): Promise { return new Promise((resolve, reject) => { let input = ''; @@ -250,13 +192,8 @@ async function resolveSendMessage(message: string | undefined, options: { stdin? return message; } -function createCommandSendReporter(): SendReporter { - return { - info: (text) => text.startsWith(' - ') ? ui.text(text) : ui.info(text), - warning: (text) => ui.warning(text), - success: (text) => ui.success(text), - error: (text) => ui.error(text), - }; +function applyActionExit(result: ApplicationActionResult): void { + if (result.cliExitCode !== undefined) process.exit(result.cliExitCode); } export function registerAgentCommand(program: Command): void { @@ -273,70 +210,18 @@ export function registerAgentCommand(program: Command): void { .option('--cwd ', 'Working directory for the agent (default: current directory)') .option('--debug', 'Enable debug logging') .action(withErrorHandler('start agent', async (options) => { - if (options.debug) { - enableDebug(); - } const agentType = options.type as string; const mode = options.mode as string; const cwd = path.resolve(options.cwd ?? process.cwd()); const agentName = (options.name as string | undefined) ?? generateAgentName(cwd); - - if (!(agentType in AGENTS)) { - ui.error(`Unsupported agent type "${agentType}". Supported: ${Object.keys(AGENTS).join(', ')}.`); - process.exit(1); - } - if (!['interactive', 'print'].includes(mode)) { - throw new Error(`Unsupported agent mode "${mode}". Supported: interactive, print.`); - } - if (mode === 'print' && agentType !== 'claude') { - throw new Error('Print mode currently supports only --type claude.'); - } - if (!NAME_REGEX.test(agentName)) { - ui.error( - `Invalid name "${agentName}". Use lowercase letters, digits, and hyphens only. ` + - 'Must start and end with a letter or digit, 2–64 characters.' - ); - process.exit(1); - } - if (!fs.existsSync(cwd)) { - ui.error(`Directory "${cwd}" does not exist.`); - process.exit(1); - } - - try { - if (mode === 'print') { - const entry = await createPrintAgentService().create({ name: agentName, cwd }); - ui.success(`Print agent "${entry.name}" started (${entry.provider}, ID ${entry.id})`); - ui.text(`Working directory: ${formatCwd(entry.cwd)}`); - ui.text('State: ready (Claude session not started)'); - return; - } - const entry = await startAgent( - { type: agentType as StartableAgentType, name: agentName, cwd }, - { - tmux: new TmuxManager(), - registry: AgentRegistry.default(), - onWarning: (msg) => ui.warning(msg), - }, - ); - ui.success(`Agent "${entry.name}" started (${entry.type}, PID ${entry.pid})`); - ui.text(`Working directory: ${formatCwd(entry.cwd)}`); - ui.text(`Attach: tmux attach -t ${entry.tmuxSession}`); - } catch (err) { - if (err instanceof TmuxUnavailableError) { - ui.error('tmux is not installed or not in PATH. Install it first (e.g., brew install tmux).'); - } else if (err instanceof AgentNameInUseError) { - ui.error(`Agent "${err.agentName}" is already running (PID ${err.pid}). Choose a different name.`); - } else if (err instanceof AgentPidPollTimeoutError) { - ui.error( - `Agent process not found after ${err.timeoutMs / 1000}s. ` + - `Verify that "${err.command}" is in PATH inside the tmux environment.` - ); - } else { - throw err; - } - process.exit(1); - } + const result = await createCliAgentActionService(Boolean(options.debug)).start({ + agentType, + mode, + name: agentName, + cwd, + debug: options.debug, + }); + applyActionExit(result); })); agentCommand @@ -537,70 +422,8 @@ export function registerAgentCommand(program: Command): void { .description('Focus a running agent terminal') .option('--debug', 'Trace how the agent terminal is resolved and focused') .action(withErrorHandler('open agent', async (name, options) => { - const terminalLogger = options.debug ? createLogger('terminal') : undefined; - if (options.debug) { - enableDebug(); - } - const manager = createAgentManager(); - // When --debug is set, route the focus manager's decision trace to - // the ai-devkit:terminal debug logger (enabled above) so users can - // see which terminal matched and how focus was attempted. - const focusManager = new TerminalFocusManager( - terminalLogger ? (message: string) => terminalLogger(message) : undefined, - ); - - 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; - } - - let targetAgent = resolved; - - if (Array.isArray(resolved)) { - ui.warning(`Multiple agents match "${name}":`); - - const selectedAgent = await select({ - message: 'Select an agent to open:', - choices: resolved.map(a => ({ - name: `${a.name} (${formatStatus(a.status)}) - ${a.summary}`, - value: a - })) - }); - targetAgent = selectedAgent; - } - - const agent = targetAgent as AgentInfo; - if (!agent.pid) { - ui.error(`Cannot focus agent "${agent.name}" (No PID found).`); - return; - } - - const spinner = ui.spinner(`Switching focus to ${agent.name}...`); - spinner.start(); - - const location = await focusManager.findTerminal(agent.pid); - if (!location) { - spinner.fail(`Could not find terminal window for agent "${agent.name}" (PID: ${agent.pid}).`); - return; - } - - const success = await focusManager.focusTerminal(location); - - if (success) { - spinner.succeed(`Focused ${agent.name}!`); - } else { - spinner.fail(`Failed to switch focus to ${agent.name}.`); - } + const result = await createCliAgentActionService(Boolean(options.debug)).open({ agentName: name }); + applyActionExit(result); })); agentCommand @@ -613,97 +436,24 @@ export function registerAgentCommand(program: Command): void { .option('--timeout ', 'Maximum time to wait with --wait, in milliseconds') .option('-j, --json', 'Output wait result as JSON') .action(withErrorHandler('send message', async (message, options) => { - assertSendTargetOptions(options); const prompt = await resolveSendMessage(message, options); - const manager = createAgentManager(); - const focusManager = new TerminalFocusManager(); - - if (options.group) { - const group = createDefaultAgentGroupService().get(options.group); - if (!group) { - throw new AgentGroupNotFoundError(options.group); - } - await sendToAgentGroup({ group, prompt, manager, focusManager }); - return; - } - - const printService = createPrintAgentService(); - const printResolved = await printService.store.resolve(options.id); - if (Array.isArray(printResolved)) { - throw new Error(`Multiple print agents match "${options.id}".`); - } - if (printResolved) { - if (options.timeout !== undefined) { - throw new Error('--timeout is not supported for synchronous print agents.'); - } - if (options.id !== printResolved.id) { - const liveAgents = await manager.listAgents(); - const liveExact = liveAgents.filter((agent) => agent.name.toLowerCase() === String(options.id).toLowerCase()); - if (liveExact.length > 0) { - throw new Error(`Agent name "${options.id}" is ambiguous across interactive and print modes. Use the print agent ID.`); - } - } - const result = await printService.send(options.id, prompt); - if (options.json) { - console.log(JSON.stringify({ - target: { id: result.agentId, name: result.agentName, provider: 'claude', mode: 'print' }, - response: result.result, - exitCode: result.exitCode, - sessionId: result.sessionId, - }, null, 2)); - } else { - ui.text(sanitizeProviderOutput(result.result)); - } - return; - } - - await sendToAgent({ - id: options.id, - prompt, - manager, - focusManager, + const result = await createCliAgentActionService().send({ + agentName: options.id, + groupName: options.group, + message: prompt, wait: options.wait, timeout: options.timeout, json: options.json, - reporter: createCommandSendReporter(), - writeWaitStatus, }); + applyActionExit(result); })); 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}.`); + const result = await createCliAgentActionService().kill({ agentName: name }); + applyActionExit(result); })); agentCommand @@ -815,33 +565,8 @@ export function registerAgentCommand(program: Command): void { .command('rename ') .description('Rename an agent in the registry') .action(withErrorHandler('rename agent', async (currentName: string, newName: string) => { - if (!NAME_REGEX.test(newName)) { - ui.error( - `Invalid name "${newName}". Use lowercase letters, digits, and hyphens only. ` + - 'Must start and end with a letter or digit, 2–64 characters.' - ); - process.exit(1); - return; - } - - if (currentName === newName) { - ui.info(`Agent "${currentName}" already has that name.`); - return; - } - - try { - AgentRegistry.default().rename(currentName, newName); - ui.success(`Agent "${currentName}" renamed to "${newName}".`); - } catch (err) { - if (err instanceof RenameNotFoundError) { - ui.error(err.message); - } else if (err instanceof RenameConflictError) { - ui.error(`Agent "${err.agentName}" is already in use. Choose a different name.`); - } else { - throw err; - } - process.exit(1); - } + const result = await createCliAgentActionService().rename({ currentName, newName }); + applyActionExit(result); })); agentCommand diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 5de4c033..46427657 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -1,5 +1,3 @@ -import * as path from 'path'; -import { fileURLToPath } from 'url'; import { Command } from 'commander'; import chalk from 'chalk'; import { Telegraf } from 'telegraf'; @@ -18,32 +16,10 @@ import { withErrorHandler } from '../util/errors.js'; import { createLogger, enableDebug } from '../util/debug.js'; import { getErrorMessage } from '../util/text.js'; import { confirm, password } from '@inquirer/prompts'; +import { createCliChannelActionService } from '../services/channel/cli-channel-action-service.js'; import { ChannelService } from '../services/channel/channel.service.js'; -import { runChannelBridge } from '../services/channel/channel-runner.js'; const debug = createLogger('channel'); -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -function resolveDaemonLaunch(): { command: string; args: string[] } { - if (path.extname(__filename) === '.ts') { - return { - command: process.execPath, - args: [ - '--no-warnings', - '--loader', - 'ts-node/esm', - path.resolve(__dirname, '..', 'channel-daemon.ts'), - ], - }; - } - - return { - command: process.execPath, - args: [path.resolve(__dirname, '..', 'channel-daemon.js')], - }; -} - function redactSecrets(message: string, secrets: string[]): string { return secrets.reduce( (redacted, secret) => secret ? redacted.split(secret).join('[REDACTED]') : redacted, @@ -238,67 +214,11 @@ export function registerChannelCommand(program: Command): void { .option('--daemon', 'Start the channel bridge in the background') .option('--debug', 'Enable debug logging') .action(withErrorHandler('start channel bridge', async (name: string | undefined, options) => { - if (options.debug) { - enableDebug(); - } - - const configStore = new ConfigStore(); - debug('Loading channel configuration from ConfigStore'); - const config = await configStore.getConfig(); - const channelName = channelService.resolveStartChannelName(config, name); - debug(`Starting channel bridge: channel=${channelName}, agent=${options.agent}`); - const channelEntry = config.channels[channelName]; - const runningBridge = await channelService.getLiveBridgeByChannel(channelName); - - if (!channelEntry) { - ui.error(`No channel configured with name "${channelName}".`); - const availableChannels = Object.keys(config.channels); - if (availableChannels.length > 0) { - ui.info(`Available channels: ${availableChannels.join(', ')}`); - } - return; - } - - if (options.daemon) { - const daemonLaunch = resolveDaemonLaunch(); - const daemonArgs = [ - ...daemonLaunch.args, - '--channel', - channelName, - '--agent', - options.agent, - ]; - if (options.debug) { - daemonArgs.push('--debug'); - } - - const bridge = await channelService.startDaemonBridge({ - channelName, - channelType: channelEntry.type, - agentName: options.agent, - command: daemonLaunch.command, - args: daemonArgs, - cwd: process.cwd(), - }); - - ui.success(`Channel bridge daemon started for "${channelName}" (PID: ${bridge.bridgePid}).`); - if (bridge.logPath) { - ui.info(`Logs: ${bridge.logPath}`); - } - ui.info(`Run "ai-devkit channel stop ${channelName}" to stop it.`); - return; - } - - if (runningBridge) { - ui.error(`Channel "${channelName}" bridge is already running (PID: ${runningBridge.bridgePid}).`); - return; - } - - await runChannelBridge({ - channelName, + await createCliChannelActionService(channelService).start({ + channelName: name, agentName: options.agent, - configStore, - channelService, + daemon: Boolean(options.daemon), + debug: options.debug, }); })); @@ -306,13 +226,7 @@ export function registerChannelCommand(program: Command): void { .command('stop [name]') .description('Stop a running channel bridge') .action(withErrorHandler('stop channel bridge', async (name: string | undefined) => { - const result = await channelService.stopBridge(name); - if (!result.stopped || !result.bridge) { - ui.info('No running channel bridge found.'); - return; - } - - ui.success(`Channel bridge stopped: ${result.bridge.channelName} (PID: ${result.bridge.bridgePid}).`); + await createCliChannelActionService(channelService).stop({ channelName: name }); })); channelCommand diff --git a/packages/cli/src/services/agent/agent.service.ts b/packages/cli/src/services/agent/agent.service.ts index accabb04..c3f93e63 100644 --- a/packages/cli/src/services/agent/agent.service.ts +++ b/packages/cli/src/services/agent/agent.service.ts @@ -1,683 +1 @@ -import { - AGENTS, - AgentStatus, - TtyWriter, - type AgentAdapter, - type AgentInfo, - type AgentManager, - type AgentRegistry, - type TerminalFocusManager, - type TerminalLocation, - type AgentType, - type ConversationMessage, - type RegistryEntry, - type StartableAgentType, - type TmuxManager, -} from '@ai-devkit/agent-manager'; -import { createLogger } from '../../util/debug.js'; -import { parseMilliseconds, sleep } from '../../util/time.js'; -import { ui } from '../../util/terminal-ui.js'; -import type { AgentGroup } from './agent-group.service.js'; - -const debug = createLogger('agent'); - -export interface AgentSendWaitTarget { - id: string; - name: string; - type: AgentType; - pid: number; - sessionId: string; - sessionFilePath: string; -} - -export interface AgentSendWaitOptions { - pollIntervalMs: number; - maxWaitMs: number; - timeoutLabel?: string; -} - -export interface AgentSendWaitResult { - agentName: string; - agentType: AgentType; - pid: number; - sessionId: string; - sessionFilePath: string; - messages: ConversationMessage[]; - finalStatus: AgentStatus; - elapsedMs: number; -} - -export interface WaitForAgentResponseParams { - manager: Pick; - adapter: Pick; - target: AgentSendWaitTarget; - initialMessageCount: number; - options: AgentSendWaitOptions; - onAssistantMessage: (message: ConversationMessage) => void; - onStatus?: (message: string) => void; -} - -export interface SendReporter { - info(message: string): void; - warning(message: string): void; - success(message: string): void; - error(message: string): void; -} - -interface GroupTarget { - member: string; - agent: AgentInfo; -} - -export interface SendToAgentOptions { - id: string; - prompt: string; - manager: Pick; - focusManager: Pick; - wait?: boolean; - timeout?: string; - json?: boolean; - reporter?: SendReporter; - writer?: typeof TtyWriter.send; - writeWaitStatus?: (message: string) => void; - writeAssistantMessage?: (message: ConversationMessage) => void; - writeJson?: (value: object) => void; -} - -export interface SendToAgentGroupOptions { - group: AgentGroup; - prompt: string; - manager: Pick; - focusManager: Pick; - reporter?: SendReporter; - writer?: typeof TtyWriter.send; -} - -export function assertSendTargetOptions(options: { id?: string; group?: string; wait?: boolean; timeout?: string; json?: boolean }): void { - const targetCount = Number(Boolean(options.id)) + Number(Boolean(options.group)); - if (targetCount !== 1) { - throw new Error('Use exactly one of --id or --group.'); - } - if (options.group && options.wait) { - throw new Error('Use --wait only with --id; group wait mode is not supported.'); - } - if (options.group && options.timeout !== undefined) { - throw new Error('Use --timeout only with --id --wait; group wait mode is not supported.'); - } - if (options.group && options.json) { - throw new Error('Use --json only with --id --wait; group JSON output is not supported.'); - } - if (options.timeout !== undefined && !options.wait) { - throw new Error('Use --timeout only with --wait.'); - } - if (options.timeout !== undefined) { - parseSendWaitTimeout(options.timeout); - } -} - -function findSameAgent(target: AgentSendWaitTarget, agents: AgentInfo[]): AgentInfo | undefined { - return agents.find((agent) => agent.pid === target.pid) - ?? agents.find((agent) => agent.sessionId === target.sessionId && agent.type === target.type); -} - -function readNewAssistantMessages( - adapter: Pick, - sessionFilePath: string, - lastSeenCount: number, -): { messages: ConversationMessage[]; nextSeenCount: number } { - const conversation = adapter.getConversation(sessionFilePath, { verbose: false }); - const newMessages = conversation.slice(lastSeenCount); - const assistantMessages = newMessages.filter((message) => ( - message.role === 'assistant' && Boolean(message.content) - )); - - return { - messages: assistantMessages, - nextSeenCount: conversation.length, - }; -} - -export async function waitForAgentResponse(params: WaitForAgentResponseParams): Promise { - const { manager, adapter, target, initialMessageCount, options, onAssistantMessage, onStatus } = params; - const startedAt = Date.now(); - let lastSeenCount = initialMessageCount; - const messages: ConversationMessage[] = []; - - while (Date.now() - startedAt < options.maxWaitMs) { - let transcriptReadSucceeded = false; - try { - const read = readNewAssistantMessages(adapter, target.sessionFilePath, lastSeenCount); - lastSeenCount = read.nextSeenCount; - transcriptReadSucceeded = true; - - for (const message of read.messages) { - messages.push(message); - onAssistantMessage(message); - } - } catch { - // Transcript files can be observed mid-write. Treat read failures as - // transient while the status loop still has time to prove completion. - } - - const agents = await manager.listAgents(); - const agent = findSameAgent(target, agents); - if (!agent) { - throw new Error(`Agent "${target.name}" is no longer running.`); - } - - const hasAssistantOutput = messages.length > 0; - const canCompleteOnStatus = - agent.status === AgentStatus.WAITING || - (agent.status === AgentStatus.IDLE && hasAssistantOutput); - - if (canCompleteOnStatus && transcriptReadSucceeded) { - if (messages.length === 0) { - onStatus?.(`Agent "${target.name}" returned to waiting without assistant output.`); - } - - return { - agentName: target.name, - agentType: target.type, - pid: target.pid, - sessionId: target.sessionId, - sessionFilePath: target.sessionFilePath, - messages, - finalStatus: agent.status, - elapsedMs: Date.now() - startedAt, - }; - } - - const elapsedMs = Date.now() - startedAt; - const remainingMs = options.maxWaitMs - elapsedMs; - await sleep(Math.min(options.pollIntervalMs, remainingMs)); - } - - throw new Error(`Timed out waiting for agent "${target.name}" after ${options.timeoutLabel ?? `${options.maxWaitMs}ms`}.`); -} - -export async function sendToAgent({ - id, - prompt, - manager, - focusManager, - wait = false, - timeout, - json = false, - reporter = ui, - writer = TtyWriter.send, - writeWaitStatus = (message) => process.stderr.write(`${message}\n`), - writeAssistantMessage = (message) => process.stdout.write(`${message.content}\n`), - writeJson = (value) => console.log(JSON.stringify(value, null, 2)), -}: SendToAgentOptions): Promise { - const waitTimeout = parseSendWaitTimeout(timeout); - const agents = await manager.listAgents(); - if (agents.length === 0) { - reporter.error('No running agents found.'); - return; - } - - const resolved = manager.resolveAgent(id, agents); - if (!resolved) { - reporter.error(`No agent found matching "${id}".`); - reporter.info('Available agents:'); - agents.forEach((agent) => reporter.info(` - ${agent.name}`)); - return; - } - - if (Array.isArray(resolved)) { - reporter.error(`Multiple agents match "${id}":`); - resolved.forEach((agent) => reporter.info(` - ${agent.name} (${formatStatus(agent.status)})`)); - reporter.info('Please use a more specific identifier.'); - return; - } - - const agent = resolved; - if (![AgentStatus.WAITING, AgentStatus.IDLE].includes(agent.status)) { - const warning = `Agent "${agent.name}" is not waiting for input (status: ${agent.status}). Sending anyway.`; - if (wait) { - writeWaitStatus(warning); - } else { - reporter.warning(warning); - } - } - - const waitContext = wait ? prepareWaitMode(manager, agent) : undefined; - const location = await focusManager.findTerminal(agent.pid); - if (!location) { - if (wait) { - throw new Error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); - } - reporter.error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); - return; - } - - await writer(location, prompt); - - if (!wait) { - reporter.success(`Sent message to ${agent.name}.`); - return; - } - - if (!waitContext) { - throw new Error('Wait mode was not prepared.'); - } - - const waitResult = await waitForAgentResponse({ - manager, - adapter: waitContext.adapter, - target: { - id, - name: agent.name, - type: agent.type, - pid: agent.pid, - sessionId: agent.sessionId, - sessionFilePath: waitContext.sessionFilePath, - }, - initialMessageCount: waitContext.initialMessageCount, - options: { - pollIntervalMs: AGENT_SEND_WAIT_POLL_INTERVAL_MS, - maxWaitMs: waitTimeout.maxWaitMs, - timeoutLabel: waitTimeout.label, - }, - onAssistantMessage: (message) => { - if (!json) writeAssistantMessage(message); - }, - onStatus: writeWaitStatus, - }); - - if (json) { - writeJson(toAgentSendWaitJson(waitResult, agent, prompt, id)); - } -} - -export async function sendToAgentGroup({ - group, - prompt, - manager, - focusManager, - reporter = ui, - writer = TtyWriter.send, -}: SendToAgentGroupOptions): Promise { - if (group.members.length === 0) { - throw new Error(`Agent group "${group.name}" has no members.`); - } - - const agents = await manager.listAgents(); - if (agents.length === 0) { - reporter.error('No running agents found.'); - process.exitCode = 1; - return; - } - - const resolution = resolveGroupTargets(group, agents, manager); - if (resolution.errors.length > 0) { - reportResolutionErrors(group.name, resolution.errors, reporter); - process.exitCode = 1; - return; - } - - const targets = dedupeTargets(resolution.targets, reporter); - await deliverGroupMessage({ - groupName: group.name, - targets, - prompt, - focusManager, - reporter, - writer, - }); -} - -function parseSendWaitTimeout(value: string | undefined): { maxWaitMs: number; label?: string } { - try { - const parsed = parseMilliseconds(value, AGENT_SEND_WAIT_MAX_WAIT_MS); - return { maxWaitMs: parsed.milliseconds, label: parsed.label }; - } catch (error) { - throw new Error(`Invalid --timeout. ${(error as Error).message} Example: 30000.`); - } -} - -function prepareWaitMode(manager: Pick, agent: AgentInfo): { - adapter: AgentAdapter; - sessionFilePath: string; - initialMessageCount: number; -} { - if (!agent.sessionFilePath) { - throw new Error(`No session file found for agent "${agent.name}"; cannot wait for response.`); - } - - const adapter = manager.getAdapter(agent.type); - if (!adapter) { - throw new Error(`Unsupported agent type: ${agent.type}`); - } - - return { - adapter, - sessionFilePath: agent.sessionFilePath, - initialMessageCount: adapter.getConversation(agent.sessionFilePath, { verbose: false }).length, - }; -} - -function toAgentSendWaitJson(result: AgentSendWaitResult, agent: AgentInfo, prompt: string, targetId: string): object { - return { - target: { - id: targetId, - name: agent.name, - type: agent.type, - pid: agent.pid, - status: agent.status, - summary: agent.summary, - projectPath: agent.projectPath, - sessionId: agent.sessionId, - sessionFilePath: result.sessionFilePath, - lastActive: agent.lastActive, - }, - prompt, - responseMessages: result.messages, - elapsedMs: result.elapsedMs, - finalStatus: result.finalStatus, - }; -} - -function formatStatus(status: AgentStatus): string { - const label = { - [AgentStatus.RUNNING]: 'run', - [AgentStatus.WAITING]: 'wait', - [AgentStatus.IDLE]: 'idle', - [AgentStatus.UNKNOWN]: 'unknown', - }[status] ?? 'unknown'; - return `${statusEmoji(status)} ${label}`; -} - -function statusEmoji(status: AgentStatus): string { - return { - [AgentStatus.RUNNING]: '\u{1F7E2}', - [AgentStatus.WAITING]: '\u{1F7E1}', - [AgentStatus.IDLE]: '\u{26AA}', - [AgentStatus.UNKNOWN]: '\u{2753}', - }[status] ?? '\u{2753}'; -} - -function resolveGroupTargets( - group: AgentGroup, - agents: AgentInfo[], - manager: Pick, -): { targets: GroupTarget[]; errors: string[] } { - const targets: GroupTarget[] = []; - const errors: string[] = []; - - for (const member of group.members) { - const resolved = manager.resolveAgent(member, agents); - if (!resolved) { - errors.push(` - ${member}: no running agent matched`); - continue; - } - if (Array.isArray(resolved)) { - errors.push(` - ${member}: matched multiple agents (${resolved.map((agent) => agent.name).join(', ')})`); - continue; - } - targets.push({ member, agent: resolved }); - } - - return { targets, errors }; -} - -function reportResolutionErrors(groupName: string, errors: string[], reporter: SendReporter): void { - reporter.error(`Cannot send to group "${groupName}" because some members could not be resolved.`); - for (const error of errors) { - reporter.error(error); - } -} - -function dedupeTargets(targets: GroupTarget[], reporter: SendReporter): GroupTarget[] { - const uniqueTargets: GroupTarget[] = []; - const seen = new Set(); - - for (const target of targets) { - const key = targetKey(target.agent); - if (seen.has(key)) { - reporter.info(`Skipped duplicate target "${target.agent.name}" from group member "${target.member}".`); - continue; - } - seen.add(key); - uniqueTargets.push(target); - } - - return uniqueTargets; -} - -async function deliverGroupMessage(options: { - groupName: string; - targets: GroupTarget[]; - prompt: string; - focusManager: Pick; - reporter: SendReporter; - writer: (location: TerminalLocation, message: string) => Promise; -}): Promise { - let successCount = 0; - let failureCount = 0; - - for (const { agent } of options.targets) { - warnIfAgentIsBusy(agent, options.reporter); - - try { - const location = await options.focusManager.findTerminal(agent.pid); - if (!location) { - throw new Error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); - } - await options.writer(location, options.prompt); - successCount += 1; - options.reporter.success(`Sent message to ${agent.name}.`); - } catch (error) { - failureCount += 1; - options.reporter.error(`Failed to send to ${agent.name}: ${(error as Error).message}`); - } - } - - reportDeliverySummary(options.groupName, successCount, failureCount, options.reporter); -} - -function warnIfAgentIsBusy(agent: AgentInfo, reporter: SendReporter): void { - if (![AgentStatus.WAITING, AgentStatus.IDLE].includes(agent.status)) { - reporter.warning(`Agent "${agent.name}" is not waiting for input (status: ${agent.status}). Sending anyway.`); - } -} - -function reportDeliverySummary(groupName: string, successCount: number, failureCount: number, reporter: SendReporter): void { - if (failureCount > 0) { - reporter.error(`Sent message to ${successCount} agent(s), failed for ${failureCount} agent(s) in group "${groupName}".`); - process.exitCode = 1; - return; - } - - reporter.success(`Sent message to ${successCount} agent(s) in group "${groupName}".`); -} - -function targetKey(agent: AgentInfo): string { - return agent.pid ? `pid:${agent.pid}` : `name:${agent.name}`; -} - -const AGENT_SEND_WAIT_POLL_INTERVAL_MS = 2000; -const AGENT_SEND_WAIT_MAX_WAIT_MS = 10 * 60 * 1000; - -export const DEFAULT_PID_POLL_INTERVAL_MS = 500; -export const DEFAULT_PID_POLL_TIMEOUT_MS = 15_000; -const REQUIRED_STABLE_PID_POLLS = 5; - -export interface StartAgentOptions { - type: StartableAgentType; - name: string; - cwd: string; - pollIntervalMs?: number; - pollTimeoutMs?: number; -} - -export interface StartAgentDeps { - tmux: TmuxManager; - registry: AgentRegistry; - /** Called for non-fatal events (e.g., replacing an orphan tmux session). */ - 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.'); - this.name = 'TmuxUnavailableError'; - } -} - -export class AgentNameInUseError extends Error { - constructor(public agentName: string, public pid: number) { - super(`Agent "${agentName}" is already running (PID ${pid}).`); - this.name = 'AgentNameInUseError'; - } -} - -export class AgentPidPollTimeoutError extends Error { - constructor(public agentName: string, public command: string, public timeoutMs: number) { - super(`Agent process not found after ${timeoutMs / 1000}s.`); - this.name = 'AgentPidPollTimeoutError'; - } -} - -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, - * and register the entry. On poll timeout the tmux session is torn down so no - * orphan is left behind. - * - * Callers are responsible for input-format validation (name regex, cwd existence) - * before invoking this service. - */ -export async function startAgent( - opts: StartAgentOptions, - deps: StartAgentDeps, -): Promise { - const { tmux, registry, onWarning } = deps; - const agent = AGENTS[opts.type]; - const intervalMs = opts.pollIntervalMs ?? DEFAULT_PID_POLL_INTERVAL_MS; - const timeoutMs = opts.pollTimeoutMs ?? DEFAULT_PID_POLL_TIMEOUT_MS; - - debug(`startAgent: type=${opts.type}, name=${opts.name}, cwd=${opts.cwd}, pollTimeoutMs=${timeoutMs}`); - - if (!await tmux.isAvailable()) { - debug('startAgent: tmux unavailable'); - throw new TmuxUnavailableError(); - } - - registry.prune(); - const existing = registry.lookup(opts.name); - if (existing) { - debug(`startAgent: name already in use pid=${existing.pid}`); - throw new AgentNameInUseError(opts.name, existing.pid); - } - - if (await tmux.sessionExists(opts.name)) { - onWarning?.( - `tmux session "${opts.name}" already exists but has no live registry entry — it will be replaced.`, - ); - await tmux.killSession(opts.name); - } - - debug(`startAgent: creating tmux session ${opts.name}`); - await tmux.createSession(opts.name, opts.cwd); - debug(`startAgent: sending launch command "${agent.command}"`); - await tmux.sendKeys(opts.name, agent.command); - - const agentPid = await pollForPid(tmux, opts.name, agent.matches, intervalMs, timeoutMs); - if (agentPid === null) { - debug(`startAgent: PID poll timed out after ${timeoutMs}ms`); - await tmux.killSession(opts.name); - throw new AgentPidPollTimeoutError(opts.name, agent.command, timeoutMs); - } - debug(`startAgent: detected stable PID ${agentPid}`); - - const entry: RegistryEntry = { - name: opts.name, - type: opts.type, - pid: agentPid, - tmuxSession: opts.name, - cwd: opts.cwd, - startedAt: new Date().toISOString(), - sessionId: '', - sessionFilePath: '', - }; - registry.register(entry); - debug(`startAgent: registered ${entry.name}`); - return entry; -} - -async function pollForPid( - tmux: TmuxManager, - session: string, - matches: (psCommand: string) => boolean, - intervalMs: number, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - let candidatePid: number | null = null; - let stablePolls = 0; - - while (Date.now() < deadline) { - const pid = await tmux.findAgentPid(session, matches); - if (pid !== null) { - if (pid === candidatePid) { - stablePolls += 1; - } else { - candidatePid = pid; - stablePolls = 1; - } - - debug(`pollForPid: candidatePid=${pid}, stablePolls=${stablePolls}`); - if (stablePolls >= REQUIRED_STABLE_PID_POLLS) return pid; - } - await new Promise((r) => setTimeout(r, intervalMs)); - } - - return null; -} +export * from '@ai-devkit/agent-manager'; diff --git a/packages/cli/src/services/agent/cli-agent-action-service.ts b/packages/cli/src/services/agent/cli-agent-action-service.ts new file mode 100644 index 00000000..70d0ec55 --- /dev/null +++ b/packages/cli/src/services/agent/cli-agent-action-service.ts @@ -0,0 +1,55 @@ +import { select } from '@inquirer/prompts'; +import { + AgentManager, + AgentRegistry, + ClaudeCodeAdapter, + ClaudePrintAgentService, + CodexAdapter, + CopilotAdapter, + GeminiCliAdapter, + GrokCliAdapter, + OpenCodeAdapter, + PiAdapter, + PrintAgentStore, + TerminalFocusManager, + TmuxManager, + TtyWriter, + createAgentActionService, + type AgentActionService, +} from '@ai-devkit/agent-manager'; +import { createLogger, enableDebug } from '../../util/debug.js'; +import { ui } from '../../util/terminal-ui.js'; +import { createDefaultAgentGroupService } from './agent-group.service.js'; +import { killAgent, sendToAgent, sendToAgentGroup, startAgent } from './agent.service.js'; + +export function createCliAgentManager(): AgentManager { + const manager = new AgentManager(AgentRegistry.default()); + manager.registerAdapter(new ClaudeCodeAdapter()); + manager.registerAdapter(new CodexAdapter()); + manager.registerAdapter(new CopilotAdapter()); + manager.registerAdapter(new GeminiCliAdapter()); + manager.registerAdapter(new GrokCliAdapter()); + manager.registerAdapter(new OpenCodeAdapter()); + manager.registerAdapter(new PiAdapter()); + return manager; +} + +export function createCliAgentActionService(debug = false): AgentActionService { + if (debug) enableDebug(); + const logger = debug ? createLogger('terminal') : undefined; + const registry = AgentRegistry.default(); + return createAgentActionService({ + manager: createCliAgentManager(), + registry, + tmux: new TmuxManager(), + printService: new ClaudePrintAgentService({ store: new PrintAgentStore() }), + reporter: ui, + groupService: createDefaultAgentGroupService(), + selectAgent: (options) => select(options), + createFocusManager: () => new TerminalFocusManager(logger), + startAgent, + killAgent, + sendToAgent: (options) => sendToAgent({ ...options, writer: TtyWriter.send }), + sendToAgentGroup: (options) => sendToAgentGroup({ ...options, writer: TtyWriter.send }), + }); +} diff --git a/packages/cli/src/services/channel/channel.service.ts b/packages/cli/src/services/channel/channel.service.ts index 6da4cf5d..d4b455cd 100644 --- a/packages/cli/src/services/channel/channel.service.ts +++ b/packages/cli/src/services/channel/channel.service.ts @@ -1,215 +1,6 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { spawn } from 'child_process'; -import type { ChannelConfig, TelegramConfig } from '@ai-devkit/channel-connector'; - -const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'channel-bridges.json'); -const DEFAULT_TELEGRAM_CHANNEL_NAME = 'telegram'; -const CHANNEL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; - -export interface ChannelBridgeProcess { - channelName: string; - channelType: string; - agentName: string; - agentPid: number; - bridgePid: number; - startedAt: string; - logPath?: string; -} - -interface ChannelBridgeFile { - bridges: Record; -} - -type PidChecker = (pid: number) => boolean; -type DetachedSpawner = ( - command: string, - args: string[], - options: { cwd: string; detached: true; stdio: ['ignore', number, number] }, -) => { pid?: number; unref: () => void }; -type ProcessKiller = (pid: number, signal: NodeJS.Signals) => void; - -export interface StartDaemonBridgeInput { - channelName: string; - channelType: string; - agentName: string; - command: string; - args: string[]; - cwd: string; -} - -export interface StopBridgeResult { - stopped: boolean; - bridge?: ChannelBridgeProcess; -} - -function defaultPidChecker(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -export class ChannelService { - constructor( - private readonly registryPath = DEFAULT_REGISTRY_PATH, - private readonly isPidAlive: PidChecker = defaultPidChecker, - private readonly spawnDetached: DetachedSpawner = (command, args, options) => spawn(command, args, options), - private readonly killProcess: ProcessKiller = (pid, signal) => { - process.kill(pid, signal); - }, - ) {} - - resolveConnectChannelName(name: string | undefined): string { - const channelName = (name ?? DEFAULT_TELEGRAM_CHANNEL_NAME).trim(); - if (!CHANNEL_NAME_PATTERN.test(channelName)) { - throw new Error('Channel name must be kebab-case using lowercase letters, numbers, and hyphens.'); - } - return channelName; - } - - assertUniqueTelegramToken(config: ChannelConfig, targetName: string, botToken: string): void { - for (const [name, entry] of Object.entries(config.channels)) { - if (name === targetName || entry.type !== DEFAULT_TELEGRAM_CHANNEL_NAME) continue; - const telegramConfig = entry.config as TelegramConfig; - if (telegramConfig.botToken === botToken) { - throw new Error(`Telegram bot token is already configured for channel "${name}".`); - } - } - } - - resolveStartChannelName(config: ChannelConfig, name: string | undefined): string { - if (name !== undefined) return this.resolveConnectChannelName(name); - - const telegramChannels = Object.entries(config.channels) - .filter(([, entry]) => entry.type === DEFAULT_TELEGRAM_CHANNEL_NAME) - .map(([channelName]) => channelName); - - if (telegramChannels.length === 1) return telegramChannels[0]; - if (telegramChannels.length > 1) { - throw new Error(`Multiple Telegram channels configured. Specify one: ${telegramChannels.join(', ')}`); - } - throw new Error('No Telegram channel configured. Run "ai-devkit channel connect telegram" first.'); - } - - async getLiveBridges(): Promise { - const registry = await this.readBridgeRegistry(); - const liveEntries = Object.entries(registry.bridges) - .filter(([, bridge]) => this.isPidAlive(bridge.bridgePid)); - - const next: ChannelBridgeFile = { bridges: Object.fromEntries(liveEntries) }; - await this.writeBridgeRegistry(next); - return Object.values(next.bridges); - } - - async getLiveBridgeByChannel(channelName: string): Promise { - const liveBridges = await this.getLiveBridges(); - return liveBridges.find(bridge => bridge.channelName === channelName); - } - - async registerBridge(processInfo: ChannelBridgeProcess): Promise { - const registry = await this.readBridgeRegistry(); - registry.bridges[processInfo.channelName] = processInfo; - await this.writeBridgeRegistry(registry); - } - - async startDaemonBridge(input: StartDaemonBridgeInput): Promise { - const runningBridge = await this.getLiveBridgeByChannel(input.channelName); - if (runningBridge) { - throw new Error(`Channel "${input.channelName}" bridge is already running (PID: ${runningBridge.bridgePid}).`); - } - - const logPath = this.getBridgeLogPath(input.channelName); - const logFd = this.openBridgeLog(input, logPath); - let child: { pid?: number; unref: () => void }; - try { - child = this.spawnDetached(input.command, input.args, { - cwd: input.cwd, - detached: true, - stdio: ['ignore', logFd, logFd], - }); - } finally { - fs.closeSync(logFd); - } - - if (!child.pid) { - throw new Error('Failed to start channel bridge daemon: child process did not report a PID.'); - } - - child.unref(); - - const bridge: ChannelBridgeProcess = { - channelName: input.channelName, - channelType: input.channelType, - agentName: input.agentName, - agentPid: 0, - bridgePid: child.pid, - startedAt: new Date().toISOString(), - logPath, - }; - - await this.registerBridge(bridge); - return bridge; - } - - async stopBridge(channelName?: string): Promise { - const liveBridges = await this.getLiveBridges(); - - if (liveBridges.length === 0) { - return { stopped: false }; - } - - let bridge: ChannelBridgeProcess | undefined; - if (channelName) { - bridge = liveBridges.find(candidate => candidate.channelName === this.resolveConnectChannelName(channelName)); - if (!bridge) { - return { stopped: false }; - } - } else if (liveBridges.length === 1) { - bridge = liveBridges[0]; - } else { - throw new Error(`Multiple channel bridges are running. Specify one: ${liveBridges.map(candidate => candidate.channelName).join(', ')}`); - } - - this.killProcess(bridge.bridgePid, 'SIGTERM'); - await this.unregisterBridge(bridge.channelName); - - return { stopped: true, bridge }; - } - - async unregisterBridge(channelName: string): Promise { - const registry = await this.readBridgeRegistry(); - delete registry.bridges[channelName]; - await this.writeBridgeRegistry(registry); - } - - private async readBridgeRegistry(): Promise { - try { - const raw = fs.readFileSync(this.registryPath, 'utf-8'); - return JSON.parse(raw) as ChannelBridgeFile; - } catch { - return { bridges: {} }; - } - } - - private async writeBridgeRegistry(registry: ChannelBridgeFile): Promise { - const dir = path.dirname(this.registryPath); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(this.registryPath, JSON.stringify(registry, null, 2), { mode: 0o600 }); - } - - private getBridgeLogPath(channelName: string): string { - return path.join(path.dirname(this.registryPath), 'channel-logs', `${channelName}.log`); - } - - private openBridgeLog(input: StartDaemonBridgeInput, logPath: string): number { - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - const logFd = fs.openSync(logPath, 'a', 0o600); - fs.writeSync(logFd, `[${new Date().toISOString()}] Starting channel daemon: ${input.channelName} -> ${input.agentName}\n`); - fs.writeSync(logFd, `[command] ${input.command} ${input.args.join(' ')}\n`); - return logFd; - } -} +export { ChannelService } from '@ai-devkit/channel-connector'; +export type { + ChannelBridgeProcess, + StartDaemonBridgeInput, + StopBridgeResult, +} from '@ai-devkit/channel-connector'; diff --git a/packages/cli/src/services/channel/cli-channel-action-service.ts b/packages/cli/src/services/channel/cli-channel-action-service.ts new file mode 100644 index 00000000..f3b94e48 --- /dev/null +++ b/packages/cli/src/services/channel/cli-channel-action-service.ts @@ -0,0 +1,73 @@ +import { + ConfigStore, + createChannelActionService, + type ChannelActionResult, +} from '@ai-devkit/channel-connector'; +import { createLogger, enableDebug } from '../../util/debug.js'; +import { ui } from '../../util/terminal-ui.js'; +import { resolveDaemonLaunch } from './daemon-launch.js'; +import { runChannelBridge } from './channel-runner.js'; +import { ChannelService } from './channel.service.js'; + +const debug = createLogger('channel'); + +export interface StartCliChannelInput { + channelName?: string; + agentName: string; + daemon: boolean; + debug?: boolean; +} + +export interface CliChannelActionService { + start(input: StartCliChannelInput): Promise; + stop(input: { channelName?: string }): Promise; +} + +export function createCliChannelActionService( + channelService = new ChannelService(), +): CliChannelActionService { + const configStore = new ConfigStore(); + const actions = createChannelActionService({ + configStore, + bridgeService: channelService, + reporter: ui, + }); + + return { + async start(input) { + if (input.debug) enableDebug(); + if (input.daemon) { + return actions.startDaemon({ + channelName: input.channelName, + agentName: input.agentName, + launch: resolveDaemonLaunch(), + debug: input.debug, + }); + } + + debug('Loading channel configuration from ConfigStore'); + const config = await configStore.getConfig(); + const channelName = channelService.resolveStartChannelName(config, input.channelName); + debug(`Starting channel bridge: channel=${channelName}, agent=${input.agentName}`); + const channelEntry = config.channels[channelName]; + const runningBridge = await channelService.getLiveBridgeByChannel(channelName); + + if (!channelEntry) { + const message = `No channel configured with name "${channelName}".`; + ui.error(message); + const availableChannels = Object.keys(config.channels); + if (availableChannels.length > 0) ui.info(`Available channels: ${availableChannels.join(', ')}`); + return { ok: false, message }; + } + if (runningBridge) { + const message = `Channel "${channelName}" bridge is already running (PID: ${runningBridge.bridgePid}).`; + ui.error(message); + return { ok: false, message }; + } + + await runChannelBridge({ channelName, agentName: input.agentName, configStore, channelService }); + return { ok: true }; + }, + stop: (input) => actions.stop(input), + }; +} diff --git a/packages/cli/src/services/channel/daemon-launch.ts b/packages/cli/src/services/channel/daemon-launch.ts new file mode 100644 index 00000000..154283bc --- /dev/null +++ b/packages/cli/src/services/channel/daemon-launch.ts @@ -0,0 +1,26 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import type { DaemonLaunch } from '@ai-devkit/channel-connector'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export function resolveDaemonLaunch(): DaemonLaunch { + if (path.extname(filename) === '.ts') { + return { + command: process.execPath, + args: [ + '--no-warnings', + '--loader', + 'ts-node/esm', + path.resolve(dirname, '..', '..', 'channel-daemon.ts'), + ], + cwd: process.cwd(), + }; + } + return { + command: process.execPath, + args: [path.resolve(dirname, '..', '..', 'channel-daemon.js')], + cwd: process.cwd(), + }; +} diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 06a41a90..ad2e463d 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Box, useApp, useInput, type RenderOptions } from 'ink'; import type { AgentManager } from '@ai-devkit/agent-manager'; import { @@ -17,6 +17,7 @@ import { StatusFooter } from './StatusFooter.js'; import { ChatInput } from './ChatInput.js'; import { HeaderBar } from './HeaderBar.js'; import { runAction } from './actions/runAction.js'; +import { createConsoleActionExecutor } from './actions/pendingAction.js'; import { StartAgentPane } from './StartAgentPane.js'; import { RenameAgentPane } from './RenameAgentPane.js'; import { ChannelSelectPane } from './ChannelSelectPane.js'; @@ -84,6 +85,10 @@ const ConsoleAppShell: React.FC<{ const [focus, setFocus] = useState('list'); const [inputLines, setInputLines] = useState(1); const [transient, setTransient] = useState(null); + const runConsoleAction = useMemo(() => createConsoleActionExecutor( + runAction, + (label) => setTransient({ kind: 'info', text: label }), + ), []); const [rightPaneMode, setRightPaneMode] = useState({ type: 'preview' }); const [detailScrollOffset, setDetailScrollOffset] = useState(0); const startPaneActive = rightPaneMode.type === 'start-agent'; @@ -150,6 +155,7 @@ const ConsoleAppShell: React.FC<{ handleStartCancel, handleStartSubmit, } = useStartAgentPane({ + runConsoleAction, refresh, setFocus, setRightPaneMode, @@ -160,7 +166,7 @@ const ConsoleAppShell: React.FC<{ pendingKillName, openKillConfirm, handleKillInput, - } = useKillAgentAction({ setTransient }); + } = useKillAgentAction({ runConsoleAction, setTransient }); const { renamePaneError, @@ -169,6 +175,7 @@ const ConsoleAppShell: React.FC<{ handleRenameCancel, handleRenameSubmit, } = useRenameAgentPane({ + runConsoleAction, setFocus, setRightPaneMode, setTransient, @@ -179,6 +186,7 @@ const ConsoleAppShell: React.FC<{ startChannel, stopAgentChannel, } = useChannelActions({ + runConsoleAction, channelStatuses, refreshChannels, refreshConfiguredChannels, @@ -190,14 +198,16 @@ const ConsoleAppShell: React.FC<{ setFocus('list'); const agent = getSelectedAgent(); if (!agent) return; - void runAction({ type: 'send', agentName: agent.name, message: text }).then(result => { + const action = runConsoleAction({ type: 'send', agentName: agent.name, message: text }); + if (!action) return; + void action.then(result => { if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { setTransient({ kind: 'error', text: result.error ?? `send exited ${result.exitCode}` }); } else { setTransient({ kind: 'info', text: `Message sent to ${agent.name}` }); } }); - }, [getSelectedAgent]); + }, [getSelectedAgent, runConsoleAction]); const handleInputCancel = useCallback(() => { setFocus('list'); @@ -226,9 +236,13 @@ const ConsoleAppShell: React.FC<{ if (input === 'o') { const agent = getSelectedAgent(); if (!agent) return; - void runAction({ type: 'open', agentName: agent.name }).then(result => { + const action = runConsoleAction({ type: 'open', agentName: agent.name }); + if (!action) return; + void action.then(result => { if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { setTransient({ kind: 'error', text: result.error ?? `open exited ${result.exitCode}` }); + } else { + setTransient(null); } }); return; diff --git a/packages/cli/src/tui/console/actions/pendingAction.ts b/packages/cli/src/tui/console/actions/pendingAction.ts new file mode 100644 index 00000000..53e55867 --- /dev/null +++ b/packages/cli/src/tui/console/actions/pendingAction.ts @@ -0,0 +1,78 @@ +import type { ConsoleAction } from './types.js'; +import type { ActionResult } from './runAction.js'; + +export interface PendingActionExecution { + started: boolean; + promise?: Promise; +} + +export interface PendingActionRunner { + isPending(key: string): boolean; + run(key: string, label: string, action: () => Promise): PendingActionExecution; +} + +export function createPendingActionRunner( + onPending: (label: string) => void, +): PendingActionRunner { + const pending = new Set(); + + return { + isPending: (key) => pending.has(key), + run(key: string, label: string, action: () => Promise): PendingActionExecution { + if (pending.has(key)) return { started: false }; + pending.add(key); + onPending(label); + let actionPromise: Promise; + try { + actionPromise = action(); + } catch (error) { + pending.delete(key); + throw error; + } + return { + started: true, + promise: actionPromise.finally(() => { + pending.delete(key); + }), + }; + }, + }; +} + +export interface PendingActionIdentity { + key: string; + label: string; +} + +export function getPendingActionIdentity(action: ConsoleAction): PendingActionIdentity { + switch (action.type) { + case 'send': + return { key: `send:${action.agentName}`, label: 'Sending' }; + case 'open': + return { key: `open:${action.agentName}`, label: 'Opening' }; + case 'start': + return { key: `start:${action.name}`, label: 'Starting' }; + case 'kill': + return { key: `kill:${action.agentName}`, label: 'Killing' }; + case 'rename': + return { key: `rename:${action.currentName}`, label: 'Renaming' }; + case 'channel-start': + return { key: `channel-start:${action.channelName}`, label: 'Starting channel' }; + case 'channel-stop': + return { key: `channel-stop:${action.channelName}`, label: 'Stopping channel' }; + } +} + +export type RunConsoleAction = (action: ConsoleAction) => Promise | null; + +export function createConsoleActionExecutor( + execute: (action: ConsoleAction) => Promise, + onPending: (label: string) => void, +): RunConsoleAction { + const runner = createPendingActionRunner(onPending); + return (action) => { + const { key, label } = getPendingActionIdentity(action); + const execution = runner.run(key, label, () => execute(action)); + return execution.started ? execution.promise! : null; + }; +} diff --git a/packages/cli/src/tui/console/actions/runAction.ts b/packages/cli/src/tui/console/actions/runAction.ts index 0e67c855..0bf1f7bd 100644 --- a/packages/cli/src/tui/console/actions/runAction.ts +++ b/packages/cli/src/tui/console/actions/runAction.ts @@ -1,4 +1,13 @@ -import { spawn } from 'child_process'; +import { + createAgentActionService, + type AgentActionReporter, + type ApplicationActionResult, +} from '@ai-devkit/agent-manager'; +import { + createChannelActionService, + type ChannelActionReporter, +} from '@ai-devkit/channel-connector'; +import { resolveDaemonLaunch } from '../../../services/channel/daemon-launch.js'; import type { ConsoleAction } from './types.js'; export interface ActionResult { @@ -6,40 +15,92 @@ export interface ActionResult { error?: string; } -function resolveCliEntry(): { command: string; baseArgs: string[] } { - return { command: process.execPath, baseArgs: [...process.execArgv, process.argv[1]] }; +type StartAction = Extract; +type StartConsoleActionInput = Pick; + +export interface ConsoleActionServices { + open(input: { agentName: string }): Promise; + send(input: { agentName: string; message: string }): Promise; + start(input: StartConsoleActionInput): Promise; + kill(input: { agentName: string }): Promise; + rename(input: { currentName: string; newName: string }): Promise; + startChannel(input: { channelName: string; agentName: string }): Promise; + stopChannel(input: { channelName: string }): Promise; +} + +function createDefaultConsoleActionServices(): ConsoleActionServices { + const noOutput = () => undefined; + const reporter: AgentActionReporter & ChannelActionReporter = { + text: noOutput, + info: noOutput, + success: noOutput, + warning: noOutput, + error: noOutput, + spinner: () => ({ start: noOutput, succeed: noOutput, fail: noOutput }), + }; + const agent = createAgentActionService({ reporter }); + const channel = createChannelActionService({ reporter }); + return { + open: ({ agentName }) => agent.open({ agentName }), + send: ({ agentName, message }) => agent.send({ agentName, message }), + start: ({ agentType, name, cwd }) => agent.start({ + agentType, + mode: 'interactive', + name, + cwd, + }), + kill: ({ agentName }) => agent.kill({ agentName }), + rename: ({ currentName, newName }) => agent.rename({ currentName, newName }), + startChannel: ({ channelName, agentName }) => channel.startDaemon({ + channelName, + agentName, + launch: resolveDaemonLaunch(), + }), + stopChannel: ({ channelName }) => channel.stop({ channelName }), + }; } -export async function runAction(action: ConsoleAction): Promise { - const { command, baseArgs } = resolveCliEntry(); - const argv = (() => { +function toActionResult(result: ApplicationActionResult): ActionResult { + return result.ok + ? { exitCode: 0 } + : { exitCode: result.cliExitCode ?? 1, error: result.message }; +} + +export async function runAction( + action: ConsoleAction, + services: ConsoleActionServices = createDefaultConsoleActionServices(), +): Promise { + try { switch (action.type) { case 'open': - return [...baseArgs, 'agent', 'open', action.agentName]; + return toActionResult(await services.open({ agentName: action.agentName })); case 'send': - return [...baseArgs, 'agent', 'send', action.message, '--id', action.agentName]; + return toActionResult(await services.send({ agentName: action.agentName, message: action.message })); case 'start': - return [...baseArgs, 'agent', 'start', '--type', action.agentType, '--name', action.name, '--cwd', action.cwd]; + return toActionResult(await services.start({ + agentType: action.agentType, + name: action.name, + cwd: action.cwd, + })); case 'kill': - return [...baseArgs, 'agent', 'kill', action.agentName]; + return toActionResult(await services.kill({ agentName: action.agentName })); case 'rename': - return [...baseArgs, 'agent', 'rename', action.currentName, action.newName]; + return toActionResult(await services.rename({ + currentName: action.currentName, + newName: action.newName, + })); case 'channel-start': - return [...baseArgs, 'channel', 'start', action.channelName, '--agent', action.agentName, '--daemon']; + return toActionResult(await services.startChannel({ + channelName: action.channelName, + agentName: action.agentName, + })); case 'channel-stop': - return [...baseArgs, 'channel', 'stop', action.channelName]; + return toActionResult(await services.stopChannel({ channelName: action.channelName })); } - })(); - - return new Promise((resolve) => { - // Use pipe so the subprocess never takes over the TUI's terminal. - const child = spawn(command, argv, { stdio: ['ignore', 'pipe', 'pipe'] }); - const stderrChunks: Buffer[] = []; - child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); - child.once('error', (err) => resolve({ exitCode: null, error: err.message })); - child.once('exit', (code) => { - const stderr = Buffer.concat(stderrChunks).toString().trim(); - resolve({ exitCode: code, error: code !== 0 && stderr ? stderr : undefined }); - }); - }); + } catch (error) { + return { + exitCode: null, + error: error instanceof Error ? error.message : String(error), + }; + } } diff --git a/packages/cli/src/tui/console/hooks/useChannelActions.ts b/packages/cli/src/tui/console/hooks/useChannelActions.ts index ab36fb54..2fba430c 100644 --- a/packages/cli/src/tui/console/hooks/useChannelActions.ts +++ b/packages/cli/src/tui/console/hooks/useChannelActions.ts @@ -1,10 +1,11 @@ import { useCallback, type Dispatch, type SetStateAction } from 'react'; import type { AgentInfo } from '@ai-devkit/agent-manager'; -import { runAction } from '../actions/runAction.js'; import type { ActionResult } from '../actions/runAction.js'; +import type { RunConsoleAction } from '../actions/pendingAction.js'; import type { AgentChannelStatusMap, RightPaneMode, TransientMessage } from '../types.js'; interface UseChannelActionsInput { + runConsoleAction: RunConsoleAction; channelStatuses: AgentChannelStatusMap; refreshChannels: () => Promise; refreshConfiguredChannels: () => Promise; @@ -36,6 +37,7 @@ export function getConnectedChannelName( } export function useChannelActions({ + runConsoleAction, channelStatuses, refreshChannels, refreshConfiguredChannels, @@ -54,7 +56,9 @@ export function useChannelActions({ }, [refreshConfiguredChannels, setRightPaneMode, setTransient]); const startChannel = useCallback((channelName: string, agentName: string) => { - void runAction({ type: 'channel-start', channelName, agentName }) + const action = runConsoleAction({ type: 'channel-start', channelName, agentName }); + if (!action) return; + void action .then(async result => { const actionError = getChannelActionError('channel start', result); if (actionError) { @@ -69,7 +73,7 @@ export function useChannelActions({ .catch(err => { setTransient({ kind: 'error', text: errorMessage(err) }); }); - }, [refreshChannels, setRightPaneMode, setTransient]); + }, [refreshChannels, runConsoleAction, setRightPaneMode, setTransient]); const stopAgentChannel = useCallback((agent: AgentInfo | null) => { const channelName = getConnectedChannelName(agent, channelStatuses); @@ -78,7 +82,9 @@ export function useChannelActions({ return; } - void runAction({ type: 'channel-stop', channelName }) + const action = runConsoleAction({ type: 'channel-stop', channelName }); + if (!action) return; + void action .then(async result => { const actionError = getChannelActionError('channel stop', result); if (actionError) { @@ -92,7 +98,7 @@ export function useChannelActions({ .catch(err => { setTransient({ kind: 'error', text: errorMessage(err) }); }); - }, [channelStatuses, refreshChannels, setTransient]); + }, [channelStatuses, refreshChannels, runConsoleAction, setTransient]); return { openChannelSelect, diff --git a/packages/cli/src/tui/console/hooks/useKillAgentAction.ts b/packages/cli/src/tui/console/hooks/useKillAgentAction.ts index e6d3656e..2c44ab7c 100644 --- a/packages/cli/src/tui/console/hooks/useKillAgentAction.ts +++ b/packages/cli/src/tui/console/hooks/useKillAgentAction.ts @@ -1,5 +1,5 @@ import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'; -import { runAction } from '../actions/runAction.js'; +import type { RunConsoleAction } from '../actions/pendingAction.js'; import type { TransientMessage } from '../types.js'; interface ConsoleInputKey { @@ -8,6 +8,7 @@ interface ConsoleInputKey { } interface UseKillAgentActionOptions { + runConsoleAction: RunConsoleAction; setTransient: Dispatch>; } @@ -24,7 +25,7 @@ export function getKillInputDecision( return 'consume'; } -export function useKillAgentAction({ setTransient }: UseKillAgentActionOptions) { +export function useKillAgentAction({ runConsoleAction, setTransient }: UseKillAgentActionOptions) { const [pendingKillName, setPendingKillName] = useState(null); const openKillConfirm = useCallback((agentName: string) => { @@ -39,14 +40,16 @@ export function useKillAgentAction({ setTransient }: UseKillAgentActionOptions) if (!pendingKillName) return; const agentName = pendingKillName; setPendingKillName(null); - void runAction({ type: 'kill', agentName }).then(result => { + const action = runConsoleAction({ type: 'kill', agentName }); + if (!action) return; + void action.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]); + }, [pendingKillName, runConsoleAction, setTransient]); const handleKillInput = useCallback((input: string, key: ConsoleInputKey): boolean => { switch (getKillInputDecision(pendingKillName, input, key)) { diff --git a/packages/cli/src/tui/console/hooks/useRenameAgentPane.ts b/packages/cli/src/tui/console/hooks/useRenameAgentPane.ts index 848a9168..b3f879b5 100644 --- a/packages/cli/src/tui/console/hooks/useRenameAgentPane.ts +++ b/packages/cli/src/tui/console/hooks/useRenameAgentPane.ts @@ -1,8 +1,10 @@ import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'; -import { runAction, type ActionResult } from '../actions/runAction.js'; +import type { ActionResult } from '../actions/runAction.js'; +import type { RunConsoleAction } from '../actions/pendingAction.js'; import type { ConsoleFocus, RightPaneMode, TransientMessage } from '../types.js'; interface UseRenameAgentPaneOptions { + runConsoleAction: RunConsoleAction; setFocus: Dispatch>; setRightPaneMode: Dispatch>; setTransient: Dispatch>; @@ -19,6 +21,7 @@ export function getRenameActionError(result: ActionResult): string | null { } export function useRenameAgentPane({ + runConsoleAction, setFocus, setRightPaneMode, setTransient, @@ -42,7 +45,9 @@ export function useRenameAgentPane({ if (isRenamingAgent) return; setIsRenamingAgent(true); setRenamePaneError(null); - void runAction({ type: 'rename', currentName, newName: values.newName }).then(result => { + const action = runConsoleAction({ type: 'rename', currentName, newName: values.newName }); + if (!action) return; + void action.then(result => { const error = getRenameActionError(result); if (error) { setRenamePaneError(error); @@ -53,7 +58,7 @@ export function useRenameAgentPane({ }).finally(() => { setIsRenamingAgent(false); }); - }, [isRenamingAgent, setRightPaneMode, setTransient]); + }, [isRenamingAgent, runConsoleAction, setRightPaneMode, setTransient]); return { renamePaneError, diff --git a/packages/cli/src/tui/console/hooks/useStartAgentPane.ts b/packages/cli/src/tui/console/hooks/useStartAgentPane.ts index 5c1d8c97..6625b73a 100644 --- a/packages/cli/src/tui/console/hooks/useStartAgentPane.ts +++ b/packages/cli/src/tui/console/hooks/useStartAgentPane.ts @@ -1,12 +1,13 @@ import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'; import type { StartableAgentType } from '@ai-devkit/agent-manager'; -import { runAction } from '../actions/runAction.js'; +import type { RunConsoleAction } from '../actions/pendingAction.js'; import { generateAgentName } from '../../../util/agent.js'; import type { ConsoleFocus, RightPaneMode, TransientMessage } from '../types.js'; type StartDefaults = { name: string; cwd: string }; interface UseStartAgentPaneOptions { + runConsoleAction: RunConsoleAction; refresh: () => Promise; setFocus: Dispatch>; setRightPaneMode: Dispatch>; @@ -25,6 +26,7 @@ function createStartDefaults(): StartDefaults { } export function useStartAgentPane({ + runConsoleAction, refresh, setFocus, setRightPaneMode, @@ -51,7 +53,9 @@ export function useStartAgentPane({ if (isStartingAgent) return; setIsStartingAgent(true); setStartPaneError(null); - void runAction({ type: 'start', agentType: values.type, name: values.name, cwd: values.cwd }).then(async result => { + const action = runConsoleAction({ type: 'start', agentType: values.type, name: values.name, cwd: values.cwd }); + if (!action) return; + void action.then(async result => { if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) { setStartPaneError(result.error ?? `start exited ${result.exitCode}`); return; @@ -62,7 +66,7 @@ export function useStartAgentPane({ }).finally(() => { setIsStartingAgent(false); }); - }, [isStartingAgent, refresh, setRightPaneMode, setTransient]); + }, [isStartingAgent, refresh, runConsoleAction, setRightPaneMode, setTransient]); return { startDefaults,