diff --git a/docs/ai/design/2026-09-10-feature-herdr-runtime-integration.md b/docs/ai/design/2026-09-10-feature-herdr-runtime-integration.md new file mode 100644 index 00000000..1a3d2b3a --- /dev/null +++ b/docs/ai/design/2026-09-10-feature-herdr-runtime-integration.md @@ -0,0 +1,169 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture + +## Architecture Overview + +Add a small runtime dispatch boundary around managed interactive agent operations. Keep agent adapters responsible for understanding agent tools such as Codex, Claude, Pi, Gemini, and Copilot. Keep runtime implementations responsible for where interactive processes live and how terminal IO/focus works. + +```mermaid +graph TD + CLI[ai-devkit agent commands] --> Config[Runtime config resolver] + Config --> RuntimeFactory[Agent runtime factory] + RuntimeFactory --> TmuxRuntime[Tmux agent runtime] + RuntimeFactory --> HerdrRuntime[Herdr agent runtime] + CLI --> AgentService[agent.service managed operations] + AgentService --> RuntimeFactory + AgentService --> Registry[(agents registry DB)] + AgentService --> Adapters[Agent adapters] + TmuxRuntime --> Tmux[tmux CLI] + HerdrRuntime --> Herdr[Herdr CLI/API] + Adapters --> SessionFiles[Agent session files] + AgentService --> Channels[Channels and groups] +``` + +Responsibilities: + +- Global config resolver: parse `~/.ai-devkit/.ai-devkit.json` runtime config once near command entry. +- Runtime factory: return a Herdr runtime only when the global provider is `herdr`; otherwise keep the existing tmux branch. +- Runtime implementation: start, send, read/wait, focus/open, stop for a terminal backend. +- Agent registry: persist AI DevKit names, type, cwd, session metadata, runtime provider, and opaque runtime ref. +- Agent adapters: detect and parse agent-specific process/session/transcript data. +- Herdr: own sessions, panes, PTYs, process IO, focus/open behavior, and lifecycle detection. + +## Data Models + +Config: + +```json +// ~/.ai-devkit/.ai-devkit.json +{ + "agentRuntime": { + "provider": "herdr" + } +} +``` + +Type model: + +```ts +type AgentRuntimeProvider = 'tmux' | 'herdr'; + +interface RuntimeBackedRegistryFields { + runtime: AgentRuntimeProvider; + runtimeRef: unknown | null; +} +``` + +Registry migration: + +```sql +ALTER TABLE agents ADD COLUMN runtime TEXT NOT NULL DEFAULT 'tmux'; +ALTER TABLE agents ADD COLUMN runtime_ref TEXT NOT NULL DEFAULT ''; +``` + +Compatibility: + +- Treat `runtime_ref` as the canonical runtime handle for both tmux and Herdr. +- Keep the physical `tmux_session` column only as a legacy backfill/compatibility column. +- For tmux records, write `runtime_ref = { "session": "" }` and mirror that session into `tmux_session` only for old readers. +- For Herdr records, write `runtime = 'herdr'` and Herdr's opaque ref JSON. +- When `runtime` is missing on old records, read as `tmux`. +- When tmux `runtime_ref` is empty, synthesize `{ "session": tmux_session }` in memory. + +Example Herdr ref: + +```json +{ + "session": "default", + "paneId": "w1:p2", + "agentName": "reviewer" +} +``` + +## API Design + +Internal Herdr runtime interfaces: + +```ts +interface HerdrStartRuntime { + readonly provider: 'herdr'; + isAvailable(): Promise; + startAgent(input: RuntimeStartInput): Promise; +} + +interface HerdrInteractiveRuntime extends HerdrStartRuntime { + send(input: RuntimeSendInput): Promise; + wait(input: RuntimeWaitInput): Promise; + readOutput(input: RuntimeReadOutputInput): Promise; + focus(input: RuntimeFocusInput): Promise; + stop(input: RuntimeStopInput): Promise; +} + +type RuntimeAvailability = + | { ok: true; insideRuntime: boolean; currentPaneId?: string } + | { ok: false; reason: 'binary-missing' | 'backend-unreachable' | 'invalid-environment'; detail: string }; +``` + +Herdr availability rules: + +- If provider is `herdr`, require the `herdr` binary. +- Require Herdr backend/API reachability through `herdr api snapshot` or equivalent. +- Use `HERDR_ENV=1` and `HERDR_PANE_ID` only as context signals. +- Do not require the user to run `ai-devkit agent start` from inside Herdr. + +Runtime dispatch: + +- `agent start`: use configured global runtime. +- `agent send`, `agent send --wait`, focus/open, and kill: use the target registry row's stored runtime. +- Old registry rows without runtime use tmux. + +## Component Breakdown + +- `packages/cli/src/util/config.ts`: validate global `agentRuntime.provider` using provider values exported by `agent-manager`. +- `packages/cli/src/lib/GlobalConfig.ts`: read global runtime provider from `~/.ai-devkit/.ai-devkit.json`. +- `packages/cli/src/lib/Config.ts`: delegate runtime provider lookup to global config for caller compatibility. +- `packages/cli/src/services/agent/agent.service.ts`: keep managed-agent command orchestration while consuming runtime interfaces from `agent-manager`. +- `packages/agent-manager/src/terminal/TmuxManager.ts`: keep as low-level tmux helper. +- `packages/agent-manager/src/runtime/AgentRuntime.ts`: centralize runtime interfaces, Herdr runtime construction, and registry-entry runtime predicates. +- `packages/agent-manager/src/runtime/HerdrAgentRuntime.ts`: add injectable Herdr CLI client and typed parsing/errors. +- `packages/agent-manager/src/utils/AgentRegistry.ts`: persist runtime/runtimeRef with old-row defaults. +- `packages/agent-manager/src/database/migrations/005_agent_runtime.sql`: additive migration. +- Herdr CLI client: injectable exec dependency, JSON parsing, typed errors. + +## Design Decisions + +- Use `runtimeRef` in active code paths. The legacy `tmux_session` DB column remains only to backfill old rows and avoid a destructive SQLite migration in this feature branch. +- Store Herdr refs in `runtime_ref`, not a new DB. AI DevKit already owns agent registry records; a second DB would duplicate identity and lifecycle state. +- Use stored runtime for existing agents. A target agent should be operated through the backend that owns its live session, regardless of current config. +- Do not mirror Herdr details into `agent detail`. AI DevKit detail should show AI DevKit assignment/session metadata, with a compact runtime reference/debug field if needed. +- Use Herdr CLI/API initially. Socket API can replace the transport later behind the same runtime interface. +- Treat Herdr IDs as opaque. The adapter captures returned IDs from Herdr responses and never guesses or derives them. + +## Non-Functional Requirements + +Reliability: + +- Runtime availability errors should be specific and actionable. +- Malformed `runtime_ref` should not crash unrelated agent list operations; report target-specific failures for operations that need the ref. +- Existing tmux flows must keep passing tests. + +Performance: + +- CLI-based Herdr operations are acceptable for MVP. +- Runtime interface should allow a future socket/event-backed Herdr client without changing command semantics. + +Security: + +- Do not execute shell strings through a shell when calling Herdr; use argument arrays. +- Do not store secrets in `runtime_ref`. +- Treat Herdr response JSON as untrusted input and validate required fields. + +Compatibility: + +- Old records and current docs/tests should remain valid during the migration. +- User-facing messages should stop hardcoding "tmux" where the operation is now runtime-generic. diff --git a/docs/ai/implementation/2026-09-10-feature-herdr-runtime-integration.md b/docs/ai/implementation/2026-09-10-feature-herdr-runtime-integration.md new file mode 100644 index 00000000..20bb6c0c --- /dev/null +++ b/docs/ai/implementation/2026-09-10-feature-herdr-runtime-integration.md @@ -0,0 +1,135 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Development Setup + +- Work in `.worktrees/feature-herdr-runtime-integration` on branch `feature-herdr-runtime-integration`. +- Use the existing npm workspace commands and focused Vitest targets for `packages/cli` and `packages/agent-manager`. +- Herdr command contracts must be confirmed before real integration code is finalized. Until then, inject a Herdr command runner so tests can use fake JSON responses. + +## Code Structure + +- `packages/cli/src/util/config.ts`: shared runtime provider validation. +- `packages/cli/src/lib/GlobalConfig.ts`: runtime config owner and global provider lookup. +- `packages/cli/src/lib/Config.ts`: global runtime provider delegation for caller compatibility. +- `packages/cli/src/services/agent/agent.service.ts`: managed agent orchestration that consumes runtime contracts from `agent-manager`. +- `packages/cli/src/commands/agent.ts`: command-level config reading, UI rendering, and runtime factory usage. +- `packages/agent-manager/src/utils/AgentRegistry.ts`: runtime metadata persistence. +- `packages/agent-manager/src/database/migrations/005_agent_runtime.sql`: additive registry migration. +- `packages/agent-manager/src/terminal/TmuxManager.ts`: low-level tmux operations. +- `packages/agent-manager/src/runtime/AgentRuntime.ts`: runtime interfaces, Herdr runtime factory, and Herdr registry-entry predicate. +- `packages/agent-manager/src/runtime/HerdrAgentRuntime.ts`: injected Herdr CLI client used by the CLI and reusable by later console/channel code. + +## Implementation Notes + +### Core Features + +- Runtime config: + - Parse only `agentRuntime.provider` from global `~/.ai-devkit/.ai-devkit.json`. + - Ignore project-level `agentRuntime` in project install config. + - Default to `tmux` for missing config, missing `agentRuntime`, or missing provider. + - Reject unknown providers with valid values. +- Registry migration: + - Add `runtime` and `runtime_ref`. + - Keep the physical `tmux_session` column only for legacy row backfill and old-reader compatibility. + - Parse `runtime_ref` as opaque JSON for known providers. + - Preserve old rows as tmux. +- Runtime dispatch: + - Start uses configured runtime. + - Send/focus/kill use stored runtime on the target row. + - Managed start/stop/focus/send orchestration lives in `agent-manager`; CLI passes the selected runtime provider and renders results. + - Runtime contracts, factories, and runtime-entry predicates are exported from `agent-manager`; CLI does not define Herdr runtime interfaces or construct the Herdr adapter class directly. + - Herdr references are captured from Herdr responses only. +- Herdr availability: + - For `provider = herdr`, require Herdr binary availability and backend/API reachability. + - Treat `HERDR_ENV` and `HERDR_PANE_ID` as optional context signals. +- Cleanup pass: + - Removed an unused Herdr availability error reason. + - Simplified Herdr runtime ref parsing to normalize each field once. + - Trimmed duplicate runtime-provider tests from `ConfigManager`; `GlobalConfigManager` and config util tests own those behavior cases. +- Refactor pass: + - Moved runtime start/stop/focus/send orchestration from CLI agent service into `agent-manager`. + - Moved corresponding start/stop/focus/send tests into agent-manager runtime tests. + - Kept CLI responsible for option parsing, config lookup, UI messages, and send/wait output formatting. + +### Implemented Behavior + +- Global config accepts only `tmux` and `herdr`; missing config, missing `agentRuntime`, and missing provider resolve to `tmux`. +- Project `.ai-devkit.json` does not configure runtime selection. +- Existing registry rows without runtime columns are migrated/read as tmux-backed records. +- Registry entries expose `runtime` and opaque JSON `runtimeRef`; tmux rows use `{ session: "" }`. +- Old SQLite rows with only `tmux_session` synthesize `{ session: tmux_session }` when read. +- `agent start` reads global runtime config once for interactive mode and starts through tmux or Herdr accordingly. +- `agent send`, `agent send --wait`, `agent open`, and `agent kill` use stored Herdr refs when the target registry row is Herdr-backed. +- Runtime-aware start, stop, focus, and prompt delivery are centralized in `packages/agent-manager/src/runtime/ManagedAgentRuntime.ts`; `agent send --wait` reads AI DevKit session transcripts for both tmux and Herdr-backed agents. +- `agent send --wait` waits through a short transcript-flush grace period before reporting that an agent returned to waiting without assistant output. +- Durable mode is unchanged and remains separate from interactive runtime selection. + +### Patterns & Best Practices + +- Use `execFile` or injected argument-array command runners, not shell string execution. +- Validate Herdr JSON response shape before storing refs. +- Keep errors domain-specific enough for CLI messages and tests. +- Do not leak Herdr's full session model into AI DevKit detail/list data structures. +- Active call sites should consume `runtimeRef`; do not add new `tmuxSession` reads. + +## Integration Points + +- Herdr CLI/API: + - availability: `herdr --version` or equivalent plus `herdr api snapshot` or equivalent. + - start: command returns session, pane ID, agent name, and optional PID. + - send: command targets a returned pane/session ref. + - read/wait: command returns output/result where available; otherwise AI DevKit transcript polling remains the result source. + - focus/open: command targets returned pane/session ref. + - stop: command delegates lifecycle cleanup to Herdr where supported. +- Agent adapters: + - Continue parsing agent-specific session files and summaries. + - Do not become Herdr-aware unless Herdr needs adapter-specific launch metadata. + +## Error Handling + +- Unknown runtime provider: fail before side effects. +- Herdr binary missing: fail before start/send with clear installation/PATH guidance. +- Herdr backend unreachable: fail before start/send with backend/API guidance. +- Missing required Herdr IDs in response: fail and do not register the agent. +- PID unavailable: handle explicitly; do not guess. +- Stale runtime ref: fail target operation and suggest focusing/listing/restarting the agent. +- Malformed registry runtime ref: treat as target-specific data corruption, not a global list crash. + +## Performance Considerations + +- CLI process startup overhead is acceptable for MVP. +- Avoid repeated runtime config reads inside loops or per-target group sends. +- Keep the runtime boundary transport-agnostic so Herdr socket/event support can replace CLI calls later. + +## Security Notes + +- Do not pass prompts through shell interpolation. +- Do not store secrets in runtime refs or debug logs. +- Treat Herdr response JSON as untrusted. +- Preserve existing AI DevKit ownership over channel permissions, assignments, and validation evidence. + +## Validation Evidence + +- `packages/cli`: `npx vitest run src/__tests__/util/config.test.ts src/__tests__/lib/Config.test.ts src/__tests__/lib/GlobalConfig.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` +- `packages/cli`: `npx tsc --noEmit` +- `packages/agent-manager`: `npx vitest run src/__tests__/utils/AgentRegistry.test.ts src/__tests__/runtime/AgentRuntime.test.ts src/__tests__/runtime/HerdrAgentRuntime.test.ts` +- `packages/agent-manager`: `npm run typecheck` +- `packages/agent-manager`: `npm run build` +- repository: `npx ai-devkit@latest lint --feature herdr-runtime-integration` +- repository: `git diff --check` +- Refactor validation: + - `packages/agent-manager`: `npx vitest run src/__tests__/runtime/ManagedAgentRuntime.test.ts src/__tests__/runtime/AgentRuntime.test.ts src/__tests__/runtime/HerdrAgentRuntime.test.ts` + - `packages/agent-manager`: `npm run typecheck` + - `packages/agent-manager`: `npm run lint` + - `packages/agent-manager`: `npm run build` + - `packages/cli`: `npx vitest run src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts src/__tests__/services/plugin/plugin-loader.service.test.ts` + - `packages/cli`: `npx tsc --noEmit` + - `packages/cli`: `npm run lint` + - repository: `npx ai-devkit@latest lint --feature herdr-runtime-integration` + - repository: `git diff --check` diff --git a/docs/ai/planning/2026-09-10-feature-herdr-runtime-integration.md b/docs/ai/planning/2026-09-10-feature-herdr-runtime-integration.md new file mode 100644 index 00000000..4d253bbc --- /dev/null +++ b/docs/ai/planning/2026-09-10-feature-herdr-runtime-integration.md @@ -0,0 +1,68 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] Milestone 1: Runtime config and registry schema foundation. +- [x] Milestone 2: Runtime dispatch for managed start/send/focus/stop. +- [x] Milestone 3: Herdr CLI/API adapter and focused validation. + +## Task Breakdown + +### Phase 1: Foundation + +- [x] Task 1.1: Add global `agentRuntime.provider` config parsing with tmux defaults, unknown-provider validation, and project-config ignore behavior. Validation: focused config tests. +- [x] Task 1.2: Add `runtime` and `runtime_ref` columns to the `agents` registry migration path while keeping `tmux_session`. Validation: migration and registry tests. +- [x] Task 1.3: Extend `RegistryEntry` mapping/merge logic to round-trip runtime metadata and treat old records as tmux. Validation: AgentRegistry unit tests. +- [x] Task 1.4: Define runtime provider types, availability result types, runtime refs, and target operation inputs. Validation: typecheck and focused unit tests. + +### Phase 2: Core Features + +- [x] Task 2.1: Preserve current tmux behavior as the default service branch. Validation: existing agent start tests plus new runtime dispatch tests. +- [x] Task 2.2: Update `agent start` to resolve the global runtime once and call the selected runtime. Validation: CLI/service tests for tmux default and Herdr configured provider. +- [x] Task 2.3: Update send, send-and-wait, read output, focus/open, and kill/stop paths to use the target registry row's stored runtime. Validation: CLI/service tests for stored-runtime dispatch. +- [x] Task 2.4: Keep AI DevKit detail/list metadata AI DevKit-owned and add only minimal runtime metadata to registry entries. Validation: existing list/detail tests. + +### Phase 3: Integration & Polish + +- [x] Task 3.1: Implement Herdr availability using binary check plus backend/API snapshot, with `HERDR_ENV` and `HERDR_PANE_ID` as optional context. Validation: mocked Herdr client tests. +- [x] Task 3.2: Implement Herdr start/send/read-output/focus/stop command mappings with structured JSON parsing and no guessed IDs. Validation: mocked command tests. +- [x] Task 3.3: Add user-facing error messages for missing Herdr binary, unreachable backend, stale refs, malformed refs, and runtime mismatch cases. Validation: CLI tests. +- [x] Task 3.4: Update docs/user-visible command descriptions that currently hardcode tmux for runtime-generic behavior. Validation: docs lint. +- [x] Task 3.5: Run focused package tests and lint. Manual Herdr smoke testing remains deferred until a live Herdr backend is available. Validation: recorded task evidence. + +## Dependencies + +- Task 1.1 must land before command routing uses runtime config. +- Tasks 1.2 and 1.3 must land before Herdr runtime refs can be persisted. +- Task 2.1 should precede Herdr runtime implementation to prove the abstraction preserves tmux behavior. +- Herdr command mappings require exact Herdr CLI/API command and JSON response contracts. +- Send-and-wait behavior depends on whether Herdr can provide output boundaries or whether AI DevKit must keep using session transcript polling. + +## Timeline & Estimates + +- Foundation: medium, mostly schema/config/tests. +- Runtime dispatch: medium-high, touches existing command paths and tests. +- Herdr adapter: medium, but depends on command/API contract confirmation. +- Manual smoke testing: small if Herdr is installed and backend available; otherwise blocked/deferred with documented gap. + +## Risks & Mitigation + +- Risk: Registry identity currently keys on `(type, pid)`, while Herdr refs are pane/session based. Mitigation: confirm Herdr PID behavior early; if PID is optional, plan a focused identity change before Herdr start. +- Risk: Removing `tmux_session` now causes broad churn. Mitigation: keep it for MVP and migrate call sites gradually. +- Risk: Herdr CLI output format changes or is not JSON. Mitigation: require structured responses for MVP code and keep the client injected/testable. +- Risk: `agent send --wait` semantics diverge across runtime providers. Mitigation: use Herdr only for prompt delivery, then preserve AI DevKit transcript-based waiting for both tmux and Herdr-backed agents. +- Risk: Global runtime config conflicts with old stored rows. Mitigation: operate existing agents by stored runtime and make mismatch messaging explicit. +- Risk: Herdr may be configured from outside Herdr. Mitigation: availability checks binary and backend reachability, not only `HERDR_ENV`. + +## Resources Needed + +- Herdr CLI/API command reference or source inspection. +- Existing AI DevKit CLI and agent-manager test suites. +- Temporary SQLite fixtures for migration tests. +- A real Herdr environment for final manual smoke validation. diff --git a/docs/ai/requirements/2026-09-10-feature-herdr-runtime-integration.md b/docs/ai/requirements/2026-09-10-feature-herdr-runtime-integration.md new file mode 100644 index 00000000..6215c612 --- /dev/null +++ b/docs/ai/requirements/2026-09-10-feature-herdr-runtime-integration.md @@ -0,0 +1,108 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement + +AI DevKit currently treats managed interactive agents as tmux-backed sessions. Herdr can own richer terminal/session behavior, but AI DevKit should continue to own its existing workflow state, memory, skills, channels, assignments, validation evidence, and agent registry metadata. + +The integration should let users configure Herdr as the single active managed-agent runtime without adding per-command runtime flags or duplicating Herdr's terminal/session inventory into AI DevKit. + +Affected users: + +- AI DevKit users who want Herdr to manage terminal panes, PTYs, focus/open behavior, and agent lifecycle detection. +- Users who still expect tmux to remain the default when no runtime config exists. +- Future automation and channel users who depend on stable `agent start`, `agent send`, and `agent send --wait` semantics. + +Current workaround: + +- Use AI DevKit's tmux-backed managed agents, or manually run agents inside Herdr without AI DevKit runtime awareness. + +## Goals & Objectives + +Goals: + +- Keep tmux as the default runtime. +- Add runtime selection from global `~/.ai-devkit/.ai-devkit.json`, not project `.ai-devkit.json` and not per command. +- Support exactly `tmux` and `herdr` providers for MVP. +- Resolve missing config, missing `agentRuntime`, or missing `agentRuntime.provider` to `tmux`. +- Fail early on unknown providers with the valid values. +- Let Herdr own terminal sessions, panes, PTYs, process IO, focus/open behavior, and agent lifecycle detection. +- Let AI DevKit remain owner of task state, memory, skills, channels, assignments, validation evidence, agent names, and agent registry records. +- Store Herdr runtime references in the existing agent DB record. +- Preserve compatibility with old tmux-backed records. +- Support Herdr availability checks, managed start, prompt send, send-and-wait, output/result read, and focus/open for human intervention. + +Non-goals for MVP: + +- No `--runtime` command flag. +- No per-agent or per-command mixed runtime selection. +- No project-level runtime selection. +- No full Herdr session list/detail mirroring into AI DevKit. +- No separate Herdr agent database. +- No guessing Herdr pane/session IDs. +- No socket API dependency for MVP; CLI/API command integration is acceptable first. +- Active code should stop using `tmuxSession`; tmux session identity lives in `runtime_ref` as `{ "session": "" }`. +- No removal of the existing physical `tmux_session` column in the initial migration; keep it only for legacy row backfill and compatibility. + +## User Stories & Use Cases + +- As an AI DevKit user with no runtime config, I want managed agents to keep using tmux so existing workflows continue unchanged. +- As an AI DevKit user, I want to set `{ "agentRuntime": { "provider": "herdr" } }` once in global config so managed-agent commands use Herdr consistently. +- As a user running `ai-devkit agent start` outside Herdr, I want AI DevKit to use Herdr when configured if the Herdr binary and backend are available. +- As a user running inside Herdr, I want AI DevKit to use `HERDR_ENV` and `HERDR_PANE_ID` as context signals when available. +- As an automation user, I want `agent send --wait` to continue returning agent output/result without needing to know whether tmux or Herdr backs the session. +- As a human operator, I want AI DevKit to focus/open the Herdr agent pane when intervention is required. +- As a maintainer, I want old registry rows without runtime metadata to behave as tmux records. + +Edge cases: + +- Runtime config changes from tmux to Herdr while old tmux records still exist. +- Herdr provider is configured but the `herdr` command is missing. +- Herdr provider is configured but the Herdr backend/API is unreachable. +- `HERDR_ENV=1` exists but `HERDR_PANE_ID` is missing. +- Herdr returns no process PID, stale process PID, or a PID that later exits. +- Herdr pane/session ref is stale or malformed. +- `runtime_ref` JSON in the DB is malformed. +- A group send targets a mix of legacy tmux records and Herdr records even though MVP has one configured active backend. +- A Herdr pane is renamed independently from AI DevKit's agent name. + +## Success Criteria + +- Global config parsing accepts `agentRuntime.provider` values `tmux` and `herdr`. +- Global config parsing defaults to `tmux` when config, `agentRuntime`, or `provider` is missing. +- Global config parsing rejects unknown providers with an error that lists `tmux` and `herdr`. +- `agent start` keeps current tmux behavior by default. +- `agent start` uses Herdr when global runtime provider is `herdr`. +- Herdr start records the opaque Herdr reference returned by Herdr. +- AI DevKit never fabricates Herdr pane/session IDs. +- Existing rows without runtime metadata are read as tmux-backed records. +- `tmux_session` remains supported only as a legacy database backfill source while new runtime metadata is added. +- `agent send`, `agent send --wait`, output/read behavior, and focus/open dispatch through the selected/stored runtime. +- AI DevKit detail/list surfaces AI DevKit-owned metadata and does not attempt to mirror full Herdr session details. +- Unit tests cover config resolution, registry migration/defaults, runtime dispatch, Herdr availability failures, and Herdr reference persistence. + +## Constraints & Assumptions + +- Runtime selection is global and read from `~/.ai-devkit/.ai-devkit.json`. +- Project `.ai-devkit.json` does not configure `agentRuntime`. +- Supported MVP providers are `tmux` and `herdr`. +- Missing runtime metadata on old records means `tmux`. +- The existing `agents` table remains the registry for interactive managed agents. +- The initial schema change is additive: add `runtime` and `runtime_ref`; keep `tmux_session` as a legacy physical column. +- Herdr CLI/API should provide structured output for reliable parsing. +- Herdr availability for `runtime = herdr` requires the Herdr binary and reachable backend/API. +- `HERDR_ENV` and `HERDR_PANE_ID` are context signals, not hard requirements for starting Herdr-backed agents. +- Initial implementation can shell out to Herdr CLI/API; later implementation may use a socket API for lower latency/events. +- AI DevKit's existing adapter transcript parsing remains the source of output/result interpretation where provider session files exist. + +## Questions & Open Items + +- Confirm exact Herdr CLI/API commands and JSON response schema for start, send, send-and-wait/read-output, focus/open, stop, and snapshot. +- Decide whether Herdr start can always return a process PID. If not, update registry identity assumptions before implementation. +- Decide exact user-facing behavior when a stored agent runtime differs from current global runtime config. +- Decide whether `agent kill` for Herdr should terminate only the agent process, close the pane, or delegate fully to Herdr's stop lifecycle. diff --git a/docs/ai/testing/2026-09-10-feature-herdr-runtime-integration.md b/docs/ai/testing/2026-09-10-feature-herdr-runtime-integration.md new file mode 100644 index 00000000..893683a2 --- /dev/null +++ b/docs/ai/testing/2026-09-10-feature-herdr-runtime-integration.md @@ -0,0 +1,120 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy + +## Test Coverage Goals + +- Unit coverage for all new config resolution, registry migration/defaulting, runtime dispatch, and Herdr CLI parsing/error branches. +- Integration coverage for CLI `agent start`, `agent send`, and registry persistence behavior using mocked runtime dependencies. +- Regression coverage proving default tmux behavior is unchanged when runtime config is absent. +- Manual smoke coverage against a real Herdr install/backend once exact Herdr CLI/API commands are confirmed. + +## Unit Tests + +### Global Config Resolver + +- [x] Missing global config resolves to `tmux`. +- [x] Global config without `agentRuntime` resolves to `tmux`. +- [x] Global config with empty `agentRuntime` resolves to `tmux`. +- [x] Global config with `provider: "tmux"` resolves to tmux. +- [x] Global config with `provider: "herdr"` resolves to Herdr. +- [x] Unknown global provider errors with supported values. +- [x] Project config `agentRuntime` is ignored because runtime selection is global-only. + +### Agent Registry + +- [x] New rows can persist `runtime` and `runtime_ref`. +- [x] Old rows without runtime columns/defaults read as tmux. +- [x] Tmux rows use `runtimeRef.session` as the active runtime handle. +- [x] Legacy rows with only `tmux_session` synthesize tmux runtime refs when read. +- [x] Herdr rows round-trip opaque runtime refs without normalization. +- [x] Malformed `runtime_ref` is handled predictably. +- [x] Existing merge behavior preserves managed tmux session data. + +### Runtime Dispatch + +- [x] Runtime provider constants/types are sourced from `agent-manager`. +- [x] Herdr runtime factory and Herdr registry-entry predicate are exported by `agent-manager`. +- [x] `agent start` defaults to tmux runtime. +- [x] `agent start` uses Herdr runtime when configured. +- [x] `agent send` uses the target registry row runtime. +- [x] `agent send --wait` sends through the stored runtime and waits through the AI DevKit session transcript. +- [x] `agent send --wait` keeps polling briefly when waiting status appears before assistant transcript output. +- [x] Focus/open dispatches to Herdr for Herdr-backed records. +- [x] Kill/stop dispatches through stored runtime and preserves existing tmux behavior. +- [x] Managed start/stop/focus/send orchestration is covered in `agent-manager`. + +### Herdr Runtime Client + +- [x] Availability passes when binary exists and snapshot/API succeeds outside Herdr. +- [x] Availability includes `insideRuntime` when `HERDR_ENV=1`. +- [x] Availability includes `currentPaneId` when `HERDR_PANE_ID` is set. +- [x] Missing binary returns `binary-missing`. +- [x] Snapshot/API failure returns `backend-unreachable`. +- [x] Start parses session, pane ID, agent name, and optional PID from JSON response. +- [x] Start fails if required Herdr IDs are absent. +- [x] Send passes prompt without shell interpolation. +- [ ] Output read sanitizes terminal controls where needed. + +## Integration Tests + +- [x] CLI config fixture with no runtime keeps current `agent start` tmux dependency behavior. +- [x] Global CLI config fixture with Herdr routes managed start through Herdr runtime and stores `runtime_ref`. +- [x] Existing `agent list` and `agent detail` show AI DevKit metadata for Herdr records without requiring full Herdr session mirroring. +- [x] `agent send --wait` failure paths remain clear when a target has no session file or supported adapter. +- [x] SQLite migration applies to an old registry DB and preserves old tmux rows. + +## End-to-End Tests + +- [ ] Configure `{ "agentRuntime": { "provider": "herdr" } }` in `~/.ai-devkit/.ai-devkit.json`, start a Codex or Claude agent, send a prompt, wait for output, and focus/open the Herdr pane. +- [ ] Run the same command from outside Herdr and verify binary/API fallback detection works. +- [ ] Remove runtime config and verify tmux remains the active managed runtime. +- [ ] Attempt an unknown provider and verify the CLI exits before creating sessions. + +## Test Data + +- Temporary global `.ai-devkit.json` fixtures for config resolver tests. +- Temporary SQLite registry DBs at pre- and post-migration shapes. +- Fake Herdr executable/client responses with structured JSON. +- Existing fake agent fixtures for Codex/Claude/Pi where transcript parsing is involved. + +## Test Reporting & Coverage + +- Run focused package tests during implementation. +- Run affected CLI and agent-manager test suites before review. +- Record lifecycle evidence with task tracing after fresh successful commands. +- Document any manual Herdr smoke-test gap if Herdr command contracts are unavailable locally. +- Cleanup validation: + - `packages/agent-manager`: `npx vitest run src/__tests__/utils/AgentRegistry.test.ts src/__tests__/runtime/AgentRuntime.test.ts src/__tests__/runtime/HerdrAgentRuntime.test.ts` passed, 3 files and 61 tests. + - `packages/cli`: `npx vitest run src/__tests__/lib/Config.test.ts src/__tests__/lib/GlobalConfig.test.ts src/__tests__/util/config.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` passed, 5 files and 208 tests. + - `packages/agent-manager`: `npm run typecheck` passed. + - `packages/cli`: `npx tsc --noEmit` passed. + - `packages/agent-manager`: `npm run build` passed. + - Repository: `npx ai-devkit@latest lint --feature herdr-runtime-integration` and `git diff --check` passed. +- Refactor validation: + - `packages/agent-manager`: `npx vitest run src/__tests__/runtime/ManagedAgentRuntime.test.ts src/__tests__/runtime/AgentRuntime.test.ts src/__tests__/runtime/HerdrAgentRuntime.test.ts`. + - `packages/agent-manager`: `npm run typecheck`, `npm run lint`, and `npm run build`. + - `packages/cli`: `npx vitest run src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts src/__tests__/services/plugin/plugin-loader.service.test.ts`. + - `packages/cli`: `npx tsc --noEmit` and `npm run lint`. + - Repository: `npx ai-devkit@latest lint --feature herdr-runtime-integration` and `git diff --check`. + +## Manual Testing + +- Real Herdr runtime smoke test for start/send/wait/focus. +- Confirm Herdr focus/open behavior matches user expectations from both inside and outside Herdr. +- Review user-facing error messages for missing binary, unreachable backend, and stale pane refs. + +## Performance Testing + +- No formal load test is required for MVP. +- Compare basic CLI latency of Herdr start/send against tmux only to catch severe regressions. +- Defer event/latency optimization to the later socket API migration. + +## Bug Tracking + +- Track implementation blockers in the lifecycle planning doc and task events. +- Any discovered mismatch with Herdr CLI/API contracts should become an explicit planning task before code changes proceed. diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index 2b555288..33a37f00 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -215,7 +215,8 @@ export class AgentManager { name: existing?.name ?? agent.name, type: agent.type, pid: agent.pid, - tmuxSession: existing?.tmuxSession ?? '', + runtime: existing?.runtime ?? 'tmux', + runtimeRef: existing?.runtimeRef ?? null, cwd: agent.projectPath, startedAt: existing?.startedAt ?? new Date().toISOString(), sessionId: agent.sessionId, diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index 736cd473..8a231180 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -388,7 +388,8 @@ describe('AgentManager', () => { cwd: '/cwd/a', sessionId: 'sid-a', sessionFilePath: '/path/a.jsonl', - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, }); expect(entries[0].startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); @@ -398,7 +399,8 @@ describe('AgentManager', () => { name: 'dead', type: 'claude', pid: 999999, - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, cwd: '/cwd/dead', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 'sid-dead', @@ -420,7 +422,8 @@ describe('AgentManager', () => { name: 'merry', type: 'claude', pid: process.pid, - tmuxSession: 'merry', + runtime: 'tmux', + runtimeRef: { session: 'merry' }, cwd: '/cwd/merry', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 'sid-merry', @@ -435,16 +438,17 @@ describe('AgentManager', () => { expect(agents[0].name).toBe('merry'); expect(registry.list()[0].name).toBe('merry'); - expect(registry.list()[0].tmuxSession).toBe('merry'); + expect(registry.list()[0].runtimeRef).toEqual({ session: 'merry' }); expect(registry.list()[0].startedAt).toBe('2026-05-30T00:00:00.000Z'); }); - it('preserves custom name and tmux session across two EPERM refresh cycles', async () => { + it('preserves custom name and tmux runtime ref across two EPERM refresh cycles', async () => { registry.register({ name: 'merry', type: 'claude', pid: process.pid, - tmuxSession: 'merry-tmux', + runtime: 'tmux', + runtimeRef: { session: 'merry-tmux' }, cwd: '/cwd/merry', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 'sid-merry', @@ -464,7 +468,7 @@ describe('AgentManager', () => { expect(secondRefresh[0].name).toBe('merry'); expect(registry.lookup('merry')).toMatchObject({ name: 'merry', - tmuxSession: 'merry-tmux', + runtimeRef: { session: 'merry-tmux' }, }); }); @@ -473,7 +477,8 @@ describe('AgentManager', () => { name: 'agent-list-debug', type: 'codex', pid: process.pid, - tmuxSession: 'agent-list-debug', + runtime: 'tmux', + runtimeRef: { session: 'agent-list-debug' }, cwd: '/cwd/debug', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 'pid-debug', @@ -483,7 +488,8 @@ describe('AgentManager', () => { name: `ai-devkit-${process.pid}`, type: 'codex', pid: process.pid, - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, cwd: '/cwd/debug', startedAt: '2026-05-31T00:00:00.000Z', sessionId: 'pid-debug', @@ -501,7 +507,7 @@ describe('AgentManager', () => { expect(registry.list()[0]).toMatchObject({ name: 'agent-list-debug', pid: process.pid, - tmuxSession: 'agent-list-debug', + runtimeRef: { session: 'agent-list-debug' }, }); }); @@ -633,7 +639,8 @@ describe('AgentManager', () => { name: 'cadenced', type: 'claude', pid: process.pid, - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, cwd: '/cwd/cadenced', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 'sid-cadenced', @@ -661,7 +668,8 @@ describe('AgentManager', () => { name: 'old-claude', type: 'claude', pid: process.pid, - tmuxSession: 'old-claude', + runtime: 'tmux', + runtimeRef: { session: 'old-claude' }, cwd: '/cwd/old', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 'old-session', @@ -705,7 +713,8 @@ describe('AgentManager', () => { name: 'renamed-agent', type: 'claude', pid: process.pid, - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, cwd: '/tmp', startedAt: '2026-08-16T00:00:00.000Z', sessionId: 'session', @@ -728,7 +737,8 @@ describe('AgentManager', () => { name: 'dead', type: 'claude', pid: 999999, - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, cwd: '/tmp', startedAt: '2026-08-16T00:00:00.000Z', sessionId: 'session', @@ -747,7 +757,8 @@ describe('AgentManager', () => { name: 'readonly-agent', type: 'claude', pid: process.pid, - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, cwd: '/tmp', startedAt: '2026-08-16T00:00:00.000Z', sessionId: 'session', diff --git a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts index 250d5273..fa2d6823 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -279,7 +279,8 @@ describe('CodexAdapter', () => { name: 'codex-100', type: 'codex', pid: 100, - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, cwd: '/repo-a', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 'cached', diff --git a/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts index 3b1b3b56..ad44a257 100644 --- a/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CopilotAdapter.test.ts @@ -262,7 +262,8 @@ describe('CopilotAdapter', () => { name: 'copilot-started', type: 'copilot', pid: 86800, - tmuxSession: 'copilot-started', + runtime: 'tmux', + runtimeRef: { session: 'copilot-started' }, cwd: '/repo', startedAt: '2026-06-13T19:15:16.211Z', sessionId: 'pid-86800', @@ -317,7 +318,8 @@ describe('CopilotAdapter', () => { name: 'copilot-started', type: 'copilot', pid: 14095, - tmuxSession: 'copilot-started', + runtime: 'tmux', + runtimeRef: { session: 'copilot-started' }, cwd: '/repo', startedAt: '2026-06-13T19:15:16.211Z', sessionId: 'pid-14095', diff --git a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts index 0e7a2f1f..a9ad9551 100644 --- a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts @@ -231,7 +231,8 @@ describe('GeminiCliAdapter', () => { name: 'cli-mqcqj469', type: 'gemini_cli', pid: wrapperProc.pid, - tmuxSession: 'cli-mqcqj469', + runtime: 'tmux', + runtimeRef: { session: 'cli-mqcqj469' }, cwd: wrapperProc.cwd, startedAt: '2026-06-13T19:15:16.211Z', sessionId: `pid-${wrapperProc.pid}`, @@ -427,7 +428,8 @@ describe('GeminiCliAdapter', () => { name: 'gemini-100', type: 'gemini_cli', pid: 100, - tmuxSession: '', + runtime: 'tmux', + runtimeRef: null, cwd: '/repo-a', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 's-cached', @@ -556,7 +558,7 @@ describe('GeminiCliAdapter', () => { registerEntry({ name: 'cli-mqcq0mg5', pid: wrapperProc.pid, - tmuxSession: 'cli-mqcq0mg5', + runtimeRef: { session: 'cli-mqcq0mg5' }, sessionId: 's-cached', sessionFilePath, }); diff --git a/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts b/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts deleted file mode 100644 index 5ed66924..00000000 --- a/packages/agent-manager/src/__tests__/database/DurableAgentsDatabase.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import Database from 'better-sqlite3'; -import { afterEach, describe, expect, it } from 'vitest'; -import { DatabaseConnection } from '../../database/connection.js'; -import { getSchemaVersion } from '../../database/schema.js'; - -const roots: string[] = []; - -afterEach(() => { - for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); -}); - -function dbPath(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'durable-agent-db-')); - roots.push(root); - return path.join(root, 'state', 'agents.db'); -} - -describe('durable agents schema', () => { - it('migrates to version 4 with durable constraints and indexes', () => { - const connection = new DatabaseConnection({ dbPath: dbPath() }); - expect(getSchemaVersion(connection)).toBe(4); - const table = connection.queryOne<{ sql: string }>( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'durable_agents'", - ); - expect(table?.sql).toContain("DEFAULT 'durable'"); - expect(table?.sql).toContain("state IN ('ready','running','degraded')"); - expect(connection.query<{ name: string }>( - "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'durable_agents'", - ).map(({ name }) => name)).toEqual(expect.arrayContaining([ - 'idx_durable_agents_state', 'idx_durable_agents_list', - ])); - connection.close(); - }); - - it('upgrades a version 3 database to nullable, unique provider sessions', () => { - const file = dbPath(); - fs.mkdirSync(path.dirname(file), { recursive: true }); - const raw = new Database(file); - raw.exec(` - CREATE TABLE durable_agents ( - id TEXT PRIMARY KEY, - provider_session_id TEXT NOT NULL UNIQUE - ); - INSERT INTO durable_agents (id, provider_session_id) VALUES ('existing', 'existing-session'); - PRAGMA user_version = 3; - `); - raw.close(); - - const connection = new DatabaseConnection({ dbPath: file }); - expect(getSchemaVersion(connection)).toBe(4); - expect(() => connection.execute( - 'INSERT INTO durable_agents (id, provider_session_id) VALUES (?, NULL)', ['codex'], - )).not.toThrow(); - expect(() => connection.execute( - 'INSERT INTO durable_agents (id, provider_session_id) VALUES (?, ?)', ['duplicate', 'existing-session'], - )).toThrow(/UNIQUE/i); - connection.close(); - }); - - it('enforces case-insensitive names and active-run consistency but permits new providers', () => { - const connection = new DatabaseConnection({ dbPath: dbPath() }); - const insert = (name: string, provider: string, state = 'ready') => connection.execute(` - INSERT INTO durable_agents ( - id, name, provider, mode, cwd, provider_session_id, state, session_health, - created_at, updated_at - ) VALUES (?, ?, ?, 'durable', '/tmp', ?, ?, 'uninitialized', ?, ?) - `, [crypto.randomUUID(), name, provider, crypto.randomUUID(), state, new Date().toISOString(), new Date().toISOString()]); - expect(() => insert('Alpha', 'future-provider')).not.toThrow(); - expect(() => insert('alpha', 'claude')).toThrow(/UNIQUE/i); - expect(() => insert('Broken', 'claude', 'running')).toThrow(/CHECK/i); - connection.close(); - }); -}); - -describe('readonly DatabaseConnection', () => { - it('opens an already migrated database without changing it', () => { - const file = dbPath(); - const writable = new DatabaseConnection({ dbPath: file }); - writable.close(); - const before = fs.statSync(file).mtimeMs; - const readonly = new DatabaseConnection({ dbPath: file, readonly: true }); - expect(readonly.queryOne<{ user_version: number }>('PRAGMA user_version')?.user_version).toBe(4); - readonly.close(); - expect(fs.statSync(file).mtimeMs).toBe(before); - }); - - it('does not create a missing database or migrate an old one', () => { - const missing = dbPath(); - expect(() => new DatabaseConnection({ dbPath: missing, readonly: true })).toThrow(/readonly.*exist/i); - expect(fs.existsSync(missing)).toBe(false); - - fs.mkdirSync(path.dirname(missing), { recursive: true }); - const raw = new Database(missing); - raw.pragma('user_version = 2'); - raw.close(); - expect(() => new DatabaseConnection({ dbPath: missing, readonly: true })).toThrow(/schema version 3/i); - expect(new Database(missing, { readonly: true }).pragma('user_version', { simple: true })).toBe(2); - }); -}); diff --git a/packages/agent-manager/src/__tests__/runtime/AgentRuntime.test.ts b/packages/agent-manager/src/__tests__/runtime/AgentRuntime.test.ts new file mode 100644 index 00000000..1aca7612 --- /dev/null +++ b/packages/agent-manager/src/__tests__/runtime/AgentRuntime.test.ts @@ -0,0 +1,26 @@ +import { createInteractiveRuntime, isHerdrRegistryEntry } from '../../runtime/AgentRuntime.js'; + +describe('AgentRuntime', () => { + it('creates a Herdr runtime only for the Herdr provider', () => { + expect(createInteractiveRuntime('herdr')).toMatchObject({ provider: 'herdr' }); + expect(createInteractiveRuntime('tmux')).toBeNull(); + }); + + it('identifies Herdr-backed registry entries', () => { + expect(isHerdrRegistryEntry({ + name: 'reviewer', + type: 'codex', + pid: 123, + runtime: 'herdr', + runtimeRef: { session: 'default', paneId: 'w1:p2' }, + })).toBe(true); + + expect(isHerdrRegistryEntry({ + name: 'reviewer', + type: 'codex', + pid: 123, + runtime: 'tmux', + runtimeRef: { session: 'reviewer' }, + })).toBe(false); + }); +}); diff --git a/packages/agent-manager/src/__tests__/runtime/HerdrAgentRuntime.test.ts b/packages/agent-manager/src/__tests__/runtime/HerdrAgentRuntime.test.ts new file mode 100644 index 00000000..a61b4d1d --- /dev/null +++ b/packages/agent-manager/src/__tests__/runtime/HerdrAgentRuntime.test.ts @@ -0,0 +1,302 @@ +import { HerdrAgentRuntime, HerdrRuntimeError, type HerdrCommandRunner } from '../../runtime/HerdrAgentRuntime.js'; + +function createRunner(responses: Array<{ stdout?: string; stderr?: string; reject?: Error }> = []): HerdrCommandRunner { + const runner = vi.fn(async () => { + const response = responses.shift(); + if (!response) return { stdout: '{}', stderr: '' }; + if (response.reject) throw response.reject; + return { stdout: response.stdout ?? '', stderr: response.stderr ?? '' }; + }); + return runner; +} + +function herdrCliError(response: unknown): Error { + return Object.assign(new Error('herdr command failed'), { + stdout: JSON.stringify(response), + stderr: '', + code: 1, + }); +} + +function jsonResponse(value: unknown): { stdout: string } { + return { stdout: JSON.stringify(value) }; +} + +function workspaceResponse(workspaceId = 'w1', paneId = 'w1:p1'): { stdout: string } { + return jsonResponse({ + result: { + type: 'workspace_info', + workspace: { workspace_id: workspaceId }, + root_pane: { pane_id: paneId }, + }, + }); +} + +function agentResponse({ + name = 'reviewer', + paneId = 'w1:p1', + workspaceId, + tabId, +}: { + name?: string; + paneId?: string; + workspaceId?: string; + tabId?: string; +} = {}): { stdout: string } { + return jsonResponse({ + result: { + type: 'agent_info', + agent: { + name, + agent: 'codex', + pane_id: paneId, + ...(workspaceId ? { workspace_id: workspaceId } : {}), + ...(tabId ? { tab_id: tabId } : {}), + }, + }, + }); +} + +function processInfoResponse({ + paneId = 'w1:p1', + pid, + shellPid, + processName, +}: { + paneId?: string; + pid?: number; + shellPid?: number; + processName?: string; +} = {}): { stdout: string } { + return jsonResponse({ + result: { + type: 'pane_process_info', + process_info: { + pane_id: paneId, + ...(shellPid ? { shell_pid: shellPid } : {}), + foreground_processes: pid ? [{ pid, ...(processName ? { name: processName } : {}) }] : [], + }, + }, + }); +} + +const startInput = { + name: 'reviewer', + cwd: '/repo', + kind: 'codex', + args: [], + timeoutMs: 15000, +}; + +describe('HerdrAgentRuntime', () => { + it('reports unavailable when the herdr binary is missing', async () => { + const runner = createRunner([ + { reject: Object.assign(new Error('not found'), { code: 'ENOENT' }) }, + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + + await expect(runtime.isAvailable()).resolves.toEqual({ + ok: false, + reason: 'binary-missing', + detail: 'herdr command was not found in PATH.', + }); + }); + + it('reports available outside Herdr when binary and snapshot work', async () => { + const runner = createRunner([ + { stdout: 'herdr 0.8.2\n' }, + jsonResponse({ id: 'cli:api:snapshot', result: { type: 'session_snapshot' } }), + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + + await expect(runtime.isAvailable()).resolves.toEqual({ + ok: true, + insideRuntime: false, + }); + expect(runner).toHaveBeenNthCalledWith(1, 'herdr', ['--version']); + expect(runner).toHaveBeenNthCalledWith(2, 'herdr', ['api', 'snapshot']); + }); + + it('includes Herdr environment context when present', async () => { + const runner = createRunner([ + { stdout: 'herdr 0.8.2\n' }, + jsonResponse({ id: 'cli:api:snapshot', result: { type: 'session_snapshot' } }), + ]); + const runtime = new HerdrAgentRuntime({ + runner, + env: { HERDR_ENV: '1', HERDR_PANE_ID: 'w1:p1' }, + }); + + await expect(runtime.isAvailable()).resolves.toEqual({ + ok: true, + insideRuntime: true, + currentPaneId: 'w1:p1', + }); + }); + + it('creates a workspace, starts the named agent, and returns an opaque Herdr ref', async () => { + const runner = createRunner([ + workspaceResponse(), + agentResponse({ workspaceId: 'w1', tabId: 'w1:t1' }), + processInfoResponse({ shellPid: 12000, pid: 12345, processName: 'codex' }), + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + + const result = await runtime.startAgent(startInput); + + expect(runner).toHaveBeenNthCalledWith(1, 'herdr', [ + 'workspace', 'create', '--cwd', '/repo', '--label', 'reviewer', + ]); + expect(runner).toHaveBeenNthCalledWith(2, 'herdr', [ + 'agent', 'start', 'reviewer', '--kind', 'codex', '--pane', 'w1:p1', '--timeout', '15000', + ]); + expect(runner).toHaveBeenNthCalledWith(3, 'herdr', [ + 'pane', 'process-info', '--pane', 'w1:p1', + ]); + expect(result).toEqual({ + pid: 12345, + runtimeRef: { + session: 'default', + workspaceId: 'w1', + tabId: 'w1:t1', + paneId: 'w1:p1', + agentName: 'reviewer', + }, + }); + }); + + it('falls back to the pane shell pid when no foreground process pid is reported', async () => { + const runner = createRunner([ + workspaceResponse(), + agentResponse(), + processInfoResponse({ shellPid: 12000 }), + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + + await expect(runtime.startAgent(startInput)).resolves.toMatchObject({ pid: 12000 }); + }); + + it('passes only explicit extra agent args after the Herdr start separator', async () => { + const runner = createRunner([ + workspaceResponse(), + agentResponse(), + processInfoResponse({ pid: 12345 }), + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + + await runtime.startAgent({ + ...startInput, + args: ['--model', 'gpt-5-codex'], + }); + + expect(runner).toHaveBeenNthCalledWith(2, 'herdr', [ + 'agent', 'start', 'reviewer', '--kind', 'codex', '--pane', 'w1:p1', '--timeout', '15000', '--', '--model', 'gpt-5-codex', + ]); + }); + + it('retries agent start when a freshly-created Herdr pane is temporarily busy', async () => { + const runner = createRunner([ + workspaceResponse('w15', 'w15:p1'), + { + reject: herdrCliError({ + error: { + code: 'agent_pane_busy', + message: 'agent target pane w15:p1 is not an available shell', + }, + id: 'cli:agent:start', + }), + }, + agentResponse({ paneId: 'w15:p1', workspaceId: 'w15', tabId: 'w15:t1' }), + processInfoResponse({ paneId: 'w15:p1', pid: 12345 }), + ]); + const runtime = new HerdrAgentRuntime({ + runner, + env: {}, + agentStartBusyRetryMs: 100, + agentStartBusyRetryIntervalMs: 0, + }); + + await expect(runtime.startAgent(startInput)).resolves.toMatchObject({ + runtimeRef: { + workspaceId: 'w15', + paneId: 'w15:p1', + }, + }); + + expect(runner).toHaveBeenNthCalledWith(2, 'herdr', [ + 'agent', 'start', 'reviewer', '--kind', 'codex', '--pane', 'w15:p1', '--timeout', '15000', + ]); + expect(runner).toHaveBeenNthCalledWith(3, 'herdr', [ + 'agent', 'start', 'reviewer', '--kind', 'codex', '--pane', 'w15:p1', '--timeout', '15000', + ]); + }); + + it('surfaces non-retryable Herdr agent start errors without retrying', async () => { + const runner = createRunner([ + workspaceResponse(), + { + reject: herdrCliError({ + error: { + code: 'duplicate_agent_name', + message: 'agent name reviewer already exists', + }, + id: 'cli:agent:start', + }), + }, + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + + await expect(runtime.startAgent(startInput)).rejects.toMatchObject({ + code: 'duplicate_agent_name', + message: 'agent name reviewer already exists', + }); + + expect(runner).toHaveBeenCalledTimes(2); + }); + + it('rejects start responses when Herdr process info does not include a pid', async () => { + const runner = createRunner([ + workspaceResponse(), + agentResponse(), + processInfoResponse(), + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + + await expect(runtime.startAgent(startInput)) + .rejects.toThrow(new HerdrRuntimeError('Herdr pane process-info response did not include process pid.')); + }); + + it('rejects start responses that do not include a pane id', async () => { + const runner = createRunner([ + { stdout: '{"result":{"workspace":{"workspace_id":"w1"}}}' }, + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + + await expect(runtime.startAgent(startInput)) + .rejects.toThrow(new HerdrRuntimeError('Herdr workspace create response did not include root pane id.')); + }); + + it('sends, waits, reads, focuses, and stops by Herdr pane ref', async () => { + const runner = createRunner([ + { stdout: '{}' }, + { stdout: '{"result":{"agent":{"agent_status":"done"}}}' }, + { stdout: 'done\n' }, + { stdout: '{}' }, + { stdout: '{}' }, + ]); + const runtime = new HerdrAgentRuntime({ runner, env: {} }); + const runtimeRef = { paneId: 'w1:p2', agentName: 'reviewer' }; + + await runtime.send({ runtimeRef, prompt: 'continue' }); + await runtime.wait({ runtimeRef, timeoutMs: 2000 }); + await expect(runtime.readOutput({ runtimeRef, lines: 80 })).resolves.toBe('done\n'); + await expect(runtime.focus({ runtimeRef })).resolves.toBe(true); + await runtime.stop({ runtimeRef }); + + expect(runner).toHaveBeenNthCalledWith(1, 'herdr', ['agent', 'prompt', 'w1:p2', 'continue']); + expect(runner).toHaveBeenNthCalledWith(2, 'herdr', ['agent', 'wait', 'w1:p2', '--until', 'done', '--until', 'blocked', '--timeout', '2000']); + expect(runner).toHaveBeenNthCalledWith(3, 'herdr', ['agent', 'read', 'w1:p2', '--source', 'recent-unwrapped', '--lines', '80']); + expect(runner).toHaveBeenNthCalledWith(4, 'herdr', ['agent', 'focus', 'w1:p2']); + expect(runner).toHaveBeenNthCalledWith(5, 'herdr', ['pane', 'close', 'w1:p2']); + }); +}); diff --git a/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts b/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts new file mode 100644 index 00000000..ebc579ff --- /dev/null +++ b/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts @@ -0,0 +1,524 @@ +import { + AgentNameInUseError, + AgentPidPollTimeoutError, + AgentTerminalNotFoundError, + DEFAULT_PID_POLL_TIMEOUT_MS, + focusAgent, + sendAgentPrompt, + startAgent, + stopAgent, + TmuxUnavailableError, + type StartAgentOptions, +} from '../../runtime/ManagedAgentRuntime.js'; +import type { AgentInfo } from '../../adapters/AgentAdapter.js'; +import type { AgentRegistry, RegistryEntry } from '../../utils/AgentRegistry.js'; +import type { TmuxManager } from '../../terminal/TmuxManager.js'; +import type { HerdrInteractiveRuntime } from '../../runtime/AgentRuntime.js'; + +function makeAgent(overrides: Partial = {}): AgentInfo { + return { + name: 'repo-a', + type: 'claude', + status: 'running' as AgentInfo['status'], + summary: 'Working', + pid: 10, + projectPath: '/repo', + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + lastActive: new Date('2026-05-14T00:00:00.000Z'), + ...overrides, + }; +} + +function makeTmux(over: Partial = {}): TmuxManager { + return { + isAvailable: vi.fn().mockResolvedValue(true), + sessionExists: vi.fn().mockResolvedValue(false), + createSession: vi.fn().mockResolvedValue(undefined), + sendKeys: vi.fn().mockResolvedValue(undefined), + killSession: vi.fn().mockResolvedValue(undefined), + findAgentPid: vi.fn().mockResolvedValue(12345), + ...over, + } as unknown as TmuxManager; +} + +function makeRegistry(over: Partial = {}): AgentRegistry { + return { + prune: vi.fn(), + lookup: vi.fn().mockReturnValue(null), + list: vi.fn().mockReturnValue([]), + register: vi.fn(), + isAlive: vi.fn().mockReturnValue(false), + ...over, + } as unknown as AgentRegistry; +} + +function makeRuntime(over: Partial = {}): HerdrInteractiveRuntime { + return { + provider: 'herdr', + isAvailable: vi.fn().mockResolvedValue({ ok: true, insideRuntime: false }), + startAgent: vi.fn().mockResolvedValue({ + pid: 12345, + runtimeRef: { session: 'default', paneId: 'w1:p2', agentName: 'agent1' }, + }), + send: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + readOutput: vi.fn().mockResolvedValue('done\n'), + focus: vi.fn().mockResolvedValue(true), + stop: vi.fn().mockResolvedValue(undefined), + ...over, + } as unknown as HerdrInteractiveRuntime; +} + +const startOpts: StartAgentOptions = { + type: 'claude', + name: 'agent1', + cwd: '/work', + pollIntervalMs: 1, + pollTimeoutMs: 50, +}; + +describe('managed agent runtime defaults', () => { + it('allows slower agent startup before PID polling times out', () => { + expect(DEFAULT_PID_POLL_TIMEOUT_MS).toBe(15_000); + }); +}); + +describe('stopAgent', () => { + it('sends SIGTERM to the agent PID', async () => { + const tmux = makeTmux(); + const registry = makeRegistry(); + const killProcess = vi.fn(); + + const result = await stopAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + tmux, + registry, + killProcess, + }); + + expect(killProcess).toHaveBeenCalledWith(123, 'SIGTERM'); + expect(tmux.killSession).not.toHaveBeenCalled(); + expect(result).toEqual({ + agentName: 'repo-a', + pid: 123, + runtime: 'tmux', + runtimeRef: null, + }); + }); + + it('kills the registry tmux session when present', async () => { + const tmux = makeTmux(); + const registry = makeRegistry({ + lookup: vi.fn().mockReturnValue({ + name: 'repo-a', + type: 'claude', + pid: 123, + runtime: 'tmux', + runtimeRef: { session: 'repo-a' }, + cwd: '/repo', + startedAt: '2026-06-01T00:00:00.000Z', + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + } satisfies RegistryEntry), + } as Partial); + const killProcess = vi.fn(); + + const result = await stopAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + tmux, + registry, + killProcess, + }); + + expect(killProcess).toHaveBeenCalledWith(123, 'SIGTERM'); + expect(tmux.killSession).toHaveBeenCalledWith('repo-a'); + expect(result.runtimeRef).toEqual({ session: 'repo-a' }); + }); + + it('delegates Herdr-backed agents to Herdr without sending SIGTERM directly', async () => { + const runtime = makeRuntime(); + const registry = makeRegistry({ + lookup: vi.fn().mockReturnValue({ + name: 'repo-a', + type: 'claude', + pid: 123, + runtime: 'herdr', + runtimeRef: { session: 'default', paneId: 'w1:p2' }, + cwd: '/repo', + startedAt: '2026-06-01T00:00:00.000Z', + sessionId: '', + sessionFilePath: '', + } satisfies RegistryEntry), + } as Partial); + const killProcess = vi.fn(); + + const result = await stopAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + runtime, + registry, + killProcess, + }); + + expect(runtime.stop).toHaveBeenCalledWith({ runtimeRef: { session: 'default', paneId: 'w1:p2' } }); + expect(killProcess).not.toHaveBeenCalled(); + expect(result.runtime).toBe('herdr'); + }); + + it('still kills tmux session when the process is already gone', async () => { + const tmux = makeTmux(); + const registry = makeRegistry({ + lookup: vi.fn().mockReturnValue({ + name: 'repo-a', + type: 'claude', + pid: 123, + runtime: 'tmux', + runtimeRef: { session: 'repo-a' }, + cwd: '/repo', + startedAt: '2026-06-01T00:00:00.000Z', + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + } satisfies RegistryEntry), + } as Partial); + const error = Object.assign(new Error('gone'), { code: 'ESRCH' }); + const killProcess = vi.fn(() => { throw error; }); + + await stopAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + tmux, + registry, + killProcess, + }); + + expect(tmux.killSession).toHaveBeenCalledWith('repo-a'); + }); + + it('rethrows unexpected process kill errors', async () => { + const tmux = makeTmux(); + const registry = makeRegistry(); + const error = Object.assign(new Error('permission denied'), { code: 'EPERM' }); + const killProcess = vi.fn(() => { throw error; }); + + await expect(stopAgent(makeAgent({ name: 'repo-a', pid: 123 }), { + tmux, + registry, + killProcess, + })).rejects.toThrow('permission denied'); + + expect(tmux.killSession).not.toHaveBeenCalled(); + }); +}); + +describe('focusAgent', () => { + it('delegates Herdr-backed agents to Herdr focus', async () => { + const runtimeRef = { session: 'default', paneId: 'w1:p2', agentName: 'repo-a' }; + const runtime = makeRuntime(); + const registry = makeRegistry({ + lookup: vi.fn().mockReturnValue({ + name: 'repo-a', + type: 'claude', + pid: 10, + runtime: 'herdr', + runtimeRef, + cwd: '/repo', + startedAt: '2026-06-01T00:00:00.000Z', + } satisfies RegistryEntry), + } as Partial); + const focusManager = { + findTerminal: vi.fn(), + focusTerminal: vi.fn(), + }; + + const result = await focusAgent(makeAgent(), { registry, runtime, focusManager }); + + expect(runtime.focus).toHaveBeenCalledWith({ runtimeRef }); + expect(focusManager.findTerminal).not.toHaveBeenCalled(); + expect(result).toEqual({ focused: true }); + }); + + it('reports when a tmux-backed terminal cannot be found', async () => { + const focusManager = { + findTerminal: vi.fn().mockResolvedValue(null), + focusTerminal: vi.fn(), + }; + + const result = await focusAgent(makeAgent({ pid: 10 }), { + registry: makeRegistry(), + focusManager, + }); + + expect(focusManager.findTerminal).toHaveBeenCalledWith(10); + expect(focusManager.focusTerminal).not.toHaveBeenCalled(); + expect(result).toEqual({ focused: false, reason: 'terminal-not-found' }); + }); + + it('reports when focusing a found terminal fails', async () => { + const location = { type: 'tmux', identifier: '1:1', tty: '/dev/ttys030' }; + const focusManager = { + findTerminal: vi.fn().mockResolvedValue(location), + focusTerminal: vi.fn().mockResolvedValue(false), + }; + + const result = await focusAgent(makeAgent({ pid: 10 }), { + registry: makeRegistry(), + focusManager, + }); + + expect(focusManager.focusTerminal).toHaveBeenCalledWith(location); + expect(result).toEqual({ focused: false, reason: 'focus-failed' }); + }); +}); + +describe('sendAgentPrompt', () => { + it('sends Herdr-backed prompts through Herdr', async () => { + const runtimeRef = { session: 'default', paneId: 'w1:p2', agentName: 'repo-a' }; + const runtime = makeRuntime(); + const registry = makeRegistry({ + lookup: vi.fn().mockReturnValue({ + name: 'repo-a', + type: 'claude', + pid: 10, + runtime: 'herdr', + runtimeRef, + cwd: '/repo', + startedAt: '2026-06-01T00:00:00.000Z', + } satisfies RegistryEntry), + } as Partial); + const focusManager = { + findTerminal: vi.fn(), + }; + + await sendAgentPrompt(makeAgent(), 'hello', { + registry, + runtime, + focusManager, + }); + + expect(runtime.send).toHaveBeenCalledWith({ runtimeRef, prompt: 'hello' }); + expect(focusManager.findTerminal).not.toHaveBeenCalled(); + }); + + it('writes tmux-backed prompts to the resolved terminal', async () => { + const location = { type: 'tmux', identifier: '1:1', tty: '/dev/ttys030' }; + const focusManager = { + findTerminal: vi.fn().mockResolvedValue(location), + }; + const writer = vi.fn().mockResolvedValue(undefined); + + await sendAgentPrompt(makeAgent({ pid: 10 }), 'hello', { + registry: makeRegistry(), + focusManager, + writer, + }); + + expect(focusManager.findTerminal).toHaveBeenCalledWith(10); + expect(writer).toHaveBeenCalledWith(location, 'hello'); + }); + + it('throws when a tmux-backed terminal cannot be found', async () => { + const focusManager = { + findTerminal: vi.fn().mockResolvedValue(null), + }; + + const err = await sendAgentPrompt(makeAgent({ pid: 10 }), 'hello', { + registry: makeRegistry(), + focusManager, + }).catch((error) => error); + + expect(err).toBeInstanceOf(AgentTerminalNotFoundError); + expect(err.message).toBe('Cannot find terminal for agent "repo-a" (PID: 10).'); + }); +}); + +describe('startAgent', () => { + it('happy path: creates session, sends command, polls, registers, returns entry', async () => { + const tmux = makeTmux(); + const registry = makeRegistry(); + + const entry = await startAgent( + { ...startOpts, pollTimeoutMs: 250 }, + { tmux, registry }, + ); + + expect(tmux.createSession).toHaveBeenCalledWith('agent1', '/work'); + expect(tmux.sendKeys).toHaveBeenCalledWith('agent1', 'claude'); + expect(registry.prune).toHaveBeenCalled(); + expect(registry.register).toHaveBeenCalledOnce(); + expect(entry).toMatchObject({ + name: 'agent1', + type: 'claude', + pid: 12345, + runtime: 'tmux', + runtimeRef: { session: 'agent1' }, + cwd: '/work', + pinned: false, + }); + expect(entry.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('throws TmuxUnavailableError when tmux is missing', async () => { + const tmux = makeTmux({ isAvailable: vi.fn().mockResolvedValue(false) } as Partial); + const registry = makeRegistry(); + + await expect(startAgent(startOpts, { tmux, registry })).rejects.toBeInstanceOf(TmuxUnavailableError); + expect(tmux.createSession).not.toHaveBeenCalled(); + expect(registry.register).not.toHaveBeenCalled(); + }); + + it('starts Herdr-backed agents through the configured runtime and persists runtime metadata', async () => { + const runtime = makeRuntime(); + const registry = makeRegistry(); + + const entry = await startAgent({ ...startOpts, runtimeProvider: 'herdr' }, { runtime, registry }); + + expect(runtime.isAvailable).toHaveBeenCalledOnce(); + expect(runtime.startAgent).toHaveBeenCalledWith({ + name: 'agent1', + cwd: '/work', + kind: 'claude', + args: [], + timeoutMs: 50, + }); + expect(entry).toMatchObject({ + name: 'agent1', + type: 'claude', + pid: 12345, + runtime: 'herdr', + runtimeRef: { session: 'default', paneId: 'w1:p2', agentName: 'agent1' }, + cwd: '/work', + pinned: false, + }); + expect(registry.register).toHaveBeenCalledWith(expect.objectContaining({ + runtime: 'herdr', + runtimeRef: { session: 'default', paneId: 'w1:p2', agentName: 'agent1' }, + })); + }); + + it('fails Herdr starts when the configured runtime is unavailable', async () => { + const runtime = makeRuntime({ + isAvailable: vi.fn().mockResolvedValue({ + ok: false, + reason: 'binary-missing', + detail: 'herdr command was not found in PATH.', + }), + }); + const registry = makeRegistry(); + + await expect(startAgent({ ...startOpts, runtimeProvider: 'herdr' }, { runtime, registry })).rejects.toMatchObject({ + name: 'AgentRuntimeUnavailableError', + provider: 'herdr', + reason: 'binary-missing', + }); + expect(runtime.startAgent).not.toHaveBeenCalled(); + expect(registry.register).not.toHaveBeenCalled(); + }); + + it('throws AgentNameInUseError when registry already has a live entry', async () => { + const tmux = makeTmux(); + const liveEntry: RegistryEntry = { + name: 'agent1', type: 'claude', pid: 999, + runtime: 'tmux', runtimeRef: { session: 'agent1' }, cwd: '/old', startedAt: '2026-01-01T00:00:00.000Z', + }; + const registry = makeRegistry({ lookup: vi.fn().mockReturnValue(liveEntry) } as Partial); + + const err = await startAgent(startOpts, { tmux, registry }).catch((e) => e); + expect(err).toBeInstanceOf(AgentNameInUseError); + expect(err.pid).toBe(999); + expect(tmux.createSession).not.toHaveBeenCalled(); + }); + + it('replaces orphan tmux session and calls onWarning', async () => { + const tmux = makeTmux({ sessionExists: vi.fn().mockResolvedValue(true) } as Partial); + const registry = makeRegistry(); + const onWarning = vi.fn(); + + await startAgent(startOpts, { tmux, registry, onWarning }); + + expect(onWarning).toHaveBeenCalledOnce(); + expect(onWarning.mock.calls[0][0]).toContain('agent1'); + expect(tmux.killSession).toHaveBeenCalledWith('agent1'); + expect(tmux.createSession).toHaveBeenCalledWith('agent1', '/work'); + }); + + it('on PID poll timeout: kills session and throws AgentPidPollTimeoutError', async () => { + const tmux = makeTmux({ findAgentPid: vi.fn().mockResolvedValue(null) } as Partial); + const registry = makeRegistry(); + + const err = await startAgent(startOpts, { tmux, registry }).catch((e) => e); + + expect(err).toBeInstanceOf(AgentPidPollTimeoutError); + expect(err.command).toBe('claude'); + expect(err.timeoutMs).toBe(50); + expect(tmux.killSession).toHaveBeenLastCalledWith('agent1'); + expect(registry.register).not.toHaveBeenCalled(); + }); + + it('keeps polling until findAgentPid returns a PID', async () => { + const findAgentPid = vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(42) + .mockResolvedValueOnce(42) + .mockResolvedValueOnce(42) + .mockResolvedValueOnce(42) + .mockResolvedValueOnce(42); + const tmux = makeTmux({ findAgentPid } as Partial); + const registry = makeRegistry(); + + const entry = await startAgent( + { ...startOpts, pollTimeoutMs: 250 }, + { tmux, registry }, + ); + + expect(findAgentPid).toHaveBeenCalledTimes(7); + expect(entry.pid).toBe(42); + }); + + it('waits for the launched process PID to stabilize before registering', async () => { + const findAgentPid = vi.fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(200) + .mockResolvedValueOnce(200) + .mockResolvedValueOnce(200) + .mockResolvedValueOnce(200) + .mockResolvedValueOnce(200); + const tmux = makeTmux({ findAgentPid } as Partial); + const registry = makeRegistry(); + + const entry = await startAgent(startOpts, { tmux, registry }); + + expect(findAgentPid).toHaveBeenCalledTimes(8); + expect(entry.pid).toBe(200); + expect(registry.register).toHaveBeenCalledWith(expect.objectContaining({ pid: 200 })); + }); + + it('treats an unstabilized PID as a poll timeout', async () => { + const findAgentPid = vi.fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(100); + const tmux = makeTmux({ findAgentPid } as Partial); + const registry = makeRegistry(); + + const err = await startAgent( + { ...startOpts, pollTimeoutMs: 3 }, + { tmux, registry }, + ).catch((e) => e); + + expect(err).toBeInstanceOf(AgentPidPollTimeoutError); + expect(registry.register).not.toHaveBeenCalled(); + expect(tmux.killSession).toHaveBeenLastCalledWith('agent1'); + }); + + it('prunes registry before checking for name collision', async () => { + const tmux = makeTmux(); + const registry = makeRegistry(); + const order: string[] = []; + (registry.prune as any).mockImplementation(() => order.push('prune')); + (registry.lookup as any).mockImplementation(() => { + order.push('lookup'); + return null; + }); + + await startAgent(startOpts, { tmux, registry }); + expect(order).toEqual(['prune', 'lookup']); + }); +}); diff --git a/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts b/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts index 28e28103..f27f36c0 100644 --- a/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts +++ b/packages/agent-manager/src/__tests__/utils/AgentRegistry.test.ts @@ -9,12 +9,13 @@ function makeEntry(over: Partial = {}): RegistryEntry { name: 'agent1', type: 'claude', pid: process.pid, - tmuxSession: 'agent1', cwd: '/tmp', startedAt: '2026-05-30T00:00:00.000Z', sessionId: 'sid-1', sessionFilePath: '/tmp/session.jsonl', pinned: false, + runtime: 'tmux', + runtimeRef: { session: 'agent1' }, ...over, }; } @@ -50,7 +51,7 @@ describe('AgentRegistry', () => { it('upserts in place when type and pid already exist', () => { registry.register(makeEntry({ name: 'a', pid: process.pid })); - registry.register(makeEntry({ name: 'fallback', pid: process.pid, tmuxSession: '' })); + registry.register(makeEntry({ name: 'fallback', pid: process.pid, runtimeRef: null })); const all = registry.list(); expect(all).toHaveLength(1); expect(all[0].pid).toBe(process.pid); @@ -69,18 +70,58 @@ describe('AgentRegistry', () => { expect(saved.sessionFilePath).toBe('/foo/bar.jsonl'); }); - it('preserves existing tmuxSession when incoming is empty string', () => { - registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' })); - registry.register(makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid })); + it('persists runtime metadata for Herdr-backed entries', () => { + const runtimeRef = { session: 'default', paneId: 'w1:p2', agentName: 'reviewer' }; + + registry.register(makeEntry({ + name: 'reviewer', + runtime: 'herdr', + runtimeRef, + })); + + expect(registry.lookup('reviewer')).toMatchObject({ + runtime: 'herdr', + runtimeRef, + }); + }); + + it('lets a Herdr managed start replace an existing same-pid name', () => { + const runtimeRef = { session: 'default', paneId: 'w1:p2', agentName: 'requested-name' }; + + registry.register(makeEntry({ name: 'old-name', pid: process.pid })); + registry.register(makeEntry({ + name: 'requested-name', + pid: process.pid, + runtime: 'herdr', + runtimeRef, + })); + + expect(registry.lookup('old-name')).toBeNull(); + expect(registry.lookup('requested-name')).toMatchObject({ + pid: process.pid, + runtime: 'herdr', + runtimeRef, + }); + }); + + it('preserves existing runtime ref when incoming runtime ref is empty', () => { + registry.register(makeEntry({ name: 'a', runtimeRef: { session: 'pinned' } })); + registry.register(makeEntry({ name: 'fallback', runtimeRef: null, pid: process.pid })); const saved = registry.lookup('a'); - expect(saved?.tmuxSession).toBe('pinned'); + expect(saved?.runtimeRef).toEqual({ session: 'pinned' }); expect(saved?.pid).toBe(process.pid); }); + it('defaults an empty tmux runtime ref from the tmux session', () => { + registry.register(makeEntry({ name: 'a', runtimeRef: null })); + + expect(registry.lookup('a')?.runtimeRef).toBeNull(); + }); + it('lets a managed start entry replace a generated fallback for the same pid', () => { - registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' })); - registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' })); - expect(registry.lookup('custom-name')?.tmuxSession).toBe('custom-name'); + registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, runtimeRef: null })); + registry.register(makeEntry({ name: 'custom-name', runtimeRef: { session: 'custom-name' } })); + expect(registry.lookup('custom-name')?.runtimeRef).toEqual({ session: 'custom-name' }); expect(registry.lookup(`ai-devkit-${process.pid}`)).toBeNull(); expect(registry.list()).toHaveLength(1); }); @@ -114,22 +155,22 @@ describe('AgentRegistry', () => { expect(registry.list()).toHaveLength(3); }); - it('applies the tmuxSession merge per entry', () => { - registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' })); + it('applies the tmux runtime ref merge per entry', () => { + registry.register(makeEntry({ name: 'a', runtimeRef: { session: 'pinned' } })); registry.registerBatch([ - makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid }), - makeEntry({ name: 'b', tmuxSession: '', pid: process.pid + 1 }), + makeEntry({ name: 'fallback', runtimeRef: null, pid: process.pid }), + makeEntry({ name: 'b', runtimeRef: null, pid: process.pid + 1 }), ]); - expect(registry.lookup('a')?.tmuxSession).toBe('pinned'); + expect(registry.lookup('a')?.runtimeRef).toEqual({ session: 'pinned' }); expect(registry.lookup('a')?.pid).toBe(process.pid); - expect(registry.lookup('b')?.tmuxSession).toBe(''); + expect(registry.lookup('b')?.runtimeRef).toBeNull(); }); it('handles concurrent registry instances without duplicate pid rows', () => { const other = new AgentRegistry(regPath); - registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' })); - other.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' })); - registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' })); + registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, runtimeRef: null })); + other.register(makeEntry({ name: 'custom-name', runtimeRef: { session: 'custom-name' } })); + registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, runtimeRef: null })); expect(registry.list()).toHaveLength(1); expect(registry.lookup('custom-name')?.pid).toBe(process.pid); @@ -142,7 +183,7 @@ describe('AgentRegistry', () => { name: 'new-codex', type: 'codex', pid: process.pid, - tmuxSession: '', + runtimeRef: null, })); expect(registry.lookup('old-claude')).toBeNull(); @@ -246,7 +287,7 @@ describe('AgentRegistry', () => { }); it('ignores existing legacy agents.json entries', () => { - const legacyEntry = makeEntry({ name: 'legacy', tmuxSession: 'legacy' }); + const legacyEntry = makeEntry({ name: 'legacy', runtimeRef: { session: 'legacy' } }); fs.mkdirSync(path.dirname(regPath), { recursive: true }); fs.writeFileSync(regPath, JSON.stringify({ entries: [legacyEntry] }), 'utf8'); @@ -256,6 +297,44 @@ describe('AgentRegistry', () => { expect(legacyRegistry.list()).toEqual([]); expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true); }); + + it('reads pre-runtime SQLite rows as tmux-backed records', () => { + const dbPath = regPath.replace(/\.json$/, '.db'); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.rmSync(dbPath, { force: true }); + fs.rmSync(`${dbPath}-wal`, { force: true }); + fs.rmSync(`${dbPath}-shm`, { force: true }); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE agents ( + type TEXT NOT NULL, + pid INTEGER NOT NULL, + name TEXT NOT NULL UNIQUE, + tmux_session TEXT NOT NULL DEFAULT '', + cwd TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + session_file_path TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + PRIMARY KEY (type, pid) + ); + INSERT INTO agents ( + type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at + ) VALUES ( + 'claude', ${process.pid}, 'legacy-sqlite', 'legacy-sqlite', '/tmp', + '2026-05-30T00:00:00.000Z', 'sid-1', '/tmp/session.jsonl', '2026-05-30T00:00:00.000Z' + ); + PRAGMA user_version = 1; + `); + db.close(); + + const migratedRegistry = new AgentRegistry(regPath); + + expect(migratedRegistry.lookup('legacy-sqlite')).toMatchObject({ + runtime: 'tmux', + runtimeRef: { session: 'legacy-sqlite' }, + }); + }); }); describe('isAlive', () => { @@ -311,7 +390,7 @@ describe('AgentRegistry', () => { }); it('preserves entries when liveness probing fails with EPERM', () => { - registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'tmux-custom' })); + registry.register(makeEntry({ name: 'custom-name', runtimeRef: { session: 'tmux-custom' } })); vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); }); @@ -320,7 +399,7 @@ describe('AgentRegistry', () => { expect(registry.lookup('custom-name')).toMatchObject({ name: 'custom-name', - tmuxSession: 'tmux-custom', + runtimeRef: { session: 'tmux-custom' }, }); }); @@ -373,10 +452,10 @@ describe('AgentRegistry', () => { }); it('preserves all other fields on the renamed entry', () => { - registry.register(makeEntry({ name: 'old-name', pid: process.pid, tmuxSession: 'old-name', cwd: '/my/cwd' })); + registry.register(makeEntry({ name: 'old-name', pid: process.pid, runtimeRef: { session: 'old-name' }, cwd: '/my/cwd' })); registry.rename('old-name', 'new-name'); const entry = registry.lookup('new-name'); - expect(entry?.tmuxSession).toBe('old-name'); + expect(entry?.runtimeRef).toEqual({ session: 'old-name' }); expect(entry?.cwd).toBe('/my/cwd'); expect(entry?.pid).toBe(process.pid); }); diff --git a/packages/agent-manager/src/database/migrations/005_agent_runtime.sql b/packages/agent-manager/src/database/migrations/005_agent_runtime.sql new file mode 100644 index 00000000..76e0bcb4 --- /dev/null +++ b/packages/agent-manager/src/database/migrations/005_agent_runtime.sql @@ -0,0 +1,2 @@ +ALTER TABLE agents ADD COLUMN runtime TEXT NOT NULL DEFAULT 'tmux'; +ALTER TABLE agents ADD COLUMN runtime_ref TEXT NOT NULL DEFAULT ''; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index ce3f9975..44d0aafa 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -49,9 +49,43 @@ export { TtyWriter } from './terminal/TtyWriter.js'; export type { ListAgentsOptions } from './AgentManager.js'; -export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js'; -export type { AgentRegistryOptions, RegistryEntry } from './utils/AgentRegistry.js'; +export { AgentRegistry, RenameNotFoundError, RenameConflictError, AGENT_RUNTIME_PROVIDERS, parseTmuxRuntimeRef } from './utils/AgentRegistry.js'; +export type { AgentRegistryOptions, RegistryEntry, AgentRuntimeProvider, TmuxRuntimeRef } from './utils/AgentRegistry.js'; export { TmuxManager } from './terminal/TmuxManager.js'; +export { createHerdrRuntime, createInteractiveRuntime, isHerdrRegistryEntry } from './runtime/AgentRuntime.js'; +export type { AgentRuntimeAvailability, HerdrStartRuntime, HerdrInteractiveRuntime } from './runtime/AgentRuntime.js'; +export { + startAgent, + stopAgent, + focusAgent, + sendAgentPrompt, + TmuxUnavailableError, + AgentNameInUseError, + AgentPidPollTimeoutError, + AgentRuntimeUnavailableError, + AgentTerminalNotFoundError, + DEFAULT_PID_POLL_INTERVAL_MS, + DEFAULT_PID_POLL_TIMEOUT_MS, +} from './runtime/ManagedAgentRuntime.js'; +export type { + FocusAgentDeps, + StartAgentDeps, + StartAgentOptions, + StopAgentDeps, + StopAgentResult, + FocusAgentResult, + SendAgentPromptDeps, +} from './runtime/ManagedAgentRuntime.js'; +export { HerdrAgentRuntime, HerdrRuntimeError, parseHerdrRuntimeRef } from './runtime/HerdrAgentRuntime.js'; +export type { + HerdrCommandResult, + HerdrCommandRunner, + HerdrRuntimeAvailability, + HerdrRuntimeOptions, + HerdrRuntimeRef, + HerdrStartInput, + HerdrStartResult, +} from './runtime/HerdrAgentRuntime.js'; export { AGENTS } from './utils/agents.js'; export type { AgentConfig, StartableAgentType } from './utils/agents.js'; diff --git a/packages/agent-manager/src/runtime/AgentRuntime.ts b/packages/agent-manager/src/runtime/AgentRuntime.ts new file mode 100644 index 00000000..5cf1443f --- /dev/null +++ b/packages/agent-manager/src/runtime/AgentRuntime.ts @@ -0,0 +1,44 @@ +import type { AgentRuntimeProvider, RegistryEntry } from '../utils/AgentRegistry.js'; +import type { HerdrRuntimeAvailability } from './HerdrAgentRuntime.js'; +import { HerdrAgentRuntime } from './HerdrAgentRuntime.js'; + +export type AgentRuntimeAvailability = HerdrRuntimeAvailability; + +export interface HerdrStartRuntime { + provider: 'herdr'; + isAvailable(): Promise; + startAgent(input: { + name: string; + cwd: string; + kind: string; + args: string[]; + timeoutMs: number; + }): Promise<{ + pid: number; + runtimeRef: unknown; + }>; +} + +export interface HerdrInteractiveRuntime extends HerdrStartRuntime { + send(input: { runtimeRef: unknown; prompt: string }): Promise; + wait(input: { runtimeRef: unknown; timeoutMs: number }): Promise; + readOutput(input: { runtimeRef: unknown; lines?: number }): Promise; + focus(input: { runtimeRef: unknown }): Promise; + stop(input: { runtimeRef: unknown }): Promise; +} + +export function createHerdrRuntime(): HerdrInteractiveRuntime { + return new HerdrAgentRuntime(); +} + +export function createInteractiveRuntime(provider: AgentRuntimeProvider): HerdrInteractiveRuntime | null { + if (provider === 'herdr') return createHerdrRuntime(); + return null; +} + +export function isHerdrRegistryEntry(entry: unknown): entry is RegistryEntry & { runtime: 'herdr'; runtimeRef: unknown } { + return typeof entry === 'object' + && entry !== null + && (entry as { runtime?: unknown }).runtime === 'herdr' + && 'runtimeRef' in entry; +} diff --git a/packages/agent-manager/src/runtime/HerdrAgentRuntime.ts b/packages/agent-manager/src/runtime/HerdrAgentRuntime.ts new file mode 100644 index 00000000..f9dc1295 --- /dev/null +++ b/packages/agent-manager/src/runtime/HerdrAgentRuntime.ts @@ -0,0 +1,343 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); +const AGENT_START_BUSY_RETRY_MS = 5000; +const AGENT_START_BUSY_RETRY_INTERVAL_MS = 100; + +export interface HerdrCommandResult { + stdout: string; + stderr: string; +} + +export type HerdrCommandRunner = (command: string, args: string[]) => Promise; + +export interface HerdrRuntimeOptions { + runner?: HerdrCommandRunner; + env?: NodeJS.ProcessEnv; + agentStartBusyRetryMs?: number; + agentStartBusyRetryIntervalMs?: number; +} + +export type HerdrRuntimeAvailability = + | { ok: true; insideRuntime: boolean; currentPaneId?: string } + | { ok: false; reason: 'binary-missing' | 'backend-unreachable'; detail: string }; + +export interface HerdrRuntimeRef { + session: string; + workspaceId?: string; + tabId?: string; + paneId: string; + agentName?: string; +} + +export interface HerdrStartInput { + name: string; + cwd: string; + kind: string; + args?: string[]; + timeoutMs?: number; +} + +export interface HerdrStartResult { + pid: number; + runtimeRef: HerdrRuntimeRef; +} + +export class HerdrRuntimeError extends Error { + readonly code?: string; + + constructor(message: string, options: { code?: string } = {}) { + super(message); + this.name = 'HerdrRuntimeError'; + this.code = options.code; + } +} + +export class HerdrAgentRuntime { + readonly provider = 'herdr'; + + private readonly runner: HerdrCommandRunner; + private readonly env: NodeJS.ProcessEnv; + private readonly agentStartBusyRetryMs: number; + private readonly agentStartBusyRetryIntervalMs: number; + + constructor(options: HerdrRuntimeOptions = {}) { + this.runner = options.runner ?? defaultRunner; + this.env = options.env ?? process.env; + this.agentStartBusyRetryMs = options.agentStartBusyRetryMs ?? AGENT_START_BUSY_RETRY_MS; + this.agentStartBusyRetryIntervalMs = options.agentStartBusyRetryIntervalMs ?? AGENT_START_BUSY_RETRY_INTERVAL_MS; + } + + async isAvailable(): Promise { + try { + await this.runner('herdr', ['--version']); + } catch (error) { + if (isCommandMissing(error)) { + return { + ok: false, + reason: 'binary-missing', + detail: 'herdr command was not found in PATH.', + }; + } + return { + ok: false, + reason: 'backend-unreachable', + detail: (error as Error).message, + }; + } + + try { + await this.runner('herdr', ['api', 'snapshot']); + } catch (error) { + return { + ok: false, + reason: 'backend-unreachable', + detail: (error as Error).message, + }; + } + + const currentPaneId = nonEmptyString(this.env.HERDR_PANE_ID); + return { + ok: true, + insideRuntime: this.env.HERDR_ENV === '1', + ...(currentPaneId ? { currentPaneId } : {}), + }; + } + + async startAgent(input: HerdrStartInput): Promise { + const workspace = await this.runJson([ + 'workspace', + 'create', + '--cwd', + input.cwd, + '--label', + input.name, + ]); + const workspaceId = getString(workspace, ['result', 'workspace', 'workspace_id']); + const paneId = getString(workspace, ['result', 'root_pane', 'pane_id']); + if (!paneId) { + throw new HerdrRuntimeError('Herdr workspace create response did not include root pane id.'); + } + + const agentResponse = await this.runAgentStartJson([ + 'agent', + 'start', + input.name, + '--kind', + input.kind, + '--pane', + paneId, + ...(input.timeoutMs ? ['--timeout', String(input.timeoutMs)] : []), + ...(input.args?.length ? ['--', ...input.args] : []), + ]); + const agentPaneId = getString(agentResponse, ['result', 'agent', 'pane_id']) ?? paneId; + const agentName = getString(agentResponse, ['result', 'agent', 'name']) ?? input.name; + const tabId = getString(agentResponse, ['result', 'agent', 'tab_id']); + const processInfoResponse = await this.runJson(['pane', 'process-info', '--pane', agentPaneId]); + const processInfo = getObject(processInfoResponse, ['result', 'process_info']); + const pid = extractProcessInfoPid(processInfo); + if (pid === null) { + throw new HerdrRuntimeError('Herdr pane process-info response did not include process pid.'); + } + + return { + pid, + runtimeRef: { + session: 'default', + ...(workspaceId ? { workspaceId } : {}), + ...(tabId ? { tabId } : {}), + paneId: agentPaneId, + agentName, + }, + }; + } + + async send(input: { runtimeRef: unknown; prompt: string }): Promise { + const ref = parseHerdrRuntimeRef(input.runtimeRef); + await this.runner('herdr', ['agent', 'prompt', ref.paneId, input.prompt]); + } + + async wait(input: { runtimeRef: unknown; timeoutMs: number }): Promise { + const ref = parseHerdrRuntimeRef(input.runtimeRef); + await this.runner('herdr', [ + 'agent', + 'wait', + ref.paneId, + '--until', + 'done', + '--until', + 'blocked', + '--timeout', + String(input.timeoutMs), + ]); + } + + async readOutput(input: { runtimeRef: unknown; lines?: number }): Promise { + const ref = parseHerdrRuntimeRef(input.runtimeRef); + const { stdout } = await this.runner('herdr', [ + 'agent', + 'read', + ref.paneId, + '--source', + 'recent-unwrapped', + '--lines', + String(input.lines ?? 120), + ]); + return stdout; + } + + async focus(input: { runtimeRef: unknown }): Promise { + const ref = parseHerdrRuntimeRef(input.runtimeRef); + await this.runner('herdr', ['agent', 'focus', ref.paneId]); + return true; + } + + async stop(input: { runtimeRef: unknown }): Promise { + const ref = parseHerdrRuntimeRef(input.runtimeRef); + await this.runner('herdr', ['pane', 'close', ref.paneId]); + } + + private async runJson(args: string[]): Promise { + let stdout: string; + try { + ({ stdout } = await this.runner('herdr', args)); + } catch (error) { + throw parseHerdrCommandError(error); + } + try { + const parsed = JSON.parse(stdout) as unknown; + const herdrError = herdrErrorFromResponse(parsed); + if (herdrError) throw herdrError; + return parsed; + } catch (error) { + if (error instanceof HerdrRuntimeError) throw error; + throw new HerdrRuntimeError(`Herdr returned invalid JSON: ${(error as Error).message}`); + } + } + + private async runAgentStartJson(args: string[]): Promise { + const deadline = Date.now() + this.agentStartBusyRetryMs; + let lastBusyError: HerdrRuntimeError | null = null; + do { + try { + return await this.runJson(args); + } catch (error) { + if (!(error instanceof HerdrRuntimeError) || error.code !== 'agent_pane_busy') throw error; + lastBusyError = error; + if (Date.now() >= deadline) break; + await sleep(this.agentStartBusyRetryIntervalMs); + } + } while (Date.now() < deadline); + throw lastBusyError ?? new HerdrRuntimeError('Herdr agent start failed because the target pane stayed busy.', { code: 'agent_pane_busy' }); + } +} + +export function parseHerdrRuntimeRef(value: unknown): HerdrRuntimeRef { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new HerdrRuntimeError('Herdr runtime ref is missing or invalid.'); + } + const ref = value as { + session?: unknown; + workspaceId?: unknown; + tabId?: unknown; + paneId?: unknown; + agentName?: unknown; + }; + const session = nonEmptyString(ref.session) ?? 'default'; + const workspaceId = nonEmptyString(ref.workspaceId); + const tabId = nonEmptyString(ref.tabId); + const paneId = nonEmptyString(ref.paneId); + const agentName = nonEmptyString(ref.agentName); + if (!paneId) { + throw new HerdrRuntimeError('Herdr runtime ref is missing paneId.'); + } + return { + session, + paneId, + ...(workspaceId ? { workspaceId } : {}), + ...(tabId ? { tabId } : {}), + ...(agentName ? { agentName } : {}), + }; +} + +function defaultRunner(command: string, args: string[]): Promise { + return execFileAsync(command, args).then(({ stdout, stderr }) => ({ stdout, stderr })); +} + +function parseHerdrCommandError(error: unknown): HerdrRuntimeError { + const stdout = typeof error === 'object' && error && 'stdout' in error + ? String((error as { stdout?: unknown }).stdout ?? '') + : ''; + const stderr = typeof error === 'object' && error && 'stderr' in error + ? String((error as { stderr?: unknown }).stderr ?? '') + : ''; + const parsed = parseJson(stdout) ?? parseJson(stderr); + if (parsed) { + const herdrError = herdrErrorFromResponse(parsed); + if (herdrError) return herdrError; + } + return new HerdrRuntimeError((error as Error).message); +} + +function herdrErrorFromResponse(value: unknown): HerdrRuntimeError | null { + const error = getObject(value, ['error']); + if (!error) return null; + const message = nonEmptyString(error.message) ?? 'Herdr command failed.'; + const code = nonEmptyString(error.code); + return new HerdrRuntimeError(message, code ? { code } : {}); +} + +function parseJson(value: string): unknown | null { + if (!value.trim()) return null; + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +function sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isCommandMissing(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function getObject(value: unknown, path: string[]): Record | null { + let current = value; + for (const part of path) { + if (!current || typeof current !== 'object' || Array.isArray(current) || !(part in current)) return null; + current = (current as Record)[part]; + } + return current && typeof current === 'object' && !Array.isArray(current) + ? current as Record + : null; +} + +function getString(value: unknown, path: string[]): string | undefined { + const parent = getObject(value, path.slice(0, -1)); + return parent ? nonEmptyString(parent[path[path.length - 1]!]) : undefined; +} + +function extractProcessInfoPid(processInfo: Record | null): number | null { + if (!processInfo) return null; + const processes = processInfo.foreground_processes; + if (!Array.isArray(processes)) return numberOrNull(processInfo.shell_pid); + for (const processInfo of processes) { + if (!processInfo || typeof processInfo !== 'object') continue; + const pid = numberOrNull((processInfo as { pid?: unknown }).pid); + if (pid !== null) return pid; + } + return numberOrNull(processInfo.shell_pid); +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null; +} diff --git a/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts new file mode 100644 index 00000000..5adccdbc --- /dev/null +++ b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts @@ -0,0 +1,336 @@ +import type { AgentInfo } from '../adapters/AgentAdapter.js'; +import type { TerminalLocation } from '../terminal/TerminalFocusManager.js'; +import { TmuxManager } from '../terminal/TmuxManager.js'; +import { TtyWriter } from '../terminal/TtyWriter.js'; +import { + AgentRegistry, + parseTmuxRuntimeRef, + type AgentRuntimeProvider, + type RegistryEntry, +} from '../utils/AgentRegistry.js'; +import { AGENTS, type StartableAgentType } from '../utils/agents.js'; +import { createHerdrRuntime, isHerdrRegistryEntry, type HerdrStartRuntime, type HerdrInteractiveRuntime } from './AgentRuntime.js'; + +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; + runtimeProvider?: AgentRuntimeProvider; + pollIntervalMs?: number; + pollTimeoutMs?: number; +} + +export interface StartAgentDeps { + tmux?: TmuxManager; + runtime?: HerdrStartRuntime; + registry?: AgentRegistry; + /** Called for non-fatal events (e.g., replacing an orphan tmux session). */ + onWarning?: (message: string) => void; +} + +export interface StopAgentDeps { + tmux?: Pick; + runtime?: HerdrInteractiveRuntime; + registry?: Pick; + killProcess?: (pid: number, signal: NodeJS.Signals) => void; +} + +export interface StopAgentResult { + agentName: string; + pid: number; + runtime: AgentRuntimeProvider; + runtimeRef: unknown | null; +} + +export interface FocusAgentDeps { + runtime?: HerdrInteractiveRuntime; + registry?: Pick; + focusManager: { + findTerminal(pid: number): Promise; + focusTerminal(location: TerminalLocation): Promise; + }; +} + +export type FocusAgentResult = + | { focused: true } + | { focused: false; reason: 'terminal-not-found' | 'focus-failed' }; + +export interface SendAgentPromptDeps { + runtime?: HerdrInteractiveRuntime; + registry?: Pick; + focusManager: { + findTerminal(pid: number): Promise; + }; + writer?: (location: TerminalLocation, text: string) => Promise; +} + +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'; + } +} + +export class AgentRuntimeUnavailableError extends Error { + constructor(public provider: AgentRuntimeProvider, public reason: string, public detail: string) { + super(`${provider} runtime is unavailable (${reason}): ${detail}`); + this.name = 'AgentRuntimeUnavailableError'; + } +} + +export class AgentTerminalNotFoundError extends Error { + constructor(public agentName: string, public pid: number) { + super(`Cannot find terminal for agent "${agentName}" (PID: ${pid}).`); + this.name = 'AgentTerminalNotFoundError'; + } +} + +/** + * Orchestrate `agent start`: ensure the configured runtime is available, + * create the runtime session, detect the agent PID, and register the entry. + * Callers are responsible for user input validation before invoking this. + */ +export async function startAgent( + opts: StartAgentOptions, + deps: StartAgentDeps = {}, +): Promise { + const registry = deps.registry ?? AgentRegistry.default(); + const agent = AGENTS[opts.type]; + const intervalMs = opts.pollIntervalMs ?? DEFAULT_PID_POLL_INTERVAL_MS; + const timeoutMs = opts.pollTimeoutMs ?? DEFAULT_PID_POLL_TIMEOUT_MS; + const provider = opts.runtimeProvider ?? deps.runtime?.provider ?? 'tmux'; + + if (provider === 'herdr') { + return startAgentWithHerdr(opts, deps.runtime ?? createHerdrRuntime(), registry, timeoutMs); + } + + const tmux = deps.tmux ?? new TmuxManager(); + if (!await tmux.isAvailable()) { + throw new TmuxUnavailableError(); + } + + registry.prune(); + const existing = registry.lookup(opts.name); + if (existing) { + throw new AgentNameInUseError(opts.name, existing.pid); + } + + if (await tmux.sessionExists(opts.name)) { + deps.onWarning?.( + `tmux session "${opts.name}" already exists but has no live registry entry — it will be replaced.`, + ); + await tmux.killSession(opts.name); + } + + await tmux.createSession(opts.name, opts.cwd); + await tmux.sendKeys(opts.name, agent.command); + + const agentPid = await pollForPid(tmux, opts.name, agent.matches, intervalMs, timeoutMs); + if (agentPid === null) { + await tmux.killSession(opts.name); + throw new AgentPidPollTimeoutError(opts.name, agent.command, timeoutMs); + } + + const entry: RegistryEntry = { + name: opts.name, + type: opts.type, + pid: agentPid, + runtime: 'tmux', + runtimeRef: { session: opts.name }, + cwd: opts.cwd, + startedAt: new Date().toISOString(), + sessionId: '', + sessionFilePath: '', + pinned: false, + }; + registry.register(entry); + return entry; +} + +export async function stopAgent( + agent: Pick, + deps: StopAgentDeps = {}, +): Promise { + const registry = deps.registry ?? AgentRegistry.default(); + const registryEntry = registry.lookup(agent.name); + if (isHerdrRegistryEntry(registryEntry)) { + await (deps.runtime ?? createHerdrRuntime()).stop({ runtimeRef: registryEntry.runtimeRef }); + return { + agentName: agent.name, + pid: agent.pid, + runtime: 'herdr', + runtimeRef: registryEntry.runtimeRef, + }; + } + + const tmux = deps.tmux ?? new TmuxManager(); + const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal)); + const tmuxRuntimeRef = registryEntry?.runtime === 'tmux' + ? parseTmuxRuntimeRef(registryEntry.runtimeRef) + : null; + + try { + killProcess(agent.pid, 'SIGTERM'); + } catch (error) { + if (!isProcessAlreadyGone(error)) { + throw error; + } + } + + if (tmuxRuntimeRef) { + await tmux.killSession(tmuxRuntimeRef.session); + } + + return { + agentName: agent.name, + pid: agent.pid, + runtime: 'tmux', + runtimeRef: registryEntry?.runtimeRef ?? null, + }; +} + +export async function focusAgent( + agent: Pick, + deps: FocusAgentDeps, +): Promise { + const registry = deps.registry ?? AgentRegistry.default(); + const registryEntry = registry.lookup(agent.name); + if (isHerdrRegistryEntry(registryEntry)) { + const focused = await (deps.runtime ?? createHerdrRuntime()).focus({ runtimeRef: registryEntry.runtimeRef }); + return focused ? { focused: true } : { focused: false, reason: 'focus-failed' }; + } + + const location = await deps.focusManager.findTerminal(agent.pid); + if (!location) return { focused: false, reason: 'terminal-not-found' }; + + const focused = await deps.focusManager.focusTerminal(location); + return focused ? { focused: true } : { focused: false, reason: 'focus-failed' }; +} + +export async function sendAgentPrompt( + agent: Pick, + prompt: string, + deps: SendAgentPromptDeps, +): Promise { + const registry = deps.registry ?? AgentRegistry.default(); + const registryEntry = registry.lookup(agent.name); + + if (isHerdrRegistryEntry(registryEntry)) { + await (deps.runtime ?? createHerdrRuntime()).send({ runtimeRef: registryEntry.runtimeRef, prompt }); + return; + } + + const location = await deps.focusManager.findTerminal(agent.pid); + if (!location) { + throw new AgentTerminalNotFoundError(agent.name, agent.pid); + } + + await (deps.writer ?? TtyWriter.send)(location, prompt); +} + +async function startAgentWithHerdr( + opts: StartAgentOptions, + runtime: HerdrStartRuntime, + registry: AgentRegistry, + timeoutMs: number, +): Promise { + const availability = await runtime.isAvailable(); + if (!availability.ok) { + throw new AgentRuntimeUnavailableError(runtime.provider, availability.reason, availability.detail); + } + + registry.prune(); + const existing = registry.lookup(opts.name); + if (existing) { + throw new AgentNameInUseError(opts.name, existing.pid); + } + + const result = await runtime.startAgent({ + name: opts.name, + cwd: opts.cwd, + kind: herdrAgentKind(opts.type), + args: [], + timeoutMs, + }); + + const entry: RegistryEntry = { + name: opts.name, + type: opts.type, + pid: result.pid, + runtime: runtime.provider, + runtimeRef: result.runtimeRef, + cwd: opts.cwd, + startedAt: new Date().toISOString(), + sessionId: '', + sessionFilePath: '', + pinned: false, + }; + registry.register(entry); + return entry; +} + +function herdrAgentKind(type: StartableAgentType): string { + return { + claude: 'claude', + codex: 'codex', + copilot: 'copilot', + gemini_cli: 'gemini', + grok_cli: 'grok', + opencode: 'opencode', + pi: 'pi', + }[type]; +} + +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; + } + + if (stablePolls >= REQUIRED_STABLE_PID_POLLS) return pid; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + return null; +} + +function isProcessAlreadyGone(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && (error as NodeJS.ErrnoException).code === 'ESRCH'; +} diff --git a/packages/agent-manager/src/utils/AgentRegistry.ts b/packages/agent-manager/src/utils/AgentRegistry.ts index 3614c07c..99def9bd 100644 --- a/packages/agent-manager/src/utils/AgentRegistry.ts +++ b/packages/agent-manager/src/utils/AgentRegistry.ts @@ -24,7 +24,8 @@ export interface RegistryEntry { name: string; type: AgentType; pid: number; - tmuxSession: string; + runtime: AgentRuntimeProvider; + runtimeRef: unknown | null; cwd: string; startedAt: string; // ISO 8601 sessionId: string; @@ -38,6 +39,8 @@ interface RegistryRow { type: AgentType; pid: number; tmux_session: string; + runtime?: string; + runtime_ref?: string; cwd: string; started_at: string; session_id: string; @@ -46,6 +49,12 @@ interface RegistryRow { pinned: number; } +export const AGENT_RUNTIME_PROVIDERS = ['tmux', 'herdr'] as const; +export type AgentRuntimeProvider = typeof AGENT_RUNTIME_PROVIDERS[number]; +export interface TmuxRuntimeRef { + session: string; +} + const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json'); const DEFAULT_PRUNE_INTERVAL_MS = 30_000; @@ -84,11 +93,13 @@ export class AgentRegistry { } private rowToEntry(row: RegistryRow): RegistryEntry { + const runtime = this.parseRuntime(row.runtime); return { name: row.name, type: row.type, pid: row.pid, - tmuxSession: row.tmux_session, + runtime, + runtimeRef: this.parseRuntimeRef(runtime, row.runtime_ref, row.tmux_session), cwd: row.cwd, startedAt: row.started_at, sessionId: row.session_id, @@ -98,13 +109,38 @@ export class AgentRegistry { }; } + private parseRuntime(value: string | undefined): AgentRuntimeProvider { + return value === 'herdr' ? 'herdr' : 'tmux'; + } + + private parseRuntimeRef( + runtime: AgentRuntimeProvider, + raw: string | undefined, + legacyTmuxSession: string, + ): unknown | null { + if (raw) { + try { + return JSON.parse(raw) as unknown; + } catch { + return null; + } + } + + if (runtime === 'tmux' && legacyTmuxSession) { + return { session: legacyTmuxSession }; + } + + return null; + } + private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry { if (!existing) return incoming; - const incomingIsManaged = Boolean(incoming.tmuxSession); + const incomingIsManaged = incoming.runtime === 'herdr' || Boolean(parseTmuxRuntimeRef(incoming.runtimeRef)); return { ...existing, name: incomingIsManaged ? incoming.name : existing.name, - tmuxSession: incoming.tmuxSession || existing.tmuxSession, + runtime: incoming.runtime ?? existing.runtime, + runtimeRef: incoming.runtimeRef ?? existing.runtimeRef, cwd: incoming.cwd || existing.cwd, startedAt: existing.startedAt || incoming.startedAt, sessionId: incoming.sessionId || existing.sessionId, @@ -136,7 +172,8 @@ export class AgentRegistry { return left.name === right.name && left.type === right.type && left.pid === right.pid - && left.tmuxSession === right.tmuxSession + && left.runtime === right.runtime + && JSON.stringify(left.runtimeRef ?? null) === JSON.stringify(right.runtimeRef ?? null) && left.cwd === right.cwd && left.startedAt === right.startedAt && left.sessionId === right.sessionId @@ -156,19 +193,28 @@ export class AgentRegistry { this.db.instance.prepare(` INSERT INTO agents ( type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at + , runtime, runtime_ref ) VALUES ( - @type, @pid, @name, @tmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt + @type, @pid, @name, @legacyTmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt + , @runtime, @runtimeRefJson ) ON CONFLICT(type, pid) DO UPDATE SET name = excluded.name, tmux_session = excluded.tmux_session, + runtime = excluded.runtime, + runtime_ref = excluded.runtime_ref, cwd = excluded.cwd, started_at = agents.started_at, session_id = excluded.session_id, session_file_path = excluded.session_file_path, updated_at = excluded.updated_at - `).run({ ...entry, updatedAt: this.now().toISOString() }); + `).run({ + ...entry, + legacyTmuxSession: parseTmuxRuntimeRef(entry.runtimeRef)?.session ?? '', + runtimeRefJson: JSON.stringify(entry.runtimeRef ?? null), + updatedAt: this.now().toISOString(), + }); } private needsWrite(incoming: RegistryEntry): boolean { @@ -286,3 +332,9 @@ export class AgentRegistry { return rows.map((row) => this.rowToEntry(row)); } } + +export function parseTmuxRuntimeRef(value: unknown): TmuxRuntimeRef | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const session = (value as { session?: unknown }).session; + return typeof session === 'string' && session.trim() ? { session } : null; +} diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 46858068..9a0b5130 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -1,7 +1,7 @@ import type { Mock } from 'vitest'; import { Command } from 'commander'; -import { AgentManager, AgentStatus, TerminalFocusManager } from '@ai-devkit/agent-manager'; +import { AgentManager, AgentStatus, DEFAULT_PID_POLL_TIMEOUT_MS, TerminalFocusManager } from '@ai-devkit/agent-manager'; import { registerAgentCommand } from '../../commands/agent.js'; import { ui } from '../../util/terminal-ui.js'; @@ -56,8 +56,24 @@ const mockSpinner: any = { const mockSelect: any = vi.fn(); const mockTtyWriterSend = vi.fn<(location: any, message: string) => Promise>().mockResolvedValue(undefined); -const mockKillAgent = vi.fn<(...args: any[]) => Promise>(); -const { mockEnableDebug, mockDebugLogger, mockTmuxIsAvailable, mockTmuxInstructions } = vi.hoisted(() => ({ +const mockStartAgent = vi.fn<(...args: any[]) => Promise>(); +const mockStopAgent = vi.fn<(...args: any[]) => Promise>(); +const mockFocusAgent = vi.fn<(...args: any[]) => Promise<{ focused: true } | { focused: false; reason: string }>>(); +const mockSendAgentPrompt = vi.fn<(...args: any[]) => Promise>(); +const { + mockEnableDebug, + mockDebugLogger, + mockTmuxIsAvailable, + mockTmuxInstructions, + mockAgentRuntimeProvider, + mockHerdrIsAvailable, + mockHerdrStartAgent, + mockHerdrSend, + mockHerdrWait, + mockHerdrReadOutput, + mockHerdrFocus, + mockHerdrStop, +} = vi.hoisted(() => ({ mockEnableDebug: vi.fn(), mockDebugLogger: vi.fn(), mockTmuxIsAvailable: vi.fn().mockResolvedValue(true), @@ -65,6 +81,17 @@ const { mockEnableDebug, mockDebugLogger, mockTmuxIsAvailable, mockTmuxInstructi command: 'sudo apt-get update && sudo apt-get install tmux', message: 'Install it with: sudo apt-get update && sudo apt-get install tmux.', }), + mockAgentRuntimeProvider: vi.fn().mockResolvedValue('tmux'), + mockHerdrIsAvailable: vi.fn().mockResolvedValue({ ok: true, insideRuntime: false }), + mockHerdrStartAgent: vi.fn().mockResolvedValue({ + pid: 12345, + runtimeRef: { session: 'default', paneId: 'w1:p2', agentName: 'agent1' }, + }), + mockHerdrSend: vi.fn().mockResolvedValue(undefined), + mockHerdrWait: vi.fn().mockResolvedValue(undefined), + mockHerdrReadOutput: vi.fn().mockResolvedValue('done\n'), + mockHerdrFocus: vi.fn().mockResolvedValue(true), + mockHerdrStop: vi.fn().mockResolvedValue(undefined), })); let restoreStdin: (() => void) | undefined; @@ -87,7 +114,15 @@ const mockRegistry: any = { rename: vi.fn(), }; -const { RenameNotFoundError, RenameConflictError } = vi.hoisted(() => { +const { + RenameNotFoundError, + RenameConflictError, + TmuxUnavailableError, + AgentNameInUseError, + AgentPidPollTimeoutError, + AgentRuntimeUnavailableError, + AgentTerminalNotFoundError, +} = vi.hoisted(() => { class RenameNotFoundError extends Error { agentName: string; constructor(agentName: string) { @@ -104,7 +139,45 @@ const { RenameNotFoundError, RenameConflictError } = vi.hoisted(() => { this.agentName = agentName; } } - return { RenameNotFoundError, RenameConflictError }; + class TmuxUnavailableError extends Error { + constructor() { + super('tmux is not installed or not in PATH.'); + this.name = 'TmuxUnavailableError'; + } + } + class AgentNameInUseError extends Error { + constructor(public agentName: string, public pid: number) { + super(`Agent "${agentName}" is already running (PID ${pid}).`); + this.name = 'AgentNameInUseError'; + } + } + 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'; + } + } + class AgentRuntimeUnavailableError extends Error { + constructor(public provider: string, public reason: string, public detail: string) { + super(`${provider} runtime is unavailable (${reason}): ${detail}`); + this.name = 'AgentRuntimeUnavailableError'; + } + } + class AgentTerminalNotFoundError extends Error { + constructor(public agentName: string, public pid: number) { + super(`Cannot find terminal for agent "${agentName}" (PID: ${pid}).`); + this.name = 'AgentTerminalNotFoundError'; + } + } + return { + RenameNotFoundError, + RenameConflictError, + TmuxUnavailableError, + AgentNameInUseError, + AgentPidPollTimeoutError, + AgentRuntimeUnavailableError, + AgentTerminalNotFoundError, + }; }); vi.mock('@ai-devkit/agent-manager', () => ({ @@ -132,14 +205,47 @@ vi.mock('@ai-devkit/agent-manager', () => ({ AgentRegistry: { default: vi.fn(function () { return mockRegistry; }), }, - TmuxManager: vi.fn(function () { return { - isAvailable: mockTmuxIsAvailable, - sessionExists: vi.fn().mockResolvedValue(false), - createSession: vi.fn().mockResolvedValue(undefined), - sendKeys: vi.fn().mockResolvedValue(undefined), - findAgentPid: vi.fn().mockResolvedValue(12345), - killSession: vi.fn().mockResolvedValue(undefined), - }; }), + startAgent: (...args: any[]) => mockStartAgent(...args), + stopAgent: (...args: any[]) => mockStopAgent(...args), + focusAgent: (...args: any[]) => mockFocusAgent(...args), + sendAgentPrompt: (...args: any[]) => mockSendAgentPrompt(...args), + TmuxUnavailableError, + AgentNameInUseError, + AgentPidPollTimeoutError, + AgentRuntimeUnavailableError, + AgentTerminalNotFoundError, + DEFAULT_PID_POLL_TIMEOUT_MS: 15_000, + createHerdrRuntime: vi.fn(() => ({ + provider: 'herdr', + isAvailable: mockHerdrIsAvailable, + startAgent: mockHerdrStartAgent, + send: mockHerdrSend, + wait: mockHerdrWait, + readOutput: mockHerdrReadOutput, + focus: mockHerdrFocus, + stop: mockHerdrStop, + })), + createInteractiveRuntime: vi.fn((provider: string) => provider === 'herdr' ? { + provider: 'herdr', + isAvailable: mockHerdrIsAvailable, + startAgent: mockHerdrStartAgent, + send: mockHerdrSend, + wait: mockHerdrWait, + readOutput: mockHerdrReadOutput, + focus: mockHerdrFocus, + stop: mockHerdrStop, + } : null), + isHerdrRegistryEntry: vi.fn((entry: unknown) => ( + typeof entry === 'object' + && entry !== null + && (entry as { runtime?: unknown }).runtime === 'herdr' + && 'runtimeRef' in entry + )), + parseTmuxRuntimeRef: vi.fn((value: unknown) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const session = (value as { session?: unknown }).session; + return typeof session === 'string' && session ? { session } : null; + }), AGENTS: { claude: { command: 'claude', matches: () => true }, codex: { command: 'codex', matches: () => true }, @@ -181,13 +287,13 @@ vi.mock('../../util/tmux.js', () => ({ vi.mock('../../util/tmux-deps.js', () => ({ createTmuxInspectionDeps: () => ({}) })); -vi.mock('../../services/agent/agent.service.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - killAgent: (...args: any[]) => mockKillAgent(...args), - }; -}); +vi.mock('../../lib/Config.js', () => ({ + ConfigManager: vi.fn(function () { + return { + getAgentRuntimeProvider: mockAgentRuntimeProvider, + }; + }), +})); vi.mock('../../tui/console/ConsoleApp.js', () => ({ ConsoleApp: () => null, @@ -265,8 +371,105 @@ describe('agent command', () => { mockFocusManager.findTerminal.mockReset(); mockFocusManager.focusTerminal.mockReset(); mockTtyWriterSend.mockReset().mockResolvedValue(undefined); - mockKillAgent.mockReset(); + mockStartAgent.mockReset().mockImplementation(async (opts: any) => { + if (opts.runtimeProvider === 'herdr') { + const availability = await mockHerdrIsAvailable(); + if (!availability.ok) { + throw new AgentRuntimeUnavailableError('herdr', availability.reason, availability.detail); + } + const result = await mockHerdrStartAgent({ + name: opts.name, + cwd: opts.cwd, + kind: opts.type === 'gemini_cli' ? 'gemini' : opts.type, + args: [], + timeoutMs: DEFAULT_PID_POLL_TIMEOUT_MS, + }); + return { + name: opts.name, + type: opts.type, + pid: result.pid, + runtime: 'herdr', + runtimeRef: result.runtimeRef, + cwd: opts.cwd, + startedAt: new Date().toISOString(), + sessionId: '', + sessionFilePath: '', + pinned: false, + }; + } + if (!await mockTmuxIsAvailable()) throw new TmuxUnavailableError(); + return { + name: opts.name, + type: opts.type, + pid: 12345, + runtime: 'tmux', + runtimeRef: { session: opts.name }, + cwd: opts.cwd, + startedAt: new Date().toISOString(), + sessionId: '', + sessionFilePath: '', + pinned: false, + }; + }); + mockStopAgent.mockReset().mockImplementation(async (agent: any, deps: any) => { + const registryEntry = deps.registry.lookup(agent.name); + if (registryEntry?.runtime === 'herdr') { + await mockHerdrStop({ runtimeRef: registryEntry.runtimeRef }); + return { + agentName: agent.name, + pid: agent.pid, + runtime: 'herdr', + runtimeRef: registryEntry.runtimeRef, + }; + } + return { + agentName: agent.name, + pid: agent.pid, + runtime: 'tmux', + runtimeRef: { session: agent.name }, + }; + }); + mockFocusAgent.mockReset().mockImplementation(async (_agent: any, deps: any) => { + const registryEntry = deps.registry.lookup(_agent.name); + if (registryEntry?.runtime === 'herdr') { + await mockHerdrFocus({ runtimeRef: registryEntry.runtimeRef }); + return { focused: true }; + } + const location = await deps.focusManager.findTerminal(_agent.pid); + if (!location) return { focused: false, reason: 'terminal-not-found' }; + const focused = await deps.focusManager.focusTerminal(location); + return focused ? { focused: true } : { focused: false, reason: 'focus-failed' }; + }); + mockSendAgentPrompt.mockReset().mockImplementation(async (agent: any, prompt: string, deps: any) => { + const registryEntry = deps.registry?.lookup(agent.name); + if (registryEntry?.runtime === 'herdr') { + await mockHerdrSend({ runtimeRef: registryEntry.runtimeRef, prompt }); + return; + } + const location = await deps.focusManager.findTerminal(agent.pid); + if (!location) { + throw new AgentTerminalNotFoundError(agent.name, agent.pid); + } + await deps.writer(location, prompt); + }); + mockRegistry.prune.mockReset(); + mockRegistry.lookup.mockReset().mockReturnValue(null); + mockRegistry.list.mockReset().mockReturnValue([]); + mockRegistry.register.mockReset(); + mockRegistry.isAlive.mockReset().mockReturnValue(false); + mockRegistry.rename.mockReset(); mockTmuxIsAvailable.mockReset().mockResolvedValue(true); + mockAgentRuntimeProvider.mockReset().mockResolvedValue('tmux'); + mockHerdrIsAvailable.mockReset().mockResolvedValue({ ok: true, insideRuntime: false }); + mockHerdrStartAgent.mockReset().mockResolvedValue({ + pid: 12345, + runtimeRef: { session: 'default', paneId: 'w1:p2', agentName: 'agent1' }, + }); + mockHerdrSend.mockReset().mockResolvedValue(undefined); + mockHerdrWait.mockReset().mockResolvedValue(undefined); + mockHerdrReadOutput.mockReset().mockResolvedValue('done\n'); + mockHerdrFocus.mockReset().mockResolvedValue(true); + mockHerdrStop.mockReset().mockResolvedValue(undefined); mockTmuxInstructions.mockReset().mockResolvedValue({ command: 'sudo apt-get update && sudo apt-get install tmux', message: 'Install it with: sudo apt-get update && sudo apt-get install tmux.', @@ -386,6 +589,46 @@ describe('agent command', () => { expect(process.exit).toHaveBeenCalledWith(1); }); + it('starts interactive agents with Herdr when the global runtime provider is herdr', async () => { + mockAgentRuntimeProvider.mockResolvedValue('herdr'); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync(['node', 'test', 'agent', 'start', '--type', 'codex', '--name', 'agent1', '--cwd', process.cwd()]); + + expect(mockHerdrIsAvailable).toHaveBeenCalledOnce(); + expect(mockHerdrStartAgent).toHaveBeenCalledWith({ + name: 'agent1', + cwd: process.cwd(), + kind: 'codex', + args: [], + timeoutMs: DEFAULT_PID_POLL_TIMEOUT_MS, + }); + expect(mockTmuxIsAvailable).not.toHaveBeenCalled(); + expect(ui.success).toHaveBeenCalledWith('Agent "agent1" started (codex, PID 12345)'); + expect(ui.text).toHaveBeenCalledWith('Runtime: herdr'); + expect(ui.text).not.toHaveBeenCalledWith(expect.stringContaining('tmux attach')); + }); + + it('reports Herdr runtime availability errors during interactive start', async () => { + mockAgentRuntimeProvider.mockResolvedValue('herdr'); + mockHerdrIsAvailable.mockResolvedValue({ + ok: false, + reason: 'backend-unreachable', + detail: 'herdr api snapshot failed.', + }); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync(['node', 'test', 'agent', 'start', '--type', 'codex', '--name', 'agent1']); + + expect(ui.error).toHaveBeenCalledWith( + 'Herdr runtime is unavailable (backend-unreachable): herdr api snapshot failed.', + ); + expect(mockHerdrStartAgent).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); + it('shows info when no agents are running', async () => { mockManager.listAgents.mockResolvedValue([]); @@ -539,6 +782,71 @@ Waiting on user input`, expect(mockSpinner.succeed).toHaveBeenCalledWith('Focused repo-a!'); }); + it('reports when an agent terminal cannot be found', async () => { + const agent = { + name: 'repo-a', + status: AgentStatus.WAITING, + summary: 'A', + lastActive: new Date(), + pid: 10, + }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockFocusManager.findTerminal.mockResolvedValue(null); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'open', 'repo-a']); + + expect(mockFocusManager.findTerminal).toHaveBeenCalledWith(10); + expect(mockFocusManager.focusTerminal).not.toHaveBeenCalled(); + expect(mockSpinner.fail).toHaveBeenCalledWith('Could not find terminal window for agent "repo-a" (PID: 10).'); + }); + + it('reports when terminal focus fails after locating the agent', async () => { + const agent = { + name: 'repo-a', + status: AgentStatus.WAITING, + summary: 'A', + lastActive: new Date(), + pid: 10, + }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockFocusManager.findTerminal.mockResolvedValue({ type: 'tmux', identifier: '1:1' }); + mockFocusManager.focusTerminal.mockResolvedValue(false); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'open', 'repo-a']); + + expect(mockFocusManager.findTerminal).toHaveBeenCalledWith(10); + expect(mockFocusManager.focusTerminal).toHaveBeenCalled(); + expect(mockSpinner.fail).toHaveBeenCalledWith('Failed to switch focus to "repo-a".'); + }); + + it('focuses Herdr-backed agents through Herdr instead of terminal PID lookup', async () => { + const agent = { + name: 'repo-a', + status: AgentStatus.WAITING, + summary: 'A', + lastActive: new Date(), + pid: 10, + }; + const runtimeRef = { session: 'default', paneId: 'w1:p2', agentName: 'repo-a' }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockRegistry.lookup.mockReturnValue({ name: 'repo-a', runtime: 'herdr', runtimeRef, pid: 10 }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'open', 'repo-a']); + + expect(mockHerdrFocus).toHaveBeenCalledWith({ runtimeRef }); + expect(mockFocusManager.findTerminal).not.toHaveBeenCalled(); + expect(mockSpinner.succeed).toHaveBeenCalledWith('Focused repo-a!'); + }); + it('enables debug logging and wires a terminal trace when opening with --debug', async () => { const agent = { name: 'repo-a', @@ -575,10 +883,11 @@ Waiting on user input`, }; mockManager.listAgents.mockResolvedValue([agent]); mockManager.resolveAgent.mockReturnValue(agent); - mockKillAgent.mockResolvedValue({ + mockStopAgent.mockResolvedValue({ agentName: 'repo-a', pid: 10, - tmuxSession: 'repo-a', + runtime: 'tmux', + runtimeRef: { session: 'repo-a' }, }); const program = new Command(); @@ -586,13 +895,36 @@ Waiting on user input`, await program.parseAsync(['node', 'test', 'agent', 'kill', 'repo-a']); expect(mockManager.resolveAgent).toHaveBeenCalledWith('repo-a', [agent]); - expect(mockKillAgent).toHaveBeenCalledWith(agent, expect.objectContaining({ - tmux: expect.any(Object), + expect(mockStopAgent).toHaveBeenCalledWith(agent, expect.objectContaining({ registry: mockRegistry, + runtime: expect.any(Object), })); expect(ui.success).toHaveBeenCalledWith('Stopped agent "repo-a" (PID 10) and tmux session "repo-a".'); }); + it('stops Herdr-backed agents through Herdr instead of tmux cleanup', async () => { + const agent = { + name: 'repo-a', + type: 'codex', + status: AgentStatus.RUNNING, + summary: 'A', + lastActive: new Date(), + pid: 10, + }; + const runtimeRef = { session: 'default', paneId: 'w1:p2', agentName: 'repo-a' }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockRegistry.lookup.mockReturnValue({ name: 'repo-a', runtime: 'herdr', runtimeRef, pid: 10 }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'kill', 'repo-a']); + + expect(mockHerdrStop).toHaveBeenCalledWith({ runtimeRef }); + expect(mockStopAgent).toHaveBeenCalled(); + expect(ui.success).toHaveBeenCalledWith('Stopped agent "repo-a" (PID 10) and Herdr pane.'); + }); + it('does not kill when target is ambiguous', async () => { const agents = [ { name: 'repo-a', status: AgentStatus.RUNNING, summary: 'A', lastActive: new Date(), pid: 10 }, @@ -606,7 +938,7 @@ Waiting on user input`, await program.parseAsync(['node', 'test', 'agent', 'kill', 'repo']); expect(ui.error).toHaveBeenCalledWith('Multiple agents match "repo":'); - expect(mockKillAgent).not.toHaveBeenCalled(); + expect(mockStopAgent).not.toHaveBeenCalled(); }); it('does not kill when target is not found', async () => { @@ -622,7 +954,7 @@ Waiting on user input`, expect(ui.error).toHaveBeenCalledWith('No agent found matching "missing".'); expect(ui.info).toHaveBeenCalledWith('Available agents:'); - expect(mockKillAgent).not.toHaveBeenCalled(); + expect(mockStopAgent).not.toHaveBeenCalled(); }); it('creates an agent group with multiple members', async () => { @@ -775,6 +1107,43 @@ Waiting on user input`, expect(ui.success).toHaveBeenCalledWith('Sent message to repo-a.'); }); + it('sends Herdr-backed prompts and waits through the AI DevKit session transcript', async () => { + const agent = { + name: 'repo-a', + type: 'codex', + status: AgentStatus.WAITING, + summary: 'Waiting', + lastActive: new Date(), + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }; + const runtimeRef = { session: 'default', paneId: 'w1:p2', agentName: 'repo-a' }; + const historical = [{ role: 'assistant', content: 'old response' }]; + const withNewResponse = [...historical, { role: 'assistant', content: 'done' }]; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockManager.getAdapter.mockReturnValue(mockAgentAdapter); + mockAgentAdapter.getConversation + .mockReturnValueOnce(historical) + .mockReturnValueOnce(withNewResponse); + mockRegistry.lookup.mockReturnValue({ name: 'repo-a', runtime: 'herdr', runtimeRef, pid: 10 }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--wait', '--timeout', '2000']); + + expect(mockHerdrSend).toHaveBeenCalledWith({ runtimeRef, prompt: 'continue' }); + expect(mockManager.getAdapter).toHaveBeenCalledWith('codex'); + expect(mockAgentAdapter.getConversation).toHaveBeenCalledWith('/tmp/session.jsonl', { verbose: false }); + expect(mockAgentAdapter.getConversation.mock.invocationCallOrder[0]) + .toBeLessThan(mockHerdrSend.mock.invocationCallOrder[0]); + expect(mockHerdrWait).not.toHaveBeenCalled(); + expect(mockHerdrReadOutput).not.toHaveBeenCalled(); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); + expect(stdoutSpy).toHaveBeenCalledWith('done\n'); + }); + it('starts a durable Claude durable agent without tmux', async () => { mockDurableService.create.mockResolvedValue({ id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', @@ -1150,7 +1519,11 @@ Waiting on user input`, mockManager.listAgents.mockResolvedValue([agent]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); - mockAgentAdapter.getConversation.mockReturnValue([]); + mockAgentAdapter.getConversation + .mockReturnValueOnce([]) + .mockReturnValue([ + { role: 'assistant', content: 'done' }, + ]); mockFocusManager.findTerminal.mockResolvedValue(location); mockTtyWriterSend.mockResolvedValue(undefined); @@ -1308,7 +1681,11 @@ Waiting on user input`, mockManager.listAgents.mockResolvedValue([agent]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); - mockAgentAdapter.getConversation.mockReturnValue([]); + mockAgentAdapter.getConversation + .mockReturnValueOnce([]) + .mockReturnValue([ + { role: 'assistant', content: 'done' }, + ]); mockFocusManager.findTerminal.mockResolvedValue(null); const program = new Command(); @@ -1411,10 +1788,14 @@ Waiting on user input`, const location = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; mockManager.listAgents .mockResolvedValueOnce([agent]) - .mockResolvedValueOnce([{ ...agent, status: AgentStatus.WAITING }]); + .mockResolvedValue([{ ...agent, status: AgentStatus.WAITING }]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); - mockAgentAdapter.getConversation.mockReturnValue([]); + mockAgentAdapter.getConversation + .mockReturnValueOnce([]) + .mockReturnValue([ + { role: 'assistant', content: 'done' }, + ]); mockFocusManager.findTerminal.mockResolvedValue(location); mockTtyWriterSend.mockResolvedValue(undefined); @@ -1443,10 +1824,14 @@ Waiting on user input`, const location = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; mockManager.listAgents .mockResolvedValueOnce([agent]) - .mockResolvedValueOnce([{ ...agent, status: AgentStatus.WAITING }]); + .mockResolvedValue([{ ...agent, status: AgentStatus.WAITING }]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); - mockAgentAdapter.getConversation.mockReturnValue([]); + mockAgentAdapter.getConversation + .mockReturnValueOnce([]) + .mockReturnValue([ + { role: 'assistant', content: 'done' }, + ]); mockFocusManager.findTerminal.mockResolvedValue(location); mockTtyWriterSend.mockResolvedValue(undefined); @@ -1457,7 +1842,6 @@ Waiting on user input`, expect(stderrSpy).toHaveBeenCalledWith( 'Agent "repo-a" is not waiting for input (status: running). Sending anyway.\n' ); - expect(stderrSpy).toHaveBeenCalledWith('Agent "repo-a" returned to waiting without assistant output.\n'); }); it('shows error when terminal cannot be found', async () => { diff --git a/packages/cli/src/__tests__/lib/Config.test.ts b/packages/cli/src/__tests__/lib/Config.test.ts index 961cc6a6..c06a124f 100644 --- a/packages/cli/src/__tests__/lib/Config.test.ts +++ b/packages/cli/src/__tests__/lib/Config.test.ts @@ -115,6 +115,33 @@ describe('ConfigManager', () => { }); }); + describe('getAgentRuntimeProvider', () => { + it('returns herdr when configured globally', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + agentRuntime: { provider: 'herdr' }, + }); + + await expect(configManager.getAgentRuntimeProvider()).resolves.toBe('herdr'); + }); + + it('ignores project-level runtime config because agent runtime is global-only', async () => { + (mockFs.pathExists as any).mockImplementation(async (configPath: string) => ( + configPath === '/test/dir/.ai-devkit.json' + )); + (mockFs.readJson as any).mockResolvedValue({ + version: '1.0.0', + environments: [], + phases: [], + createdAt: '2026-09-10T00:00:00.000Z', + agentRuntime: { provider: 'herdr' }, + }); + + await expect(configManager.getAgentRuntimeProvider()).resolves.toBe('tmux'); + expect(mockFs.readJson).not.toHaveBeenCalled(); + }); + }); + describe('create', () => { it('should create config with default values', async () => { const expectedConfig: DevKitConfig = { diff --git a/packages/cli/src/__tests__/lib/GlobalConfig.test.ts b/packages/cli/src/__tests__/lib/GlobalConfig.test.ts index 779b0c46..00b6af50 100644 --- a/packages/cli/src/__tests__/lib/GlobalConfig.test.ts +++ b/packages/cli/src/__tests__/lib/GlobalConfig.test.ts @@ -100,6 +100,37 @@ describe('GlobalConfigManager', () => { }); }); + describe('getAgentRuntimeProvider', () => { + it('defaults to tmux when global config is missing', async () => { + (mockFs.pathExists as any).mockResolvedValue(false); + + await expect(configManager.getAgentRuntimeProvider()).resolves.toBe('tmux'); + }); + + it('defaults to tmux when global agentRuntime provider is missing', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ agentRuntime: {} }); + + await expect(configManager.getAgentRuntimeProvider()).resolves.toBe('tmux'); + }); + + it('returns herdr when configured globally', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ agentRuntime: { provider: 'herdr' } }); + + await expect(configManager.getAgentRuntimeProvider()).resolves.toBe('herdr'); + }); + + it('rejects unknown global runtime providers', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ agentRuntime: { provider: 'screen' } }); + + await expect(configManager.getAgentRuntimeProvider()).rejects.toThrow( + 'agentRuntime.provider has unsupported value "screen"; supported values: tmux, herdr', + ); + }); + }); + describe('addSkillRegistry', () => { it('creates a missing global config with the registry', async () => { (mockFs.pathExists as any).mockResolvedValue(false); diff --git a/packages/cli/src/__tests__/services/agent/agent.service.test.ts b/packages/cli/src/__tests__/services/agent/agent.service.test.ts index 3a34b234..90499155 100644 --- a/packages/cli/src/__tests__/services/agent/agent.service.test.ts +++ b/packages/cli/src/__tests__/services/agent/agent.service.test.ts @@ -2,21 +2,12 @@ import { AgentStatus, type AgentInfo, - type AgentRegistry, type ConversationMessage, - type RegistryEntry, - type TmuxManager, } from '@ai-devkit/agent-manager'; import { waitForAgentResponse, assertSendTargetOptions, sendToAgentGroup, - startAgent, - killAgent, - AgentNameInUseError, - AgentPidPollTimeoutError, - TmuxUnavailableError, - DEFAULT_PID_POLL_TIMEOUT_MS, } from '../../../services/agent/agent.service.js'; function makeAgent(overrides: Partial = {}): AgentInfo { @@ -303,7 +294,7 @@ describe('waitForAgentResponse', () => { sessionFilePath: '/tmp/session.jsonl', }, initialMessageCount: 0, - options: { pollIntervalMs: 0, maxWaitMs: 1000 }, + options: { pollIntervalMs: 0, maxWaitMs: 1000, emptyWaitingGraceMs: 0 }, onAssistantMessage: vi.fn(), onStatus, }); @@ -312,6 +303,50 @@ describe('waitForAgentResponse', () => { expect(onStatus).toHaveBeenCalledWith('Agent "repo-a" returned to waiting without assistant output.'); }); + it('keeps polling briefly when waiting status appears before assistant transcript output', async () => { + const waiting = makeAgent({ status: AgentStatus.WAITING }); + const manager = { + listAgents: vi.fn<() => Promise>().mockResolvedValue([waiting]), + }; + const adapter = { + getConversation: vi.fn<() => ConversationMessage[]>() + .mockReturnValueOnce([ + makeMessage({ role: 'user', content: 'new prompt' }), + ]) + .mockReturnValueOnce([ + makeMessage({ role: 'user', content: 'new prompt' }), + makeMessage({ role: 'assistant', content: 'flushed response' }), + ]), + }; + const onAssistantMessage = vi.fn(); + const onStatus = vi.fn<(message: string) => void>(); + + const result = await waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 0, maxWaitMs: 1000 }, + onAssistantMessage, + onStatus, + }); + + expect(result.messages).toEqual([ + expect.objectContaining({ role: 'assistant', content: 'flushed response' }), + ]); + expect(onAssistantMessage).toHaveBeenCalledWith( + expect.objectContaining({ role: 'assistant', content: 'flushed response' }), + ); + expect(onStatus).not.toHaveBeenCalledWith('Agent "repo-a" returned to waiting without assistant output.'); + }); + it('waits for the configured poll interval before polling again', async () => { vi.useFakeTimers(); try { @@ -462,281 +497,6 @@ describe('waitForAgentResponse', () => { }); }); -function makeTmux(over: Partial = {}): TmuxManager { - return { - isAvailable: vi.fn().mockResolvedValue(true), - sessionExists: vi.fn().mockResolvedValue(false), - createSession: vi.fn().mockResolvedValue(undefined), - sendKeys: vi.fn().mockResolvedValue(undefined), - killSession: vi.fn().mockResolvedValue(undefined), - findAgentPid: vi.fn().mockResolvedValue(12345), - ...over, - } as unknown as TmuxManager; -} - -function makeRegistry(over: Partial = {}): AgentRegistry { - return { - prune: vi.fn(), - lookup: vi.fn().mockReturnValue(null), - list: vi.fn().mockReturnValue([]), - register: vi.fn(), - isAlive: vi.fn().mockReturnValue(false), - ...over, - } as unknown as AgentRegistry; -} - -const startOpts = { - type: 'claude' as const, - name: 'agent1', - cwd: '/work', - pollIntervalMs: 1, - pollTimeoutMs: 50, -}; - -describe('agent start defaults', () => { - it('allows slower agent startup before PID polling times out', () => { - expect(DEFAULT_PID_POLL_TIMEOUT_MS).toBe(15_000); - }); -}); - -describe('killAgent', () => { - it('sends SIGTERM to the agent PID', async () => { - const tmux = makeTmux(); - const registry = makeRegistry(); - const killProcess = vi.fn(); - - const result = await killAgent(makeAgent({ name: 'repo-a', pid: 123 }), { - tmux, - registry, - killProcess, - }); - - expect(killProcess).toHaveBeenCalledWith(123, 'SIGTERM'); - expect(tmux.killSession).not.toHaveBeenCalled(); - expect(result).toEqual({ - agentName: 'repo-a', - pid: 123, - tmuxSession: null, - }); - }); - - it('kills the registry tmux session when present', async () => { - const tmux = makeTmux(); - const registry = makeRegistry({ - lookup: vi.fn().mockReturnValue({ - name: 'repo-a', - type: 'claude', - pid: 123, - tmuxSession: 'repo-a', - cwd: '/repo', - startedAt: '2026-06-01T00:00:00.000Z', - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - } satisfies RegistryEntry), - } as Partial); - const killProcess = vi.fn(); - - const result = await killAgent(makeAgent({ name: 'repo-a', pid: 123 }), { - tmux, - registry, - killProcess, - }); - - expect(killProcess).toHaveBeenCalledWith(123, 'SIGTERM'); - expect(tmux.killSession).toHaveBeenCalledWith('repo-a'); - expect(result.tmuxSession).toBe('repo-a'); - }); - - it('still kills tmux session when the process is already gone', async () => { - const tmux = makeTmux(); - const registry = makeRegistry({ - lookup: vi.fn().mockReturnValue({ - name: 'repo-a', - type: 'claude', - pid: 123, - tmuxSession: 'repo-a', - cwd: '/repo', - startedAt: '2026-06-01T00:00:00.000Z', - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - } satisfies RegistryEntry), - } as Partial); - const error = Object.assign(new Error('gone'), { code: 'ESRCH' }); - const killProcess = vi.fn(() => { throw error; }); - - await killAgent(makeAgent({ name: 'repo-a', pid: 123 }), { - tmux, - registry, - killProcess, - }); - - expect(tmux.killSession).toHaveBeenCalledWith('repo-a'); - }); - - it('rethrows unexpected process kill errors', async () => { - const tmux = makeTmux(); - const registry = makeRegistry(); - const error = Object.assign(new Error('permission denied'), { code: 'EPERM' }); - const killProcess = vi.fn(() => { throw error; }); - - await expect(killAgent(makeAgent({ name: 'repo-a', pid: 123 }), { - tmux, - registry, - killProcess, - })).rejects.toThrow('permission denied'); - - expect(tmux.killSession).not.toHaveBeenCalled(); - }); -}); - -describe('startAgent', () => { - it('happy path: creates session, sends command, polls, registers, returns entry', async () => { - const tmux = makeTmux(); - const registry = makeRegistry(); - - const entry = await startAgent( - { ...startOpts, pollTimeoutMs: 250 }, - { tmux, registry }, - ); - - expect(tmux.createSession).toHaveBeenCalledWith('agent1', '/work'); - expect(tmux.sendKeys).toHaveBeenCalledWith('agent1', 'claude'); - expect(registry.prune).toHaveBeenCalled(); - expect(registry.register).toHaveBeenCalledOnce(); - expect(entry).toMatchObject({ - name: 'agent1', - type: 'claude', - pid: 12345, - tmuxSession: 'agent1', - cwd: '/work', - pinned: false, - }); - expect(entry.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it('throws TmuxUnavailableError when tmux is missing', async () => { - const tmux = makeTmux({ isAvailable: vi.fn().mockResolvedValue(false) } as Partial); - const registry = makeRegistry(); - - await expect(startAgent(startOpts, { tmux, registry })).rejects.toBeInstanceOf(TmuxUnavailableError); - expect(tmux.createSession).not.toHaveBeenCalled(); - expect(registry.register).not.toHaveBeenCalled(); - }); - - it('throws AgentNameInUseError when registry already has a live entry', async () => { - const tmux = makeTmux(); - const liveEntry: RegistryEntry = { - name: 'agent1', type: 'claude', pid: 999, - tmuxSession: 'agent1', cwd: '/old', startedAt: '2026-01-01T00:00:00.000Z', - }; - const registry = makeRegistry({ lookup: vi.fn().mockReturnValue(liveEntry) } as Partial); - - const err = await startAgent(startOpts, { tmux, registry }).catch((e) => e); - expect(err).toBeInstanceOf(AgentNameInUseError); - expect(err.pid).toBe(999); - expect(tmux.createSession).not.toHaveBeenCalled(); - }); - - it('replaces orphan tmux session and calls onWarning', async () => { - const tmux = makeTmux({ sessionExists: vi.fn().mockResolvedValue(true) } as Partial); - const registry = makeRegistry(); - const onWarning = vi.fn(); - - await startAgent(startOpts, { tmux, registry, onWarning }); - - expect(onWarning).toHaveBeenCalledOnce(); - expect(onWarning.mock.calls[0][0]).toContain('agent1'); - expect(tmux.killSession).toHaveBeenCalledWith('agent1'); - expect(tmux.createSession).toHaveBeenCalledWith('agent1', '/work'); - }); - - it('on PID poll timeout: kills session and throws AgentPidPollTimeoutError', async () => { - const tmux = makeTmux({ findAgentPid: vi.fn().mockResolvedValue(null) } as Partial); - const registry = makeRegistry(); - - const err = await startAgent(startOpts, { tmux, registry }).catch((e) => e); - - expect(err).toBeInstanceOf(AgentPidPollTimeoutError); - expect(err.command).toBe('claude'); - expect(err.timeoutMs).toBe(50); - expect(tmux.killSession).toHaveBeenLastCalledWith('agent1'); - expect(registry.register).not.toHaveBeenCalled(); - }); - - it('keeps polling until findAgentPid returns a PID', async () => { - const findAgentPid = vi.fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(42) - .mockResolvedValueOnce(42) - .mockResolvedValueOnce(42) - .mockResolvedValueOnce(42) - .mockResolvedValueOnce(42); - const tmux = makeTmux({ findAgentPid } as Partial); - const registry = makeRegistry(); - - const entry = await startAgent( - { ...startOpts, pollTimeoutMs: 250 }, - { tmux, registry }, - ); - - expect(findAgentPid).toHaveBeenCalledTimes(7); - expect(entry.pid).toBe(42); - }); - - it('waits for the launched process PID to stabilize before registering', async () => { - const findAgentPid = vi.fn() - .mockResolvedValueOnce(100) - .mockResolvedValueOnce(100) - .mockResolvedValueOnce(100) - .mockResolvedValueOnce(200) - .mockResolvedValueOnce(200) - .mockResolvedValueOnce(200) - .mockResolvedValueOnce(200) - .mockResolvedValueOnce(200); - const tmux = makeTmux({ findAgentPid } as Partial); - const registry = makeRegistry(); - - const entry = await startAgent(startOpts, { tmux, registry }); - - expect(findAgentPid).toHaveBeenCalledTimes(8); - expect(entry.pid).toBe(200); - expect(registry.register).toHaveBeenCalledWith(expect.objectContaining({ pid: 200 })); - }); - - it('treats an unstabilized PID as a poll timeout', async () => { - const findAgentPid = vi.fn() - .mockResolvedValueOnce(100) - .mockResolvedValueOnce(100) - .mockResolvedValueOnce(100); - const tmux = makeTmux({ findAgentPid } as Partial); - const registry = makeRegistry(); - - const err = await startAgent( - { ...startOpts, pollTimeoutMs: 3 }, - { tmux, registry }, - ).catch((e) => e); - - expect(err).toBeInstanceOf(AgentPidPollTimeoutError); - expect(registry.register).not.toHaveBeenCalled(); - expect(tmux.killSession).toHaveBeenLastCalledWith('agent1'); - }); - - it('prunes registry before checking for name collision', async () => { - const tmux = makeTmux(); - const registry = makeRegistry(); - const order: string[] = []; - (registry.prune as any).mockImplementation(() => order.push('prune')); - (registry.lookup as any).mockImplementation(() => { - order.push('lookup'); - return null; - }); - - await startAgent(startOpts, { tmux, registry }); - expect(order).toEqual(['prune', 'lookup']); - }); -}); - describe('sendToAgentGroup', () => { const reporter = { info: vi.fn(), diff --git a/packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts b/packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts index f8d30511..1167bc14 100644 --- a/packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts +++ b/packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts @@ -10,7 +10,13 @@ import { type LoadedPluginCommand, } from '../../../services/plugin/plugin-loader.service.js'; -vi.mock('os'); +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: vi.fn(actual.homedir), + }; +}); describe('plugin loader service', () => { let tempHome: string; diff --git a/packages/cli/src/__tests__/util/config.test.ts b/packages/cli/src/__tests__/util/config.test.ts index 38e91d8d..b56021fe 100644 --- a/packages/cli/src/__tests__/util/config.test.ts +++ b/packages/cli/src/__tests__/util/config.test.ts @@ -1,4 +1,4 @@ -import { validateInstallConfig } from '../../util/config.js'; +import { resolveAgentRuntimeProvider, validateInstallConfig } from '../../util/config.js'; describe('config util', () => { it('validates and normalizes valid install config', () => { @@ -69,4 +69,30 @@ describe('config util', () => { { registry: 'codeaholicguy/ai-devkit', name: 'dev-lifecycle' } ]); }); + + it('does not include project-level agent runtime in install config', () => { + const result = validateInstallConfig({}, '/tmp/.ai-devkit.json'); + + expect(result).not.toHaveProperty('agentRuntime'); + }); + + it('ignores project-level agent runtime because runtime is global-only', () => { + const result = validateInstallConfig({ + agentRuntime: { provider: 'herdr' }, + }, '/tmp/.ai-devkit.json'); + + expect(result).not.toHaveProperty('agentRuntime'); + }); + + it('defaults agent runtime provider to tmux when global config omits it', () => { + expect(resolveAgentRuntimeProvider(undefined)).toBe('tmux'); + }); + + it('accepts herdr as a global agent runtime provider', () => { + expect(resolveAgentRuntimeProvider('herdr')).toBe('herdr'); + }); + + it('rejects unknown global agent runtime providers with supported values', () => { + expect(() => resolveAgentRuntimeProvider('screen')).toThrow('agentRuntime.provider has unsupported value "screen"; supported values: tmux, herdr'); + }); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index aea3f495..d67cb300 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -23,9 +23,17 @@ import { AgentRegistry, RenameNotFoundError, RenameConflictError, - TmuxManager, AGENTS, AGENT_MODES, + parseTmuxRuntimeRef, + startAgent, + stopAgent, + focusAgent, + TmuxUnavailableError, + AgentNameInUseError, + AgentPidPollTimeoutError, + AgentRuntimeUnavailableError, + createHerdrRuntime, type StartableAgentType, type AgentInfo, type AgentType, @@ -43,15 +51,10 @@ 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, @@ -63,6 +66,7 @@ import { generateAgentName } from '../util/agent.js'; import { select } from '@inquirer/prompts'; import { resolveTmuxInstallInstructions } from '../util/tmux.js'; import { createTmuxInspectionDeps } from '../util/tmux-deps.js'; +import { ConfigManager } from '../lib/Config.js'; // eslint-disable-next-line no-control-regex const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; @@ -275,7 +279,7 @@ export function registerAgentCommand(program: Command): void { agentCommand .command('start') - .description('Start a new agent in a managed tmux session') + .description('Start a new agent in the configured interactive runtime') .requiredOption('--type ', `Agent type: ${Object.keys(AGENTS).join(', ')}`) .option('--mode ', 'Agent mode: interactive or durable', 'interactive') .option('--name ', 'Human-readable name for the agent (lowercase alphanumeric + hyphens, 2-64 chars; default: {folder}-{timestamp})') @@ -321,21 +325,25 @@ export function registerAgentCommand(program: Command): void { ui.text(`State: ready (${formatPrintProvider(entry.provider)} session not started)`); return; } + const runtimeProvider = await new ConfigManager().getAgentRuntimeProvider(); const entry = await startAgent( - { type: agentType as StartableAgentType, name: agentName, cwd }, - { - tmux: new TmuxManager(), - registry: AgentRegistry.default(), - onWarning: (msg) => ui.warning(msg), - }, + { type: agentType as StartableAgentType, name: agentName, cwd, runtimeProvider }, + { onWarning: (msg: string) => 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}`); + if (entry.runtime === 'herdr') { + ui.text('Runtime: herdr'); + } else { + const tmuxRef = parseTmuxRuntimeRef(entry.runtimeRef); + if (tmuxRef) ui.text(`Attach: tmux attach -t ${tmuxRef.session}`); + } } catch (err) { if (err instanceof TmuxUnavailableError) { const instructions = await resolveTmuxInstallInstructions(createTmuxInspectionDeps()); ui.error(`tmux is not installed or not in PATH. ${instructions.message}`); + } else if (err instanceof AgentRuntimeUnavailableError) { + ui.error(`Herdr runtime is unavailable (${err.reason}): ${err.detail}`); } 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) { @@ -599,19 +607,21 @@ export function registerAgentCommand(program: Command): void { const spinner = ui.spinner(`Switching focus to ${agent.name}...`); spinner.start(); - const location = await focusManager.findTerminal(agent.pid); - if (!location) { + const focusResult = await focusAgent(agent, { + registry: AgentRegistry.default(), + runtime: createHerdrRuntime(), + focusManager, + }); + if (!focusResult.focused && focusResult.reason === 'terminal-not-found') { 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}.`); + if (!focusResult.focused) { + spinner.fail(`Failed to switch focus to "${agent.name}".`); + return; } + + spinner.succeed(`Focused ${agent.name}!`); })); agentCommand @@ -674,6 +684,8 @@ export function registerAgentCommand(program: Command): void { prompt, manager, focusManager, + registry: AgentRegistry.default(), + runtime: createHerdrRuntime(), wait: options.wait, timeout: options.timeout, json: options.json, @@ -684,7 +696,7 @@ export function registerAgentCommand(program: Command): void { agentCommand .command('kill ') - .description('Stop a running agent and clean up its managed tmux session') + .description('Stop a running agent and clean up its managed runtime') .action(withErrorHandler('kill agent', async (name: string) => { const manager = createAgentManager(); const agents = await manager.listAgents(); @@ -709,12 +721,18 @@ export function registerAgentCommand(program: Command): void { return; } - const result = await killAgent(resolved, { - tmux: new TmuxManager(), - registry: AgentRegistry.default(), + const registry = AgentRegistry.default(); + const result = await stopAgent(resolved, { + registry, + runtime: createHerdrRuntime(), }); + if (result.runtime === 'herdr') { + ui.success(`Stopped agent "${resolved.name}" (PID ${resolved.pid}) and Herdr pane.`); + return; + } - const suffix = result.tmuxSession ? ` and tmux session "${result.tmuxSession}"` : ''; + const tmuxRef = parseTmuxRuntimeRef(result.runtimeRef); + const suffix = tmuxRef ? ` and tmux session "${tmuxRef.session}"` : ''; ui.success(`Stopped agent "${result.agentName}" (PID ${result.pid})${suffix}.`); })); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9dd0b1c3..2fa6f5e6 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -270,7 +270,7 @@ export async function initCommand(options: InitOptions) { phases: selectedPhases, registries, skills: desiredSkills, - mcpServers + mcpServers, }, { overwrite: options.overwrite, nonInteractive diff --git a/packages/cli/src/lib/Config.ts b/packages/cli/src/lib/Config.ts index 4de02299..2e9ed47e 100644 --- a/packages/cli/src/lib/Config.ts +++ b/packages/cli/src/lib/Config.ts @@ -1,7 +1,7 @@ import fs from 'fs-extra'; import * as path from 'path'; import { DevKitConfig, Phase, EnvironmentCode, ConfigSkill, DEFAULT_DOCS_DIR, DEFAULT_PHASES } from '../types.js'; -import { filterStringRecord } from '../util/config.js'; +import { filterStringRecord, type AgentRuntimeProvider } from '../util/config.js'; import { ConfigNotFoundError } from '../util/errors.js'; import { AddSkillRegistryOptions, normalizeRegistrySources, planSkillRegistryAdd, planSkillRegistryRemove } from '../services/skill/registry/skill-registry-source.js'; import { GlobalConfigManager } from './GlobalConfig.js'; @@ -118,6 +118,10 @@ export class ConfigManager { return globalConfig?.memory?.semantic === true; } + async getAgentRuntimeProvider(): Promise { + return new GlobalConfigManager().getAgentRuntimeProvider(); + } + private resolveConfiguredPath(configuredPath: unknown): string | undefined { if (typeof configuredPath !== 'string') { return undefined; diff --git a/packages/cli/src/lib/GlobalConfig.ts b/packages/cli/src/lib/GlobalConfig.ts index 1b912fff..4fa0837b 100644 --- a/packages/cli/src/lib/GlobalConfig.ts +++ b/packages/cli/src/lib/GlobalConfig.ts @@ -2,7 +2,7 @@ import fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import { GlobalDevKitConfig } from '../types.js'; -import { filterStringRecord } from '../util/config.js'; +import { filterStringRecord, resolveAgentRuntimeProvider, type AgentRuntimeProvider } from '../util/config.js'; import { CliError } from '../util/errors.js'; import { AddSkillRegistryOptions, normalizeRegistrySources, planSkillRegistryAdd, planSkillRegistryRemove } from '../services/skill/registry/skill-registry-source.js'; import { ui } from '../util/terminal-ui.js'; @@ -76,6 +76,11 @@ export class GlobalConfigManager { return normalizePlugins(config?.plugins); } + async getAgentRuntimeProvider(): Promise { + const config = await this.read(); + return resolveAgentRuntimeProvider(config?.agentRuntime?.provider); + } + async addPlugin(pluginName: string): Promise { const config = await this.read() ?? {}; const plugins = normalizePlugins(config.plugins); diff --git a/packages/cli/src/services/agent/agent.service.ts b/packages/cli/src/services/agent/agent.service.ts index 913e438c..1c202bbc 100644 --- a/packages/cli/src/services/agent/agent.service.ts +++ b/packages/cli/src/services/agent/agent.service.ts @@ -1,5 +1,4 @@ import { - AGENTS, AgentStatus, TtyWriter, type AgentAdapter, @@ -10,17 +9,14 @@ import { type TerminalLocation, type AgentType, type ConversationMessage, - type RegistryEntry, - type StartableAgentType, - type TmuxManager, + type HerdrInteractiveRuntime, + AgentTerminalNotFoundError, + sendAgentPrompt, } 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; @@ -34,6 +30,7 @@ export interface AgentSendWaitOptions { pollIntervalMs: number; maxWaitMs: number; timeoutLabel?: string; + emptyWaitingGraceMs?: number; } export interface AgentSendWaitResult { @@ -74,6 +71,8 @@ export interface SendToAgentOptions { prompt: string; manager: Pick; focusManager: Pick; + registry?: Pick; + runtime?: HerdrInteractiveRuntime; wait?: boolean; timeout?: string; json?: boolean; @@ -141,7 +140,9 @@ export async function waitForAgentResponse(params: WaitForAgentResponseParams): const { manager, adapter, target, initialMessageCount, options, onAssistantMessage, onStatus } = params; const startedAt = Date.now(); let lastSeenCount = initialMessageCount; + let emptyWaitingSince: number | null = null; const messages: ConversationMessage[] = []; + const emptyWaitingGraceMs = options.emptyWaitingGraceMs ?? AGENT_SEND_WAIT_EMPTY_WAITING_GRACE_MS; while (Date.now() - startedAt < options.maxWaitMs) { let transcriptReadSucceeded = false; @@ -171,10 +172,28 @@ export async function waitForAgentResponse(params: WaitForAgentResponseParams): (agent.status === AgentStatus.IDLE && hasAssistantOutput); if (canCompleteOnStatus && transcriptReadSucceeded) { - if (messages.length === 0) { - onStatus?.(`Agent "${target.name}" returned to waiting without assistant output.`); + if (messages.length > 0) { + return { + agentName: target.name, + agentType: target.type, + pid: target.pid, + sessionId: target.sessionId, + sessionFilePath: target.sessionFilePath, + messages, + finalStatus: agent.status, + elapsedMs: Date.now() - startedAt, + }; + } + + emptyWaitingSince ??= Date.now(); + if (Date.now() - emptyWaitingSince < emptyWaitingGraceMs) { + const elapsedMs = Date.now() - startedAt; + const remainingMs = options.maxWaitMs - elapsedMs; + await sleep(Math.min(options.pollIntervalMs, remainingMs, emptyWaitingGraceMs)); + continue; } + onStatus?.(`Agent "${target.name}" returned to waiting without assistant output.`); return { agentName: target.name, agentType: target.type, @@ -200,6 +219,8 @@ export async function sendToAgent({ prompt, manager, focusManager, + registry, + runtime, wait = false, timeout, json = false, @@ -242,16 +263,22 @@ export async function sendToAgent({ } 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}).`); + const sendFailed = await sendAgentPrompt(agent, prompt, { + registry, + runtime, + focusManager, + writer, + }).then(() => false).catch((error) => { + if (error instanceof AgentTerminalNotFoundError) { + if (wait) { + throw error; + } + reporter.error(error.message); + return true; } - reporter.error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); - return; - } - - await writer(location, prompt); + throw error; + }); + if (sendFailed) return; if (!wait) { reporter.success(`Sent message to ${agent.name}.`); @@ -498,187 +525,4 @@ function targetKey(agent: AgentInfo): string { 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: '', - pinned: false, - }; - 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; -} +const AGENT_SEND_WAIT_EMPTY_WAITING_GRACE_MS = 1000; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 49b2a23b..78d38a9d 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,3 +1,5 @@ +import type { AgentRuntimeProvider } from '@ai-devkit/agent-manager'; + export type Phase = | 'requirements' | 'design' @@ -62,6 +64,9 @@ export interface GlobalDevKitConfig { path?: string; semantic?: boolean; }; + agentRuntime?: { + provider?: AgentRuntimeProvider; + }; } export interface PhaseMetadata { diff --git a/packages/cli/src/util/config.ts b/packages/cli/src/util/config.ts index 15edcba8..548d374b 100644 --- a/packages/cli/src/util/config.ts +++ b/packages/cli/src/util/config.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; +import { AGENT_RUNTIME_PROVIDERS, type AgentRuntimeProvider } from '@ai-devkit/agent-manager'; import { ConfigSkill, EnvironmentCode, McpServerDefinition, Phase, AVAILABLE_PHASES } from '../types.js'; import { isValidEnvironmentCode } from './env.js'; +export type { AgentRuntimeProvider }; + export interface InstallConfigData { environments: EnvironmentCode[]; phases: Phase[]; @@ -56,7 +59,7 @@ const installConfigSchema = z.object({ env: z.record(z.string(), z.string()).optional(), url: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), - })).optional().default({}) + })).optional().default({}), }).transform((data, ctx) => { const phaseValues = data.phases ?? []; @@ -75,10 +78,30 @@ const installConfigSchema = z.object({ phases: dedupe(phaseValues) as Phase[], registries: data.registries, skills: dedupeSkills(data.skills), - mcpServers: data.mcpServers as Record + mcpServers: data.mcpServers as Record, }; }); +export function resolveAgentRuntimeProvider( + value: string | undefined, + ctx?: z.RefinementCtx, +): AgentRuntimeProvider { + if (value === undefined) return 'tmux'; + if ((AGENT_RUNTIME_PROVIDERS as readonly string[]).includes(value)) { + return value as AgentRuntimeProvider; + } + const message = `has unsupported value "${value}"; supported values: ${AGENT_RUNTIME_PROVIDERS.join(', ')}`; + if (!ctx) { + throw new Error(`agentRuntime.provider ${message}`); + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['agentRuntime', 'provider'], + message, + }); + return z.NEVER; +} + export function validateInstallConfig(data: unknown, configPath: string): InstallConfigData { const parsed = installConfigSchema.safeParse(data); @@ -103,7 +126,7 @@ function formatZodIssue(error: z.ZodError): string { return issue.message; } - return `${formatPath(issue.path)} ${issue.message}`; + return `${formatPath(issue.path as Array)} ${issue.message}`; } function formatPath(pathParts: Array): string {