diff --git a/.ai-devkit.json b/.ai-devkit.json index b0583f9b..9367354b 100644 --- a/.ai-devkit.json +++ b/.ai-devkit.json @@ -108,6 +108,10 @@ { "registry": "codeaholicguy/ai-devkit", "name": "changelog" + }, + { + "registry": "codeaholicguy/ai-devkit", + "name": "agent-management" } ], "updatedAt": "2026-06-27T20:37:59.097Z" diff --git a/docs/ai/design/2026-08-07-feature-agent-print-mode.md b/docs/ai/design/2026-08-07-feature-agent-print-mode.md new file mode 100644 index 00000000..062459a7 --- /dev/null +++ b/docs/ai/design/2026-08-07-feature-agent-print-mode.md @@ -0,0 +1,410 @@ +--- +phase: design +title: Claude Print-Mode Agent Design +description: Minimal durable print-agent identity, execution, locking, and CLI integration +--- + +# Claude Print-Mode Agent Design + +## Architecture Overview + +Print agents are an additive control path beside the existing process adapters. Existing `AgentManager`, terminal discovery, tmux start, interactive send/wait, groups, channels, and TUI continue to operate on live `AgentInfo` objects. A small print-agent service in `agent-manager` owns durable records and Claude print execution; CLI command orchestration combines the two target kinds only for start, list, detail, and direct send. + +```mermaid +flowchart LR + CLI[agent start/list/detail/send] --> Resolver[CLI target resolver] + Resolver --> Live[Existing AgentManager] + Resolver --> Print[ClaudePrintAgentService] + Live --> Terminal[PID / terminal / tmux path] + Print --> Store[Atomic JSON store] + Print --> Lock[Per-agent run lock] + Print --> Runner[ClaudePrintRunner] + Runner -->|prompt via stdin| Claude[ephemeral claude -p] + Claude -->|NDJSON stdout| Runner + Claude --> Native[(Claude native transcript)] +``` + +### Design boundaries + +- `AgentInfo` remains the live-process type with a required PID. Print agents do not fabricate one. +- `PrintAgent` is a separate durable type. +- `AgentManager.listAgents()` remains live-only so existing TUI, channels, groups, kill, open, rename, and terminal flows do not accidentally acquire print semantics. +- CLI list/detail/direct-send use a small combined resolver. Other commands remain unchanged. +- Only Claude is implemented. The runner is injectable for tests but no generic multi-provider framework is introduced. + +## Data Models + +### Store file + +Default path: `~/.ai-devkit/print-agents.json`. + +```ts +interface PrintAgentStoreFile { + version: 1; + agents: PrintAgent[]; +} + +type PrintAgentState = 'ready' | 'running' | 'degraded'; +type PrintSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; +type PrintRunStatus = 'succeeded' | 'failed' | 'interrupted'; + +interface PrintAgent { + id: string; // immutable AI DevKit UUID + name: string; // unique among print agents + provider: 'claude'; + mode: 'print'; + cwd: string; // canonical real path + providerSessionId: string; // immutable caller-assigned Claude UUID + state: PrintAgentState; + sessionHealth: PrintSessionHealth; + createdAt: string; + updatedAt: string; + lastActiveAt: string | null; + lastResult: PrintLastResult | null; + activeRun: PrintActiveRun | null; +} + +interface PrintLastResult { + status: PrintRunStatus; + completedAt: string; + exitCode: number | null; + summary: string; // sanitized and bounded +} + +interface ProcessIdentity { + pid: number; + startedAt: string; // OS-observed process start identity +} + +interface PrintActiveRun { + token: string; // random ownership token + owner: ProcessIdentity; + provider: ProcessIdentity | null; + startedAt: string; +} +``` + +No prompts, transcripts, event history, tool inputs, full provider output, queues, or multiple provider sessions are stored. + +### Lock files + +- Store mutation lock: sibling directory `print-agents.json.lock`. +- Per-agent execution lock: `~/.ai-devkit/print-agent-locks/.lock/owner.json`. +- Directory creation with `mkdir` is the cross-process atomic primitive. +- Lock owner metadata uses the same token and process identities as `activeRun`. + +The per-agent lock is authoritative for exclusion. Persisted `activeRun` makes state inspectable and supports recovery. The store mutation lock serializes create and record updates but is held only for short file operations, never for a Claude run. + +## API Design + +### Store + +```ts +interface PrintAgentStoreOptions { + filePath?: string; + lockTimeoutMs?: number; + now?: () => Date; + processInspector?: ProcessInspector; +} + +class PrintAgentStore { + create(input: CreatePrintAgentInput): Promise; + list(): Promise; + getById(id: string): Promise; + resolve(ref: string): Promise; + acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }>; + recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; + completeRun(id: string, token: string, result: PrintRunCompletion): Promise; + failRun(id: string, token: string, result: PrintRunFailure): Promise; + reconcile(id?: string): Promise; +} +``` + +All state-changing methods take the store mutation lock, reread current state, verify the ownership token, write a temporary file with owner-only permissions, `fsync` as practical, and atomically rename it over the target. + +### Claude capability probe + +```ts +interface ClaudeCliProbe { + validate(executable?: string): Promise<{ + executable: string; + version: string; + }>; +} +``` + +Validation runs only `claude --version` and `claude --help`. It requires help text to advertise `--print`, `--session-id`, `--resume`, `--output-format`, and `stream-json`. It does not authenticate, invoke a model, inspect transcripts, or compare against a speculative hard-coded maximum version. + +### Runner + +```ts +interface ClaudePrintRunRequest { + agent: PrintAgent; + prompt: string; + executable?: string; + firstRun: boolean; + onSpawn(identity: ProcessIdentity): Promise; +} + +interface ClaudePrintRunResult { + sessionId: string; + result: string; + exitCode: number; +} + +interface ClaudePrintRunner { + run(request: ClaudePrintRunRequest): Promise; +} +``` + +Initial argv: + +```text +-p --session-id UUID --output-format stream-json --verbose +``` + +Resume argv: + +```text +-p --resume UUID --output-format stream-json --verbose +``` + +The runner uses `spawn` with `shell: false`, the stored cwd, piped stdin/stdout/stderr, and no prompt argv. It intentionally does not add `--continue`, permission modes, allowlists, bypass flags, `--bare`, MCP, hook, or settings flags. + +The runner follows this order to close the crash-recovery race: + +1. Spawn Claude with stdin open and no prompt in argv. +2. Obtain and fingerprint the provider PID. +3. Await `onSpawn`, which persists the provider identity under the owned lock. +4. Only after persistence succeeds, write the prompt to stdin and close stdin. + +If the parent dies before step 3, the provider has received no prompt. If it dies after step 3, recovery knows the provider process identity and must retain busy state while that exact process remains alive. + +### Stream protocol + +The runner uses a bounded incremental NDJSON decoder: + +- maximum line size: 1 MiB; +- maximum captured stderr: 64 KiB; +- maximum persisted result summary: 4 KiB; +- unknown object/event types are ignored; +- non-object JSON and malformed lines fail the run; +- every string `session_id` observed must equal the stored UUID; +- exactly one terminal `type: "result"` event is required; +- its result text must be a string and its session ID must match; +- successful completion requires both a valid result event and exit code 0. + +Full result text may be returned to the invoking terminal/JSON response, but only the bounded sanitized summary is persisted. + +### Combined CLI target resolution + +```ts +type DirectAgentTarget = + | { kind: 'interactive'; agent: AgentInfo } + | { kind: 'print'; agent: PrintAgent }; +``` + +Resolution order: + +1. Exact print-agent stable ID. +2. Gather exact case-insensitive name matches across print and live agents. +3. If exactly one, use it; if multiple, report ambiguity with mode/type. +4. Apply existing live-agent partial matching only when no print name matches. +5. Print names are not partially matched in MVP, preventing a durable target from unexpectedly shadowing existing live partial resolution. + +Direct `agent send --id` uses this resolver. Group sends remain live-only. + +### Send option behavior + +- Print sends are always synchronous. +- `--wait` is accepted as a no-op semantic confirmation, preserving scripts that add it. +- `--timeout` is rejected for print agents with a clear error. Enforcing it would require process cancellation semantics that are explicitly outside the MVP; silently ignoring it would be unsafe. Interactive timeout behavior is unchanged. +- `--json` emits a print-specific result object without echoing the prompt. +- Interactive send behavior and JSON shape remain unchanged. + +## Component Breakdown + +### `agent-manager` + +- `print/PrintAgent.ts`: durable types and typed errors. +- `print/PrintAgentStore.ts`: atomic JSON persistence, name/ID resolution, locking, ownership, reconciliation, and path safety. +- `print/ProcessInspector.ts`: exact PID/start-time liveness checks, injectable in tests. +- `print/ClaudeCliProbe.ts`: non-billable local capability validation. +- `print/ClaudePrintRunner.ts`: safe process launch, stdin delivery, bounded stream parsing, session verification. +- `print/ClaudePrintAgentService.ts`: create/send orchestration and state transitions. +- Public exports from the package index. + +### `cli` + +- Extend start option parsing with `--mode ` defaulting to `interactive`. +- Route only `claude + print` to the print service; route all default/interactive calls to existing `startAgent` unchanged. +- Add combined list rows and JSON representation. +- Add combined direct-send resolution and print execution branch. +- Add combined agent detail rendering. +- Keep open, rename, kill, groups, channels, and TUI on existing live-agent paths. + +### Tests and fixtures + +- Store tests use temporary directories and injected process identities. +- Runner tests use an executable fake Claude fixture or injected spawn behavior. +- CLI tests inject print services/stores and preserve existing mocks. +- Fake end-to-end test uses a temporary store, temporary cwd, deterministic NDJSON, and invocation capture. It must prove first-send `--session-id`, later `--resume`, prompt-on-stdin, and stable persistence without network/model use. + +## Lifecycle Data Flows + +### Create + +```mermaid +sequenceDiagram + participant U as User + participant C as CLI + participant P as Capability Probe + participant S as Print Store + U->>C: agent start --type claude --mode print + C->>C: validate name and realpath(cwd) + C->>P: --version and --help + P-->>C: required flags present + C->>S: create(agent UUID, Claude UUID) + S-->>C: ready / uninitialized + C-->>U: stable agent identity +``` + +### Send/resume + +```mermaid +sequenceDiagram + participant U as User + participant C as CLI + participant S as Print Store + participant R as Runner + participant P as Claude process + U->>C: agent send --id ref + C->>S: resolve + acquireRun + alt lock is live + S-->>C: busy error + C-->>U: non-zero busy result + else acquired + C->>R: run(first or resume) + R->>P: spawn argv, stdin still empty + R->>S: persist provider PID/start + R->>P: write prompt to stdin + P-->>R: bounded stream-json + R->>R: verify session and terminal result + R->>S: complete/fail if token still owned + C-->>U: result or typed failure + end +``` + +### Reconciliation + +On list, detail, create, and acquire: + +1. Inspect records marked `running` and their lock metadata. +2. If owner or recorded provider identity is alive with the same OS start time, retain `running`/busy. +3. If lock metadata is temporarily incomplete and younger than the lock initialization grace period, retain busy. +4. If neither exact process is alive, acquire the store mutation lock, verify state again, mark last result `interrupted`, set session health `unknown`, set state `degraded`, clear `activeRun`, and remove the owned stale lock directory. +5. A degraded agent may be sent again only when no live lock remains. Acquisition moves it to `running`; success restores `ready/healthy`. + +No recovery path kills a process. + +## Design Decisions + +### Atomic JSON instead of SQLite + +Chosen because the record set is tiny, no query/event history is required, the repository already uses atomic JSON registries, and atomic `mkdir` supplies the missing cross-process exclusion. SQLite would add schema/migration/driver scope for one record collection and one lock invariant. + +The current live `agents.json` is not reused because it prunes dead PIDs and models only interactive process lifetime. A separate file prevents semantic coupling and backward-compatibility risk. + +### Separate print type instead of weakening `AgentInfo` + +Making PID optional would ripple through adapters, terminal managers, TUI, channels, groups, sorters, and tests. A union only at the three affected CLI workflows keeps process assumptions explicit. + +### Synchronous runner rather than worker/queue + +This directly implements the binding user journey and makes ownership simple: the sending CLI process owns one provider child. Concurrent attempts fail through the run lock. + +### Caller-assigned provider UUID + +AI DevKit knows the expected identity before launch and can pass `--session-id` on the first run. Stream output verifies rather than discovers identity. Later runs use exact `--resume`; `--continue` is prohibited. + +### Preserve configured Claude behavior + +The feature adds no permission or customization flags. Claude loads the same cwd/user/project configuration it normally would. This preserves user intent and interactive compatibility, while documentation and error output make clear that non-interactive permission requests cannot be answered by AI DevKit. + +### No automatic retry + +Provider runs may execute tools and external side effects. Any protocol, exit, or interruption failure is reported and persisted once; the user decides whether to send another message. + +## Security Design + +### Prompt and secret disclosure + +- Prompt travels only through stdin after provider identity persistence. +- Prompt is never logged, persisted, included in errors, or returned in JSON metadata. +- Spawn uses discrete argv and `shell: false`. +- Provider stderr/result persistence is sanitized, control characters normalized, and length-bounded. + +### Session and cwd binding + +- Creation requires an existing directory and stores `realpath`. +- Send rechecks that the path is an existing directory and that `realpath` still equals the stored value. +- The immutable provider UUID cannot be replaced by output. +- Any emitted mismatched session ID degrades the agent and fails the run. + +### Store and symlink safety + +- The configured store parent must be an actual directory, not a symlink. +- Existing store, temporary, lock, and lock-owner paths are checked with `lstat` and rejected when symlinked. +- Store files use owner-only mode where supported. +- Temporary names include a random token and use exclusive creation. +- Atomic rename occurs only within the validated parent. +- Lock removal verifies the expected directory and ownership token before deleting its contained metadata and directory. + +### Busy locking and PID reuse + +- Liveness requires PID plus OS-observed process start time. +- Ownership tokens prevent a late finisher from clearing a replacement lock. +- The prompt is withheld until provider identity is durable. +- Missing/corrupt young lock metadata fails closed as busy. +- Stale recovery never sends signals. + +### Provider output + +- NDJSON is untrusted input with explicit byte limits. +- Parsed objects are inspected through type guards, not cast as trusted domain values. +- Prototype-bearing or unknown fields do not flow into stored objects. +- Output cannot change cwd, provider UUID, executable, store path, or lock ownership. + +### Permission and side effects + +- No bypass or auto-approval option is introduced. +- Existing Claude settings, hooks, skills, plugins, MCP servers, and permissions may still cause tools or side effects; this is explicitly visible in docs and is why automatic retry is forbidden. +- A provider failure is not rollback and is never labeled success. + +### Interactive compatibility + +- Existing adapter, registry, terminal, kill, group, channel, and TUI APIs remain live-only. +- Print resolution is added only to explicitly reviewed CLI paths. +- Interactive is the default start mode. + +## Non-Functional Requirements + +- Store operations should complete in milliseconds for normal local agent counts. +- Lock acquisition fails quickly with a bounded timeout; run locks never wait for another send. +- Reads tolerate a missing store as an empty collection but surface malformed or unsafe storage. +- Writes are crash-safe at the file replacement boundary. +- Output parsing has constant per-line memory bounds and bounded persisted diagnostics. +- Unknown future Claude stream events remain forward-compatible. +- CLI capability validation is based on current help surface rather than a hard-coded version allowlist. +- All provider tests are deterministic and offline. + +## Official Provider Basis + +- Claude Code documents `-p` as non-interactive, stdin input, structured `stream-json`, a final result message with session metadata, and exit code 0/non-zero success semantics: . +- Claude Code documents exact session resumption by ID and locally persisted project-associated sessions: . +- Permission modes and auto-approval change tool behavior, so the MVP intentionally adds none: . +- Hooks and project configuration may execute during a run; they are inherited rather than silently disabled: . + +## Design Review Result + +Every requirements goal, user story, success criterion, constraint, and explicit non-goal has a corresponding component, data field, control flow, or test seam. No blocking architecture decision remains. The implementation plan must preserve the narrow CLI integration boundary and must not expand this design into queues, generic provider contracts, transcript storage, channels, tasks, or TUI behavior. diff --git a/docs/ai/implementation/2026-08-07-feature-agent-print-mode.md b/docs/ai/implementation/2026-08-07-feature-agent-print-mode.md new file mode 100644 index 00000000..a7ade401 --- /dev/null +++ b/docs/ai/implementation/2026-08-07-feature-agent-print-mode.md @@ -0,0 +1,91 @@ +--- +phase: implementation +title: Claude Print-Mode Agent Implementation +description: Implementation record, decisions, validation, and deviations +--- + +# Claude Print-Mode Agent Implementation + +## Status + +- Current task: 4.4 validation and formal reviews. +- Completed: Tasks 1.1–4.3. +- Task tracing: unavailable (`unknown command 'task'`). + +## Changes + +### Task 1.1 + +- Added `packages/agent-manager/src/print/PrintAgent.ts` with the durable record, state, session health, last-result, active-run, and process-identity contracts. +- Added classified print-agent/store/Claude errors that do not carry prompt content. +- Exported the public contracts from `@ai-devkit/agent-manager`. + +## TDD Evidence + +- Red: `npx vitest run src/__tests__/print/PrintAgent.test.ts` failed because `PrintAgentBusyError` was absent. +- Green/refactor: the same focused test passed (1/1), followed by `npm run typecheck` exit 0. +- Task 1.2 red: three focused store tests failed because `PrintAgentStore` was absent. +- Task 1.2 green/refactor: all three store tests passed and `npm run typecheck` exited 0. +- Task 1.3 red: two run-ownership tests failed because acquisition/completion methods were absent. +- Task 1.3 green/refactor: all five store tests passed and `npm run typecheck` exited 0. +- Task 2.1 red: two probe tests failed because `ClaudeCliProbe` was absent. +- Task 2.1 green/refactor: both probe tests passed and `npm run typecheck` exited 0. +- Task 2.2 red: runner tests failed because `ClaudePrintRunner` was absent. +- Task 2.2 green/refactor: both runner tests passed; an initial typecheck caught an unsafe spread narrowing, then the full test/typecheck gate passed after correction. +- Tasks 2.3–3.3 used focused service and CLI red/green cycles for create, first/resumed send, list/detail, and direct-send routing. +- Task 4.1 added an executable fake Claude fixture proving no invocation at create, prompt-only stdin, exact first `--session-id`, and exact later `--resume`. +- Task 4.2 red/green hardening covered stale/incomplete locks, cwd replacement, abandoned mutation locks, secret-bearing stderr, and unsupported timeout behavior. + +### Task 1.2 + +- Added a separate versioned `~/.ai-devkit/print-agents.json` store. +- Added canonical cwd validation, distinct UUID generation, exact ID/name resolution, duplicate-name rejection, atomic exclusive temp-file replacement, owner-only mode, bounded mutation locking, and symlink rejection. + +### Task 1.3 + +- Added atomic per-agent lock directories, random ownership tokens, owner/provider PID-start fingerprints, fail-fast busy errors, token-checked state changes, and no-signal stale recovery. +- Provider identity can be persisted before prompt delivery, closing the material parent-crash race described by the design. + +### Task 2.1 + +- Added an injectable capability probe that invokes only `claude --version` and `claude --help`, requires the documented print/session/stream flags, and sanitizes bounded diagnostics. + +### Task 2.2 + +- Added exact first/resume argv construction with `shell: false`, prompt-only stdin after durable provider identity, bounded NDJSON parsing, drained-but-undisclosed stderr, tolerant unknown events, strict session verification, and terminal-result plus exit-code success criteria. + +### Tasks 2.3–4.3 + +- Added create/send orchestration with fail-fast ownership and no retries. +- Added the narrow CLI integrations for print start, merged list/detail, and synchronous direct send while leaving interactive defaults and excluded commands unchanged. +- Added deterministic unit/integration fixtures that never invoke a real model. +- Added crash recovery for old mutation and incomplete run locks and exact cwd/session binding checks. +- Documented inherited Claude permissions, hooks, MCP/tool side effects, and explicit print-mode timeout rejection. + +## Design Alignment + +- `AgentInfo` remains unchanged and process-specific. +- Print-agent identity is a separate durable type. +- No channel, task, receipt, daemon, queue, cancellation, deletion, transcript, or non-Claude provider behavior was added. + +## Deviations and Follow-ups + +- Claude CLI output details beyond the locally verified 2.1.220 help and captured official documentation remain protected by the startup capability probe and are tracked as compatibility behavior, not hard-coded version assumptions. + +## Formal Security Review + +- Scope: new print-agent domain/store/probe/runner/service, direct CLI integrations, fixtures, and documentation. Trust boundaries are CLI caller → local state → ephemeral Claude process → untrusted stream/output; the local OS account is the authorization boundary. +- Remediated `SEC-PRINT-001` (medium, data exposure): provider stderr could contain an echoed prompt or tool secret. The runner now drains stderr but never reflects or persists it; a regression test uses a secret-bearing failure. +- Remediated `SEC-PRINT-002` (medium, availability/business logic): a crash could strand the global mutation lock. Old empty mutation locks are atomically quarantined and removed after a bounded age; live short operations remain protected. +- Remediated `SEC-PRINT-003` (medium, workflow correctness): print `--timeout` was accepted but unenforced. It is now explicitly rejected, because adding termination/cancellation is outside scope. +- Remediated `SEC-PRINT-004` (low, terminal injection): human-rendered provider results now strip OSC and control bytes; JSON output remains structured data. +- Verified controls: prompt only on stdin, `shell: false`, fixed allowlisted argv, canonical cwd binding, exact provider UUID matching, atomic fail-fast run lock, PID/start fingerprints, bounded stream lines, owner-only state files, symlink rejection, no retries, and no permission-bypass flags. +- Dependency audit: 0 critical, 29 high, 11 moderate, and 2 low advisories in the existing workspace dependency graph. No dependency was added by this feature; remediation of repository-wide advisory chains is outside this feature scope. +- Residual risk: Claude still inherits user/project settings, hooks, MCP servers, permissions, and tool side effects. A parent/process crash may leave the provider action outcome unknown; the agent becomes degraded and AI DevKit never retries automatically. Local users who can already modify the same account's state/config remain inside the authorization boundary. + +## Validation Evidence + +- Agent manager: lint/build passed; 24 files and 497 tests passed; coverage 89.66% statements, 77.98% branches, 96.13% functions, 92.88% lines (new print module: 80.5% statements, 70.08% branches, 95.71% functions, 85.51% lines). +- CLI: lint/build passed with five pre-existing warnings in untouched files; 78 files and 921 tests passed; coverage 70.97% statements, 61.29% branches, 69.58% functions, 72.04% lines. +- Base and feature lifecycle lint passed. The executable fake-provider journey passed without a real or billable Claude prompt. +- Existing agent-manager tests emit process-listener count warnings; no new persistent listeners are registered by the print implementation. diff --git a/docs/ai/planning/2026-08-07-feature-agent-print-mode.md b/docs/ai/planning/2026-08-07-feature-agent-print-mode.md new file mode 100644 index 00000000..f89c65c0 --- /dev/null +++ b/docs/ai/planning/2026-08-07-feature-agent-print-mode.md @@ -0,0 +1,153 @@ +--- +phase: planning +title: Claude Print-Mode Agent Implementation Plan +description: Ordered TDD tasks for durable Claude print agents +--- + +# Claude Print-Mode Agent Implementation Plan + +## Milestones + +- [x] Milestone 1: Durable identity, safe storage, and locking foundation. +- [x] Milestone 2: Claude capability validation and synchronous provider execution. +- [x] Milestone 3: CLI start/list/detail/send integration with interactive compatibility. +- [ ] Milestone 4: Offline end-to-end validation, security hardening, and documentation. + +## Task Breakdown + +Every production behavior follows strict red → green → refactor. After each task, run its focused tests and reconcile this checklist before beginning the next task. + +### Phase 1: Durable foundation + +- [x] Task 1.1: Add print-agent domain types and typed errors. + - Outcome: stable record/state/result/process-identity contracts exported from `agent-manager`. + - Dependencies: approved requirements/design. + - Validation: type-level/unit tests for valid public shapes and error classification. + - Scenarios: store, locking, list/detail contract foundations. + +- [x] Task 1.2: Implement atomic JSON persistence and safe create/list/resolve. + - Outcome: separate versioned `print-agents.json`, canonical cwd, UUID creation, case-insensitive unique names, atomic replacement, and symlink rejection. + - Dependencies: Task 1.1. + - Validation: focused store tests including malformed storage, permissions, contention, and unsafe paths. + - Scenarios: print store/resolution unit tests and create/list integration. + +- [x] Task 1.3: Implement per-agent run locking and reconciliation. + - Outcome: atomic fail-fast busy acquisition, token-checked completion, PID/start fingerprinting, provider identity persistence, and safe abandoned-state recovery. + - Dependencies: Task 1.2. + - Validation: concurrent store instances, PID reuse, live provider, incomplete lock, stale recovery, and late-finisher tests. + - Scenarios: all busy-locking/recovery tests. + +### Phase 2: Claude execution + +- [x] Task 2.1: Implement non-billable Claude CLI capability probe. + - Outcome: injectable `--version`/`--help` validation for required flags only. + - Dependencies: Task 1.1. + - Validation: focused probe tests; no prompt/provider call. + - Scenarios: capability probe tests. + +- [x] Task 2.2: Implement bounded Claude stream parser and safe runner. + - Outcome: exact initial/resume argv, stdin prompt handshake, canonical cwd, bounded NDJSON/stderr, session verification, and terminal-result/exit validation. + - Dependencies: Tasks 1.1 and 1.3. + - Validation: fake spawn/executable tests for all normal and malformed stream cases. + - Scenarios: all runner/parser tests and provider identity mismatch integration. + +- [x] Task 2.3: Implement print-agent create/send orchestration. + - Outcome: start validates then persists without spawn; send acquires, runs once, completes ready or records degraded, and never retries. + - Dependencies: Tasks 1.2, 1.3, 2.1, and 2.2. + - Validation: service-level first-send, resume, busy, failure, and recovery tests. + - Scenarios: print service tests. + +### Phase 3: CLI integration + +- [x] Task 3.1: Add print mode to `agent start` without changing interactive defaults. + - Outcome: `--mode interactive|print`, Claude-only validation, correct output, and existing tmux path unchanged. + - Dependencies: Task 2.3. + - Validation: command tests for omitted/interactive/print/invalid combinations. + - Scenarios: CLI start tests. + +- [x] Task 3.2: Add combined print/live list and detail presentation. + - Outcome: durable agents remain visible without PIDs and expose required human/JSON metadata. + - Dependencies: Task 2.3. + - Validation: list/detail command tests, ambiguity fixtures, no fake terminal fields. + - Scenarios: CLI list/detail and create/list/detail integration. + +- [x] Task 3.3: Add combined direct-send resolution and synchronous print output. + - Outcome: exact stable ID/unique name resolution, cross-mode ambiguity, print send execution, and documented wait/timeout/JSON behavior. + - Dependencies: Tasks 3.1 and 3.2. + - Validation: direct-send command/service tests plus existing interactive send regression tests. + - Scenarios: all CLI send tests. + +- [x] Task 3.4: Prove excluded integrations remain unchanged. + - Outcome: groups, open, rename, kill, channels, and TUI retain live-agent behavior. + - Dependencies: Task 3.3. + - Validation: focused existing tests and diff inspection show no print routing in excluded paths. + - Scenarios: adjacent regression checklist. + +### Phase 4: Validation and hardening + +- [x] Task 4.1: Add fake-provider end-to-end fixture and CLI journey. + - Outcome: offline start → first send → resume → busy validation through built CLI/service boundary. + - Dependencies: Phase 3. + - Validation: deterministic E2E output and invocation/stdin capture. + - Scenarios: all end-to-end tests. + +- [x] Task 4.2: Complete coverage and edge-case hardening. + - Outcome: new code reaches target coverage; uncovered error/path/parser branches receive TDD tests and fixes. + - Dependencies: Task 4.1. + - Validation: agent-manager/CLI coverage reports plus performance/limit tests. + - Scenarios: coverage and performance sections. + +- [x] Task 4.3: Update implementation/testing/user documentation. + - Outcome: implementation record, completed testing evidence, CLI/package docs, permission/side-effect warnings, and compatibility notes. + - Dependencies: verified behavior. + - Validation: base/feature docs lint and documentation review. + +- [x] Task 4.4: Run implementation check, formal security review, and holistic code review; fix all blocking findings via TDD. + - Outcome: design alignment, security coverage, review readiness, and known-risk record. + - Dependencies: Tasks 4.1–4.3. + - Validation: lifecycle Phase 7, Phase 8, security-review, Phase 9, and fresh verification commands. + +- [ ] Task 4.5: Commit, fetch/rebase latest `origin/main`, revalidate, push, and open PR. + - Outcome: conventional local commit, clean rebased branch, published PR against main. + - Dependencies: Task 4.4 and user publication approval. + - Validation: clean status, commit SHA, post-rebase full validation, remote branch, and PR URL. + +## Dependencies + +- Tasks are sequential because storage contracts underpin runner/service/CLI behavior. +- Task 2.1 can technically run beside storage work but remains sequential to preserve strict lifecycle reconciliation. +- No real Claude credentials, model access, transcript, channel, task database, or daemon is required. +- Official docs and local `claude --help` define required capabilities; unverified drift is handled by the startup capability probe. +- Optional AI DevKit task tracing is unavailable (`npx ai-devkit@latest task list --name agent-print-mode --json` → `unknown command 'task'`). + +## Timeline & Estimates + +- Foundation: medium effort; locking/path safety is the highest-risk portion. +- Claude execution: medium effort; protocol parsing and spawn handshake require careful fixtures. +- CLI integration: medium effort; compatibility tests dominate. +- Validation/review/publication: medium effort; coverage and rebase may reveal additional work. + +No calendar commitment is inferred; work proceeds sequentially through the approved lifecycle. + +## Risks & Mitigation + +- **Concurrent session corruption:** atomic per-agent lock; fail fast; exact process identities. +- **Parent crash around spawn:** persist provider PID/start before sending prompt through stdin. +- **Unsafe filesystem targets:** `realpath`/`lstat`, exclusive temp creation, same-directory rename, token-checked lock removal. +- **Provider protocol drift:** capability probe, tolerant unknown events, strict required result/session validation. +- **Secret leakage:** stdin prompt, bounded sanitized diagnostics, no prompt/transcript persistence. +- **Permission/tool side effects:** inherit user configuration, add no bypass flags, never auto-retry. +- **Interactive regression:** separate durable type and narrow CLI integration; full relevant regressions. +- **Scope expansion:** excluded commands/integrations are explicitly checked and documented. + +## Resources Needed + +- Existing agent-manager and CLI packages/tests. +- Local Claude CLI help/version only; no model invocation. +- Official Claude headless/session/permission/hook documentation. +- Temporary filesystem and fake-provider fixtures. +- AI DevKit TDD, verify, testing, security-review, dev-review, commit, and PR skills. + +## Progress Summary + +Milestones 1–3 and Tasks 4.1–4.3 are complete. TDD hardening added age-bounded incomplete run-lock recovery, abandoned mutation-lock recovery, cwd binding checks, provider stderr non-disclosure, and explicit rejection of unsupported print timeouts. Task 4.4 lifecycle reviews and fresh validation are in progress; no scope blockers were discovered. diff --git a/docs/ai/requirements/2026-08-07-feature-agent-print-mode.md b/docs/ai/requirements/2026-08-07-feature-agent-print-mode.md new file mode 100644 index 00000000..5b0d011a --- /dev/null +++ b/docs/ai/requirements/2026-08-07-feature-agent-print-mode.md @@ -0,0 +1,222 @@ +--- +phase: requirements +title: Claude Print-Mode Agents +description: Durable AI DevKit agents backed by synchronous Claude print-mode runs +--- + +# Claude Print-Mode Agents + +## Problem Statement + +AI DevKit currently models an agent as a continuously running interactive process. Agent identity, discovery, status, sending, and waiting all depend on a live PID, terminal, and provider transcript. This prevents users and orchestration from addressing a durable agent identity when they want Claude Code to run only for the duration of each message. + +The feature must add a Claude-first print mode in which AI DevKit owns a stable logical agent identity and a minimal durable mapping to a caller-assigned Claude session UUID. Claude Code remains responsible for its native conversation transcript. Each message launches one synchronous, ephemeral `claude -p` process and later messages resume the exact same provider conversation. + +### Terminology + +- **Logical agent:** the durable AI DevKit identity created by `agent start --mode print`. +- **Provider session:** the Claude conversation identified by the caller-assigned Claude UUID. +- **Provider process:** one ephemeral `claude -p` child process. +- **Run:** processing one `agent send` message by one provider process. + +The logical agent exists while no provider process is running. One logical agent owns exactly one Claude provider session in this feature. + +## Goals & Objectives + +### Primary goals + +- Preserve the existing `ai-devkit agent start` and `ai-devkit agent send --id` user journey. +- Add `ai-devkit agent start --type claude --mode print --name NAME --cwd PATH`. +- Keep interactive mode as the default and leave its behavior unchanged. +- At print-agent start: + - validate the name, cwd, Claude executable, installed Claude version, and required print-mode capabilities without invoking a model; + - generate a stable AI DevKit agent ID and a valid caller-assigned Claude session UUID; + - persist a minimal durable local mapping and initial `ready` state; + - do not launch Claude and do not create or discover a transcript. +- Resolve print agents on send by exact stable agent ID or unique name. +- Atomically acquire a per-agent busy state before launching Claude; a concurrent send must fail clearly instead of waiting or queueing. +- Send the prompt through child-process stdin, never through command-line arguments. +- On the first send, synchronously invoke Claude with the equivalent of: + + ```text + claude -p --session-id --output-format stream-json --verbose + ``` + +- On later sends, invoke the same mode with exact `--resume `; never use `--continue`. +- Parse Claude stream JSON, verify the emitted provider session ID equals the stored caller-assigned UUID, capture the final result, and return the logical agent to `ready`. +- Keep print agents visible in list and detail output with stable identity, provider, mode, cwd, `ready`/`running`/`degraded` state, session health, last activity, and last result. +- Detect interrupted or abandoned busy state safely and expose/recover it without permitting concurrent use of the same Claude session. +- Validate all behavior without a real or billable Claude prompt. + +### Secondary goals + +- Keep persistence and new types small and repository-consistent. +- Isolate provider execution enough for deterministic fake-provider tests. +- Preserve a clean future seam for other run-based providers without implementing them now. +- Provide JSON output that identifies print agents without inventing a fake PID or terminal. + +### Non-goals + +- Queues, schedulers, servers, workers, daemons, or background sends. +- Channel integration or changes to channel behavior. +- Task attribution, receipt generation, or assurance automation. +- Cancellation or new kill/delete behavior. +- Transcript duplication, transcript parsing as the durable source of truth, or transcript cleanup. +- More than one provider session per logical agent. +- Print/headless adapters for Codex, Gemini, or any provider other than Claude. +- Interactive permission prompting or forwarding approvals from print-mode runs. +- Automatic retry of a failed run. +- Cross-host, shared, or multi-user agent storage. +- Changing existing interactive agent start, list, detail, send, wait, open, rename, kill, or channel semantics beyond the minimum additive resolution needed for print agents. + +## User Stories & Use Cases + +### Create a print agent + +As an AI DevKit user, I can run: + +```bash +ai-devkit agent start --type claude --mode print --name reviewer --cwd /repo +``` + +and receive a stable AI DevKit agent ID. Creation validates local configuration and persists an idle logical agent, but consumes no model tokens and creates no Claude transcript. + +### Send the first message + +As a user, I can run: + +```bash +ai-devkit agent send --id reviewer "Review the authentication design" +``` + +AI DevKit resolves the unique print-agent name, acquires its busy state, starts Claude synchronously, sends the prompt via stdin, streams/parses provider events, verifies the stored session UUID, records the outcome, and exits when the run is terminal. + +### Resume the same context + +As a user, I can send a later message to the stable agent ID or its unique name. AI DevKit resumes the exact stored Claude UUID in the same bound cwd so Claude retains conversation context. + +### Observe an idle durable agent + +As a user, I can list or inspect a print agent even when no Claude process or transcript exists. The output distinguishes the durable logical agent from an interactive process and reports its session health and last run outcome. + +### Reject concurrent sends + +As a user or script, if one send already owns the agent, a second send exits non-zero with a clear busy error identifying the agent. It does not enqueue, wait, inject input, or start another Claude process. + +### Recover from an interrupted caller + +As a user, if the AI DevKit process dies after marking the agent busy, a later operation detects stale ownership using persisted owner/run metadata and process liveness. It must not steal a lock from a still-running owner. A genuinely abandoned state is recovered to a safe state and surfaced in detail/history metadata as a degraded or interrupted last result. + +## Success Criteria + +### CLI and compatibility + +- `agent start` accepts `--mode interactive|print`; omitted mode is `interactive`. +- `--mode print` is accepted only with `--type claude` and rejects unsupported combinations before persistence. +- Existing interactive command tests remain unchanged or are augmented only for additive mode parsing. +- Existing interactive agents continue to use tmux/process detection and terminal input. +- `agent send --id` resolves both existing interactive agents and durable print agents without ambiguous silent preference. Exact stable print-agent ID wins; duplicate or ambiguous names produce an actionable error. +- Print-agent sends are synchronous. Existing `--wait` behavior for interactive agents remains intact; print sends already wait for completion and must not introduce a second execution path. + +### Identity and persistence + +- Creation generates two distinct valid UUIDs: an immutable AI DevKit agent ID and immutable Claude session ID. +- The durable record stores only the information required for identity, binding, state, safe locking, and last-run display. +- Durable persistence uses an atomic, crash-safe local update convention consistent with the repository. +- Name uniqueness rules are explicit and deterministic for durable agents. +- A stored cwd is canonicalized and remains bound to the provider session; later sends cannot silently resume it from another cwd. +- Print agents survive CLI process exit and remain listable without a provider PID. + +### Provider execution + +- No Claude process is spawned during `agent start --mode print`. +- The first send passes `-p`, `--session-id`, `--output-format stream-json`, and `--verbose` as discrete argv values. +- Later sends pass `-p`, `--resume`, the exact stored UUID, `--output-format stream-json`, and `--verbose`. +- Prompt content is written only to stdin and never appears in provider argv, normal status output, or error messages. +- The process cwd is exactly the canonical stored cwd. +- Every parseable provider event claiming a session ID must agree with the stored session ID; any mismatch fails the run and marks the agent degraded. +- Success requires a valid terminal result event and successful provider exit. Truncated output, malformed terminal output, session mismatch, or non-zero exit becomes a recorded failure/degraded result. +- Unknown stream event types are tolerated without treating their untrusted fields as trusted state. +- Provider stderr and errors are bounded and sanitized before persistence or user display. + +### Busy locking and recovery + +- Busy acquisition is atomic across concurrent CLI processes. +- Exactly one send may invoke a provider for a logical agent at a time. +- Busy metadata identifies the owning AI DevKit process and run start time sufficiently to distinguish a live owner from an abandoned state. +- Cleanup returns the agent to `ready` only if the finishing process still owns the busy marker. +- Provider failure before or after process spawn cannot leave a live owner incorrectly reported as available. +- Crash recovery never terminates an unknown process and never relies only on PID without enough metadata to mitigate PID reuse. + +### List and detail + +- Human and JSON list/detail output include stable ID, name, provider `claude`, mode `print`, canonical cwd, state, session health, last activity, and last result. +- No fake PID, tmux session, terminal, or transcript path is fabricated. +- Before first send, session health communicates that the caller-assigned identity is initialized but no provider transcript/run has yet been observed. +- A running print agent is visible as `running`; a provider/session/protocol failure is visible as `degraded`; a successful or safely recovered agent is `ready`. + +### Validation + +- Tests inject a fake Claude executable or process launcher; no test invokes a real model. +- Deterministic fixtures cover initial session creation, resume, streaming chunks, final result, stderr, non-zero exit, malformed JSON, missing final result, session mismatch, concurrent sends, stale lock recovery, and cwd binding. +- Focused package tests, full relevant tests, typecheck, lint, build, coverage, security review, and fake-provider end-to-end validation pass. + +## Constraints & Assumptions + +### Product constraints + +- This is an additive Claude-only print mode, not a redesign of all agent adapters. +- Synchronous execution is intentional. The process running `agent send` owns the provider child until completion. +- Concurrent sends fail immediately; there is no queue or implicit retry. +- Claude owns its native transcript and retention behavior. AI DevKit owns only the logical identity, binding, minimal state, and last result metadata. +- Print agents have no terminal, so terminal-specific operations remain interactive-only and retain their existing semantics. + +### Technical constraints + +- `origin/main` at feature start is the authoritative code baseline. +- Use the repository's existing Node.js/TypeScript conventions and safe `execFile`/spawn-style argv separation. +- Persist locally beneath AI DevKit's existing user data area, using the smallest repository-consistent design selected during design review. +- Writes must be atomic and safe against symlink/path substitution where AI DevKit controls the target. +- Provider executable resolution must be injectable in tests and must not allow shell interpolation. +- Current local Claude Code is version `2.1.220`; implementation must rely only on documented flags confirmed by local help and official Claude documentation. +- Claude print sessions are resumable by explicit session ID and native transcripts are project-associated. The stored cwd/session binding is therefore security- and correctness-sensitive. +- Permission behavior remains Claude's configured behavior for this MVP. AI DevKit must not add `--dangerously-skip-permissions`, `bypassPermissions`, auto-approval flags, tool allowlists, hooks, MCP configuration, `--bare`, or other policy-changing flags implicitly. +- Because print mode cannot present interactive approval UI, denied/unapproved tool actions may cause provider failure; this must be reported clearly rather than bypassed. +- Provider stdout is an untrusted, incrementally delivered protocol stream. Parsing must be bounded and resilient to chunk boundaries. + +### Security constraints + +- Prompts and provider output may contain secrets and must not be logged by default. +- Prompt content must not appear in argv. +- Cwd must resolve to an existing directory at creation and remain safely bound thereafter. +- Durable store initialization and atomic replacement must reject unsafe symlink targets. +- Provider-reported session identity cannot overwrite the stored binding. +- Errors must avoid echoing prompts or unbounded raw provider output. +- A non-zero exit or malformed/mismatched output must never be presented as success. +- Existing interactive behavior must not be weakened by print-mode resolution or persistence. + +### Assumptions accepted for MVP + +- Local single-user execution is the supported deployment model. +- A valid local Claude CLI and authentication already exist; start validates the executable/version/capability surface without validating credentials through a model call. +- The Claude CLI persists a new print session on the first actual run, not at logical-agent creation. +- Last result is a bounded summary/status suitable for list/detail, not a transcript copy. +- Stable agent IDs are displayed in full JSON and may be shortened for human output only when unambiguous. + +## Alternatives Considered + +1. **Extend the current JSON registry with durable print records — recommended starting point if atomic cross-process locking can be made safe with a small dedicated store.** Reuses repository conventions and minimizes dependencies, but the live registry's PID-pruning semantics must not own durable records. +2. **Create a small dedicated SQLite agent database.** Strong transactions and locking, but introduces a larger persistence surface than this MVP may need. +3. **Discover Claude transcripts and treat them as agents.** Rejected because creation must predate transcripts, stable AI DevKit identity differs from provider session identity, and filesystem discovery is not an authorization or ownership boundary. + +The design phase must choose between a dedicated atomic JSON store and a minimal SQLite store based on demonstrated locking/crash-safety needs, without introducing speculative run/event schemas. + +## Questions & Open Items + +No blocking product questions remain. The following are design decisions constrained by the acceptance criteria: + +- Select the smallest persistence mechanism that provides atomic busy acquisition and safe stale-owner recovery. +- Define the exact bounded last-result and session-health representation. +- Define deterministic resolution behavior when an interactive and print agent share a name. +- Define the supported Claude capability/version probe using `--version` and `--help` without invoking a model. +- Define how `agent send --wait`, `--timeout`, and `--json` render for an already-synchronous print send while preserving interactive behavior. diff --git a/docs/ai/testing/2026-08-07-feature-agent-print-mode.md b/docs/ai/testing/2026-08-07-feature-agent-print-mode.md new file mode 100644 index 00000000..ef48b1f9 --- /dev/null +++ b/docs/ai/testing/2026-08-07-feature-agent-print-mode.md @@ -0,0 +1,151 @@ +--- +phase: testing +title: Claude Print-Mode Agent Testing Strategy +description: Offline TDD, security, integration, and compatibility validation +--- + +# Claude Print-Mode Agent Testing Strategy + +## Test Coverage Goals + +- Target 100% branch/function coverage for new print-agent store, probe, parser, runner, and orchestration modules. +- Cover every requirements success criterion and design state transition. +- Keep all provider tests offline and non-billable. +- Re-run existing agent-manager and CLI suites to prove interactive compatibility. +- Treat untested error, locking, parsing, and path-safety branches as blocking gaps unless explicitly justified. + +## Unit Tests + +### Print agent store and resolution + +- [ ] Creates a durable print agent with distinct valid AI DevKit and Claude UUIDs. +- [ ] Canonicalizes an existing cwd and rejects missing/non-directory paths. +- [ ] Rejects duplicate print-agent names case-insensitively. +- [ ] Resolves an exact stable ID and unique exact name without partial print-name matching. +- [ ] Treats a missing store as empty and rejects malformed or unsupported-version storage. +- [ ] Persists with atomic replacement and owner-only file permissions. +- [ ] Rejects symlinked parent, store, temp, mutation-lock, execution-lock, and owner metadata paths. +- [ ] Bounds mutation-lock waiting and reports contention. + +### Busy locking and recovery + +- [ ] Acquires one per-agent run lock and atomically records `running` state. +- [ ] Rejects a concurrent acquisition as busy without waiting or spawning. +- [ ] Uses an ownership token so a late finisher cannot clear another run. +- [ ] Records provider PID plus OS start identity before prompt delivery. +- [ ] Retains busy state while the exact owner or provider process remains alive. +- [ ] Does not trust a recycled PID with a different start identity. +- [ ] Fails closed for young incomplete/corrupt lock metadata. +- [ ] Recovers a genuinely abandoned lock as `degraded` with interrupted last result. +- [ ] Never signals a process during reconciliation. +- [ ] Restores `ready/healthy` after a later successful run. + +### Claude CLI capability probe + +- [ ] Runs only injected `claude --version` and `claude --help` commands. +- [ ] Accepts help containing all required print/session/stream flags. +- [ ] Rejects a missing executable, non-zero probe, or missing required capability. +- [ ] Returns a bounded sanitized version and never invokes a model prompt. + +### Claude stream parser and runner + +- [ ] Builds initial argv with `-p --session-id UUID --output-format stream-json --verbose`. +- [ ] Builds resume argv with exact `-p --resume UUID --output-format stream-json --verbose`. +- [ ] Never includes prompt text or `--continue` in argv. +- [ ] Uses `shell: false` and exact canonical cwd. +- [ ] Persists provider identity through `onSpawn` before writing prompt bytes to stdin. +- [ ] Handles JSON split across stdout chunks and multibyte UTF-8 boundaries. +- [ ] Accepts unknown event types while verifying every present string session ID. +- [ ] Requires exactly one valid terminal result and exit code 0 for success. +- [ ] Rejects session mismatch, malformed JSON, non-object JSON, oversized line, missing result, duplicate result, invalid result text, and non-zero exit. +- [ ] Bounds and sanitizes stderr and persisted result summary. +- [ ] Does not persist prompt, full stdout, tool input, or transcript content. + +### Print service + +- [ ] Start validates configuration before persistence and never spawns Claude. +- [ ] First send acquires, invokes initial session, verifies result, and completes ready. +- [ ] Later send invokes exact resume session. +- [ ] Failure records degraded state and releases only the owned lock. +- [ ] Busy failure returns before runner invocation. +- [ ] Timeout/failure does not retry automatically. + +### CLI command behavior + +- [ ] Omitted `--mode` routes to existing interactive start unchanged. +- [ ] `--mode interactive` routes to existing interactive start unchanged. +- [ ] `--mode print` accepts Claude only and rejects other provider combinations. +- [ ] Print start displays stable identity and does not display PID/tmux attach instructions. +- [ ] List merges live and durable rows without fake PID/session file values. +- [ ] Detail renders provider, mode, cwd, state, health, activity, and last result. +- [ ] Direct send resolves exact print ID, unique names, and reports cross-mode ambiguity. +- [ ] Existing live partial resolution remains available when no print exact name matches. +- [ ] Print `--wait`, `--timeout`, and `--json` follow the documented synchronous behavior without changing interactive behavior. +- [ ] Groups, open, rename, kill, channels, and TUI remain on live-agent paths. + +## Integration Tests + +- [ ] Temporary store create → list → detail works with no provider process or transcript. +- [ ] Fake provider first send records the caller-assigned session and returns ready. +- [ ] Second fake-provider send uses exact resume UUID and preserves stable agent ID. +- [ ] Two concurrent service instances against one store produce one run and one busy failure. +- [ ] Simulated parent crash with a live recorded provider retains busy state. +- [ ] Simulated dead owner/provider recovers degraded state, then permits a successful later send. +- [ ] Provider session mismatch cannot mutate the stored binding. +- [ ] Cwd replacement/symlink change after creation blocks send. +- [ ] Store failure after spawn occurs before prompt delivery. +- [ ] Existing agent-manager and CLI suites remain green. + +## End-to-End Tests + +- [ ] Invoke the built CLI with an injected fake `claude` executable, temporary HOME/store, and temporary cwd. +- [ ] Run `agent start --type claude --mode print`, verify no fake-provider invocation and no transcript fixture. +- [ ] Run first `agent send`, verify captured argv/session ID, stdin prompt, cwd, stream result, and ready state. +- [ ] Run second `agent send`, verify exact `--resume`, same provider UUID, and updated last result. +- [ ] Hold one fake run open and verify a second CLI send exits non-zero with a clear busy error. +- [ ] Verify JSON list/detail/send output omits prompt and fake PID/terminal fields. + +## Test Data + +- Temporary directories for HOME, store, lock root, and bound project cwd. +- Deterministic UUID/time/process-inspector injections. +- Fake Claude executable or spawn boundary supporting: + - `--version` and `--help` responses; + - invocation capture without prompt argv; + - stdin capture; + - deterministic initial/resume stream fixtures; + - chunked and multibyte output; + - delayed completion for concurrency; + - malformed/oversized/mismatched/missing/duplicate result output; + - bounded/unbounded stderr attempts; + - configurable exit code. +- No real Claude authentication, API request, transcript, hook, MCP server, or model prompt. + +## Test Reporting & Coverage + +- Focused red/green cycles: package-specific Vitest test paths. +- Agent manager: `npx nx run agent-manager:test` and coverage target invocation. +- CLI: `npx nx run cli:test` and coverage target invocation. +- Relevant full suite: repository-native test target(s) determined from `package.json`/Nx. +- Static checks: feature/base AI DevKit lint, ESLint, TypeScript typecheck, and build. +- Security: formal `security-review` against requirements, design, diff, dependencies, and validation output. +- Final review: holistic `dev-review`, followed by rebase and the same fresh validation set. +- Record exact command outputs and any justified coverage gap in this document during Phase 8. + +## Manual Testing + +- No real Claude prompt is permitted. +- Human inspection is limited to CLI help/output snapshots and fake-provider validation. +- Verify error messages are actionable without disclosing prompts, raw secrets, or unsafe paths. + +## Performance Testing + +- [ ] Verify list/read performance remains practical with at least 100 synthetic print records. +- [ ] Verify lock contention fails within the configured bounded interval. +- [ ] Verify oversized provider lines and stderr do not cause unbounded memory growth. + +## Bug Tracking + +- Blocking correctness/security issues discovered during implementation are added to the planning document immediately. +- Every fix follows a new red/green/refactor TDD cycle. +- No external issue is created unless separately requested. diff --git a/packages/agent-manager/README.md b/packages/agent-manager/README.md index e5a5f7ad..df31ee50 100644 --- a/packages/agent-manager/README.md +++ b/packages/agent-manager/README.md @@ -24,6 +24,20 @@ ai-devkit agent send "run the tests and report back" --id --wait npm test 2>&1 | ai-devkit agent send --id --stdin ``` +Claude Code can also be registered as a durable print-mode agent. Registration +does not launch Claude; each send starts one synchronous process and later sends +resume the same Claude session: + +```bash +ai-devkit agent start --type claude --mode print --name reviewer --cwd /path/to/project +ai-devkit agent send "review the current diff" --id reviewer +``` + +Print mode inherits Claude Code's settings, permissions, hooks, MCP servers, and +tool side effects for that working directory. AI DevKit adds no permission bypass +or automatic retry, and prompts are delivered over stdin rather than command-line +arguments. `--timeout` is not supported for print agents in this first release. + Use this package directly only when building custom tooling around AI DevKit's agent detection and control surface. ## Documentation diff --git a/packages/agent-manager/src/__tests__/fixtures/fake-claude.cjs b/packages/agent-manager/src/__tests__/fixtures/fake-claude.cjs new file mode 100755 index 00000000..dfa2a84a --- /dev/null +++ b/packages/agent-manager/src/__tests__/fixtures/fake-claude.cjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +const fs = require('node:fs'); + +const args = process.argv.slice(2); +if (args[0] === '--version') { + process.stdout.write('fake-claude 2.1.220\n'); + process.exit(0); +} +if (args[0] === '--help') { + process.stdout.write('--print -p --session-id --resume --output-format stream-json --verbose\n'); + process.exit(0); +} + +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { prompt += chunk; }); +process.stdin.on('end', () => { + const flag = args.includes('--session-id') ? '--session-id' : '--resume'; + const sessionId = args[args.indexOf(flag) + 1]; + const capture = process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE; + if (capture) fs.appendFileSync(capture, `${JSON.stringify({ args, prompt, cwd: process.cwd() })}\n`); + process.stdout.write(`${JSON.stringify({ type: 'system', subtype: 'init', session_id: sessionId })}\n`); + process.stdout.write(`${JSON.stringify({ type: 'result', session_id: sessionId, result: `answer:${prompt}` })}\n`); +}); diff --git a/packages/agent-manager/src/__tests__/print/ClaudeCliProbe.test.ts b/packages/agent-manager/src/__tests__/print/ClaudeCliProbe.test.ts new file mode 100644 index 00000000..f513c62c --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/ClaudeCliProbe.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('ClaudeCliProbe', () => { + it('validates only version/help and requires the print session flags', async () => { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('ClaudeCliProbe'); + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: '2.1.220\n', stderr: '' }) + .mockResolvedValueOnce({ + stdout: '--print --session-id --resume --output-format stream-json', stderr: '', + }); + const Probe = api.ClaudeCliProbe as new (options: unknown) => { validate(): Promise }; + + await expect(new Probe({ exec }).validate()).resolves.toEqual({ + executable: 'claude', version: '2.1.220', + }); + expect(exec.mock.calls).toEqual([ + ['claude', ['--version']], + ['claude', ['--help']], + ]); + }); + + it('rejects a CLI missing a required capability', async () => { + const api = await import('../../index.js') as Record; + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: 'old', stderr: '' }) + .mockResolvedValueOnce({ stdout: '--print only', stderr: '' }); + const Probe = api.ClaudeCliProbe as new (options: unknown) => { validate(): Promise }; + + await expect(new Probe({ exec }).validate()).rejects.toMatchObject({ code: 'CLAUDE_CLI_UNSUPPORTED' }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts new file mode 100644 index 00000000..8c4e2920 --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintAgent.integration.test.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + ClaudeCliProbe, + ClaudePrintAgentService, + ClaudePrintRunner, + PrintAgentStore, +} from '../../index.js'; + +const roots: string[] = []; +const originalCapture = process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE; + +afterEach(() => { + if (originalCapture === undefined) delete process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE; + else process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE = originalCapture; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Claude print-agent fake-provider journey', () => { + it('creates without invocation, then starts and resumes the same session through stdin', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'print-agent-integration-')); + roots.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + const capture = path.join(root, 'capture.jsonl'); + process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE = capture; + const executable = fileURLToPath(new URL('../fixtures/fake-claude.cjs', import.meta.url)); + const store = new PrintAgentStore({ filePath: path.join(root, 'state', 'print-agents.json') }); + const service = new ClaudePrintAgentService({ + store, + probe: new ClaudeCliProbe({ executable }), + runner: new ClaudePrintRunner(), + executable, + }); + + const created = await service.create({ name: 'reviewer', cwd }); + expect(fs.existsSync(capture)).toBe(false); + + await expect(service.send(created.id, 'first secret')).resolves.toMatchObject({ result: 'answer:first secret' }); + await expect(service.send(created.id, 'follow up')).resolves.toMatchObject({ result: 'answer:follow up' }); + + const invocations = fs.readFileSync(capture, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + expect(invocations[0]).toMatchObject({ prompt: 'first secret', cwd: fs.realpathSync(cwd) }); + expect(invocations[0].args).toContain('--session-id'); + expect(invocations[0].args).not.toContain('first secret'); + expect(invocations[1]).toMatchObject({ prompt: 'follow up', cwd: fs.realpathSync(cwd) }); + expect(invocations[1].args).toContain('--resume'); + expect(invocations[1].args[invocations[1].args.indexOf('--resume') + 1]).toBe(created.providerSessionId); + + const persisted = await store.getById(created.id); + expect(persisted).toMatchObject({ state: 'ready', sessionHealth: 'healthy' }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts new file mode 100644 index 00000000..f6d4e529 --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('ClaudePrintAgentService', () => { + it('validates before create and does not run Claude', async () => { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('ClaudePrintAgentService'); + const probe = { validate: vi.fn().mockResolvedValue({ executable: 'claude', version: '2.1.220' }) }; + const store = { create: vi.fn().mockResolvedValue({ id: 'agent-id', name: 'reviewer' }) }; + const runner = { run: vi.fn() }; + const Service = api.ClaudePrintAgentService as new (options: unknown) => any; + + await expect(new Service({ store, probe, runner }).create({ name: 'reviewer', cwd: '/project' })) + .resolves.toMatchObject({ id: 'agent-id' }); + expect(probe.validate).toHaveBeenCalledOnce(); + expect(store.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: '/project' }); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('runs first and resumed sends and records provider identity/results', async () => { + const api = await import('../../index.js') as Record; + const base = { id: 'id', name: 'reviewer', providerSessionId: 'session', sessionHealth: 'uninitialized' }; + const store = { + resolve: vi.fn().mockResolvedValue(base), + acquireRun: vi.fn() + .mockResolvedValueOnce({ agent: base, token: 'one' }) + .mockResolvedValueOnce({ agent: { ...base, sessionHealth: 'healthy' }, token: 'two' }), + recordProviderProcess: vi.fn(), completeRun: vi.fn().mockResolvedValue({}), + }; + const runner = { run: vi.fn().mockImplementation(async (request) => { + await request.onSpawn({ pid: 42, startedAt: 'start' }); + return { sessionId: 'session', result: 'answer', exitCode: 0 }; + }) }; + const Service = api.ClaudePrintAgentService as new (options: unknown) => any; + const service = new Service({ store, probe: { validate: vi.fn() }, runner, executable: 'fake-claude' }); + + await service.send('reviewer', 'first'); + await service.send('id', 'later'); + + expect(runner.run.mock.calls[0][0]).toMatchObject({ prompt: 'first', firstRun: true, executable: 'fake-claude' }); + expect(runner.run.mock.calls[1][0]).toMatchObject({ prompt: 'later', firstRun: false, executable: 'fake-claude' }); + expect(store.recordProviderProcess).toHaveBeenCalledWith('id', 'one', { pid: 42, startedAt: 'start' }); + expect(store.completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ + status: 'succeeded', exitCode: 0, sessionHealth: 'healthy', + })); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts new file mode 100644 index 00000000..fb8519e4 --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintRunner.test.ts @@ -0,0 +1,105 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import type { PrintAgent } from '../../index.js'; + +function agent(): PrintAgent { + return { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', + cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'running', + sessionHealth: 'uninitialized', createdAt: '', updatedAt: '', lastActiveAt: null, lastResult: null, + activeRun: null, + }; +} + +function fakeSpawn(events: object[], exitCode = 0) { + const calls: unknown[][] = []; + const promptChunks: Buffer[] = []; + const child = new EventEmitter() as any; + child.pid = 4242; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { promptChunks.push(Buffer.from(chunk)); callback(); }, + final(callback) { + for (const event of events) child.stdout.write(`${JSON.stringify(event)}\n`); + child.stdout.end(); + queueMicrotask(() => child.emit('close', exitCode, null)); + callback(); + }, + }); + const spawn = vi.fn((...args: unknown[]) => { calls.push(args); return child; }); + return { spawn, calls, promptChunks }; +} + +describe('ClaudePrintRunner', () => { + it('starts a caller-assigned session and persists provider identity before stdin', async () => { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('ClaudePrintRunner'); + const fixture = fakeSpawn([ + { type: 'system', subtype: 'init', session_id: agent().providerSessionId }, + { type: 'result', session_id: agent().providerSessionId, result: 'done' }, + ]); + let persisted = false; + const Runner = api.ClaudePrintRunner as new (options: unknown) => any; + const runner = new Runner({ spawn: fixture.spawn, processInspector: { + getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }), + } }); + + const result = await runner.run({ + agent: agent(), prompt: 'secret prompt', executable: 'claude-test', firstRun: true, + onSpawn: async () => { expect(fixture.promptChunks).toHaveLength(0); persisted = true; }, + }); + + expect(persisted).toBe(true); + expect(fixture.calls[0]).toEqual([ + 'claude-test', + ['-p', '--session-id', agent().providerSessionId, '--output-format', 'stream-json', '--verbose'], + expect.objectContaining({ cwd: '/project', shell: false, stdio: ['pipe', 'pipe', 'pipe'] }), + ]); + expect(JSON.stringify(fixture.calls)).not.toContain('secret prompt'); + expect(Buffer.concat(fixture.promptChunks).toString()).toBe('secret prompt'); + expect(result).toEqual({ sessionId: agent().providerSessionId, result: 'done', exitCode: 0 }); + }); + + it('uses exact resume and rejects a mismatched result session', async () => { + const api = await import('../../index.js') as Record; + const fixture = fakeSpawn([{ type: 'result', session_id: 'wrong', result: 'nope' }]); + const Runner = api.ClaudePrintRunner as new (options: unknown) => any; + const runner = new Runner({ spawn: fixture.spawn, processInspector: { + getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }), + } }); + + await expect(runner.run({ + agent: agent(), prompt: 'followup', executable: 'claude', firstRun: false, onSpawn: vi.fn(), + })).rejects.toMatchObject({ code: 'CLAUDE_SESSION_MISMATCH' }); + expect(fixture.calls[0]![1]).toEqual([ + '-p', '--resume', agent().providerSessionId, '--output-format', 'stream-json', '--verbose', + ]); + }); + + it('does not disclose provider stderr in a failed-run error', async () => { + const api = await import('../../index.js') as Record; + const fixture = fakeSpawn([], 1); + const Runner = api.ClaudePrintRunner as new (options: unknown) => any; + const runner = new Runner({ spawn: fixture.spawn, processInspector: { + getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }), + } }); + fixture.spawn.mockImplementationOnce((...args: unknown[]) => { + const child = (fakeSpawn([], 1).spawn as any)(...args); + child.stdin = new Writable({ + final(callback) { + child.stderr.write('secret prompt echoed by provider'); + child.stderr.end(); + queueMicrotask(() => child.emit('close', 1, null)); + callback(); + }, + }); + return child; + }); + + await expect(runner.run({ + agent: agent(), prompt: 'secret prompt', firstRun: true, onSpawn: vi.fn(), + })).rejects.not.toThrow(/secret prompt/); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/PrintAgent.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgent.test.ts new file mode 100644 index 00000000..a7639a37 --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/PrintAgent.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +describe('print-agent public domain', () => { + it('exports a classified busy error without exposing prompt data', async () => { + const api = await import('../../index.js') as Record; + + expect(api).toHaveProperty('PrintAgentBusyError'); + const ErrorType = api.PrintAgentBusyError as new (agentId: string, name: string) => Error & { + code: string; + agentId: string; + }; + const error = new ErrorType('agent-id', 'reviewer'); + + expect(error).toMatchObject({ + name: 'PrintAgentBusyError', + code: 'PRINT_AGENT_BUSY', + agentId: 'agent-id', + message: 'Print agent "reviewer" is busy.', + }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts new file mode 100644 index 00000000..85b4c8e6 --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts @@ -0,0 +1,192 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +async function loadStore(): Promise { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('PrintAgentStore'); + return api.PrintAgentStore; +} + +function fixture(): { root: string; cwd: string; filePath: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'print-agent-store-')); + tempDirs.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + return { root, cwd, filePath: path.join(root, 'state', 'print-agents.json') }; +} + +describe('PrintAgentStore create/list/resolve', () => { + it('creates distinct durable identities with a canonical cwd and lists them', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + const store = new PrintAgentStore({ filePath, now: () => new Date('2026-08-07T09:00:00Z') }); + + const agent = await store.create({ name: 'reviewer', cwd }); + + expect(agent).toMatchObject({ + name: 'reviewer', + provider: 'claude', + mode: 'print', + cwd: fs.realpathSync(cwd), + state: 'ready', + sessionHealth: 'uninitialized', + activeRun: null, + }); + expect(agent.id).toMatch(/^[0-9a-f-]{36}$/); + expect(agent.providerSessionId).toMatch(/^[0-9a-f-]{36}$/); + expect(agent.id).not.toBe(agent.providerSessionId); + expect(await store.list()).toEqual([agent]); + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + }); + + it('resolves exact ids and names and rejects duplicate names', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + const store = new PrintAgentStore({ filePath }); + const agent = await store.create({ name: 'Reviewer', cwd }); + + expect(await store.resolve(agent.id)).toMatchObject({ id: agent.id }); + expect(await store.resolve('reviewer')).toMatchObject({ id: agent.id }); + expect(await store.resolve('view')).toBeNull(); + await expect(store.create({ name: 'reviewer', cwd })).rejects.toMatchObject({ + code: 'PRINT_AGENT_NAME_CONFLICT', + }); + }); + + it('rejects missing cwd, malformed storage, and symlinked store targets', async () => { + const PrintAgentStore = await loadStore(); + const { root, cwd, filePath } = fixture(); + const store = new PrintAgentStore({ filePath }); + + await expect(store.create({ name: 'missing', cwd: path.join(root, 'missing') })) + .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '{bad json', { mode: 0o600 }); + await expect(store.list()).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + + fs.rmSync(filePath); + const target = path.join(root, 'target.json'); + fs.writeFileSync(target, JSON.stringify({ version: 1, agents: [] })); + fs.symlinkSync(target, filePath); + await expect(store.create({ name: 'unsafe', cwd })).rejects.toMatchObject({ + code: 'PRINT_AGENT_STORE', + }); + }); + + it('recovers an abandoned old mutation lock after a crash', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const lockPath = `${filePath}.lock`; + fs.mkdirSync(lockPath); + const old = new Date(Date.now() - 60_000); + fs.utimesSync(lockPath, old, old); + const store = new PrintAgentStore({ filePath, mutationLockStaleMs: 10 }); + + await expect(store.create({ name: 'recovered', cwd })).resolves.toMatchObject({ name: 'recovered' }); + }); +}); + +describe('PrintAgentStore run ownership', () => { + it('fails fast when another exact owner is live and completes only for its token', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + const live = new Map([[process.pid, 'owner-start']]); + const processInspector = { getIdentity: (pid: number) => { + const startedAt = live.get(pid); + return startedAt ? { pid, startedAt } : null; + } }; + const store = new PrintAgentStore({ filePath, processInspector }); + const agent = await store.create({ name: 'runner', cwd }); + + const acquired = await store.acquireRun(agent.id); + await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_BUSY' }); + await expect(store.completeRun(agent.id, 'wrong-token', { + status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy', + })).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + + const completed = await store.completeRun(agent.id, acquired.token, { + status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy', + }); + expect(completed).toMatchObject({ state: 'ready', sessionHealth: 'healthy', activeRun: null }); + }); + + it('retains busy for a live provider then recovers a dead run without signaling it', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + const live = new Map([[process.pid, 'owner-start'], [4242, 'provider-start']]); + const processInspector = { getIdentity: (pid: number) => { + const startedAt = live.get(pid); + return startedAt ? { pid, startedAt } : null; + } }; + const first = new PrintAgentStore({ filePath, processInspector }); + const agent = await first.create({ name: 'recoverable', cwd }); + const run = await first.acquireRun(agent.id); + await first.recordProviderProcess(agent.id, run.token, { pid: 4242, startedAt: 'provider-start' }); + + live.delete(process.pid); + await expect(first.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_BUSY' }); + + live.delete(4242); + live.set(process.pid, 'replacement-owner-start'); + const recovered = await first.acquireRun(agent.id); + expect(recovered.agent).toMatchObject({ + state: 'running', + lastResult: { status: 'interrupted' }, + }); + await first.completeRun(agent.id, recovered.token, { + status: 'failed', exitCode: 1, summary: 'failed', sessionHealth: 'unknown', + }); + }); + + it('reconciles an old incomplete lock to degraded during list', async () => { + const PrintAgentStore = await loadStore(); + const { root, cwd, filePath } = fixture(); + const live = new Map([[process.pid, 'owner-start']]); + const store = new PrintAgentStore({ filePath, incompleteLockGraceMs: 10, processInspector: { + getIdentity: (pid: number) => { + const startedAt = live.get(pid); + return startedAt ? { pid, startedAt } : null; + }, + } }); + const agent = await store.create({ name: 'crashed', cwd }); + await store.acquireRun(agent.id); + const lockPath = path.join(root, 'state', 'print-agent-locks', `${agent.id}.lock`); + fs.unlinkSync(path.join(lockPath, 'owner.json')); + const old = new Date(Date.now() - 1000); + fs.utimesSync(lockPath, old, old); + live.clear(); + + const listed = await store.list(); + + expect(listed[0]).toMatchObject({ + state: 'degraded', + sessionHealth: 'unknown', + activeRun: null, + lastResult: { status: 'interrupted' }, + }); + }); + + it('rejects send acquisition when the bound cwd is replaced by a symlink', async () => { + const PrintAgentStore = await loadStore(); + const { root, cwd, filePath } = fixture(); + const store = new PrintAgentStore({ filePath }); + const agent = await store.create({ name: 'bound', cwd }); + const moved = path.join(root, 'moved-project'); + const other = path.join(root, 'other-project'); + fs.renameSync(cwd, moved); + fs.mkdirSync(other); + fs.symlinkSync(other, cwd); + + await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + }); +}); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index fc68ac30..ced400ec 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -34,3 +34,42 @@ export type { AgentConfig, StartableAgentType } from './utils/agents.js'; export type { AgentRequest } from './utils/agent-requests.js'; export { getAgentRequestPath, readLatestAgentRequest, writeAgentRequest } from './utils/agent-requests.js'; + +export { + PrintAgentError, + PrintAgentBusyError, + PrintAgentNotFoundError, + PrintAgentStoreError, + PrintAgentNameConflictError, + ClaudePrintError, +} from './print/PrintAgent.js'; +export type { + PrintAgent, + PrintAgentState, + PrintSessionHealth, + PrintRunStatus, + PrintActiveRun, + PrintLastResult, + ProcessIdentity, +} from './print/PrintAgent.js'; +export { PrintAgentStore } from './print/PrintAgentStore.js'; +export { LocalProcessInspector } from './print/PrintAgentStore.js'; +export type { + CreatePrintAgentInput, + PrintAgentStoreOptions, + ProcessInspector, + PrintRunCompletion, +} from './print/PrintAgentStore.js'; +export { ClaudeCliProbe } from './print/ClaudeCliProbe.js'; +export type { ClaudeCliProbeOptions } from './print/ClaudeCliProbe.js'; +export { ClaudePrintRunner } from './print/ClaudePrintRunner.js'; +export type { + ClaudePrintRunnerOptions, + ClaudePrintRunRequest, + ClaudePrintRunResult, +} from './print/ClaudePrintRunner.js'; +export { ClaudePrintAgentService } from './print/ClaudePrintAgentService.js'; +export type { + ClaudePrintAgentServiceOptions, + ClaudePrintSendResult, +} from './print/ClaudePrintAgentService.js'; diff --git a/packages/agent-manager/src/print/ClaudeCliProbe.ts b/packages/agent-manager/src/print/ClaudeCliProbe.ts new file mode 100644 index 00000000..d14cfafb --- /dev/null +++ b/packages/agent-manager/src/print/ClaudeCliProbe.ts @@ -0,0 +1,58 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { ClaudePrintError } from './PrintAgent.js'; + +type ExecResult = { stdout: string; stderr: string }; +type Exec = (file: string, args: string[]) => Promise; + +const execFileAsync = promisify(execFile); +const REQUIRED = ['--print', '--session-id', '--resume', '--output-format', 'stream-json']; + +export interface ClaudeCliProbeOptions { + executable?: string; + exec?: Exec; +} + +export class ClaudeCliProbe { + private readonly executable: string; + private readonly exec: Exec; + + constructor(options: ClaudeCliProbeOptions = {}) { + this.executable = options.executable ?? 'claude'; + this.exec = options.exec ?? (async (file, args) => { + const result = await execFileAsync(file, args, { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; + }); + } + + async validate(): Promise<{ executable: string; version: string }> { + try { + const versionResult = await this.exec(this.executable, ['--version']); + const helpResult = await this.exec(this.executable, ['--help']); + const missing = REQUIRED.filter((capability) => !helpResult.stdout.includes(capability)); + if (missing.length > 0) { + throw new ClaudePrintError( + `Claude CLI does not support required print-mode capabilities: ${missing.join(', ')}.`, + 'CLAUDE_CLI_UNSUPPORTED', + ); + } + return { + executable: this.executable, + version: sanitize(versionResult.stdout, 256) || 'unknown', + }; + } catch (error) { + if (error instanceof ClaudePrintError) throw error; + throw new ClaudePrintError( + `Claude CLI validation failed: ${sanitize((error as Error).message, 512)}`, + 'CLAUDE_CLI_UNAVAILABLE', + ); + } + } +} + +function sanitize(value: string, max: number): string { + return Array.from(value, (character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127 ? ' ' : character; + }).join('').trim().slice(0, max); +} diff --git a/packages/agent-manager/src/print/ClaudePrintAgentService.ts b/packages/agent-manager/src/print/ClaudePrintAgentService.ts new file mode 100644 index 00000000..26195b26 --- /dev/null +++ b/packages/agent-manager/src/print/ClaudePrintAgentService.ts @@ -0,0 +1,94 @@ +import type { PrintAgent, ProcessIdentity } from './PrintAgent.js'; +import { ClaudePrintError, PrintAgentNotFoundError } from './PrintAgent.js'; +import { ClaudeCliProbe } from './ClaudeCliProbe.js'; +import { ClaudePrintRunner, type ClaudePrintRunResult } from './ClaudePrintRunner.js'; +import { PrintAgentStore, type CreatePrintAgentInput, type PrintRunCompletion } from './PrintAgentStore.js'; + +interface StoreLike { + create(input: CreatePrintAgentInput): Promise; + list(): Promise; + resolve(reference: string): Promise; + acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }>; + recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; + completeRun(id: string, token: string, result: PrintRunCompletion): Promise; +} + +interface ProbeLike { validate(): Promise<{ executable: string; version: string }> } +interface RunnerLike { run(request: Parameters[0]): Promise } + +export interface ClaudePrintAgentServiceOptions { + store?: StoreLike; + probe?: ProbeLike; + runner?: RunnerLike; + executable?: string; +} + +export interface ClaudePrintSendResult extends ClaudePrintRunResult { + agentId: string; + agentName: string; +} + +export class ClaudePrintAgentService { + readonly store: StoreLike; + private readonly probe: ProbeLike; + private readonly runner: RunnerLike; + private readonly executable?: string; + + constructor(options: ClaudePrintAgentServiceOptions = {}) { + this.store = options.store ?? new PrintAgentStore(); + this.probe = options.probe ?? new ClaudeCliProbe(); + this.runner = options.runner ?? new ClaudePrintRunner(); + this.executable = options.executable; + } + + async create(input: CreatePrintAgentInput): Promise { + await this.probe.validate(); + return this.store.create(input); + } + + async send(reference: string, prompt: string): Promise { + const resolved = await this.store.resolve(reference); + if (!resolved) throw new PrintAgentNotFoundError(reference); + if (Array.isArray(resolved)) { + throw new ClaudePrintError(`Multiple print agents match "${reference}".`, 'PRINT_AGENT_AMBIGUOUS'); + } + const acquired = await this.store.acquireRun(resolved.id); + try { + const result = await this.runner.run({ + agent: acquired.agent, + prompt, + executable: this.executable, + firstRun: acquired.agent.sessionHealth === 'uninitialized', + onSpawn: (identity) => this.store.recordProviderProcess(resolved.id, acquired.token, identity), + }); + await this.store.completeRun(resolved.id, acquired.token, { + status: 'succeeded', + exitCode: result.exitCode, + summary: sanitize(result.result, 4096), + sessionHealth: 'healthy', + }); + return { ...result, agentId: resolved.id, agentName: resolved.name }; + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + const sessionHealth = error instanceof ClaudePrintError && error.code === 'CLAUDE_SESSION_MISMATCH' + ? 'mismatch' as const + : 'unknown' as const; + await this.store.completeRun(resolved.id, acquired.token, { + status: 'failed', + exitCode: null, + summary: sanitize(failure.message, 4096), + sessionHealth, + }); + throw error; + } + } +} + +function sanitize(value: string, max: number): string { + return Array.from(value, (character) => { + const code = character.charCodeAt(0); + return (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) + ? ' ' + : character; + }).join('').trim().slice(0, max); +} diff --git a/packages/agent-manager/src/print/ClaudePrintRunner.ts b/packages/agent-manager/src/print/ClaudePrintRunner.ts new file mode 100644 index 00000000..588effd8 --- /dev/null +++ b/packages/agent-manager/src/print/ClaudePrintRunner.ts @@ -0,0 +1,139 @@ +import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; +import type { PrintAgent, ProcessIdentity } from './PrintAgent.js'; +import { ClaudePrintError } from './PrintAgent.js'; +import { LocalProcessInspector, type ProcessInspector } from './PrintAgentStore.js'; + +type Spawn = ( + command: string, + args: readonly string[], + options: SpawnOptionsWithoutStdio & { stdio: ['pipe', 'pipe', 'pipe'] }, +) => ChildProcessWithoutNullStreams; + +export interface ClaudePrintRunRequest { + agent: PrintAgent; + prompt: string; + executable?: string; + firstRun: boolean; + onSpawn(identity: ProcessIdentity): Promise; +} + +export interface ClaudePrintRunResult { + sessionId: string; + result: string; + exitCode: number; +} + +export interface ClaudePrintRunnerOptions { + spawn?: Spawn; + processInspector?: ProcessInspector; + maxLineBytes?: number; +} + +export class ClaudePrintRunner { + private readonly spawn: Spawn; + private readonly processInspector: ProcessInspector; + private readonly maxLineBytes: number; + + constructor(options: ClaudePrintRunnerOptions = {}) { + this.spawn = options.spawn ?? (nodeSpawn as Spawn); + this.processInspector = options.processInspector ?? new LocalProcessInspector(); + this.maxLineBytes = options.maxLineBytes ?? 1024 * 1024; + } + + async run(request: ClaudePrintRunRequest): Promise { + const sessionArgs = request.firstRun + ? ['--session-id', request.agent.providerSessionId] + : ['--resume', request.agent.providerSessionId]; + const args = ['-p', ...sessionArgs, '--output-format', 'stream-json', '--verbose']; + const child = this.spawn(request.executable ?? 'claude', args, { + cwd: request.agent.cwd, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (!child.pid) { + child.kill(); + throw new ClaudePrintError('Claude process did not provide a PID.', 'CLAUDE_PROCESS_IDENTITY'); + } + const identity = this.processInspector.getIdentity(child.pid); + if (!identity) { + child.kill(); + throw new ClaudePrintError('Cannot verify Claude process identity.', 'CLAUDE_PROCESS_IDENTITY'); + } + + let buffer = Buffer.alloc(0); + let terminal: ClaudePrintRunResult | null = null; + let protocolError: ClaudePrintError | null = null; + + child.stdout.on('data', (chunk: Buffer | string) => { + if (protocolError) return; + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (buffer.length > this.maxLineBytes && buffer.indexOf(0x0a) < 0) { + protocolError = new ClaudePrintError('Claude stream line exceeded the safety limit.', 'CLAUDE_STREAM_OVERSIZED'); + return; + } + let newline: number; + while ((newline = buffer.indexOf(0x0a)) >= 0) { + const line = buffer.subarray(0, newline); + buffer = buffer.subarray(newline + 1); + if (line.length === 0) continue; + if (line.length > this.maxLineBytes) { + protocolError = new ClaudePrintError('Claude stream line exceeded the safety limit.', 'CLAUDE_STREAM_OVERSIZED'); + return; + } + try { + const value = JSON.parse(line.toString('utf8')) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ClaudePrintError('Claude emitted a non-object stream message.', 'CLAUDE_STREAM_INVALID'); + } + const event = value as Record; + if (typeof event.session_id === 'string' && event.session_id !== request.agent.providerSessionId) { + throw new ClaudePrintError('Claude returned a different session identity.', 'CLAUDE_SESSION_MISMATCH'); + } + if (event.type === 'result') { + if (terminal) throw new ClaudePrintError('Claude emitted more than one terminal result.', 'CLAUDE_STREAM_INVALID'); + if (typeof event.session_id !== 'string' || typeof event.result !== 'string') { + throw new ClaudePrintError('Claude emitted an invalid terminal result.', 'CLAUDE_STREAM_INVALID'); + } + terminal = { sessionId: event.session_id, result: event.result, exitCode: 0 }; + } + } catch (error) { + protocolError = error instanceof ClaudePrintError + ? error + : new ClaudePrintError('Claude emitted malformed stream JSON.', 'CLAUDE_STREAM_INVALID'); + return; + } + } + }); + // Drain provider diagnostics without reflecting potentially sensitive prompt/tool data. + child.stderr.resume(); + + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + + try { + await request.onSpawn(identity); + } catch (error) { + child.kill(); + throw error; + } + + child.stdin.end(request.prompt); + const { code, signal } = await closed; + + if (protocolError) throw protocolError; + if (buffer.length > 0) { + throw new ClaudePrintError('Claude stream ended with incomplete JSON.', 'CLAUDE_STREAM_INVALID'); + } + if (code !== 0) { + throw new ClaudePrintError( + `Claude print run failed${signal ? ` (${signal})` : '.'}`, + 'CLAUDE_PROCESS_FAILED', + ); + } + if (!terminal) throw new ClaudePrintError('Claude stream ended without a terminal result.', 'CLAUDE_RESULT_MISSING'); + const finalResult = terminal as ClaudePrintRunResult; + return { sessionId: finalResult.sessionId, result: finalResult.result, exitCode: code }; + } +} diff --git a/packages/agent-manager/src/print/PrintAgent.ts b/packages/agent-manager/src/print/PrintAgent.ts new file mode 100644 index 00000000..ca812e34 --- /dev/null +++ b/packages/agent-manager/src/print/PrintAgent.ts @@ -0,0 +1,86 @@ +export type PrintAgentState = 'ready' | 'running' | 'degraded'; +export type PrintSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; +export type PrintRunStatus = 'succeeded' | 'failed' | 'interrupted'; + +export interface ProcessIdentity { + pid: number; + startedAt: string; +} + +export interface PrintActiveRun { + token: string; + owner: ProcessIdentity; + provider: ProcessIdentity | null; + startedAt: string; +} + +export interface PrintLastResult { + status: PrintRunStatus; + completedAt: string; + exitCode: number | null; + summary: string; +} + +export interface PrintAgent { + id: string; + name: string; + provider: 'claude'; + mode: 'print'; + cwd: string; + providerSessionId: string; + state: PrintAgentState; + sessionHealth: PrintSessionHealth; + createdAt: string; + updatedAt: string; + lastActiveAt: string | null; + lastResult: PrintLastResult | null; + activeRun: PrintActiveRun | null; +} + +export class PrintAgentError extends Error { + constructor( + message: string, + public readonly code: string, + ) { + super(message); + this.name = 'PrintAgentError'; + } +} + +export class PrintAgentBusyError extends PrintAgentError { + constructor( + public readonly agentId: string, + agentName: string, + ) { + super(`Print agent "${agentName}" is busy.`, 'PRINT_AGENT_BUSY'); + this.name = 'PrintAgentBusyError'; + } +} + +export class PrintAgentNotFoundError extends PrintAgentError { + constructor(public readonly reference: string) { + super(`Print agent "${reference}" was not found.`, 'PRINT_AGENT_NOT_FOUND'); + this.name = 'PrintAgentNotFoundError'; + } +} + +export class PrintAgentStoreError extends PrintAgentError { + constructor(message: string) { + super(message, 'PRINT_AGENT_STORE'); + this.name = 'PrintAgentStoreError'; + } +} + +export class PrintAgentNameConflictError extends PrintAgentError { + constructor(public readonly agentName: string) { + super(`Print agent name "${agentName}" is already in use.`, 'PRINT_AGENT_NAME_CONFLICT'); + this.name = 'PrintAgentNameConflictError'; + } +} + +export class ClaudePrintError extends PrintAgentError { + constructor(message: string, code = 'CLAUDE_PRINT_FAILED') { + super(message, code); + this.name = 'ClaudePrintError'; + } +} diff --git a/packages/agent-manager/src/print/PrintAgentStore.ts b/packages/agent-manager/src/print/PrintAgentStore.ts new file mode 100644 index 00000000..db560aa0 --- /dev/null +++ b/packages/agent-manager/src/print/PrintAgentStore.ts @@ -0,0 +1,503 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { execFileSync } from 'child_process'; +import type { PrintAgent, ProcessIdentity, PrintRunStatus, PrintSessionHealth } from './PrintAgent.js'; +import { + PrintAgentBusyError, + PrintAgentNameConflictError, + PrintAgentNotFoundError, + PrintAgentStoreError, +} from './PrintAgent.js'; + +interface PrintAgentStoreFile { + version: 1; + agents: PrintAgent[]; +} + +export interface CreatePrintAgentInput { + name: string; + cwd: string; +} + +export interface PrintAgentStoreOptions { + filePath?: string; + lockTimeoutMs?: number; + now?: () => Date; + processInspector?: ProcessInspector; + incompleteLockGraceMs?: number; + mutationLockStaleMs?: number; +} + +export interface ProcessInspector { + getIdentity(pid: number): ProcessIdentity | null; +} + +export interface PrintRunCompletion { + status: PrintRunStatus; + exitCode: number | null; + summary: string; + sessionHealth: PrintSessionHealth; +} + +const DEFAULT_FILE = path.join(os.homedir(), '.ai-devkit', 'print-agents.json'); + +export class PrintAgentStore { + readonly filePath: string; + private readonly lockPath: string; + private readonly lockTimeoutMs: number; + private readonly now: () => Date; + private readonly processInspector: ProcessInspector; + private readonly runLocksRoot: string; + private readonly incompleteLockGraceMs: number; + private readonly mutationLockStaleMs: number; + + constructor(options: PrintAgentStoreOptions = {}) { + this.filePath = options.filePath ?? DEFAULT_FILE; + this.lockPath = `${this.filePath}.lock`; + this.lockTimeoutMs = options.lockTimeoutMs ?? 2000; + this.now = options.now ?? (() => new Date()); + this.processInspector = options.processInspector ?? new LocalProcessInspector(); + this.runLocksRoot = path.join(path.dirname(this.filePath), 'print-agent-locks'); + this.incompleteLockGraceMs = options.incompleteLockGraceMs ?? 30_000; + this.mutationLockStaleMs = options.mutationLockStaleMs ?? 30_000; + } + + async create(input: CreatePrintAgentInput): Promise { + const cwd = this.canonicalDirectory(input.cwd); + return this.withMutationLock(async () => { + const data = this.readFile(); + if (data.agents.some((agent) => agent.name.toLowerCase() === input.name.toLowerCase())) { + throw new PrintAgentNameConflictError(input.name); + } + const timestamp = this.now().toISOString(); + let id = randomUUID(); + let providerSessionId = randomUUID(); + while (providerSessionId === id) providerSessionId = randomUUID(); + while (data.agents.some((agent) => agent.id === id)) id = randomUUID(); + const agent: PrintAgent = { + id, + name: input.name, + provider: 'claude', + mode: 'print', + cwd, + providerSessionId, + state: 'ready', + sessionHealth: 'uninitialized', + createdAt: timestamp, + updatedAt: timestamp, + lastActiveAt: null, + lastResult: null, + activeRun: null, + }; + data.agents.push(agent); + this.writeFile(data); + return structuredClone(agent); + }); + } + + async list(): Promise { + await this.reconcile(); + return this.listRaw(); + } + + async getById(id: string): Promise { + return (await this.list()).find((agent) => agent.id === id) ?? null; + } + + async reconcile(): Promise { + const running = this.listRaw().filter((agent) => agent.state === 'running' && agent.activeRun); + for (const snapshot of running) { + const lockPath = this.runLockPath(snapshot.id); + const metadata = this.readRunLock(snapshot.id); + if (metadata && this.isActive(metadata)) continue; + if (!metadata && this.isYoungLock(lockPath)) continue; + + if (fs.existsSync(lockPath)) { + const quarantine = `${lockPath}.stale-${randomUUID()}`; + try { + fs.renameSync(lockPath, quarantine); + this.removeLockDirectory(quarantine); + } catch { + continue; + } + } + const completedAt = this.now().toISOString(); + await this.updateAgent(snapshot.id, (current) => { + if (current.state !== 'running' || current.activeRun?.token !== snapshot.activeRun?.token) return current; + return { + ...current, + state: 'degraded', + sessionHealth: 'unknown', + activeRun: null, + updatedAt: completedAt, + lastActiveAt: completedAt, + lastResult: { + status: 'interrupted', + completedAt, + exitCode: null, + summary: 'Previous print run was interrupted.', + }, + }; + }); + } + } + + async resolve(reference: string): Promise { + const agents = await this.list(); + const byId = agents.find((agent) => agent.id === reference); + if (byId) return byId; + const matches = agents.filter((agent) => agent.name.toLowerCase() === reference.toLowerCase()); + if (matches.length === 0) return null; + return matches.length === 1 ? matches[0]! : matches; + } + + async acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }> { + const existing = await this.getById(id); + if (!existing) throw new PrintAgentNotFoundError(id); + this.validateBoundCwd(existing.cwd); + const runLock = this.runLockPath(id); + let recoveredStale = false; + + for (;;) { + this.ensureRunLocksRoot(); + this.assertNotSymlink(runLock); + try { + fs.mkdirSync(runLock, { mode: 0o700 }); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw new PrintAgentStoreError(`Cannot acquire print-agent run lock: ${(error as Error).message}`); + } + const metadata = this.readRunLock(id); + if (!metadata || this.isActive(metadata)) { + throw new PrintAgentBusyError(id, existing.name); + } + const quarantine = `${runLock}.stale-${randomUUID()}`; + try { + fs.renameSync(runLock, quarantine); + this.removeLockDirectory(quarantine); + recoveredStale = true; + } catch { + // Another contender changed the lock. Retry and inspect the winner. + } + } + } + + const owner = this.processInspector.getIdentity(process.pid); + if (!owner) { + this.removeLockDirectory(runLock); + throw new PrintAgentStoreError('Cannot determine the current process identity.'); + } + const token = randomUUID(); + const startedAt = this.now().toISOString(); + const activeRun = { token, owner, provider: null, startedAt }; + this.writeRunLock(id, activeRun); + + try { + const agent = await this.updateAgent(id, (current) => ({ + ...current, + state: 'running', + activeRun, + updatedAt: startedAt, + ...(recoveredStale ? { + sessionHealth: 'unknown' as const, + lastResult: { + status: 'interrupted' as const, + completedAt: startedAt, + exitCode: null, + summary: 'Previous print run was interrupted.', + }, + } : {}), + })); + return { agent, token }; + } catch (error) { + this.removeOwnedRunLock(id, token); + throw error; + } + } + + async recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise { + const metadata = this.requireOwnedRun(id, token); + const next = { ...metadata, provider: identity }; + this.writeRunLock(id, next); + await this.updateAgent(id, (agent) => { + if (agent.activeRun?.token !== token) throw new PrintAgentStoreError('Print run ownership changed.'); + return { ...agent, activeRun: next, updatedAt: this.now().toISOString() }; + }); + } + + async completeRun(id: string, token: string, result: PrintRunCompletion): Promise { + this.requireOwnedRun(id, token); + const completedAt = this.now().toISOString(); + const agent = await this.updateAgent(id, (current) => { + if (current.activeRun?.token !== token) throw new PrintAgentStoreError('Print run ownership changed.'); + return { + ...current, + state: result.status === 'succeeded' ? 'ready' : 'degraded', + sessionHealth: result.sessionHealth, + activeRun: null, + lastActiveAt: completedAt, + updatedAt: completedAt, + lastResult: { + status: result.status, + completedAt, + exitCode: result.exitCode, + summary: result.summary.slice(0, 4096), + }, + }; + }); + this.removeOwnedRunLock(id, token); + return agent; + } + + private canonicalDirectory(input: string): string { + try { + const resolved = fs.realpathSync(input); + if (!fs.statSync(resolved).isDirectory()) throw new Error('not a directory'); + return resolved; + } catch { + throw new PrintAgentStoreError(`Print agent cwd is not an existing directory: ${input}`); + } + } + + private validateBoundCwd(bound: string): void { + try { + const stat = fs.lstatSync(bound); + if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(bound) !== bound) { + throw new Error('binding changed'); + } + } catch { + throw new PrintAgentStoreError(`Print agent cwd binding is no longer safe: ${bound}`); + } + } + + private ensureSafeParent(): string { + const parent = path.dirname(this.filePath); + fs.mkdirSync(parent, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(parent); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new PrintAgentStoreError(`Unsafe print-agent store directory: ${parent}`); + } + return parent; + } + + private ensureRunLocksRoot(): void { + this.ensureSafeParent(); + fs.mkdirSync(this.runLocksRoot, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(this.runLocksRoot); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new PrintAgentStoreError(`Unsafe print-agent lock directory: ${this.runLocksRoot}`); + } + } + + private assertNotSymlink(target: string): void { + try { + if (fs.lstatSync(target).isSymbolicLink()) { + throw new PrintAgentStoreError(`Unsafe symbolic link in print-agent storage: ${target}`); + } + } catch (error) { + if (error instanceof PrintAgentStoreError) throw error; + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new PrintAgentStoreError(`Cannot inspect print-agent storage: ${target}`); + } + } + } + + private readFile(): PrintAgentStoreFile { + this.ensureSafeParent(); + this.assertNotSymlink(this.filePath); + if (!fs.existsSync(this.filePath)) return { version: 1, agents: [] }; + try { + const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as unknown; + if (!this.isStoreFile(parsed)) throw new Error('invalid schema'); + return parsed; + } catch { + throw new PrintAgentStoreError(`Invalid print-agent store: ${this.filePath}`); + } + } + + private listRaw(): PrintAgent[] { + return this.readFile().agents.map((agent) => structuredClone(agent)); + } + + private isStoreFile(value: unknown): value is PrintAgentStoreFile { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return record.version === 1 && Array.isArray(record.agents); + } + + private writeFile(data: PrintAgentStoreFile): void { + const parent = this.ensureSafeParent(); + this.assertNotSymlink(this.filePath); + const temp = path.join(parent, `.print-agents-${process.pid}-${randomUUID()}.tmp`); + this.assertNotSymlink(temp); + let fd: number | undefined; + try { + fd = fs.openSync(temp, 'wx', 0o600); + fs.writeFileSync(fd, JSON.stringify(data, null, 2), 'utf8'); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(temp, this.filePath); + fs.chmodSync(this.filePath, 0o600); + } catch (error) { + if (fd !== undefined) fs.closeSync(fd); + try { fs.unlinkSync(temp); } catch { /* best effort */ } + if (error instanceof PrintAgentStoreError) throw error; + throw new PrintAgentStoreError(`Failed to update print-agent store: ${(error as Error).message}`); + } + } + + private async withMutationLock(operation: () => Promise): Promise { + this.ensureSafeParent(); + const started = Date.now(); + for (;;) { + this.assertNotSymlink(this.lockPath); + try { + fs.mkdirSync(this.lockPath, { mode: 0o700 }); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw new PrintAgentStoreError(`Cannot acquire print-agent store lock: ${(error as Error).message}`); + } + if (!this.isYoungMutationLock()) { + const quarantine = `${this.lockPath}.stale-${randomUUID()}`; + try { + fs.renameSync(this.lockPath, quarantine); + fs.rmdirSync(quarantine); + continue; + } catch { + // Another contender changed the lock. Retry until the bounded timeout. + } + } + if (Date.now() - started >= this.lockTimeoutMs) { + throw new PrintAgentStoreError('Timed out acquiring print-agent store lock.'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + try { + return await operation(); + } finally { + try { fs.rmdirSync(this.lockPath); } catch { /* surfaced by later contention */ } + } + } + + private isYoungMutationLock(): boolean { + try { + this.assertNotSymlink(this.lockPath); + const stat = fs.statSync(this.lockPath); + return stat.isDirectory() && Date.now() - stat.mtimeMs < this.mutationLockStaleMs; + } catch { + return false; + } + } + + private async updateAgent(id: string, update: (agent: PrintAgent) => PrintAgent): Promise { + return this.withMutationLock(async () => { + const data = this.readFile(); + const index = data.agents.findIndex((agent) => agent.id === id); + if (index < 0) throw new PrintAgentNotFoundError(id); + const next = update(data.agents[index]!); + data.agents[index] = next; + this.writeFile(data); + return structuredClone(next); + }); + } + + private runLockPath(id: string): string { + if (!/^[0-9a-f-]{36}$/i.test(id)) throw new PrintAgentStoreError('Invalid print-agent id.'); + return path.join(this.runLocksRoot, `${id}.lock`); + } + + private readRunLock(id: string): import('./PrintAgent.js').PrintActiveRun | null { + const ownerPath = path.join(this.runLockPath(id), 'owner.json'); + try { + this.assertNotSymlink(ownerPath); + const value = JSON.parse(fs.readFileSync(ownerPath, 'utf8')) as import('./PrintAgent.js').PrintActiveRun; + if (!value || typeof value.token !== 'string' || !value.owner || typeof value.owner.pid !== 'number') return null; + return value; + } catch { + return null; + } + } + + private writeRunLock(id: string, metadata: import('./PrintAgent.js').PrintActiveRun): void { + const lockPath = this.runLockPath(id); + const ownerPath = path.join(lockPath, 'owner.json'); + const tempPath = path.join(lockPath, `.owner-${randomUUID()}.tmp`); + this.assertNotSymlink(lockPath); + this.assertNotSymlink(ownerPath); + fs.writeFileSync(tempPath, JSON.stringify(metadata), { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + fs.renameSync(tempPath, ownerPath); + } + + private requireOwnedRun(id: string, token: string): import('./PrintAgent.js').PrintActiveRun { + const metadata = this.readRunLock(id); + if (!metadata || metadata.token !== token) throw new PrintAgentStoreError('Print run ownership changed.'); + return metadata; + } + + private isActive(metadata: import('./PrintAgent.js').PrintActiveRun): boolean { + return this.sameProcess(metadata.owner) || (metadata.provider !== null && this.sameProcess(metadata.provider)); + } + + private sameProcess(expected: ProcessIdentity): boolean { + const actual = this.processInspector.getIdentity(expected.pid); + return actual !== null && actual.startedAt === expected.startedAt; + } + + private isYoungLock(lockPath: string): boolean { + try { + this.assertNotSymlink(lockPath); + return Date.now() - fs.statSync(lockPath).mtimeMs < this.incompleteLockGraceMs; + } catch { + return false; + } + } + + private removeOwnedRunLock(id: string, token: string): void { + const metadata = this.readRunLock(id); + if (!metadata || metadata.token !== token) return; + this.removeLockDirectory(this.runLockPath(id)); + } + + private removeLockDirectory(lockPath: string): void { + this.assertNotSymlink(lockPath); + try { + for (const name of fs.readdirSync(lockPath)) { + const entry = path.join(lockPath, name); + this.assertNotSymlink(entry); + if (!fs.lstatSync(entry).isFile()) throw new PrintAgentStoreError(`Unsafe entry in print-agent lock: ${entry}`); + fs.unlinkSync(entry); + } + fs.rmdirSync(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } +} + +export class LocalProcessInspector implements ProcessInspector { + getIdentity(pid: number): ProcessIdentity | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + if (process.platform === 'linux') { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const close = stat.lastIndexOf(')'); + const fields = stat.slice(close + 2).split(' '); + const startTicks = fields[19]; + if (!startTicks) return null; + return { pid, startedAt: `linux:${startTicks}` }; + } + const startedAt = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return startedAt ? { pid, startedAt } : null; + } catch { + return null; + } + } +} diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index ad2264da..a45e8cf1 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -13,6 +13,17 @@ const mockManager: any = { getAdapter: vi.fn(), }; +const mockPrintStore: any = { + list: vi.fn().mockResolvedValue([]), + resolve: vi.fn().mockResolvedValue(null), +}; + +const mockPrintService: any = { + store: mockPrintStore, + create: vi.fn(), + send: vi.fn(), +}; + const mockAgentAdapter: any = { getConversation: vi.fn(), }; @@ -86,6 +97,8 @@ vi.mock('@ai-devkit/agent-manager', () => ({ GrokCliAdapter: vi.fn(), OpenCodeAdapter: vi.fn(), PiAdapter: vi.fn(), + PrintAgentStore: vi.fn(function () { return mockPrintStore; }), + ClaudePrintAgentService: vi.fn(function () { return mockPrintService; }), TerminalFocusManager: vi.fn(function () { return mockFocusManager; }), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { @@ -215,6 +228,10 @@ describe('agent command', () => { mockManager.resolveAgent.mockReset(); mockManager.getAdapter.mockReset(); mockAgentAdapter.getConversation.mockReset(); + mockPrintStore.list.mockReset().mockResolvedValue([]); + mockPrintStore.resolve.mockReset().mockResolvedValue(null); + mockPrintService.create.mockReset(); + mockPrintService.send.mockReset(); mockFocusManager.findTerminal.mockReset(); mockFocusManager.focusTerminal.mockReset(); mockTtyWriterSend.mockReset().mockResolvedValue(undefined); @@ -274,6 +291,41 @@ describe('agent command', () => { expect(logSpy).toHaveBeenCalledWith(JSON.stringify(agents, null, 2)); }); + it('includes durable print agents in list JSON without a fake pid', async () => { + mockManager.listAgents.mockResolvedValue([]); + mockPrintStore.list.mockResolvedValue([{ + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', + cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', + sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, + }]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'list', '--json']); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output[0]).toMatchObject({ mode: 'print', state: 'ready', sessionHealth: 'uninitialized' }); + expect(output[0]).not.toHaveProperty('pid'); + }); + + it('shows durable print-agent detail without requiring a transcript', async () => { + const printAgent = { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print', + cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'ready', + sessionHealth: 'uninitialized', lastActiveAt: null, lastResult: null, + }; + mockPrintStore.resolve.mockResolvedValue(printAgent); + mockManager.listAgents.mockResolvedValue([]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'detail', '--id', printAgent.id, '--json']); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output).toMatchObject({ id: printAgent.id, provider: 'claude', mode: 'print', state: 'ready' }); + expect(output).not.toHaveProperty('conversation'); + }); + it('enables debug logging when starting an agent with --debug', async () => { const program = new Command(); registerAgentCommand(program); @@ -652,6 +704,61 @@ Waiting on user input`, expect(ui.success).toHaveBeenCalledWith('Sent message to repo-a.'); }); + it('starts a durable Claude print agent without tmux', async () => { + mockPrintService.create.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', + mode: 'print', cwd: process.cwd(), state: 'ready', + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync([ + 'node', 'test', 'agent', 'start', '--type', 'claude', '--mode', 'print', + '--name', 'reviewer', '--cwd', process.cwd(), + ]); + + expect(mockPrintService.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: process.cwd() }); + expect(ui.success).toHaveBeenCalledWith(expect.stringContaining('11111111-1111-4111-8111-111111111111')); + }); + + it('sends synchronously to an exact print-agent id without terminal injection', async () => { + const printAgent = { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', + mode: 'print', cwd: '/project', state: 'ready', + }; + mockPrintStore.resolve.mockResolvedValue(printAgent); + mockPrintService.send.mockResolvedValue({ ...printAgent, result: '\x1b]0;unsafe\x07review complete', exitCode: 0 }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync([ + 'node', 'test', 'agent', 'send', 'review this', '--id', printAgent.id, + ]); + + expect(mockPrintService.send).toHaveBeenCalledWith(printAgent.id, 'review this'); + expect(mockFocusManager.findTerminal).not.toHaveBeenCalled(); + expect(ui.text).toHaveBeenCalledWith('review complete'); + }); + + it('rejects timeout for a synchronous print send instead of silently ignoring it', async () => { + const printAgent = { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', + mode: 'print', cwd: '/project', state: 'ready', + }; + mockPrintStore.resolve.mockResolvedValue(printAgent); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync([ + 'node', 'test', 'agent', 'send', 'review this', '--id', printAgent.id, + '--wait', '--timeout', '1000', + ]); + + expect(mockPrintService.send).not.toHaveBeenCalled(); + expect(ui.error).toHaveBeenCalledWith(expect.stringContaining('--timeout is not supported')); + expect(process.exit).toHaveBeenCalledWith(1); + }); + it('reads a multi-line message from stdin when --stdin is set', async () => { const agent = { name: 'repo-a', diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 48a92eb5..c7cd9b40 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -14,6 +14,8 @@ import { GrokCliAdapter, OpenCodeAdapter, PiAdapter, + ClaudePrintAgentService, + PrintAgentStore, AgentStatus, TerminalFocusManager, AgentRegistry, @@ -71,6 +73,16 @@ function formatStatus(status: AgentStatus): string { return `${config.emoji} ${config.label}`; } +function sanitizeProviderOutput(value: string): string { + // Strip OSC controls as a unit, then remove remaining terminal control bytes except newline/tab. + // eslint-disable-next-line no-control-regex + const withoutOsc = value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, ''); + return Array.from(withoutOsc, (character) => { + const code = character.charCodeAt(0); + return (code < 32 && code !== 9 && code !== 10) || code === 127 ? '' : character; + }).join(''); +} + function formatRelativeTime(timestamp: Date): string { const diffMs = Date.now() - new Date(timestamp).getTime(); const diffMinutes = Math.floor(diffMs / 60000); @@ -179,6 +191,10 @@ function createAgentManager(): AgentManager { return manager; } +function createPrintAgentService(): ClaudePrintAgentService { + return new ClaudePrintAgentService({ store: new PrintAgentStore() }); +} + const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; function writeWaitStatus(message: string): void { @@ -247,6 +263,7 @@ export function registerAgentCommand(program: Command): void { .command('start') .description('Start a new agent in a managed tmux session') .requiredOption('--type ', `Agent type: ${Object.keys(AGENTS).join(', ')}`) + .option('--mode ', 'Agent mode: interactive or print', 'interactive') .option('--name ', 'Human-readable name for the agent (lowercase alphanumeric + hyphens, 2-64 chars; default: {folder}-{timestamp})') .option('--cwd ', 'Working directory for the agent (default: current directory)') .option('--debug', 'Enable debug logging') @@ -255,6 +272,7 @@ export function registerAgentCommand(program: Command): void { enableDebug(); } const agentType = options.type as string; + const mode = options.mode as string; const cwd = path.resolve(options.cwd ?? process.cwd()); const agentName = (options.name as string | undefined) ?? generateAgentName(cwd); @@ -262,6 +280,12 @@ export function registerAgentCommand(program: Command): void { ui.error(`Unsupported agent type "${agentType}". Supported: ${Object.keys(AGENTS).join(', ')}.`); process.exit(1); } + if (!['interactive', 'print'].includes(mode)) { + throw new Error(`Unsupported agent mode "${mode}". Supported: interactive, print.`); + } + if (mode === 'print' && agentType !== 'claude') { + throw new Error('Print mode currently supports only --type claude.'); + } if (!NAME_REGEX.test(agentName)) { ui.error( `Invalid name "${agentName}". Use lowercase letters, digits, and hyphens only. ` + @@ -275,6 +299,13 @@ export function registerAgentCommand(program: Command): void { } try { + if (mode === 'print') { + const entry = await createPrintAgentService().create({ name: agentName, cwd }); + ui.success(`Print agent "${entry.name}" started (${entry.provider}, ID ${entry.id})`); + ui.text(`Working directory: ${formatCwd(entry.cwd)}`); + ui.text('State: ready (Claude session not started)'); + return; + } const entry = await startAgent( { type: agentType as StartableAgentType, name: agentName, cwd }, { @@ -310,27 +341,35 @@ export function registerAgentCommand(program: Command): void { .action(withErrorHandler('list agents', async (options) => { const manager = createAgentManager(); const agents = await manager.listAgents(); + const printAgents = await createPrintAgentService().store.list(); if (options.json) { - console.log(JSON.stringify(agents, null, 2)); + console.log(JSON.stringify([...agents, ...printAgents], null, 2)); return; } - if (agents.length === 0) { + if (agents.length === 0 && printAgents.length === 0) { ui.info('No running agents detected.'); return; } - ui.text('Running Agents:', { breakline: true }); + ui.text('Agents:', { breakline: true }); - const rows = agents.map(agent => [ + const rows = [...agents.map(agent => [ agent.name, agent.projectPath ? path.basename(agent.projectPath) : '', formatType(agent.type), formatStatus(agent.status), formatWorkOn(agent.summary), - formatRelativeTime(agent.lastActive) - ]); + formatRelativeTime(agent.lastActive), + ]), ...printAgents.map(agent => [ + agent.name, + path.basename(agent.cwd), + 'Claude Code (print)', + agent.state, + agent.lastResult?.summary ?? agent.sessionHealth, + agent.lastActiveAt ? formatRelativeTime(new Date(agent.lastActiveAt)) : 'never', + ])]; ui.table({ headers: ['Agent', 'Project', 'Type', 'Status', 'Working On', 'Active'], @@ -575,6 +614,36 @@ export function registerAgentCommand(program: Command): void { return; } + const printService = createPrintAgentService(); + const printResolved = await printService.store.resolve(options.id); + if (Array.isArray(printResolved)) { + throw new Error(`Multiple print agents match "${options.id}".`); + } + if (printResolved) { + if (options.timeout !== undefined) { + throw new Error('--timeout is not supported for synchronous print agents.'); + } + if (options.id !== printResolved.id) { + const liveAgents = await manager.listAgents(); + const liveExact = liveAgents.filter((agent) => agent.name.toLowerCase() === String(options.id).toLowerCase()); + if (liveExact.length > 0) { + throw new Error(`Agent name "${options.id}" is ambiguous across interactive and print modes. Use the print agent ID.`); + } + } + const result = await printService.send(options.id, prompt); + if (options.json) { + console.log(JSON.stringify({ + target: { id: result.agentId, name: result.agentName, provider: 'claude', mode: 'print' }, + response: result.result, + exitCode: result.exitCode, + sessionId: result.sessionId, + }, null, 2)); + } else { + ui.text(sanitizeProviderOutput(result.result)); + } + return; + } + await sendToAgent({ id: options.id, prompt, @@ -635,8 +704,31 @@ export function registerAgentCommand(program: Command): void { .action(withErrorHandler('get agent detail', async (options) => { const manager = createAgentManager(); const agents = await manager.listAgents(); - if (agents.length === 0) { - ui.error('No running agents found.'); + const printResolved = await createPrintAgentService().store.resolve(options.id); + if (Array.isArray(printResolved)) { + throw new Error(`Multiple print agents match "${options.id}".`); + } + if (printResolved) { + const liveExact = agents.filter((agent) => agent.name.toLowerCase() === String(options.id).toLowerCase()); + if (options.id !== printResolved.id && liveExact.length > 0) { + throw new Error(`Agent name "${options.id}" is ambiguous across interactive and print modes. Use the print agent ID.`); + } + if (options.json) { + console.log(JSON.stringify(printResolved, null, 2)); + return; + } + ui.text('Print Agent Detail', { breakline: true }); + ui.text(chalk.dim('─'.repeat(40))); + ui.text(` ${chalk.bold('Agent ID:')} ${printResolved.id}`); + ui.text(` ${chalk.bold('Session ID:')} ${printResolved.providerSessionId}`); + ui.text(` ${chalk.bold('Name:')} ${printResolved.name}`); + ui.text(` ${chalk.bold('Provider:')} Claude Code`); + ui.text(` ${chalk.bold('Mode:')} print`); + ui.text(` ${chalk.bold('CWD:')} ${formatCwd(printResolved.cwd)}`); + ui.text(` ${chalk.bold('State:')} ${printResolved.state}`); + ui.text(` ${chalk.bold('Session:')} ${printResolved.sessionHealth}`); + ui.text(` ${chalk.bold('Last Active:')} ${printResolved.lastActiveAt ? formatRelativeTime(new Date(printResolved.lastActiveAt)) : 'never'}`); + if (printResolved.lastResult) ui.text(` ${chalk.bold('Last Result:')} ${printResolved.lastResult.summary}`); return; }