diff --git a/docs/ai/design/2026-08-17-feature-pi-print-mode.md b/docs/ai/design/2026-08-17-feature-pi-print-mode.md new file mode 100644 index 00000000..98dd65a5 --- /dev/null +++ b/docs/ai/design/2026-08-17-feature-pi-print-mode.md @@ -0,0 +1,71 @@ +--- +phase: design +title: Pi Print Mode Design +description: Architecture for durable Pi JSON-mode agents +--- + +# Pi Print Mode Design + +## Architecture Overview + +```mermaid +flowchart LR + CLI[agent start/send/list/detail] --> Dispatch[provider dispatch] + Dispatch --> Service[PiPrintAgentService] + Service --> Probe[PiCliProbe] + Service --> Repository[DurableAgentRepository] + Service --> Runner[PiPrintRunner] + Runner -->|pi --mode json --session-id/--session UUID| Pi[Pi CLI] + Pi -->|session header + events| Runner + Repository --> Registry[(agents.db durable_agents)] + Registry --> Console[agent list / detail] +``` + +Pi follows the merged Claude durable service/runner boundary. The open Codex design is used only as a read-only consistency reference. + +## Data Models + +- `DurableProvider`: `claude | pi`. +- `DurableAgent`: shared identity, durable mode, cwd binding, state, timestamps, active-run identity, and last result. +- `DurableAgentRepository` assigns a provider session UUID at creation and persists rows in SQLite migration 003. + +## API Design + +- `PiCliProbe.validate()` runs `pi --version` and `pi --help`, requiring `--mode`, `json`, `--session-id`, and `--session`. +- `PiPrintRunner.run(request)` uses `--session-id ` for a first run and `--session ` for resume; prompt is sent on stdin. +- `onSpawn(ProcessIdentity)` persists process ownership; the emitted session UUID must match the repository-assigned UUID. +- `PiPrintAgentService.create()` probes then creates with provider `pi`. +- `PiPrintAgentService.send()` resolves, locks, checks provider, runs, records success/failure, and always releases through `completeRun`. +- CLI creates and dispatches services by stored provider rather than assuming Claude. + +## Component Breakdown + +- `DurableAgent.ts`: provider union and Pi errors. +- `DurableAgentRepository.ts`: SQLite persistence, provider creation, and CAS run ownership. +- `PiCliProbe.ts`: sanitized capability validation. +- `PiPrintRunner.ts`: bounded JSONL parser, identity validation, lifecycle/result extraction, subprocess safety. +- `PiPrintAgentService.ts`: orchestration and state transitions. +- `agent.ts`: start validation, provider-aware send, labels, and detail output. +- Tests mock process and store boundaries following Claude print patterns. + +## Protocol Rules + +- Accept exactly one valid leading/session identity event; duplicate or invalid session identity is a protocol error. +- Verify every run emits the stored UUID. +- Collect non-empty assistant text from completed assistant messages; return the last complete assistant text. +- Require clean line-delimited JSON, a zero exit code, a session identity, `agent_end`, and at least one assistant result. +- Reject oversized lines and incomplete trailing JSON; drain stderr without echoing potentially sensitive provider content. + +## Design Decisions + +- Selected JSON mode over plain print mode for durable session identity. +- Use Pi's `--session-id` support so the durable repository remains the UUID authority. +- Extend only the shared provider union and create input; no migration or legacy import is needed. +- Keep synchronous send behavior and SQLite CAS ownership; no daemon or streaming transport. + +## Non-Functional Requirements + +- Security: `shell: false`, stdin prompts, canonical non-symlink cwd, bounded JSON lines, sanitized summaries, no stderr reflection. +- Reliability: atomic SQLite mutations, provider/session uniqueness, CAS ownership, mismatch degradation, stale-run reconciliation. +- Performance: streaming JSONL parsing with a 1 MiB default line bound; no whole-output buffering. +- Compatibility: no dependencies and no behavioral changes to interactive Pi or Claude print invocations. diff --git a/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md b/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md new file mode 100644 index 00000000..aac07525 --- /dev/null +++ b/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md @@ -0,0 +1,54 @@ +--- +phase: implementation +title: Pi Print Mode Implementation +description: Living implementation record for durable Pi print agents +--- + +# Pi Print Mode Implementation + +## Development Setup + +- Worktree: `feature-pi-print-mode`, rebased onto the durable-agents architecture on `origin/main`. +- References: merged Claude implementation under `packages/agent-manager/src/durable/`; read-only Codex worktree at `../feature-codex-print-mode`. +- Pi ground truth: installed package README, `docs/json.md`, and CLI capability probe. +- Tests run with repository Vitest/Nx scripts; no new dependencies. + +## Code Structure + +Pi provider modules live beside the Claude modules under `src/durable/`. Shared changes are limited to the provider union, repository create input, exports, and CLI dispatch. + +## Implementation Notes + +### Core Features + +- Complete: Pi support in the shared SQLite `DurableAgentRepository`; no legacy import or Pi-specific migration is needed. +- Complete: Pi capability probe, bounded JSONL runner, repository-assigned session UUID via `--session-id`, exact resume args, and service state orchestration. +- Complete: provider-aware CLI creation/send dispatch, Pi labels, and shared durable list/detail integration. +- Complete: user-facing creation uses `--mode durable`; the retired `--mode print` spelling is rejected consistently. +- Complete: pure `PiPrintProtocol` helpers make argument, session-identity, and assistant-text mapping independently testable at 100% coverage. + +### Patterns & Best Practices + +- Red-green-refactor for each planning task. +- Mock child processes and store boundaries; validate public behavior. +- Preserve Claude defaults for callers that omit provider. + +## Integration Points + +`agent start` creates through the provider service; `agent send` resolves the persisted record then dispatches by provider; list/detail/console use the shared durable repository. + +## Error Handling + +Provider-specific probe/protocol/process errors are sanitized. The service maps identity mismatches to `sessionHealth: mismatch` and other failures to `unknown`, then records completion to release ownership. + +## Performance Considerations + +Parse stdout incrementally with a 1 MiB line limit. Store only the final bounded result summary. + +## Security Notes + +No shell, prompt via stdin, canonical cwd, no stderr reflection, UUID validation, and SQLite CAS run ownership. + +## Deviations and Follow-ups + +The original file-store generalization was dropped because main now supplies SQLite persistence and CAS concurrency. Pi uses the repository-assigned UUID directly, avoiding late session binding. Provider files are isolated under `src/durable/`; shared edits remain additive. diff --git a/docs/ai/planning/2026-08-17-feature-pi-print-mode.md b/docs/ai/planning/2026-08-17-feature-pi-print-mode.md new file mode 100644 index 00000000..959ff86e --- /dev/null +++ b/docs/ai/planning/2026-08-17-feature-pi-print-mode.md @@ -0,0 +1,49 @@ +--- +phase: planning +title: Pi Print Mode Plan +description: TDD implementation plan for durable Pi print agents +--- + +# Pi Print Mode Plan + +## Milestones + +- [x] Requirements and Pi CLI investigation +- [x] Architecture and test strategy +- [x] Provider-aware durable storage +- [x] Pi probe, runner, and service +- [x] CLI integration and lifecycle verification + +## Task Breakdown + +### Phase 1: Storage Foundation + +- [x] T1: Rebase onto the SQLite durable-agent repository and add a failing test for Pi creation/provider validation. No legacy import is required. +- [x] T2: Extend the provider union and repository create input additively while retaining Claude defaults and CAS behavior. + +### Phase 2: Pi Provider + +- [x] T3: Add failing probe tests for supported, missing, unsupported, and sanitized failure cases; implement `PiCliProbe`. Depends on T2. Evidence: probe suite and coverage. Scenarios: S6-S8. +- [x] T4: Add failing runner tests for first/resume args, stdin, event parsing, identity mismatch, malformed/oversized/incomplete output, process failures, and callbacks; implement `PiPrintRunner`. Depends on T2. Evidence: runner suite and coverage. Scenarios: S9-S18. +- [x] T5: Add failing mocked-service integration tests for create/send success, resume, ambiguity/provider mismatch, binding failure, and state recording; implement `PiPrintAgentService`. Depends on T3-T4. Evidence: service suite. Scenarios: S19-S24. + +### Phase 3: CLI Integration + +- [x] T6: Add failing CLI tests for Pi print start, provider-aware send/list/detail/console representation, validation, and Claude regression; implement dispatch wiring and exports. Depends on T5. Evidence: CLI targeted suite. Scenarios: S25-S30. +- [x] T7: Update implementation/testing docs, run full relevant tests, coverage, lint, typecheck/build, and lifecycle review. Depends on all tasks. Evidence: fresh command outputs and feature lint. + +## Dependencies + +Storage generalization precedes provider code; runner and probe precede service; service precedes CLI. No new external dependencies. The Codex worktree is read-only reference material, never a branch dependency. + +## Risks & Mitigation + +- Pi protocol drift: capability probe plus strict protocol tests and explicit errors. +- Store migration regression: version-1 fixtures and full Claude print regression suite. +- Session cross-binding: ownership checks and per-provider uniqueness. +- Sensitive output leakage: stderr drain and bounded sanitized summaries. +- CLI ambiguity: dispatch from persisted provider and preserve existing exact-ID rules. + +## Progress Summary + +The obsolete file-store generalization commit was dropped during rebase. Pi provider and CLI adaptation now target `DurableAgentRepository`; fresh post-rebase validation is required before completion. diff --git a/docs/ai/requirements/2026-08-17-feature-pi-print-mode.md b/docs/ai/requirements/2026-08-17-feature-pi-print-mode.md new file mode 100644 index 00000000..ed8c2dcc --- /dev/null +++ b/docs/ai/requirements/2026-08-17-feature-pi-print-mode.md @@ -0,0 +1,65 @@ +--- +phase: requirements +title: Pi Print Mode Requirements +description: Durable non-interactive Pi coding agents managed by AI DevKit +--- + +# Pi Print Mode Requirements + +## Problem Statement + +AI DevKit can start Pi only as an interactive terminal process. Automation needs a durable, non-interactive Pi agent that can be registered once, addressed by AI DevKit ID or name, resumed across invocations, inspected alongside other agents, and reconciled after an interrupted run. + +## Goals & Objectives + +- Support `ai-devkit agent start --type pi --mode durable --name --cwd `. +- Run Pi non-interactively through its structured JSON event mode. +- Persist the Pi session UUID after the first run and resume it with `--session `. +- Reuse Claude print-agent identity, locking, lifecycle, listing, detail, and pruning semantics. +- Keep Claude print agents backward compatible and align storage with the Codex print-mode generalization in PR #148. +- Add no runtime dependencies. + +Non-goals: + +- Changing existing interactive Pi behavior. +- Streaming partial Pi output or live heartbeats to the console. +- Supporting Pi's interactive session picker (`--resume`) or forking. +- Merging or depending on the open Codex print branch. + +## User Stories & Use Cases + +- As an automation user, I can register a named Pi print agent without opening a terminal UI. +- As a user, I can send multiple prompts to that agent and retain Pi conversation context. +- As a user, I can see Pi print agents in `agent list` and `agent console`, and inspect their provider session ID. +- As a user, I receive a clear failure when Pi is missing, lacks required flags, emits invalid JSON, changes session identity, or exits unsuccessfully. +- As a user, an interrupted provider process is reconciled using existing print-agent run-lock behavior. + +## Success Criteria + +- `--type pi --mode durable` creates a persisted `provider: "pi"` agent with a repository-assigned provider session UUID. +- First send invokes `pi --mode json`, extracts and stores the session header UUID, and returns the final assistant text. +- Later sends invoke `pi --mode json --session ` and reject a different emitted UUID. +- Pi agents participate in existing list/detail/send/console flows and provider-specific dispatch. +- Claude store data remains readable and Claude tests remain green. +- New probe, protocol parsing, and argument mapping branches have 100% statement, branch, function, and line coverage. +- Agent-manager and CLI tests, typechecks/builds, and feature-doc lint pass. + +## Constraints & Assumptions + +- Ground truth is the installed `@earendil-works/pi-coding-agent`: `--mode json` is non-interactive, emits a leading `{type:"session", id}` JSON line, auto-saves sessions, and accepts `--session `. +- Pi has no Claude-style caller-assigned session ID; the store must bind the provider-emitted UUID during the first run. +- Pi JSON mode emits lifecycle events rather than one terminal result object; the runner derives the result from completed assistant messages and requires `agent_end`. +- Prompts are written to stdin to avoid shell interpolation and command-line disclosure; subprocesses use `shell: false`. +- Existing print-agent storage must migrate safely without losing Claude agents. +- The globally installed lifecycle skills satisfy execution even though project-local built-in installation fails at `.agents/skills`; optional task tracing is unavailable (`unknown command 'task'`). + +## Alternatives Considered + +- `pi -p`: simple text output but does not expose the new session UUID reliably; rejected. +- Discover the session file after execution: races with other Pi processes and couples to filesystem layout; rejected. +- `pi --mode rpc`: designed for a long-lived controller and adds unnecessary lifecycle complexity; rejected. +- `pi --mode json --session-id `: deterministic structured identity using the repository-assigned UUID; selected. + +## Questions & Open Items + +No material open items. Pi's documented session identity and resume surface resolves the durability question. diff --git a/docs/ai/testing/2026-08-17-feature-pi-print-mode.md b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md new file mode 100644 index 00000000..96ea02c2 --- /dev/null +++ b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md @@ -0,0 +1,94 @@ +--- +phase: testing +title: Pi Print Mode Testing Strategy +description: Unit, integration, CLI, and regression coverage for Pi print agents +--- + +# Pi Print Mode Testing Strategy + +## Test Coverage Goals + +- 100% statements, branches, functions, and lines for new pure probe/protocol/argument-mapping logic. +- Mocked subprocess tests; no model credentials or network calls. +- Mocked-store service integration tests for every state transition. +- CLI command tests for critical creation and dispatch flows. +- Full Claude print regression coverage and package typecheck/build. + +## Unit Tests + +### Store and Types + +- [ ] S1 Pi agents start with a null provider session while Claude retains an assigned UUID. +- [ ] S2 version-1 Claude stores load and migrate to version 2 on mutation. +- [ ] S3 an owned Pi run binds one valid UUID idempotently. +- [ ] S4 binding rejects invalid UUIDs, ownership changes, non-Pi agents, mismatches, and duplicate provider bindings. +- [ ] S5 malformed provider-discriminated records are rejected. + +### Pi CLI Probe + +- [x] S6 supported Pi help/version returns sanitized metadata. +- [x] S7 missing flags produce an unsupported-capability error. +- [x] S8 execution failures produce a sanitized unavailable error. + +### Pi JSON Runner + +- [x] S9 first-run args are `--mode json`; resume adds `--session `; prompt uses stdin and shell is disabled. +- [x] S10 provider process identity and session callbacks run. +- [x] S11 the session header and completed assistant message yield the final result. +- [x] S12 multiple assistant completions return the last complete message. +- [x] S13 missing/invalid/duplicate/mismatched session identity is rejected. +- [x] S14 malformed, non-object, oversized, or incomplete JSON is rejected. +- [x] S15 missing `agent_end` or assistant output is rejected. +- [x] S16 spawn identity/start errors and callback failures terminate safely. +- [x] S17 non-zero/signal exits become process errors. +- [x] S18 stderr is drained without inclusion in results. + +## Integration Tests + +- [x] S19 service create probes and persists provider `pi`. +- [x] S20 first send records process/session, success, health, and sanitized summary. +- [x] S21 resumed send preserves the bound session. +- [x] S22 missing/ambiguous/wrong-provider references fail clearly. +- [x] S23 protocol/store session mismatches record mismatch health. +- [x] S24 other failures record unknown health and release the run. + +## CLI and End-to-End Tests + +- [x] S25 `agent start --type pi --mode durable` creates without interactive launch. +- [x] S26 unsupported durable providers and the retired `print` mode name remain rejected. +- [x] S27 `agent send` dispatches Pi records to the Pi service and reports provider `pi`. +- [x] S28 list/detail output identifies durable Pi agents and their repository-assigned sessions. +- [x] S29 console receives the combined interactive/durable registry. +- [x] S30 Claude print start/send/list/detail behavior remains green. + +## Test Data + +Use temporary store/cwd fixtures, deterministic clocks/process identities, valid UUID fixtures, mocked child-process streams, and mocked probe/runner/store boundaries. Never invoke a live model. + +## Test Reporting & Coverage + +- Targeted: `npx vitest run ` in relevant packages. +- Coverage: package Vitest coverage scoped to Pi pure-logic files with 100% thresholds. +- Regression: `npm test --workspace @ai-devkit/agent-manager` and CLI equivalent. +- Static: package lint/typecheck/build and `npx ai-devkit@latest lint --feature pi-print-mode`. + +Final evidence (2026-08-17): + +- Agent manager: 28 files, 552 tests passed. +- CLI: 82 files, 986 tests passed. +- Pi focused suites: 4 files, 23 tests passed. +- Pure Pi protocol: 100% statements (26/26), branches (38/38), functions (4/4), and lines (20/20), enforced with `--coverage.thresholds.100=true`. +- Agent-manager and CLI package builds passed; package lints passed (CLI retains five unrelated baseline warnings and zero errors). +- Feature-doc lint passed all base, feature, branch, and worktree checks. + +## Manual Testing + +No credentialed Pi model run is required. `pi --help` and installed docs provide CLI-surface evidence; subprocess behavior is deterministic under mocks. + +## Performance and Security Testing + +Oversized-line tests exercise the memory bound. Spawn assertions cover `shell: false`, cwd binding, stdin prompt delivery, stderr draining, and provider-output sanitization. + +## Bug Tracking + +Any failing scenario returns to its implementation task, is added as a regression test first, and is documented in implementation notes. diff --git a/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts b/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts new file mode 100644 index 00000000..3aaa39c1 --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('PiCliProbe', () => { + it('validates documented JSON mode and session capabilities', async () => { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('PiCliProbe'); + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: 'pi 0.52.8', stderr: '' }) + .mockResolvedValueOnce({ stdout: '--mode json\n--session-id \n--session ', stderr: '' }); + const Probe = api.PiCliProbe as new (options: unknown) => any; + await expect(new Probe({ executable: 'fake-pi', exec }).validate()).resolves.toEqual({ + executable: 'fake-pi', version: 'pi 0.52.8', + }); + expect(exec.mock.calls).toEqual([['fake-pi', ['--version']], ['fake-pi', ['--help']]]); + }); + + it('rejects unsupported and unavailable CLIs with sanitized errors', async () => { + const api = await import('../../index.js') as Record; + const Probe = api.PiCliProbe as new (options: unknown) => any; + await expect(new Probe({ exec: vi.fn() + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '--print only', stderr: '' }) }).validate()) + .rejects.toMatchObject({ code: 'PI_CLI_UNSUPPORTED' }); + const unavailable = new Probe({ exec: vi.fn().mockRejectedValue(new Error(`bad\0${'x'.repeat(1000)}`)) }); + const error = await unavailable.validate().catch((value: Error & { code: string }) => value); + expect(error.code).toBe('PI_CLI_UNAVAILABLE'); + expect(error.message).not.toContain('\0'); + expect(error.message.length).toBeLessThan(600); + }); + + it('reports an empty version as unknown', async () => { + const api = await import('../../index.js') as Record; + const Probe = api.PiCliProbe as new (options: unknown) => any; + const exec = vi.fn().mockResolvedValueOnce({ stdout: ' \n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '--mode json --session-id --session', stderr: '' }); + await expect(new Probe({ exec }).validate()).resolves.toMatchObject({ version: 'unknown' }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/durable/PiPrintAgentService.test.ts b/packages/agent-manager/src/__tests__/durable/PiPrintAgentService.test.ts new file mode 100644 index 00000000..8f6bb2b3 --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/PiPrintAgentService.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +const SESSION = '22222222-2222-4222-8222-222222222222'; +const base = { id: 'id', name: 'reviewer', provider: 'pi', providerSessionId: SESSION, sessionHealth: 'uninitialized' }; + +describe('PiPrintAgentService', () => { + it('probes before provider-aware create', async () => { + const api = await import('../../index.js') as Record; expect(api).toHaveProperty('PiPrintAgentService'); + const probe = { validate: vi.fn() }; const repository = { create: vi.fn().mockResolvedValue(base), list: vi.fn() }; const runner = { run: vi.fn() }; + const Service = api.PiPrintAgentService as new (options: unknown) => any; + await new Service({ repository, probe, runner }).create({ name: 'reviewer', cwd: '/project' }); + expect(probe.validate).toHaveBeenCalledOnce(); expect(repository.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: '/project', provider: 'pi' }); + }); + + it('records successful first and resumed sends', async () => { + const api = await import('../../index.js') as Record; + const repository = { list: vi.fn(), 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() }; + const runner = { run: vi.fn(async (request) => { await request.onSpawn({ pid: 42, startedAt: 'start' }); return { sessionId: SESSION, result: 'answer', messages: ['answer'], exitCode: 0 }; }) }; + const Service = api.PiPrintAgentService as new (options: unknown) => any; const service = new Service({ repository, probe: { validate: vi.fn() }, runner }); + await service.send('reviewer', 'first'); await service.send('reviewer', 'later'); + expect(repository.completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ status: 'succeeded', sessionHealth: 'healthy' })); + }); + + it('records mismatches, rejects wrong providers, missing and ambiguous records', async () => { + const api = await import('../../index.js') as Record; const ErrorType = api.PiPrintError as new (message: string, code: string) => Error; + const completeRun = vi.fn(); const repository = { list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), recordProviderProcess: vi.fn(), completeRun }; + const Service = api.PiPrintAgentService as new (options: unknown) => any; + await expect(new Service({ repository, probe: { validate: vi.fn() }, runner: { run: vi.fn().mockRejectedValue(new ErrorType('bad', 'PI_SESSION_MISMATCH')) } }).send('reviewer', 'x')) + .rejects.toMatchObject({ code: 'PI_SESSION_MISMATCH' }); + expect(completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ sessionHealth: 'mismatch' })); + repository.acquireRun.mockResolvedValueOnce({ agent: { ...base, provider: 'claude' }, token: 'two' }); + await expect(new Service({ repository, probe: { validate: vi.fn() }, runner: { run: vi.fn() } }).send('reviewer', 'x')).rejects.toMatchObject({ code: 'PI_UNSUPPORTED' }); + const earlyRepository = { ...repository, resolve: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce([base, base]), acquireRun: vi.fn() }; + const early = new Service({ repository: earlyRepository, probe: { validate: vi.fn() }, runner: { run: vi.fn() } }); + await expect(early.send('missing', 'x')).rejects.toMatchObject({ code: 'DURABLE_AGENT_NOT_FOUND' }); + await expect(early.send('many', 'x')).rejects.toMatchObject({ code: 'PI_UNSUPPORTED' }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/durable/PiPrintProtocol.test.ts b/packages/agent-manager/src/__tests__/durable/PiPrintProtocol.test.ts new file mode 100644 index 00000000..c23fcc7c --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/PiPrintProtocol.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { buildPiPrintArgs, readPiAssistantText, readPiSessionId } from '../../durable/PiPrintProtocol.js'; + +const SESSION = '22222222-2222-4222-8222-222222222222'; + +describe('Pi print pure protocol mapping', () => { + it('maps first and resumed runs to exact documented arguments', () => { + expect(buildPiPrintArgs(SESSION, true)).toEqual(['--mode', 'json', '--session-id', SESSION]); + expect(buildPiPrintArgs(SESSION, false)).toEqual(['--mode', 'json', '--session', SESSION]); + }); + + it('accepts one expected UUID and rejects invalid, duplicate, and mismatched identities', () => { + expect(readPiSessionId({ id: SESSION }, null, SESSION)).toBe(SESSION); + expect(() => readPiSessionId({}, null, SESSION)).toThrowError(expect.objectContaining({ code: 'PI_PROTOCOL' })); + expect(() => readPiSessionId({ id: SESSION }, SESSION, SESSION)).toThrowError(expect.objectContaining({ code: 'PI_PROTOCOL' })); + expect(() => readPiSessionId({ id: '33333333-3333-4333-8333-333333333333' }, null, SESSION)) + .toThrowError(expect.objectContaining({ code: 'PI_SESSION_MISMATCH' })); + }); + + it('extracts only non-empty completed assistant text', () => { + expect(readPiAssistantText({ type: 'future' })).toBeNull(); + expect(readPiAssistantText({ type: 'message_end' })).toBeNull(); + expect(readPiAssistantText({ type: 'message_end', message: [] })).toBeNull(); + expect(readPiAssistantText({ type: 'message_end', message: { role: 'user', content: 'no' } })).toBeNull(); + expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: ' ' } })).toBeNull(); + expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: 'answer' } })).toBe('answer'); + expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: null } })).toBeNull(); + expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: [null, [], { type: 'thinking' }] } })).toBeNull(); + expect(readPiAssistantText({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'a' }, { type: 'text', text: 1 }, { type: 'text', text: 'b' }] } })).toBe('ab'); + }); +}); diff --git a/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts b/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts new file mode 100644 index 00000000..8bcd6656 --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts @@ -0,0 +1,107 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import type { DurableAgent } from '../../index.js'; + +const SESSION = '22222222-2222-4222-8222-222222222222'; +function agent(sessionHealth: DurableAgent['sessionHealth'] = 'uninitialized'): DurableAgent { + return { id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'pi', mode: 'durable', + cwd: '/project', providerSessionId: SESSION, state: 'running', sessionHealth, createdAt: '', + updatedAt: '', lastActiveAt: null, lastResult: null, activeRun: null }; +} +function fakeSpawn(lines: string[], exitCode = 0) { + const promptChunks: Buffer[] = []; + const child = new EventEmitter() as any; + child.pid = 4242; child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = vi.fn(); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { promptChunks.push(Buffer.from(chunk)); callback(); }, + final(callback) { child.stdout.end(lines.join('\n')); queueMicrotask(() => child.emit('close', exitCode, null)); callback(); }, + }); + return { child, spawn: vi.fn(() => child), promptChunks }; +} +function events(session = SESSION): string[] { return [ + JSON.stringify({ type: 'session', version: 3, id: session, cwd: '/project' }), + JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] } }), + JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'hidden' }, { type: 'text', text: 'final' }] } }), + JSON.stringify({ type: 'agent_end', messages: [] }), '', +]; } +async function runner(fixture: ReturnType, maxLineBytes?: number) { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('PiPrintRunner'); + const Runner = api.PiPrintRunner as new (options: unknown) => any; + return new Runner({ spawn: fixture.spawn, maxLineBytes, processInspector: { getIdentity: () => ({ pid: 4242, startedAt: 'start' }) } }); +} + +describe('PiPrintRunner', () => { + it('binds a first session and returns the last completed assistant text', async () => { + const fixture = fakeSpawn(events()); const instance = await runner(fixture); const order: string[] = []; + const result = await instance.run({ agent: agent(), prompt: 'secret', executable: 'fake-pi', + onSpawn: async () => { expect(fixture.promptChunks).toHaveLength(0); order.push('spawn'); } }); + expect(order).toEqual(['spawn']); + expect(fixture.spawn).toHaveBeenCalledWith('fake-pi', ['--mode', 'json', '--session-id', SESSION], expect.objectContaining({ cwd: '/project', shell: false })); + expect(Buffer.concat(fixture.promptChunks).toString()).toBe('secret'); + expect(result).toEqual({ sessionId: SESSION, result: 'final', messages: ['first', 'final'], exitCode: 0 }); + }); + + it('resumes the exact stored session and rejects mismatch', async () => { + const fixture = fakeSpawn(events('33333333-3333-4333-8333-333333333333')); + await expect((await runner(fixture)).run({ agent: agent('healthy'), prompt: 'later', onSpawn: vi.fn() })) + .rejects.toMatchObject({ code: 'PI_SESSION_MISMATCH' }); + expect(fixture.spawn.mock.calls[0]![1]).toEqual(['--mode', 'json', '--session', SESSION]); + }); + + it.each([ + ['malformed', ['{bad\n'], 'PI_PROTOCOL'], ['non-object', ['[]\n'], 'PI_PROTOCOL'], + ['truncated', ['{}'], 'PI_PROTOCOL'], ['missing session', [JSON.stringify({ type: 'agent_end' }), ''], 'PI_PROTOCOL'], + ['missing result', [JSON.stringify({ type: 'session', id: SESSION }), JSON.stringify({ type: 'agent_end' }), ''], 'PI_RESULT_MISSING'], + ['missing end', [JSON.stringify({ type: 'session', id: SESSION }), JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'x' }] } }), ''], 'PI_PROTOCOL'], + ])('rejects %s output', async (_name, lines, code) => { + await expect((await runner(fakeSpawn(lines as string[]))).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn() })) + .rejects.toMatchObject({ code }); + }); + + it('rejects oversized output, failed processes, and unverifiable identities', async () => { + await expect((await runner(fakeSpawn(['x'.repeat(20)]), 10)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn() })) + .rejects.toMatchObject({ code: 'PI_PROTOCOL' }); + await expect((await runner(fakeSpawn(events(), 1))).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn() })) + .rejects.toMatchObject({ code: 'PI_PROCESS' }); + const fixture = fakeSpawn([]); const api = await import('../../index.js') as Record; + const Runner = api.PiPrintRunner as new (options: unknown) => any; + await expect(new Runner({ spawn: fixture.spawn, processInspector: { getIdentity: () => null } }).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn() })) + .rejects.toMatchObject({ code: 'PI_PROCESS' }); + expect(fixture.child.kill).toHaveBeenCalledOnce(); + }); + + it('rejects invalid and duplicate session identities', async () => { + for (const lines of [ + [JSON.stringify({ type: 'session', id: 'bad' }), ''], + [JSON.stringify({ type: 'session', id: SESSION }), JSON.stringify({ type: 'session', id: SESSION }), ''], + ]) await expect((await runner(fakeSpawn(lines))).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn() })) + .rejects.toMatchObject({ code: 'PI_PROTOCOL' }); + }); + + it('kills on spawn persistence failure', async () => { + const spawnFailure = fakeSpawn([]); const failure = new Error('cannot persist'); + await expect((await runner(spawnFailure)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn().mockRejectedValue(failure), onSession: vi.fn() })).rejects.toBe(failure); + expect(spawnFailure.child.kill).toHaveBeenCalledOnce(); + }); + + it('classifies spawn errors and a missing PID as process failures', async () => { + const errored = fakeSpawn([]); + errored.child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); }, final(callback) { queueMicrotask(() => errored.child.emit('error', new Error('spawn'))); callback(); } }); + await expect((await runner(errored)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn() })).rejects.toMatchObject({ code: 'PI_PROCESS' }); + const missing = fakeSpawn([]); missing.child.pid = undefined; + await expect((await runner(missing)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn() })).rejects.toMatchObject({ code: 'PI_PROCESS' }); + expect(missing.child.kill).toHaveBeenCalledOnce(); + }); + + it('accepts string assistant content and ignores empty or unrelated messages', async () => { + const lines = [JSON.stringify({ type: 'session', id: SESSION }), JSON.stringify({ type: 'future' }), + JSON.stringify({ type: 'message_end', message: { role: 'user', content: 'ignored' } }), + JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: ' ' } }), + JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: 'answer' } }), + JSON.stringify({ type: 'agent_end' }), '']; + await expect((await runner(fakeSpawn(lines))).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn() })) + .resolves.toMatchObject({ result: 'answer' }); + }); +}); diff --git a/packages/agent-manager/src/durable/DurableAgent.ts b/packages/agent-manager/src/durable/DurableAgent.ts index 4db7b67a..2c5d7b9f 100644 --- a/packages/agent-manager/src/durable/DurableAgent.ts +++ b/packages/agent-manager/src/durable/DurableAgent.ts @@ -1,6 +1,15 @@ export type DurableAgentState = 'ready' | 'running' | 'degraded'; export type DurableSessionHealth = 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; export type DurableRunStatus = 'succeeded' | 'failed' | 'interrupted'; +export type DurableProvider = 'claude' | 'pi'; +export type PiPrintErrorCode = + | 'PI_CLI_UNAVAILABLE' + | 'PI_CLI_UNSUPPORTED' + | 'PI_PROCESS' + | 'PI_PROTOCOL' + | 'PI_RESULT_MISSING' + | 'PI_SESSION_MISMATCH' + | 'PI_UNSUPPORTED'; export const AGENT_MODES = { INTERACTIVE: 'interactive', @@ -29,7 +38,7 @@ export interface DurableLastResult { export interface DurableAgent { id: string; name: string; - provider: 'claude'; + provider: DurableProvider; mode: typeof AGENT_MODES.DURABLE; cwd: string; providerSessionId: string; @@ -89,3 +98,10 @@ export class ClaudePrintError extends DurableAgentError { this.name = 'ClaudePrintError'; } } + +export class PiPrintError extends DurableAgentError { + constructor(message: string, code: PiPrintErrorCode = 'PI_PROCESS') { + super(message, code); + this.name = 'PiPrintError'; + } +} diff --git a/packages/agent-manager/src/durable/DurableAgentRepository.ts b/packages/agent-manager/src/durable/DurableAgentRepository.ts index a0122d09..9185fa50 100644 --- a/packages/agent-manager/src/durable/DurableAgentRepository.ts +++ b/packages/agent-manager/src/durable/DurableAgentRepository.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import { randomUUID } from 'crypto'; import { execFileSync } from 'child_process'; import { DatabaseConnection, DEFAULT_AGENT_REGISTRY_DB_PATH } from '../database/index.js'; -import { AGENT_MODES, type DurableActiveRun, type DurableAgent, type ProcessIdentity, type DurableRunStatus, type DurableSessionHealth } from './DurableAgent.js'; +import { AGENT_MODES, type DurableActiveRun, type DurableAgent, type DurableProvider, type ProcessIdentity, type DurableRunStatus, type DurableSessionHealth } from './DurableAgent.js'; import { DurableAgentBusyError, DurableAgentNameConflictError, @@ -11,7 +11,7 @@ import { } from './DurableAgent.js'; interface DurableAgentRow { - id: string; name: string; provider: 'claude'; mode: typeof AGENT_MODES.DURABLE; cwd: string; provider_session_id: string; + id: string; name: string; provider: string; mode: typeof AGENT_MODES.DURABLE; cwd: string; provider_session_id: string; state: DurableAgent['state']; session_health: DurableSessionHealth; created_at: string; updated_at: string; last_active_at: string | null; last_result_status: DurableRunStatus | null; last_result_completed_at: string | null; last_result_exit_code: number | null; last_result_summary: string | null; @@ -19,7 +19,7 @@ interface DurableAgentRow { active_provider_pid: number | null; active_provider_started_at: string | null; active_run_started_at: string | null; } -export interface CreateDurableAgentInput { name: string; cwd: string } +export interface CreateDurableAgentInput { name: string; cwd: string; provider?: DurableProvider } export interface DurableAgentRepositoryOptions { dbPath?: string; @@ -64,13 +64,14 @@ export class DurableAgentRepository { const cwd = this.canonicalDirectory(input.cwd); const timestamp = this.now().toISOString(); const id = randomUUID(); + const provider = input.provider ?? 'claude'; let providerSessionId = randomUUID(); while (providerSessionId === id) providerSessionId = randomUUID(); try { this.db.execute(`INSERT INTO durable_agents ( id, name, provider, mode, cwd, provider_session_id, state, session_health, created_at, updated_at - ) VALUES (?, ?, 'claude', ?, ?, ?, 'ready', 'uninitialized', ?, ?)`, - [id, input.name, AGENT_MODES.DURABLE, cwd, providerSessionId, timestamp, timestamp]); + ) VALUES (?, ?, ?, ?, ?, ?, 'ready', 'uninitialized', ?, ?)`, + [id, input.name, provider, AGENT_MODES.DURABLE, cwd, providerSessionId, timestamp, timestamp]); } catch (error) { if (/UNIQUE constraint failed: durable_agents\.name/i.test((error as Error).message)) { throw new DurableAgentNameConflictError(input.name); @@ -227,6 +228,9 @@ export class DurableAgentRepository { } private fromRow(row: DurableAgentRow): DurableAgent { + if (row.provider !== 'claude' && row.provider !== 'pi') { + throw new DurableAgentRepositoryError(`Unsupported durable-agent provider: ${row.provider}`); + } const activeRun: DurableActiveRun | null = row.active_run_token === null ? null : { token: row.active_run_token, owner: { pid: row.active_owner_pid!, startedAt: row.active_owner_started_at! }, diff --git a/packages/agent-manager/src/durable/PiCliProbe.ts b/packages/agent-manager/src/durable/PiCliProbe.ts new file mode 100644 index 00000000..07f18b80 --- /dev/null +++ b/packages/agent-manager/src/durable/PiCliProbe.ts @@ -0,0 +1,42 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { PiPrintError } from './DurableAgent.js'; + +type ExecResult = { stdout: string; stderr: string }; +type Exec = (file: string, args: string[]) => Promise; +const execFileAsync = promisify(execFile); +const REQUIRED = ['--mode', 'json', '--session-id', '--session']; + +export interface PiCliProbeOptions { executable?: string; exec?: Exec } + +export class PiCliProbe { + private readonly executable: string; + private readonly exec: Exec; + constructor(options: PiCliProbeOptions = {}) { + this.executable = options.executable ?? 'pi'; + 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 version = await this.exec(this.executable, ['--version']); + const help = await this.exec(this.executable, ['--help']); + const missing = REQUIRED.filter((capability) => !help.stdout.includes(capability)); + if (missing.length) throw new PiPrintError( + `Pi CLI does not support required print-mode capabilities: ${missing.join(', ')}.`, 'PI_CLI_UNSUPPORTED'); + return { executable: this.executable, version: sanitize(version.stdout, 256) || 'unknown' }; + } catch (error) { + if (error instanceof PiPrintError) throw error; + throw new PiPrintError(`Pi CLI validation failed: ${sanitize((error as Error).message, 512)}`, 'PI_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/durable/PiPrintAgentService.ts b/packages/agent-manager/src/durable/PiPrintAgentService.ts new file mode 100644 index 00000000..c02ba294 --- /dev/null +++ b/packages/agent-manager/src/durable/PiPrintAgentService.ts @@ -0,0 +1,46 @@ +import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; +import { DurableAgentNotFoundError, PiPrintError } from './DurableAgent.js'; +import { PiCliProbe } from './PiCliProbe.js'; +import { PiPrintRunner, type PiPrintRunResult } from './PiPrintRunner.js'; +import { DurableAgentRepository, type CreateDurableAgentInput, type DurableRunCompletion } from './DurableAgentRepository.js'; + +interface RepositoryLike { create(input: CreateDurableAgentInput): Promise; list(): Promise; resolve(reference: string): Promise; acquireRun(id: string): Promise<{ agent: DurableAgent; token: string }>; recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; completeRun(id: string, token: string, result: DurableRunCompletion): Promise } +interface ProbeLike { validate(): Promise<{ executable: string; version: string }> } +interface RunnerLike { run(request: Parameters[0]): Promise } +export interface PiPrintAgentServiceOptions { repository?: RepositoryLike; probe?: ProbeLike; runner?: RunnerLike; executable?: string } +export interface PiPrintSendResult extends PiPrintRunResult { agentId: string; agentName: string } + +export class PiPrintAgentService { + readonly repository: RepositoryLike; private readonly probe: ProbeLike; private readonly runner: RunnerLike; private readonly executable?: string; + constructor(options: PiPrintAgentServiceOptions = {}) { + this.repository = options.repository ?? new DurableAgentRepository(); this.probe = options.probe ?? new PiCliProbe(); + this.runner = options.runner ?? new PiPrintRunner(); this.executable = options.executable; + } + async create(input: Omit): Promise { + await this.probe.validate(); return this.repository.create({ ...input, provider: 'pi' }); + } + async send(reference: string, prompt: string): Promise { + const resolved = await this.repository.resolve(reference); + if (!resolved) throw new DurableAgentNotFoundError(reference); + if (Array.isArray(resolved)) throw new PiPrintError('Multiple print agents match.', 'PI_UNSUPPORTED'); + const acquired = await this.repository.acquireRun(resolved.id); + try { + if (acquired.agent.provider !== 'pi') throw new PiPrintError('Print agent provider is not Pi.', 'PI_UNSUPPORTED'); + const result = await this.runner.run({ agent: acquired.agent, prompt, executable: this.executable, + onSpawn: (identity) => this.repository.recordProviderProcess(resolved.id, acquired.token, identity) }); + await this.repository.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 mismatch = error instanceof PiPrintError && error.code === 'PI_SESSION_MISMATCH'; + await this.repository.completeRun(resolved.id, acquired.token, { status: 'failed', exitCode: null, + summary: sanitize(failure.message, 4096), sessionHealth: mismatch ? 'mismatch' : 'unknown' }); + 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/durable/PiPrintProtocol.ts b/packages/agent-manager/src/durable/PiPrintProtocol.ts new file mode 100644 index 00000000..53ad9d61 --- /dev/null +++ b/packages/agent-manager/src/durable/PiPrintProtocol.ts @@ -0,0 +1,40 @@ +import { PiPrintError } from './DurableAgent.js'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function buildPiPrintArgs(providerSessionId: string, firstRun: boolean): string[] { + return firstRun + ? ['--mode', 'json', '--session-id', providerSessionId] + : ['--mode', 'json', '--session', providerSessionId]; +} + +export function readPiSessionId( + event: Record, + currentSessionId: string | null, + expectedSessionId: string, +): string { + if (currentSessionId !== null || !UUID_PATTERN.test(String(event.id ?? ''))) { + throw new PiPrintError('Pi emitted an invalid session identity.', 'PI_PROTOCOL'); + } + const sessionId = event.id as string; + if (expectedSessionId !== sessionId) { + throw new PiPrintError('Pi returned a different session identity.', 'PI_SESSION_MISMATCH'); + } + return sessionId; +} + +export function readPiAssistantText(event: Record): string | null { + if (event.type !== 'message_end') return null; + const message = event.message; + if (!message || typeof message !== 'object' || Array.isArray(message)) return null; + const record = message as Record; + if (record.role !== 'assistant') return null; + if (typeof record.content === 'string') return record.content.trim() ? record.content : null; + if (!Array.isArray(record.content)) return null; + const text = record.content.flatMap((part) => { + if (!part || typeof part !== 'object' || Array.isArray(part)) return []; + const block = part as Record; + return block.type === 'text' && typeof block.text === 'string' ? [block.text] : []; + }).join(''); + return text.trim() ? text : null; +} diff --git a/packages/agent-manager/src/durable/PiPrintRunner.ts b/packages/agent-manager/src/durable/PiPrintRunner.ts new file mode 100644 index 00000000..cde40afd --- /dev/null +++ b/packages/agent-manager/src/durable/PiPrintRunner.ts @@ -0,0 +1,67 @@ +import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; +import type { DurableAgent, ProcessIdentity } from './DurableAgent.js'; +import { PiPrintError } from './DurableAgent.js'; +import { LocalProcessInspector, type ProcessInspector } from './DurableAgentRepository.js'; +import { buildPiPrintArgs, readPiAssistantText, readPiSessionId } from './PiPrintProtocol.js'; + +type Spawn = (command: string, args: readonly string[], options: SpawnOptionsWithoutStdio & { stdio: ['pipe', 'pipe', 'pipe'] }) => ChildProcessWithoutNullStreams; +export interface PiPrintRunRequest { agent: DurableAgent; prompt: string; executable?: string; onSpawn(identity: ProcessIdentity): Promise } +export interface PiPrintRunResult { sessionId: string; result: string; messages: string[]; exitCode: number } +export interface PiPrintRunnerOptions { spawn?: Spawn; processInspector?: ProcessInspector; maxLineBytes?: number } + +export class PiPrintRunner { + private readonly spawn: Spawn; private readonly processInspector: ProcessInspector; private readonly maxLineBytes: number; + constructor(options: PiPrintRunnerOptions = {}) { + this.spawn = options.spawn ?? (nodeSpawn as Spawn); this.processInspector = options.processInspector ?? new LocalProcessInspector(); + this.maxLineBytes = options.maxLineBytes ?? 1024 * 1024; + } + async run(request: PiPrintRunRequest): Promise { + const args = buildPiPrintArgs(request.agent.providerSessionId, request.agent.sessionHealth === 'uninitialized'); + const child = this.spawn(request.executable ?? 'pi', args, { cwd: request.agent.cwd, shell: false, stdio: ['pipe', 'pipe', 'pipe'] }); + if (!child.pid) { child.kill(); throw new PiPrintError('Pi process did not provide a PID.', 'PI_PROCESS'); } + const identity = this.processInspector.getIdentity(child.pid); + if (!identity) { child.kill(); throw new PiPrintError('Cannot verify Pi process identity.', 'PI_PROCESS'); } + let buffer = Buffer.alloc(0); let sessionId: string | null = null; let ended = false; const messages: string[] = []; + let protocolError: PiPrintError | null = null; let processing = Promise.resolve(); + const processLine = async (line: Buffer) => { + if (!line.length) return; + if (line.length > this.maxLineBytes) throw new PiPrintError('Pi stream line exceeded the safety limit.', 'PI_PROTOCOL'); + let value: unknown; + try { value = JSON.parse(line.toString('utf8')); } catch { throw new PiPrintError('Pi emitted malformed stream JSON.', 'PI_PROTOCOL'); } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new PiPrintError('Pi emitted a non-object stream message.', 'PI_PROTOCOL'); + const event = value as Record; + if (event.type === 'session') { + sessionId = readPiSessionId(event, sessionId, request.agent.providerSessionId); + } else if (event.type === 'agent_end') ended = true; + else { + const text = readPiAssistantText(event); + if (text !== null) messages.push(text); + } + }; + 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 PiPrintError('Pi stream line exceeded the safety limit.', 'PI_PROTOCOL'); return; } + let newline: number; + while ((newline = buffer.indexOf(0x0a)) >= 0) { + const line = buffer.subarray(0, newline); buffer = buffer.subarray(newline + 1); + processing = processing.then(() => processLine(line)).catch((error) => { protocolError = error instanceof PiPrintError ? error : new PiPrintError('Pi stream processing failed.', 'PI_PROTOCOL'); }); + } + }); + 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.catch(() => { throw new PiPrintError('Pi process failed to start or communicate.', 'PI_PROCESS'); }); + await processing; + if (protocolError) throw protocolError; + if (buffer.length) throw new PiPrintError('Pi stream ended with incomplete JSON.', 'PI_PROTOCOL'); + if (code !== 0) throw new PiPrintError(`Pi print run failed${signal ? ` (${signal})` : '.'}`, 'PI_PROCESS'); + if (sessionId === null) throw new PiPrintError('Pi stream ended without a session identity.', 'PI_PROTOCOL'); + if (!ended) throw new PiPrintError('Pi stream ended before agent completion.', 'PI_PROTOCOL'); + if (!messages.length) throw new PiPrintError('Pi stream ended without an assistant result.', 'PI_RESULT_MISSING'); + return { sessionId, result: messages.at(-1)!, messages, exitCode: code }; + } +} diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index eef400cb..14bd71cc 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -45,14 +45,17 @@ export { DurableAgentRepositoryError, DurableAgentNameConflictError, ClaudePrintError, + PiPrintError, } from './durable/DurableAgent.js'; export type { DurableAgent, + DurableProvider, DurableAgentState, DurableSessionHealth, DurableRunStatus, DurableActiveRun, DurableLastResult, + PiPrintErrorCode, ProcessIdentity, } from './durable/DurableAgent.js'; export { DurableAgentRepository } from './durable/DurableAgentRepository.js'; @@ -76,3 +79,9 @@ export type { ClaudePrintAgentServiceOptions, ClaudePrintSendResult, } from './durable/ClaudePrintAgentService.js'; +export { PiCliProbe } from './durable/PiCliProbe.js'; +export type { PiCliProbeOptions } from './durable/PiCliProbe.js'; +export { PiPrintRunner } from './durable/PiPrintRunner.js'; +export type { PiPrintRunnerOptions, PiPrintRunRequest, PiPrintRunResult } from './durable/PiPrintRunner.js'; +export { PiPrintAgentService } from './durable/PiPrintAgentService.js'; +export type { PiPrintAgentServiceOptions, PiPrintSendResult } from './durable/PiPrintAgentService.js'; diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 9a349aff..5e9bb378 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -24,6 +24,12 @@ const mockDurableService: any = { send: vi.fn(), }; +const mockPiPrintService: any = { + repository: mockDurableRepository, + create: vi.fn(), + send: vi.fn(), +}; + const mockAgentAdapter: any = { getConversation: vi.fn(), }; @@ -100,6 +106,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ PiAdapter: vi.fn(), DurableAgentRepository: vi.fn(function () { return mockDurableRepository; }), ClaudePrintAgentService: vi.fn(function () { return mockDurableService; }), + PiPrintAgentService: vi.fn(function () { return mockPiPrintService; }), TerminalFocusManager: vi.fn(function () { return mockFocusManager; }), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { @@ -233,6 +240,8 @@ describe('agent command', () => { mockDurableRepository.resolve.mockReset().mockResolvedValue(null); mockDurableService.create.mockReset(); mockDurableService.send.mockReset(); + mockPiPrintService.create.mockReset(); + mockPiPrintService.send.mockReset(); mockFocusManager.findTerminal.mockReset(); mockFocusManager.focusTerminal.mockReset(); mockTtyWriterSend.mockReset().mockResolvedValue(undefined); @@ -758,6 +767,27 @@ Waiting on user input`, expect(mockDurableService.create).not.toHaveBeenCalled(); }); + it('starts a durable Pi agent without tmux', async () => { + mockPiPrintService.create.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'pi', + mode: 'durable', cwd: process.cwd(), state: 'ready', providerSessionId: '22222222-2222-4222-8222-222222222222', + }); + const program = new Command(); registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'start', '--type', 'pi', '--mode', 'durable', '--name', 'reviewer', '--cwd', process.cwd()]); + expect(mockPiPrintService.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: process.cwd() }); + expect(ui.text).toHaveBeenCalledWith('State: ready (Pi session not started)'); + }); + + it('dispatches durable send to the persisted Pi provider', async () => { + const durableAgent = { id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'pi', mode: 'durable', cwd: '/project', state: 'ready' }; + mockDurableRepository.resolve.mockResolvedValue(durableAgent); + mockPiPrintService.send.mockResolvedValue({ agentId: durableAgent.id, agentName: durableAgent.name, result: 'done', exitCode: 0, sessionId: '22222222-2222-4222-8222-222222222222' }); + const program = new Command(); registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'review', '--id', durableAgent.id, '--json']); + expect(mockPiPrintService.send).toHaveBeenCalledWith(durableAgent.id, 'review'); + expect(JSON.parse(logSpy.mock.calls[0][0] as string).target.provider).toBe('pi'); + }); + it('sends synchronously to an exact durable-agent id without terminal injection', async () => { const durableAgent = { id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 60b80cf3..af1675ba 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -16,6 +16,7 @@ import { PiAdapter, ClaudePrintAgentService, DurableAgentRepository, + PiPrintAgentService, AgentStatus, TerminalFocusManager, AgentRegistry, @@ -29,6 +30,7 @@ import { type AgentType, type ConversationMessage, type SessionSummary, + type DurableProvider, } from '@ai-devkit/agent-manager'; import { ui } from '../util/terminal-ui.js'; import { withErrorHandler } from '../util/errors.js'; @@ -192,8 +194,13 @@ function createAgentManager(): AgentManager { return manager; } -function createDurableAgentService(): ClaudePrintAgentService { - return new ClaudePrintAgentService({ repository: new DurableAgentRepository() }); +function createDurableAgentService(provider: DurableProvider = 'claude'): ClaudePrintAgentService | PiPrintAgentService { + const repository = new DurableAgentRepository(); + return provider === 'pi' ? new PiPrintAgentService({ repository }) : new ClaudePrintAgentService({ repository }); +} + +function formatPrintProvider(provider: DurableProvider): string { + return provider === 'pi' ? 'Pi' : 'Claude Code'; } const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; @@ -285,8 +292,8 @@ export function registerAgentCommand(program: Command): void { throw new Error(`Unsupported agent mode "${mode}". Supported: interactive, durable.`); } const internalMode = mode === 'durable' ? AGENT_MODES.DURABLE : AGENT_MODES.INTERACTIVE; - if (internalMode === AGENT_MODES.DURABLE && agentType !== 'claude') { - throw new Error('Durable mode currently supports only --type claude.'); + if (internalMode === AGENT_MODES.DURABLE && !['claude', 'pi'].includes(agentType)) { + throw new Error('Durable mode currently supports only --type claude or --type pi.'); } if (!NAME_REGEX.test(agentName)) { ui.error( @@ -302,10 +309,10 @@ export function registerAgentCommand(program: Command): void { try { if (internalMode === AGENT_MODES.DURABLE) { - const entry = await createDurableAgentService().create({ name: agentName, cwd }); + const entry = await createDurableAgentService(agentType as DurableProvider).create({ name: agentName, cwd }); ui.success(`Durable agent "${entry.name}" started (${entry.provider}, ID ${entry.id})`); ui.text(`Working directory: ${formatCwd(entry.cwd)}`); - ui.text('State: ready (Claude session not started)'); + ui.text(`State: ready (${formatPrintProvider(entry.provider)} session not started)`); return; } const entry = await startAgent( @@ -630,6 +637,7 @@ export function registerAgentCommand(program: Command): void { throw new Error(`Multiple durable agents match "${options.id}".`); } if (durableResolved) { + const providerService = createDurableAgentService(durableResolved.provider); if (options.timeout !== undefined) { throw new Error('--timeout is not supported for synchronous durable agents.'); } @@ -640,10 +648,10 @@ export function registerAgentCommand(program: Command): void { throw new Error(`Agent name "${options.id}" is ambiguous across interactive and durable modes. Use the durable agent ID.`); } } - const result = await durableService.send(options.id, prompt); + const result = await providerService.send(options.id, prompt); if (options.json) { console.log(JSON.stringify({ - target: { id: result.agentId, name: result.agentName, provider: 'claude', mode: AGENT_MODES.DURABLE }, + target: { id: result.agentId, name: result.agentName, provider: durableResolved.provider, mode: AGENT_MODES.DURABLE }, response: result.result, exitCode: result.exitCode, sessionId: result.sessionId, @@ -732,7 +740,7 @@ export function registerAgentCommand(program: Command): void { ui.text(` ${chalk.bold('Agent ID:')} ${durableResolved.id}`); ui.text(` ${chalk.bold('Session ID:')} ${durableResolved.providerSessionId}`); ui.text(` ${chalk.bold('Name:')} ${durableResolved.name}`); - ui.text(` ${chalk.bold('Provider:')} Claude Code`); + ui.text(` ${chalk.bold('Provider:')} ${formatPrintProvider(durableResolved.provider)}`); ui.text(` ${chalk.bold('Mode:')} durable`); ui.text(` ${chalk.bold('CWD:')} ${formatCwd(durableResolved.cwd)}`); ui.text(` ${chalk.bold('State:')} ${durableResolved.state}`);