From 39326443fc18098a1835a17d0ab9d4f291b9decb Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 14:50:45 +0200 Subject: [PATCH 1/4] feat(cli): run console actions in process --- ...8-14-feature-console-in-process-actions.md | 66 +++ ...8-14-feature-console-in-process-actions.md | 38 ++ ...8-14-feature-console-in-process-actions.md | 26 ++ ...8-14-feature-console-in-process-actions.md | 43 ++ ...8-14-feature-console-in-process-actions.md | 39 ++ .../tui/console/actions/pendingAction.test.ts | 93 ++++ .../tui/console/actions/runAction.test.ts | 227 +++++---- packages/cli/src/commands/agent.ts | 325 +------------ packages/cli/src/commands/channel.ts | 98 +--- .../cli/src/services/actions/action-result.ts | 12 + .../services/agent/agent-action.service.ts | 441 ++++++++++++++++++ .../channel/channel-action.service.ts | 153 ++++++ packages/cli/src/tui/console/ConsoleApp.tsx | 24 +- .../src/tui/console/actions/pendingAction.ts | 78 ++++ .../cli/src/tui/console/actions/runAction.ts | 113 +++-- .../tui/console/hooks/useChannelActions.ts | 16 +- .../tui/console/hooks/useKillAgentAction.ts | 11 +- .../tui/console/hooks/useRenameAgentPane.ts | 11 +- .../tui/console/hooks/useStartAgentPane.ts | 10 +- 19 files changed, 1270 insertions(+), 554 deletions(-) create mode 100644 docs/ai/design/2026-08-14-feature-console-in-process-actions.md create mode 100644 docs/ai/implementation/2026-08-14-feature-console-in-process-actions.md create mode 100644 docs/ai/planning/2026-08-14-feature-console-in-process-actions.md create mode 100644 docs/ai/requirements/2026-08-14-feature-console-in-process-actions.md create mode 100644 docs/ai/testing/2026-08-14-feature-console-in-process-actions.md create mode 100644 packages/cli/src/__tests__/tui/console/actions/pendingAction.test.ts create mode 100644 packages/cli/src/services/actions/action-result.ts create mode 100644 packages/cli/src/services/agent/agent-action.service.ts create mode 100644 packages/cli/src/services/channel/channel-action.service.ts create mode 100644 packages/cli/src/tui/console/actions/pendingAction.ts 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..20a70631 --- /dev/null +++ b/docs/ai/design/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,66 @@ +--- +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] --> Services[Agent and channel action services] + Console[Console hooks and shell] --> Runner[In-process console action runner] + Runner --> Services + Services --> AgentManager[Agent manager / terminal / tmux / registry] + Services --> Channel[ConfigStore / ChannelService] + 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 own validation and orchestration shared with the TUI. The console runner dispatches the `ConsoleAction` union directly to those services and translates their structured result to the existing `ActionResult` contract. + +## Service Contract + +```ts +interface ApplicationActionResult { + ok: boolean; + message?: string; + cliExitCode?: number; +} +``` + +Services accept explicit dependencies (manager, focus manager, registry, tmux manager, config store, channel service, daemon launch resolver, reporter, and optional interactive selector). Defaults construct the same production dependencies currently constructed in command handlers. Tests inject boundary doubles. + +`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. +- 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. + +## 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: move the existing resolver intact into the shared channel action module. 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..59bd95be --- /dev/null +++ b/docs/ai/implementation/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,38 @@ +--- +phase: implementation +title: Console In-Process Actions Implementation +description: Implementation log for shared action services +--- + +# Console In-Process Actions Implementation + +## Status + +Implementation and local validation complete; publication pending. + +## Intended Changes + +- `services/agent/agent-action.service.ts` centralizes start/open/send/kill/rename orchestration and constructs the existing manager, terminal, tmux, registry, print-agent, and group dependencies by default. +- `services/channel/channel-action.service.ts` centralizes foreground/daemon channel start and channel stop while preserving the dedicated daemon launch. +- 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, wait, and foreground-channel orchestration also moved into the services, making Commander thinner than the minimum design while preserving their existing tests and output. +- 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. + +## 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` — 80 files, 967 tests passed. +- 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, 197 files compiled. +- 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. 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..79de8992 --- /dev/null +++ b/docs/ai/planning/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,26 @@ +--- +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. +- [ ] Review diff, commit conventionally, rebase on `origin/main`, revalidate, push, and open a PR to `main`. + +## 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..667c3025 --- /dev/null +++ b/docs/ai/testing/2026-08-14-feature-console-in-process-actions.md @@ -0,0 +1,39 @@ +--- +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. +- `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: 80 files, 967 tests passed, exit 0. +- CLI lint: exit 0 with five pre-existing warnings, zero errors. +- CLI build: exit 0; SWC compiled 197 files and declaration generation completed. +- Feature-doc lint: exit 0; all required feature documents and worktree checks passed. 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..b8209588 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,131 @@ -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('../../../../services/agent/agent-action.service.js', () => ({ + createAgentActionService: mockCreateAgentActionService, +})); +vi.mock('../../../../services/channel/channel-action.service.js', () => ({ + 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', daemon: true }, + }, + { + 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..bef6f70c 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,24 +5,10 @@ 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, @@ -31,7 +16,6 @@ import { } 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 +23,13 @@ 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'; + createAgentActionService, + createAgentManager, +} from '../services/agent/agent-action.service.js'; +import type { ApplicationActionResult } from '../services/actions/action-result.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 createAgentActionService().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 createAgentActionService().open({ agentName: name, debug: options.debug }); + 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 createAgentActionService().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 createAgentActionService().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 createAgentActionService().rename({ currentName, newName }); + applyActionExit(result); })); agentCommand diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 5de4c033..0f73eee0 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'; @@ -19,31 +17,9 @@ import { createLogger, enableDebug } from '../util/debug.js'; import { getErrorMessage } from '../util/text.js'; import { confirm, password } from '@inquirer/prompts'; import { ChannelService } from '../services/channel/channel.service.js'; -import { runChannelBridge } from '../services/channel/channel-runner.js'; +import { createChannelActionService } from '../services/channel/channel-action.service.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 createChannelActionService({ 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 createChannelActionService({ channelService }).stop({ channelName: name }); })); channelCommand diff --git a/packages/cli/src/services/actions/action-result.ts b/packages/cli/src/services/actions/action-result.ts new file mode 100644 index 00000000..8bd995e0 --- /dev/null +++ b/packages/cli/src/services/actions/action-result.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/cli/src/services/agent/agent-action.service.ts b/packages/cli/src/services/agent/agent-action.service.ts new file mode 100644 index 00000000..ddc209e6 --- /dev/null +++ b/packages/cli/src/services/agent/agent-action.service.ts @@ -0,0 +1,441 @@ +import fs from 'fs'; +import os from 'os'; +import { + AGENTS, + AgentManager, + AgentRegistry, + AgentStatus, + ClaudeCodeAdapter, + ClaudePrintAgentService, + CodexAdapter, + CopilotAdapter, + GeminiCliAdapter, + GrokCliAdapter, + OpenCodeAdapter, + PiAdapter, + PrintAgentStore, + RenameConflictError, + RenameNotFoundError, + TerminalFocusManager, + TmuxManager, + type AgentInfo, + type StartableAgentType, +} from '@ai-devkit/agent-manager'; +import { select } from '@inquirer/prompts'; +import { enableDebug, createLogger } from '../../util/debug.js'; +import { ui } from '../../util/terminal-ui.js'; +import { + AgentNameInUseError, + AgentPidPollTimeoutError, + TmuxUnavailableError, + assertSendTargetOptions, + killAgent, + sendToAgent, + sendToAgentGroup, + startAgent, + type SendReporter, +} from './agent.service.js'; +import { + AgentGroupNotFoundError, + createDefaultAgentGroupService, +} from './agent-group.service.js'; +import { actionFailed, actionSucceeded, type ApplicationActionResult } from '../actions/action-result.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; +type GroupServiceLike = ReturnType; + +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: Parameters>[0]) => Promise; + writeWaitStatus?: (message: string) => void; + writeProviderOutput?: (message: string) => void; + writeJson?: (value: object) => void; +} + +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, + }; +} + +export function createAgentActionService( + dependencies: AgentActionServiceDependencies = {}, +): AgentActionService { + const reporter = dependencies.reporter ?? ui; + 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 ?? createDefaultAgentGroupService(); + const createFocusManager = dependencies.createFocusManager + ?? ((logger?: (message: string) => void) => new TerminalFocusManager(logger)); + const selectAgent = dependencies.selectAgent ?? ((options) => select(options)); + 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))); + + return { + async start(input) { + if (input.debug) enableDebug(); + 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 startAgent( + { 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) { + 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 terminalLogger = input.debug ? createLogger('terminal') : undefined; + if (input.debug) enableDebug(); + const focusManager = createFocusManager( + terminalLogger ? (message: string) => terminalLogger(message) : undefined, + ); + 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}":`); + 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) { + const group = groupService.get(input.groupName); + if (!group) throw new AgentGroupNotFoundError(input.groupName); + await sendToAgentGroup({ group, prompt: input.message, manager, focusManager }); + 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 sendToAgent({ + 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 killAgent(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) { + message = error.message; + } else if (error instanceof RenameConflictError) { + message = `Agent "${error.agentName}" is already in use. Choose a different name.`; + } else { + throw error; + } + reporter.error(message); + return actionFailed(message, 1); + } + }, + }; +} diff --git a/packages/cli/src/services/channel/channel-action.service.ts b/packages/cli/src/services/channel/channel-action.service.ts new file mode 100644 index 00000000..f5c5339d --- /dev/null +++ b/packages/cli/src/services/channel/channel-action.service.ts @@ -0,0 +1,153 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { ConfigStore } from '@ai-devkit/channel-connector'; +import { createLogger, enableDebug } from '../../util/debug.js'; +import { ui } from '../../util/terminal-ui.js'; +import { actionFailed, actionSucceeded, type ApplicationActionResult } from '../actions/action-result.js'; +import { ChannelService } from './channel.service.js'; +import { runChannelBridge } from './channel-runner.js'; + +const debug = createLogger('channel'); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export interface ChannelActionReporter { + info(message: string): void; + success(message: string): void; + error(message: string): void; +} +type ConfigStoreLike = Pick; +type ChannelServiceLike = Pick< + ChannelService, + 'resolveStartChannelName' | 'getLiveBridgeByChannel' | 'startDaemonBridge' | 'stopBridge' +>; + +export interface StartChannelActionInput { + channelName?: string; + agentName: string; + daemon: boolean; + debug?: boolean; +} + +export interface StopChannelActionInput { + channelName?: string; +} + +export interface ChannelActionService { + start(input: StartChannelActionInput): Promise; + stop(input: StopChannelActionInput): Promise; +} + +interface DaemonLaunch { + command: string; + args: string[]; +} + +export interface ChannelActionServiceDependencies { + configStore?: ConfigStoreLike; + channelService?: ChannelServiceLike; + reporter?: ChannelActionReporter; + resolveDaemonLaunch?: () => DaemonLaunch; + runBridge?: typeof runChannelBridge; + cwd?: () => string; +} + +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'), + ], + }; + } + return { + command: process.execPath, + args: [path.resolve(__dirname, '..', '..', 'channel-daemon.js')], + }; +} + +export function createChannelActionService( + dependencies: ChannelActionServiceDependencies = {}, +): ChannelActionService { + const configStore = dependencies.configStore ?? new ConfigStore(); + const channelService = dependencies.channelService ?? new ChannelService(); + const reporter = dependencies.reporter ?? ui; + const daemonLaunch = dependencies.resolveDaemonLaunch ?? resolveDaemonLaunch; + const runBridge = dependencies.runBridge ?? runChannelBridge; + const cwd = dependencies.cwd ?? (() => process.cwd()); + + return { + async start(input) { + if (input.debug) enableDebug(); + 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}".`; + reporter.error(message); + const availableChannels = Object.keys(config.channels); + if (availableChannels.length > 0) { + reporter.info(`Available channels: ${availableChannels.join(', ')}`); + } + return actionFailed(message); + } + + if (input.daemon) { + const launch = daemonLaunch(); + const args = [ + ...launch.args, + '--channel', + channelName, + '--agent', + input.agentName, + ]; + if (input.debug) args.push('--debug'); + const bridge = await channelService.startDaemonBridge({ + channelName, + channelType: channelEntry.type, + agentName: input.agentName, + command: launch.command, + args, + cwd: 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 actionSucceeded(); + } + + if (runningBridge) { + const message = `Channel "${channelName}" bridge is already running (PID: ${runningBridge.bridgePid}).`; + reporter.error(message); + return actionFailed(message); + } + + await runBridge({ + channelName, + agentName: input.agentName, + configStore: configStore as ConfigStore, + channelService: channelService as ChannelService, + }); + return actionSucceeded(); + }, + + async stop(input) { + const result = await channelService.stopBridge(input.channelName); + if (!result.stopped || !result.bridge) { + const message = 'No running channel bridge found.'; + reporter.info(message); + return actionFailed(message); + } + reporter.success(`Channel bridge stopped: ${result.bridge.channelName} (PID: ${result.bridge.bridgePid}).`); + return actionSucceeded(); + }, + }; +} 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..32e83976 100644 --- a/packages/cli/src/tui/console/actions/runAction.ts +++ b/packages/cli/src/tui/console/actions/runAction.ts @@ -1,4 +1,12 @@ -import { spawn } from 'child_process'; +import { + createAgentActionService, + type AgentActionReporter, +} from '../../../services/agent/agent-action.service.js'; +import { + createChannelActionService, + type ChannelActionReporter, +} from '../../../services/channel/channel-action.service.js'; +import type { ApplicationActionResult } from '../../../services/actions/action-result.js'; import type { ConsoleAction } from './types.js'; export interface ActionResult { @@ -6,40 +14,93 @@ 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; daemon: true }): 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.start({ + channelName, + agentName, + daemon: true, + }), + 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, + daemon: true, + })); 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, From b0847175dc2adbeab0bd99301eae85dd383c4e33 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 14:56:28 +0200 Subject: [PATCH 2/4] docs(cli): refresh console action validation --- .../2026-08-14-feature-console-in-process-actions.md | 4 ++-- .../testing/2026-08-14-feature-console-in-process-actions.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 59bd95be..cd572187 100644 --- 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 @@ -31,8 +31,8 @@ Implementation and local validation complete; publication pending. - 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` — 80 files, 967 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, 197 files compiled. +- 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. 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 index 667c3025..b5fe0acb 100644 --- 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 @@ -33,7 +33,7 @@ description: TDD coverage and validation evidence - 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: 80 files, 967 tests passed, exit 0. +- 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 197 files and declaration generation completed. +- 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. From b2556e9ea9502272f00766e98a18f446a86127ed Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 14:58:48 +0200 Subject: [PATCH 3/4] docs(cli): record console actions PR --- .../2026-08-14-feature-console-in-process-actions.md | 2 +- .../planning/2026-08-14-feature-console-in-process-actions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index cd572187..696389b2 100644 --- 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 @@ -8,7 +8,7 @@ description: Implementation log for shared action services ## Status -Implementation and local validation complete; publication pending. +Implementation, validation, and publication for review are complete. PR: https://github.com/codeaholicguy/ai-devkit/pull/160 ## Intended Changes 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 index 79de8992..4796e42c 100644 --- 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 @@ -17,7 +17,7 @@ description: TDD task queue for shared console action services - [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. -- [ ] Review diff, commit conventionally, rebase on `origin/main`, revalidate, push, and open a PR to `main`. +- [x] Review diff, commit conventionally, rebase on `origin/main`, revalidate, push, and open a PR to `main`. ## Scope Decision From 5319b33d41c40ad379ee4dd77eb0582dc54b0ec8 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 15:36:27 +0200 Subject: [PATCH 4/4] refactor(cli): move action services to owning packages --- ...8-14-feature-console-in-process-actions.md | 18 +- ...8-14-feature-console-in-process-actions.md | 8 +- ...8-14-feature-console-in-process-actions.md | 9 + ...8-14-feature-console-in-process-actions.md | 4 + .../services/AgentActionService.test.ts | 32 + packages/agent-manager/src/index.ts | 43 ++ .../src/services/ActionResult.ts} | 0 .../src/services/AgentActionService.ts} | 116 +-- .../src/services/AgentService.ts | 700 ++++++++++++++++++ .../services/ChannelActionService.test.ts | 43 ++ packages/channel-connector/src/index.ts | 17 + .../src/services/ChannelActionService.ts | 112 +++ .../src/services/ChannelService.ts | 215 ++++++ .../cli/src/__tests__/commands/agent.test.ts | 5 +- .../src/__tests__/commands/channel.test.ts | 5 +- .../tui/console/actions/runAction.test.ts | 8 +- packages/cli/src/commands/agent.ts | 18 +- packages/cli/src/commands/channel.ts | 6 +- .../cli/src/services/agent/agent.service.ts | 684 +---------------- .../agent/cli-agent-action-service.ts | 55 ++ .../channel/channel-action.service.ts | 153 ---- .../src/services/channel/channel.service.ts | 221 +----- .../channel/cli-channel-action-service.ts | 73 ++ .../cli/src/services/channel/daemon-launch.ts | 26 + .../cli/src/tui/console/actions/runAction.ts | 14 +- 25 files changed, 1448 insertions(+), 1137 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/services/AgentActionService.test.ts rename packages/{cli/src/services/actions/action-result.ts => agent-manager/src/services/ActionResult.ts} (100%) rename packages/{cli/src/services/agent/agent-action.service.ts => agent-manager/src/services/AgentActionService.ts} (81%) create mode 100644 packages/agent-manager/src/services/AgentService.ts create mode 100644 packages/channel-connector/src/__tests__/services/ChannelActionService.test.ts create mode 100644 packages/channel-connector/src/services/ChannelActionService.ts create mode 100644 packages/channel-connector/src/services/ChannelService.ts create mode 100644 packages/cli/src/services/agent/cli-agent-action-service.ts delete mode 100644 packages/cli/src/services/channel/channel-action.service.ts create mode 100644 packages/cli/src/services/channel/cli-channel-action-service.ts create mode 100644 packages/cli/src/services/channel/daemon-launch.ts 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 index 20a70631..0d78fe2d 100644 --- 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 @@ -10,17 +10,19 @@ description: Shared application-service boundary for Commander and the agent con ```mermaid flowchart LR - Commander[Commander handlers] --> Services[Agent and channel action services] + 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 --> Services - Services --> AgentManager[Agent manager / terminal / tmux / registry] - Services --> Channel[ConfigStore / ChannelService] + 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 own validation and orchestration shared with the TUI. The console runner dispatches the `ConsoleAction` union directly to those services and translates their structured result to the existing `ActionResult` contract. +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 @@ -32,7 +34,7 @@ interface ApplicationActionResult { } ``` -Services accept explicit dependencies (manager, focus manager, registry, tmux manager, config store, channel service, daemon launch resolver, reporter, and optional interactive selector). Defaults construct the same production dependencies currently constructed in command handlers. Tests inject boundary doubles. +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. @@ -45,6 +47,7 @@ Tests assert the notification and pending snapshot immediately after submission ## 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. @@ -57,10 +60,11 @@ Tests assert the notification and pending snapshot immediately after submission - 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: move the existing resolver intact into the shared channel action module. +- 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 index 696389b2..3e93c484 100644 --- 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 @@ -12,8 +12,8 @@ Implementation, validation, and publication for review are complete. PR: https:/ ## Intended Changes -- `services/agent/agent-action.service.ts` centralizes start/open/send/kill/rename orchestration and constructs the existing manager, terminal, tmux, registry, print-agent, and group dependencies by default. -- `services/channel/channel-action.service.ts` centralizes foreground/daemon channel start and channel stop while preserving the dedicated daemon launch. +- `@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. @@ -22,9 +22,10 @@ Implementation, validation, and publication for review are complete. PR: https:/ ## Decisions and Deviations - All seven actions fit coherently in the shared boundary; no action migration was deferred. -- Group, print, wait, and foreground-channel orchestration also moved into the services, making Commander thinner than the minimum design while preserving their existing tests and output. +- 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 @@ -36,3 +37,4 @@ Implementation, validation, and publication for review are complete. PR: https:/ - 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 index 4796e42c..741b3da7 100644 --- 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 @@ -19,6 +19,15 @@ description: TDD task queue for shared console action services - [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. 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 index b5fe0acb..d11b7bcb 100644 --- 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 @@ -22,6 +22,7 @@ description: TDD coverage and validation evidence ## 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` @@ -37,3 +38,6 @@ description: TDD coverage and validation evidence - 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/cli/src/services/actions/action-result.ts b/packages/agent-manager/src/services/ActionResult.ts similarity index 100% rename from packages/cli/src/services/actions/action-result.ts rename to packages/agent-manager/src/services/ActionResult.ts diff --git a/packages/cli/src/services/agent/agent-action.service.ts b/packages/agent-manager/src/services/AgentActionService.ts similarity index 81% rename from packages/cli/src/services/agent/agent-action.service.ts rename to packages/agent-manager/src/services/AgentActionService.ts index ddc209e6..d284e461 100644 --- a/packages/cli/src/services/agent/agent-action.service.ts +++ b/packages/agent-manager/src/services/AgentActionService.ts @@ -1,29 +1,20 @@ import fs from 'fs'; import os from 'os'; -import { - AGENTS, - AgentManager, - AgentRegistry, - AgentStatus, - ClaudeCodeAdapter, - ClaudePrintAgentService, - CodexAdapter, - CopilotAdapter, - GeminiCliAdapter, - GrokCliAdapter, - OpenCodeAdapter, - PiAdapter, - PrintAgentStore, - RenameConflictError, - RenameNotFoundError, - TerminalFocusManager, - TmuxManager, - type AgentInfo, - type StartableAgentType, -} from '@ai-devkit/agent-manager'; -import { select } from '@inquirer/prompts'; -import { enableDebug, createLogger } from '../../util/debug.js'; -import { ui } from '../../util/terminal-ui.js'; +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, @@ -34,12 +25,8 @@ import { sendToAgentGroup, startAgent, type SendReporter, -} from './agent.service.js'; -import { - AgentGroupNotFoundError, - createDefaultAgentGroupService, -} from './agent-group.service.js'; -import { actionFailed, actionSucceeded, type ApplicationActionResult } from '../actions/action-result.js'; +} 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 @@ -62,7 +49,14 @@ type FocusManagerLike = Pick; type TmuxLike = TmuxManager; type PrintServiceLike = Pick; -type GroupServiceLike = ReturnType; +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; @@ -111,10 +105,14 @@ export interface AgentActionServiceDependencies { printService?: PrintServiceLike; groupService?: GroupServiceLike; reporter?: AgentActionReporter; - selectAgent?: (options: Parameters>[0]) => Promise; + 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 { @@ -176,27 +174,42 @@ function createTrackedSendReporter( }; } +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 ?? ui; + 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 ?? createDefaultAgentGroupService(); + const groupService = dependencies.groupService; const createFocusManager = dependencies.createFocusManager ?? ((logger?: (message: string) => void) => new TerminalFocusManager(logger)); - const selectAgent = dependencies.selectAgent ?? ((options) => select(options)); + 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.debug) enableDebug(); if (!(input.agentType in AGENTS)) { const message = `Unsupported agent type "${input.agentType}". Supported: ${Object.keys(AGENTS).join(', ')}.`; reporter.error(message); @@ -229,7 +242,7 @@ export function createAgentActionService( return actionSucceeded(); } - const entry = await startAgent( + 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) }, ); @@ -239,7 +252,7 @@ export function createAgentActionService( return actionSucceeded(); } catch (error) { let message: string; - if (error instanceof TmuxUnavailableError) { + 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.`; @@ -255,11 +268,7 @@ export function createAgentActionService( }, async open(input) { - const terminalLogger = input.debug ? createLogger('terminal') : undefined; - if (input.debug) enableDebug(); - const focusManager = createFocusManager( - terminalLogger ? (message: string) => terminalLogger(message) : undefined, - ); + const focusManager = createFocusManager(); const agents = await manager.listAgents(); if (agents.length === 0) { const message = 'No running agents found.'; @@ -279,6 +288,11 @@ export function createAgentActionService( 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) => ({ @@ -324,9 +338,10 @@ export function createAgentActionService( 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 AgentGroupNotFoundError(input.groupName); - await sendToAgentGroup({ group, prompt: input.message, manager, focusManager }); + 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(); @@ -364,7 +379,7 @@ export function createAgentActionService( } const tracked = createTrackedSendReporter(reporter); - await sendToAgent({ + await sendToAgentOperation({ id: input.agentName!, prompt: input.message, manager, @@ -403,7 +418,7 @@ export function createAgentActionService( return actionFailed(message); } - const result = await killAgent(resolved, { tmux, registry: registry as AgentRegistry }); + 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(); @@ -426,10 +441,11 @@ export function createAgentActionService( return actionSucceeded(); } catch (error) { let message: string; - if (error instanceof RenameNotFoundError) { + if (error instanceof RenameNotFoundError || (error instanceof Error && error.name === 'RenameNotFoundError')) { message = error.message; - } else if (error instanceof RenameConflictError) { - message = `Agent "${error.agentName}" is already in use. Choose a different name.`; + } 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; } 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/runAction.test.ts b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts index b8209588..d73bfc5e 100644 --- a/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts +++ b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts @@ -5,10 +5,12 @@ const { mockCreateAgentActionService, mockCreateChannelActionService } = vi.hois mockCreateChannelActionService: vi.fn(() => ({})), })); -vi.mock('../../../../services/agent/agent-action.service.js', () => ({ +vi.mock('@ai-devkit/agent-manager', async (importOriginal) => ({ + ...await importOriginal(), createAgentActionService: mockCreateAgentActionService, })); -vi.mock('../../../../services/channel/channel-action.service.js', () => ({ +vi.mock('@ai-devkit/channel-connector', async (importOriginal) => ({ + ...await importOriginal(), createChannelActionService: mockCreateChannelActionService, })); @@ -67,7 +69,7 @@ describe('runAction', () => { { action: { type: 'channel-start', channelName: 'work', agentName: 'jarvis' } as const, method: 'startChannel' as const, - input: { channelName: 'work', agentName: 'jarvis', daemon: true }, + input: { channelName: 'work', agentName: 'jarvis' }, }, { action: { type: 'channel-stop', channelName: 'work' } as const, diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index bef6f70c..b1864799 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -13,6 +13,7 @@ import { 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'; @@ -23,10 +24,9 @@ import { toJsonSession, } from '../util/sessions.js'; import { - createAgentActionService, - createAgentManager, -} from '../services/agent/agent-action.service.js'; -import type { ApplicationActionResult } from '../services/actions/action-result.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'; @@ -214,7 +214,7 @@ export function registerAgentCommand(program: Command): void { const mode = options.mode as string; const cwd = path.resolve(options.cwd ?? process.cwd()); const agentName = (options.name as string | undefined) ?? generateAgentName(cwd); - const result = await createAgentActionService().start({ + const result = await createCliAgentActionService(Boolean(options.debug)).start({ agentType, mode, name: agentName, @@ -422,7 +422,7 @@ 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 result = await createAgentActionService().open({ agentName: name, debug: options.debug }); + const result = await createCliAgentActionService(Boolean(options.debug)).open({ agentName: name }); applyActionExit(result); })); @@ -437,7 +437,7 @@ export function registerAgentCommand(program: Command): void { .option('-j, --json', 'Output wait result as JSON') .action(withErrorHandler('send message', async (message, options) => { const prompt = await resolveSendMessage(message, options); - const result = await createAgentActionService().send({ + const result = await createCliAgentActionService().send({ agentName: options.id, groupName: options.group, message: prompt, @@ -452,7 +452,7 @@ export function registerAgentCommand(program: Command): void { .command('kill ') .description('Stop a running agent and clean up its managed tmux session') .action(withErrorHandler('kill agent', async (name: string) => { - const result = await createAgentActionService().kill({ agentName: name }); + const result = await createCliAgentActionService().kill({ agentName: name }); applyActionExit(result); })); @@ -565,7 +565,7 @@ export function registerAgentCommand(program: Command): void { .command('rename ') .description('Rename an agent in the registry') .action(withErrorHandler('rename agent', async (currentName: string, newName: string) => { - const result = await createAgentActionService().rename({ currentName, newName }); + const result = await createCliAgentActionService().rename({ currentName, newName }); applyActionExit(result); })); diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 0f73eee0..46427657 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -16,8 +16,8 @@ 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 { createChannelActionService } from '../services/channel/channel-action.service.js'; const debug = createLogger('channel'); function redactSecrets(message: string, secrets: string[]): string { @@ -214,7 +214,7 @@ 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) => { - await createChannelActionService({ channelService }).start({ + await createCliChannelActionService(channelService).start({ channelName: name, agentName: options.agent, daemon: Boolean(options.daemon), @@ -226,7 +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) => { - await createChannelActionService({ channelService }).stop({ channelName: name }); + 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-action.service.ts b/packages/cli/src/services/channel/channel-action.service.ts deleted file mode 100644 index f5c5339d..00000000 --- a/packages/cli/src/services/channel/channel-action.service.ts +++ /dev/null @@ -1,153 +0,0 @@ -import path from 'path'; -import { fileURLToPath } from 'url'; -import { ConfigStore } from '@ai-devkit/channel-connector'; -import { createLogger, enableDebug } from '../../util/debug.js'; -import { ui } from '../../util/terminal-ui.js'; -import { actionFailed, actionSucceeded, type ApplicationActionResult } from '../actions/action-result.js'; -import { ChannelService } from './channel.service.js'; -import { runChannelBridge } from './channel-runner.js'; - -const debug = createLogger('channel'); -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -export interface ChannelActionReporter { - info(message: string): void; - success(message: string): void; - error(message: string): void; -} -type ConfigStoreLike = Pick; -type ChannelServiceLike = Pick< - ChannelService, - 'resolveStartChannelName' | 'getLiveBridgeByChannel' | 'startDaemonBridge' | 'stopBridge' ->; - -export interface StartChannelActionInput { - channelName?: string; - agentName: string; - daemon: boolean; - debug?: boolean; -} - -export interface StopChannelActionInput { - channelName?: string; -} - -export interface ChannelActionService { - start(input: StartChannelActionInput): Promise; - stop(input: StopChannelActionInput): Promise; -} - -interface DaemonLaunch { - command: string; - args: string[]; -} - -export interface ChannelActionServiceDependencies { - configStore?: ConfigStoreLike; - channelService?: ChannelServiceLike; - reporter?: ChannelActionReporter; - resolveDaemonLaunch?: () => DaemonLaunch; - runBridge?: typeof runChannelBridge; - cwd?: () => string; -} - -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'), - ], - }; - } - return { - command: process.execPath, - args: [path.resolve(__dirname, '..', '..', 'channel-daemon.js')], - }; -} - -export function createChannelActionService( - dependencies: ChannelActionServiceDependencies = {}, -): ChannelActionService { - const configStore = dependencies.configStore ?? new ConfigStore(); - const channelService = dependencies.channelService ?? new ChannelService(); - const reporter = dependencies.reporter ?? ui; - const daemonLaunch = dependencies.resolveDaemonLaunch ?? resolveDaemonLaunch; - const runBridge = dependencies.runBridge ?? runChannelBridge; - const cwd = dependencies.cwd ?? (() => process.cwd()); - - return { - async start(input) { - if (input.debug) enableDebug(); - 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}".`; - reporter.error(message); - const availableChannels = Object.keys(config.channels); - if (availableChannels.length > 0) { - reporter.info(`Available channels: ${availableChannels.join(', ')}`); - } - return actionFailed(message); - } - - if (input.daemon) { - const launch = daemonLaunch(); - const args = [ - ...launch.args, - '--channel', - channelName, - '--agent', - input.agentName, - ]; - if (input.debug) args.push('--debug'); - const bridge = await channelService.startDaemonBridge({ - channelName, - channelType: channelEntry.type, - agentName: input.agentName, - command: launch.command, - args, - cwd: 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 actionSucceeded(); - } - - if (runningBridge) { - const message = `Channel "${channelName}" bridge is already running (PID: ${runningBridge.bridgePid}).`; - reporter.error(message); - return actionFailed(message); - } - - await runBridge({ - channelName, - agentName: input.agentName, - configStore: configStore as ConfigStore, - channelService: channelService as ChannelService, - }); - return actionSucceeded(); - }, - - async stop(input) { - const result = await channelService.stopBridge(input.channelName); - if (!result.stopped || !result.bridge) { - const message = 'No running channel bridge found.'; - reporter.info(message); - return actionFailed(message); - } - reporter.success(`Channel bridge stopped: ${result.bridge.channelName} (PID: ${result.bridge.bridgePid}).`); - return actionSucceeded(); - }, - }; -} 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/actions/runAction.ts b/packages/cli/src/tui/console/actions/runAction.ts index 32e83976..0bf1f7bd 100644 --- a/packages/cli/src/tui/console/actions/runAction.ts +++ b/packages/cli/src/tui/console/actions/runAction.ts @@ -1,12 +1,13 @@ import { createAgentActionService, type AgentActionReporter, -} from '../../../services/agent/agent-action.service.js'; + type ApplicationActionResult, +} from '@ai-devkit/agent-manager'; import { createChannelActionService, type ChannelActionReporter, -} from '../../../services/channel/channel-action.service.js'; -import type { ApplicationActionResult } from '../../../services/actions/action-result.js'; +} from '@ai-devkit/channel-connector'; +import { resolveDaemonLaunch } from '../../../services/channel/daemon-launch.js'; import type { ConsoleAction } from './types.js'; export interface ActionResult { @@ -23,7 +24,7 @@ export interface ConsoleActionServices { start(input: StartConsoleActionInput): Promise; kill(input: { agentName: string }): Promise; rename(input: { currentName: string; newName: string }): Promise; - startChannel(input: { channelName: string; agentName: string; daemon: true }): Promise; + startChannel(input: { channelName: string; agentName: string }): Promise; stopChannel(input: { channelName: string }): Promise; } @@ -50,10 +51,10 @@ function createDefaultConsoleActionServices(): ConsoleActionServices { }), kill: ({ agentName }) => agent.kill({ agentName }), rename: ({ currentName, newName }) => agent.rename({ currentName, newName }), - startChannel: ({ channelName, agentName }) => channel.start({ + startChannel: ({ channelName, agentName }) => channel.startDaemon({ channelName, agentName, - daemon: true, + launch: resolveDaemonLaunch(), }), stopChannel: ({ channelName }) => channel.stop({ channelName }), }; @@ -92,7 +93,6 @@ export async function runAction( return toActionResult(await services.startChannel({ channelName: action.channelName, agentName: action.agentName, - daemon: true, })); case 'channel-stop': return toActionResult(await services.stopChannel({ channelName: action.channelName }));