From 291255e66a22eacc3db1fb6380e35de983b142f5 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 17 Aug 2026 03:25:03 +0000 Subject: [PATCH 1/8] docs(ai): define Pi print mode requirements --- .../2026-08-17-feature-pi-print-mode.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/ai/requirements/2026-08-17-feature-pi-print-mode.md 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..2046bfcc --- /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 print --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 print` creates a persisted `provider: "pi"` print agent with an initially unbound provider session. +- 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` with late binding: deterministic structured identity and events using the documented CLI; selected. + +## Questions & Open Items + +No material open items. Pi's documented session identity and resume surface resolves the durability question. From ac0256b53b17181d701bcd96c1944242eb1344c2 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 17 Aug 2026 03:25:11 +0000 Subject: [PATCH 2/8] docs(ai): design Pi print mode --- .../2026-08-17-feature-pi-print-mode.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/ai/design/2026-08-17-feature-pi-print-mode.md 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..0feb609a --- /dev/null +++ b/docs/ai/design/2026-08-17-feature-pi-print-mode.md @@ -0,0 +1,75 @@ +--- +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 --> Store[PrintAgentStore v2] + Service --> Runner[PiPrintRunner] + Runner -->|pi --mode json [--session id]| Pi[Pi CLI] + Pi -->|session header + events| Runner + Runner -->|onSession UUID| Store + Store --> Registry[(print-agents.json + run locks)] + Registry --> Console[agent list / console] +``` + +Pi follows the merged Claude service/runner boundary. The open Codex design is used only as a read-only consistency reference for provider-discriminated agents and late provider-session binding. + +## Data Models + +- `PrintProvider`: `claude | pi` on this main-based branch. +- `PrintAgentBase`: shared AI DevKit identity, cwd binding, state, timestamps, active-run identity, and last result. +- `ClaudePrintAgent`: provider `claude`, provider session UUID assigned at creation. +- `PiPrintAgent`: provider `pi`, provider session UUID initially `null`, bound from Pi's session header during its first run. +- Store schema version 2 reads legacy version 1 Claude records and writes version 2. Non-null provider session IDs are unique per provider. + +## API Design + +- `PiCliProbe.validate()` runs `pi --version` and `pi --help`, requiring `--mode`, `json`, and `--session`. +- `PiPrintRunner.run(request)` spawns Pi with `['--mode', 'json']` for a first run or `['--mode', 'json', '--session', id]` for resume; prompt is sent on stdin. +- Runner callbacks: `onSpawn(ProcessIdentity)` persists process ownership; `onSession(uuid)` atomically binds/verifies provider identity. +- `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 + +- `PrintAgent.ts`: discriminated provider types and Pi errors. +- `PrintAgentStore.ts`: schema migration, provider creation, UUID validation, unique late binding. +- `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 resumed runs emit the stored UUID before binding callback succeeds. +- 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. +- Selected late binding because Pi owns session UUID creation. +- Generalize the shared store now, but add only Pi on main; future Codex integration can extend the union consistently. +- Keep synchronous send behavior and existing run locks; no daemon or streaming transport. +- Preserve version 1 reads and write version 2 to avoid breaking merged Claude installations. + +## Non-Functional Requirements + +- Security: `shell: false`, stdin prompts, canonical non-symlink cwd, bounded JSON lines, sanitized summaries, no stderr reflection. +- Reliability: atomic store mutations, provider/session uniqueness, ownership-checked binding, 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. From b9d13dbaf4847bb8ea4ebe062d8c15a323e72c54 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 17 Aug 2026 03:25:20 +0000 Subject: [PATCH 3/8] docs(ai): plan Pi print mode delivery --- .../2026-08-17-feature-pi-print-mode.md | 52 ++++++++++++ .../2026-08-17-feature-pi-print-mode.md | 49 +++++++++++ .../2026-08-17-feature-pi-print-mode.md | 85 +++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 docs/ai/implementation/2026-08-17-feature-pi-print-mode.md create mode 100644 docs/ai/planning/2026-08-17-feature-pi-print-mode.md create mode 100644 docs/ai/testing/2026-08-17-feature-pi-print-mode.md 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..19814965 --- /dev/null +++ b/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md @@ -0,0 +1,52 @@ +--- +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` from `origin/main` at `a643f4a`. +- References: merged Claude implementation under `packages/agent-manager/src/print/`; 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 + +Planned additions are `PiCliProbe.ts`, `PiPrintRunner.ts`, and `PiPrintAgentService.ts` beside the Claude modules. Shared store/types and agent CLI wiring are generalized minimally. + +## Implementation Notes + +### Core Features + +- Pending: provider-discriminated schema-v2 store with legacy reads. +- Pending: strict Pi JSONL runner with late UUID binding and resume args. +- Pending: provider-aware CLI creation/send/list/detail/console wiring. + +### 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 store union. + +## 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, ownership-checked session binding, and existing safe-file/run-lock protections. + +## Deviations and Follow-ups + +None at design completion. This document will be updated after each task with files, evidence, and deviations. 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..4a367827 --- /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 +- [ ] Provider-aware durable storage +- [ ] Pi probe, runner, and service +- [ ] CLI integration and lifecycle verification + +## Task Breakdown + +### Phase 1: Storage Foundation + +- [ ] T1: Add failing store/type tests for Pi creation, legacy migration, safe late session binding, uniqueness, and invalid records. Depends on none. Evidence: targeted store suite and 100% new pure-logic coverage. Scenarios: S1-S5. +- [ ] T2: Implement provider-discriminated agents and schema-v2 store behavior while retaining Claude compatibility. Depends on T1. Evidence: store and Claude suites green. + +### Phase 2: Pi Provider + +- [ ] 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. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 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 + +Research and design are complete. Implementation begins with storage tests, then moves through one red-green-refactor cycle per task. Optional task tracing is unavailable, so this checklist and phase commits are the durable progress record. 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..ff2933b2 --- /dev/null +++ b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md @@ -0,0 +1,85 @@ +--- +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 + +- [ ] S6 supported Pi help/version returns sanitized metadata. +- [ ] S7 missing flags produce an unsupported-capability error. +- [ ] S8 execution failures produce a sanitized unavailable error. + +### Pi JSON Runner + +- [ ] S9 first-run args are `--mode json`; resume adds `--session `; prompt uses stdin and shell is disabled. +- [ ] S10 provider process identity and session callbacks run. +- [ ] S11 the session header and completed assistant message yield the final result. +- [ ] S12 multiple assistant completions return the last complete message. +- [ ] S13 missing/invalid/duplicate/mismatched session identity is rejected. +- [ ] S14 malformed, non-object, oversized, or incomplete JSON is rejected. +- [ ] S15 missing `agent_end` or assistant output is rejected. +- [ ] S16 spawn identity/start errors and callback failures terminate safely. +- [ ] S17 non-zero/signal exits become process errors. +- [ ] S18 stderr is drained without inclusion in results. + +## Integration Tests + +- [ ] S19 service create probes and persists provider `pi`. +- [ ] S20 first send records process/session, success, health, and sanitized summary. +- [ ] S21 resumed send preserves the bound session. +- [ ] S22 missing/ambiguous/wrong-provider references fail clearly. +- [ ] S23 protocol/store session mismatches record mismatch health. +- [ ] S24 other failures record unknown health and release the run. + +## CLI and End-to-End Tests + +- [ ] S25 `agent start --type pi --mode print` creates without interactive launch. +- [ ] S26 unsupported print providers and modes remain rejected. +- [ ] S27 `agent send` dispatches Pi records to the Pi service and reports provider `pi`. +- [ ] S28 list/detail output identifies Pi print agents and nullable pre-first-run sessions safely. +- [ ] S29 console receives the combined interactive/print registry. +- [ ] 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`. + +## 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. From d2bdc7525630f7e0da256726f5752e7929261c03 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 17 Aug 2026 03:33:21 +0000 Subject: [PATCH 4/8] feat(agent): add Pi print runner and service --- .../2026-08-17-feature-pi-print-mode.md | 16 ++-- .../2026-08-17-feature-pi-print-mode.md | 16 ++-- .../2026-08-17-feature-pi-print-mode.md | 38 ++++----- .../src/__tests__/durable/PiCliProbe.test.ts | 30 +++++++ .../durable/PiPrintAgentService.test.ts | 39 ++++++++++ .../__tests__/durable/PiPrintRunner.test.ts | 74 ++++++++++++++++++ .../agent-manager/src/durable/DurableAgent.ts | 18 ++++- .../src/durable/DurableAgentRepository.ts | 14 ++-- .../agent-manager/src/durable/PiCliProbe.ts | 42 ++++++++++ .../src/durable/PiPrintAgentService.ts | 46 +++++++++++ .../src/durable/PiPrintRunner.ts | 78 +++++++++++++++++++ packages/agent-manager/src/index.ts | 9 +++ 12 files changed, 379 insertions(+), 41 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts create mode 100644 packages/agent-manager/src/__tests__/durable/PiPrintAgentService.test.ts create mode 100644 packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts create mode 100644 packages/agent-manager/src/durable/PiCliProbe.ts create mode 100644 packages/agent-manager/src/durable/PiPrintAgentService.ts create mode 100644 packages/agent-manager/src/durable/PiPrintRunner.ts 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 index 19814965..b100537d 100644 --- a/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md @@ -8,21 +8,21 @@ description: Living implementation record for durable Pi print agents ## Development Setup -- Worktree: `feature-pi-print-mode` from `origin/main` at `a643f4a`. -- References: merged Claude implementation under `packages/agent-manager/src/print/`; read-only Codex worktree at `../feature-codex-print-mode`. +- 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 -Planned additions are `PiCliProbe.ts`, `PiPrintRunner.ts`, and `PiPrintAgentService.ts` beside the Claude modules. Shared store/types and agent CLI wiring are generalized minimally. +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 -- Pending: provider-discriminated schema-v2 store with legacy reads. -- Pending: strict Pi JSONL runner with late UUID binding and resume args. +- 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. - Pending: provider-aware CLI creation/send/list/detail/console wiring. ### Patterns & Best Practices @@ -33,7 +33,7 @@ Planned additions are `PiCliProbe.ts`, `PiPrintRunner.ts`, and `PiPrintAgentServ ## 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 store union. +`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 @@ -45,8 +45,8 @@ Parse stdout incrementally with a 1 MiB line limit. Store only the final bounded ## Security Notes -No shell, prompt via stdin, canonical cwd, no stderr reflection, UUID validation, ownership-checked session binding, and existing safe-file/run-lock protections. +No shell, prompt via stdin, canonical cwd, no stderr reflection, UUID validation, and SQLite CAS run ownership. ## Deviations and Follow-ups -None at design completion. This document will be updated after each task with files, evidence, and deviations. +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. 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 index 4a367827..a73f02a7 100644 --- a/docs/ai/planning/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/planning/2026-08-17-feature-pi-print-mode.md @@ -10,22 +10,22 @@ description: TDD implementation plan for durable Pi print agents - [x] Requirements and Pi CLI investigation - [x] Architecture and test strategy -- [ ] Provider-aware durable storage -- [ ] Pi probe, runner, and service +- [x] Provider-aware durable storage +- [x] Pi probe, runner, and service - [ ] CLI integration and lifecycle verification ## Task Breakdown ### Phase 1: Storage Foundation -- [ ] T1: Add failing store/type tests for Pi creation, legacy migration, safe late session binding, uniqueness, and invalid records. Depends on none. Evidence: targeted store suite and 100% new pure-logic coverage. Scenarios: S1-S5. -- [ ] T2: Implement provider-discriminated agents and schema-v2 store behavior while retaining Claude compatibility. Depends on T1. Evidence: store and Claude suites green. +- [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 -- [ ] 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. -- [ ] 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. -- [ ] 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. +- [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 @@ -46,4 +46,4 @@ Storage generalization precedes provider code; runner and probe precede service; ## Progress Summary -Research and design are complete. Implementation begins with storage tests, then moves through one red-green-refactor cycle per task. Optional task tracing is unavailable, so this checklist and phase commits are the durable progress record. +The obsolete file-store generalization commit was dropped during rebase. Pi provider adaptation now targets `DurableAgentRepository`, with CLI integration and full verification remaining. 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 index ff2933b2..115d8732 100644 --- a/docs/ai/testing/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md @@ -26,31 +26,31 @@ description: Unit, integration, CLI, and regression coverage for Pi print agents ### Pi CLI Probe -- [ ] S6 supported Pi help/version returns sanitized metadata. -- [ ] S7 missing flags produce an unsupported-capability error. -- [ ] S8 execution failures produce a sanitized unavailable error. +- [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 -- [ ] S9 first-run args are `--mode json`; resume adds `--session `; prompt uses stdin and shell is disabled. -- [ ] S10 provider process identity and session callbacks run. -- [ ] S11 the session header and completed assistant message yield the final result. -- [ ] S12 multiple assistant completions return the last complete message. -- [ ] S13 missing/invalid/duplicate/mismatched session identity is rejected. -- [ ] S14 malformed, non-object, oversized, or incomplete JSON is rejected. -- [ ] S15 missing `agent_end` or assistant output is rejected. -- [ ] S16 spawn identity/start errors and callback failures terminate safely. -- [ ] S17 non-zero/signal exits become process errors. -- [ ] S18 stderr is drained without inclusion in results. +- [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 -- [ ] S19 service create probes and persists provider `pi`. -- [ ] S20 first send records process/session, success, health, and sanitized summary. -- [ ] S21 resumed send preserves the bound session. -- [ ] S22 missing/ambiguous/wrong-provider references fail clearly. -- [ ] S23 protocol/store session mismatches record mismatch health. -- [ ] S24 other failures record unknown health and release the run. +- [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 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..d3f27c8d --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts @@ -0,0 +1,30 @@ +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); + }); +}); 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..2bb85cf2 --- /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) }; 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 = { 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 = { 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/PiPrintRunner.test.ts b/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts new file mode 100644 index 00000000..ac5a598a --- /dev/null +++ b/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts @@ -0,0 +1,74 @@ +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(); + }); +}); 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..a07acb7d --- /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; 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/PiPrintRunner.ts b/packages/agent-manager/src/durable/PiPrintRunner.ts new file mode 100644 index 00000000..368e4754 --- /dev/null +++ b/packages/agent-manager/src/durable/PiPrintRunner.ts @@ -0,0 +1,78 @@ +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'; + +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 } +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 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 = request.agent.sessionHealth === 'uninitialized' + ? ['--mode', 'json', '--session-id', request.agent.providerSessionId] + : ['--mode', 'json', '--session', request.agent.providerSessionId]; + 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') { + if (sessionId !== null || !UUID_PATTERN.test(String(event.id ?? ''))) throw new PiPrintError('Pi emitted an invalid session identity.', 'PI_PROTOCOL'); + sessionId = event.id as string; + if (request.agent.providerSessionId !== sessionId) { + throw new PiPrintError('Pi returned a different session identity.', 'PI_SESSION_MISMATCH'); + } + } else if (event.type === 'message_end') { + const message = event.message as Record | undefined; + if (message?.role === 'assistant') { + const content = message.content; + const text = typeof content === 'string' ? content : Array.isArray(content) + ? content.filter((part): part is Record => !!part && typeof part === 'object' && !Array.isArray(part)) + .filter((part) => part.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('') : ''; + if (text.trim()) messages.push(text); + } + } else if (event.type === 'agent_end') ended = true; + }; + 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'; From 9d1c8a939bfbeacd7ed435a10e1ca94dce62a640 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 17 Aug 2026 03:35:28 +0000 Subject: [PATCH 5/8] feat(cli): wire Pi print agents --- .../2026-08-17-feature-pi-print-mode.md | 2 +- .../2026-08-17-feature-pi-print-mode.md | 4 +-- .../2026-08-17-feature-pi-print-mode.md | 12 ++++---- .../cli/src/__tests__/commands/agent.test.ts | 30 +++++++++++++++++++ packages/cli/src/commands/agent.ts | 25 ++++++++++------ 5 files changed, 55 insertions(+), 18 deletions(-) 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 index b100537d..f47d5ab9 100644 --- a/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md @@ -23,7 +23,7 @@ Pi provider modules live beside the Claude modules under `src/durable/`. Shared - 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. -- Pending: provider-aware CLI creation/send/list/detail/console wiring. +- Complete: provider-aware CLI creation/send dispatch, Pi labels, and shared durable list/detail integration. ### Patterns & Best Practices 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 index a73f02a7..72b6059c 100644 --- a/docs/ai/planning/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/planning/2026-08-17-feature-pi-print-mode.md @@ -29,7 +29,7 @@ description: TDD implementation plan for durable Pi print agents ### Phase 3: CLI Integration -- [ ] 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] 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. - [ ] 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 @@ -46,4 +46,4 @@ Storage generalization precedes provider code; runner and probe precede service; ## Progress Summary -The obsolete file-store generalization commit was dropped during rebase. Pi provider adaptation now targets `DurableAgentRepository`, with CLI integration and full verification remaining. +The obsolete file-store generalization commit was dropped during rebase. Pi provider and CLI adaptation now target `DurableAgentRepository`; full validation remains. 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 index 115d8732..15a0560b 100644 --- a/docs/ai/testing/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md @@ -54,12 +54,12 @@ description: Unit, integration, CLI, and regression coverage for Pi print agents ## CLI and End-to-End Tests -- [ ] S25 `agent start --type pi --mode print` creates without interactive launch. -- [ ] S26 unsupported print providers and modes remain rejected. -- [ ] S27 `agent send` dispatches Pi records to the Pi service and reports provider `pi`. -- [ ] S28 list/detail output identifies Pi print agents and nullable pre-first-run sessions safely. -- [ ] S29 console receives the combined interactive/print registry. -- [ ] S30 Claude print start/send/list/detail behavior remains green. +- [x] S25 `agent start --type pi --mode print` creates without interactive launch. +- [x] S26 unsupported print providers and modes remain rejected. +- [x] S27 `agent send` dispatches Pi records to the Pi service and reports provider `pi`. +- [x] S28 list/detail output identifies Pi print agents and nullable pre-first-run sessions safely. +- [x] S29 console receives the combined interactive/print registry. +- [x] S30 Claude print start/send/list/detail behavior remains green. ## Test Data 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..c3cb63e9 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,10 +194,14 @@ 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]$/; function writeWaitStatus(message: string): void { @@ -285,8 +291,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 +308,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 +636,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 +647,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 +739,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}`); From 68b479111e1469ba1a0bbbce91bad3f706e6d6f9 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Mon, 17 Aug 2026 03:41:27 +0000 Subject: [PATCH 6/8] test(agent): harden Pi print integrity --- .../2026-08-17-feature-pi-print-mode.md | 3 +- .../2026-08-17-feature-pi-print-mode.md | 6 +-- .../2026-08-17-feature-pi-print-mode.md | 9 +++++ .../src/__tests__/durable/PiCliProbe.test.ts | 8 ++++ .../durable/PiPrintAgentService.test.ts | 6 +-- .../__tests__/durable/PiPrintProtocol.test.ts | 31 ++++++++++++++ .../__tests__/durable/PiPrintRunner.test.ts | 36 +++++++++++++++++ .../src/durable/PiPrintAgentService.ts | 2 +- .../src/durable/PiPrintProtocol.ts | 40 +++++++++++++++++++ .../src/durable/PiPrintRunner.ts | 25 ++++-------- 10 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/durable/PiPrintProtocol.test.ts create mode 100644 packages/agent-manager/src/durable/PiPrintProtocol.ts 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 index f47d5ab9..fd8a3904 100644 --- a/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md @@ -24,6 +24,7 @@ Pi provider modules live beside the Claude modules under `src/durable/`. Shared - 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: pure `PiPrintProtocol` helpers make argument, session-identity, and assistant-text mapping independently testable at 100% coverage. ### Patterns & Best Practices @@ -49,4 +50,4 @@ No shell, prompt via stdin, canonical cwd, no stderr reflection, UUID validation ## 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. +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 index 72b6059c..959ff86e 100644 --- a/docs/ai/planning/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/planning/2026-08-17-feature-pi-print-mode.md @@ -12,7 +12,7 @@ description: TDD implementation plan for durable Pi print agents - [x] Architecture and test strategy - [x] Provider-aware durable storage - [x] Pi probe, runner, and service -- [ ] CLI integration and lifecycle verification +- [x] CLI integration and lifecycle verification ## Task Breakdown @@ -30,7 +30,7 @@ description: TDD implementation plan for durable Pi print agents ### 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. -- [ ] 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. +- [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 @@ -46,4 +46,4 @@ Storage generalization precedes provider code; runner and probe precede service; ## Progress Summary -The obsolete file-store generalization commit was dropped during rebase. Pi provider and CLI adaptation now target `DurableAgentRepository`; full validation remains. +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/testing/2026-08-17-feature-pi-print-mode.md b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md index 15a0560b..e17e5917 100644 --- a/docs/ai/testing/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md @@ -72,6 +72,15 @@ Use temporary store/cwd fixtures, deterministic clocks/process identities, valid - 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. diff --git a/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts b/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts index d3f27c8d..2a0ed690 100644 --- a/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts +++ b/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts @@ -27,4 +27,12 @@ describe('PiCliProbe', () => { 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', 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 index 2bb85cf2..8f6bb2b3 100644 --- a/packages/agent-manager/src/__tests__/durable/PiPrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/durable/PiPrintAgentService.test.ts @@ -5,7 +5,7 @@ const base = { id: 'id', name: 'reviewer', provider: 'pi', providerSessionId: SE 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) }; const runner = { run: vi.fn() }; + 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' }); @@ -13,7 +13,7 @@ describe('PiPrintAgentService', () => { it('records successful first and resumed sends', async () => { const api = await import('../../index.js') as Record; - const repository = { resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn() + 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 }; }) }; @@ -24,7 +24,7 @@ describe('PiPrintAgentService', () => { 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 = { resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), recordProviderProcess: vi.fn(), completeRun }; + 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' }); 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 index ac5a598a..9231535a 100644 --- a/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts @@ -71,4 +71,40 @@ describe('PiPrintRunner', () => { .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 and classifies session callback 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(); + const callbackFailure = fakeSpawn(events()); + await expect((await runner(callbackFailure)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn().mockRejectedValue(new Error('store')) })) + .rejects.toMatchObject({ code: 'PI_PROTOCOL' }); + }); + + 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/PiPrintAgentService.ts b/packages/agent-manager/src/durable/PiPrintAgentService.ts index a07acb7d..c02ba294 100644 --- a/packages/agent-manager/src/durable/PiPrintAgentService.ts +++ b/packages/agent-manager/src/durable/PiPrintAgentService.ts @@ -4,7 +4,7 @@ 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; 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 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 } 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 index 368e4754..cde40afd 100644 --- a/packages/agent-manager/src/durable/PiPrintRunner.ts +++ b/packages/agent-manager/src/durable/PiPrintRunner.ts @@ -2,12 +2,12 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOpti 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 } -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 class PiPrintRunner { private readonly spawn: Spawn; private readonly processInspector: ProcessInspector; private readonly maxLineBytes: number; @@ -16,9 +16,7 @@ export class PiPrintRunner { this.maxLineBytes = options.maxLineBytes ?? 1024 * 1024; } async run(request: PiPrintRunRequest): Promise { - const args = request.agent.sessionHealth === 'uninitialized' - ? ['--mode', 'json', '--session-id', request.agent.providerSessionId] - : ['--mode', 'json', '--session', request.agent.providerSessionId]; + 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); @@ -33,21 +31,12 @@ export class PiPrintRunner { 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') { - if (sessionId !== null || !UUID_PATTERN.test(String(event.id ?? ''))) throw new PiPrintError('Pi emitted an invalid session identity.', 'PI_PROTOCOL'); - sessionId = event.id as string; - if (request.agent.providerSessionId !== sessionId) { - throw new PiPrintError('Pi returned a different session identity.', 'PI_SESSION_MISMATCH'); - } - } else if (event.type === 'message_end') { - const message = event.message as Record | undefined; - if (message?.role === 'assistant') { - const content = message.content; - const text = typeof content === 'string' ? content : Array.isArray(content) - ? content.filter((part): part is Record => !!part && typeof part === 'object' && !Array.isArray(part)) - .filter((part) => part.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('') : ''; - if (text.trim()) messages.push(text); - } + 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; From 274c614e208185a64918b62484b0e3ce9f2efb3d Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 19 Aug 2026 19:25:21 +0000 Subject: [PATCH 7/8] fix(agent): finish Pi durable adaptation --- .../2026-08-17-feature-pi-print-mode.md | 40 +++++++++---------- .../2026-08-17-feature-pi-print-mode.md | 4 +- .../2026-08-17-feature-pi-print-mode.md | 2 +- .../src/__tests__/durable/PiCliProbe.test.ts | 2 +- .../__tests__/durable/PiPrintRunner.test.ts | 5 +-- packages/cli/src/commands/agent.ts | 1 + 6 files changed, 24 insertions(+), 30 deletions(-) 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 index 0feb609a..98dd65a5 100644 --- a/docs/ai/design/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/design/2026-08-17-feature-pi-print-mode.md @@ -13,38 +13,35 @@ flowchart LR CLI[agent start/send/list/detail] --> Dispatch[provider dispatch] Dispatch --> Service[PiPrintAgentService] Service --> Probe[PiCliProbe] - Service --> Store[PrintAgentStore v2] + Service --> Repository[DurableAgentRepository] Service --> Runner[PiPrintRunner] - Runner -->|pi --mode json [--session id]| Pi[Pi CLI] + Runner -->|pi --mode json --session-id/--session UUID| Pi[Pi CLI] Pi -->|session header + events| Runner - Runner -->|onSession UUID| Store - Store --> Registry[(print-agents.json + run locks)] - Registry --> Console[agent list / console] + Repository --> Registry[(agents.db durable_agents)] + Registry --> Console[agent list / detail] ``` -Pi follows the merged Claude service/runner boundary. The open Codex design is used only as a read-only consistency reference for provider-discriminated agents and late provider-session binding. +Pi follows the merged Claude durable service/runner boundary. The open Codex design is used only as a read-only consistency reference. ## Data Models -- `PrintProvider`: `claude | pi` on this main-based branch. -- `PrintAgentBase`: shared AI DevKit identity, cwd binding, state, timestamps, active-run identity, and last result. -- `ClaudePrintAgent`: provider `claude`, provider session UUID assigned at creation. -- `PiPrintAgent`: provider `pi`, provider session UUID initially `null`, bound from Pi's session header during its first run. -- Store schema version 2 reads legacy version 1 Claude records and writes version 2. Non-null provider session IDs are unique per provider. +- `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`, and `--session`. -- `PiPrintRunner.run(request)` spawns Pi with `['--mode', 'json']` for a first run or `['--mode', 'json', '--session', id]` for resume; prompt is sent on stdin. -- Runner callbacks: `onSpawn(ProcessIdentity)` persists process ownership; `onSession(uuid)` atomically binds/verifies provider identity. +- `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 -- `PrintAgent.ts`: discriminated provider types and Pi errors. -- `PrintAgentStore.ts`: schema migration, provider creation, UUID validation, unique late binding. +- `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. @@ -54,7 +51,7 @@ Pi follows the merged Claude service/runner boundary. The open Codex design is u ## Protocol Rules - Accept exactly one valid leading/session identity event; duplicate or invalid session identity is a protocol error. -- Verify resumed runs emit the stored UUID before binding callback succeeds. +- 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. @@ -62,14 +59,13 @@ Pi follows the merged Claude service/runner boundary. The open Codex design is u ## Design Decisions - Selected JSON mode over plain print mode for durable session identity. -- Selected late binding because Pi owns session UUID creation. -- Generalize the shared store now, but add only Pi on main; future Codex integration can extend the union consistently. -- Keep synchronous send behavior and existing run locks; no daemon or streaming transport. -- Preserve version 1 reads and write version 2 to avoid breaking merged Claude installations. +- 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 store mutations, provider/session uniqueness, ownership-checked binding, mismatch degradation, stale-run reconciliation. +- 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/requirements/2026-08-17-feature-pi-print-mode.md b/docs/ai/requirements/2026-08-17-feature-pi-print-mode.md index 2046bfcc..bd5bf7b3 100644 --- a/docs/ai/requirements/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/requirements/2026-08-17-feature-pi-print-mode.md @@ -36,7 +36,7 @@ Non-goals: ## Success Criteria -- `--type pi --mode print` creates a persisted `provider: "pi"` print agent with an initially unbound provider session. +- `--type pi --mode print` creates a persisted durable `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. @@ -58,7 +58,7 @@ Non-goals: - `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` with late binding: deterministic structured identity and events using the documented CLI; selected. +- `pi --mode json --session-id `: deterministic structured identity using the repository-assigned UUID; selected. ## Questions & Open Items 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 index e17e5917..ba4c10c9 100644 --- a/docs/ai/testing/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md @@ -57,7 +57,7 @@ description: Unit, integration, CLI, and regression coverage for Pi print agents - [x] S25 `agent start --type pi --mode print` creates without interactive launch. - [x] S26 unsupported print providers and modes remain rejected. - [x] S27 `agent send` dispatches Pi records to the Pi service and reports provider `pi`. -- [x] S28 list/detail output identifies Pi print agents and nullable pre-first-run sessions safely. +- [x] S28 list/detail output identifies durable Pi agents and their repository-assigned sessions. - [x] S29 console receives the combined interactive/print registry. - [x] S30 Claude print start/send/list/detail behavior remains green. diff --git a/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts b/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts index 2a0ed690..3aaa39c1 100644 --- a/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts +++ b/packages/agent-manager/src/__tests__/durable/PiCliProbe.test.ts @@ -32,7 +32,7 @@ describe('PiCliProbe', () => { 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', 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/PiPrintRunner.test.ts b/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts index 9231535a..8bcd6656 100644 --- a/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/durable/PiPrintRunner.test.ts @@ -80,13 +80,10 @@ describe('PiPrintRunner', () => { .rejects.toMatchObject({ code: 'PI_PROTOCOL' }); }); - it('kills on spawn persistence failure and classifies session callback failure', async () => { + 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(); - const callbackFailure = fakeSpawn(events()); - await expect((await runner(callbackFailure)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn().mockRejectedValue(new Error('store')) })) - .rejects.toMatchObject({ code: 'PI_PROTOCOL' }); }); it('classifies spawn errors and a missing PID as process failures', async () => { diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index c3cb63e9..af1675ba 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -201,6 +201,7 @@ function createDurableAgentService(provider: DurableProvider = 'claude'): Claude 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]$/; From 2135d97650caa1cbcba0fc8dbc0152d22a15e935 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 20 Aug 2026 04:27:44 +0000 Subject: [PATCH 8/8] docs(agent): align Pi with durable mode flag --- docs/ai/implementation/2026-08-17-feature-pi-print-mode.md | 1 + docs/ai/requirements/2026-08-17-feature-pi-print-mode.md | 4 ++-- docs/ai/testing/2026-08-17-feature-pi-print-mode.md | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) 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 index fd8a3904..aac07525 100644 --- a/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/implementation/2026-08-17-feature-pi-print-mode.md @@ -24,6 +24,7 @@ Pi provider modules live beside the Claude modules under `src/durable/`. Shared - 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 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 index bd5bf7b3..ed8c2dcc 100644 --- a/docs/ai/requirements/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/requirements/2026-08-17-feature-pi-print-mode.md @@ -12,7 +12,7 @@ AI DevKit can start Pi only as an interactive terminal process. Automation needs ## Goals & Objectives -- Support `ai-devkit agent start --type pi --mode print --name --cwd `. +- 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. @@ -36,7 +36,7 @@ Non-goals: ## Success Criteria -- `--type pi --mode print` creates a persisted durable `provider: "pi"` agent with a repository-assigned provider session UUID. +- `--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. 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 index ba4c10c9..96ea02c2 100644 --- a/docs/ai/testing/2026-08-17-feature-pi-print-mode.md +++ b/docs/ai/testing/2026-08-17-feature-pi-print-mode.md @@ -54,11 +54,11 @@ description: Unit, integration, CLI, and regression coverage for Pi print agents ## CLI and End-to-End Tests -- [x] S25 `agent start --type pi --mode print` creates without interactive launch. -- [x] S26 unsupported print providers and modes remain rejected. +- [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/print registry. +- [x] S29 console receives the combined interactive/durable registry. - [x] S30 Claude print start/send/list/detail behavior remains green. ## Test Data