pi-conductor

Multi-role LLM orchestration via a guarded, observable handoff state machine — installs as a pi extension exposing `/conduct`.

Packages

Package details

extension

Install pi-conductor from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-conductor
Package
pi-conductor
Version
0.22.1
Published
Sep 30, 2026
Downloads
1,940/mo · 195/wk
Author
lynellf
License
MIT
Types
extension
Size
12.2 MB
Dependencies
2 dependencies · 4 peers
Pi manifest JSON
{
  "extensions": [
    "./extensions"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-conductor

Portable agent orchestration for long-horizon coding work. pi-conductor brings Roo/Zoo-style multi-role workflows to pi without tying orchestration to an editor. Run cost-controlled workflows across budget and frontier models, local or remote providers, and terminal-native environments like SSH and tmux.

Status: pre-release. pi-conductor ships as a pi extension — install it with pi install, type /conduct <goal>, and it orchestrates a multi-role LLM workflow on top of a guarded, observable handoff state machine. The pure FSM core + SDK host driver are the engine; the extension is the UX shell around it.

Contents

What this is

pi-conductor orchestrates multi-role LLM workflows as a deterministic hub-and-spoke state machine: one orchestrator role dispatches to one or more worker roles, every transition is validated against a pinned manifest snapshot, every state change is reduced through a pure reducer, and every record is appended to a run-keyed log. Caps (per-session, per-run, per-worker visit count) are enforced as host guards that synthesize machine events through the reducer — never by mutating the checkpoint.

It ships as a pi package:

pi install ./           # from this checkout (dev)
# or, once published:
pi install npm:pi-conductor
pi install git:github.com/lynellf/pi-conductor

After install, seven slash commands are available inside any pi session:

/conduct <goal>          Start a run for <goal> using .pi/conductor.yaml
/conduct:resume <run_id> Resume a previously-started run by run_id
/conduct:list            List known runs in the conductor log
/conduct:abort           Abort the active run
/conduct:steer <message> Guide the active role before its next model call
/conduct:followup <message> Queue guidance for the next conductor prompt boundary
/conduct:copy            Copy the latest completed role response

Plus a flag:

--conduct-manifest <path>          Override the default manifest path
--conduct-sandbox-approval <path>  Load host-owned sandbox approval JSON
--conduct-controller-approval <path> Load host-owned controller registry JSON

A thin CLI fallback (bin/conduct) also ships, for non-pi consumers and scripted runs:

node dist/bin/conduct.js .pi/conductor.yaml "ship the changelog"

The CLI resolves its Pi SDK from PI_PACKAGE_DIR when set, otherwise from locally installed peers, then from the npm Pi installation behind pi on PATH. This supports pi install npm:pi-conductor, which omits host-provided peer dependencies. If pi is a shell wrapper or its SDK cannot be discovered, set PI_PACKAGE_DIR to the directory containing Pi's package.json and its importable SDK. An invalid override is reported as an error.

The CLI also provides a machine-safe mode for benchmark adapters and other noninteractive callers:

conduct \
  --non-interactive \
  --log-dir /tmp/pi-conductor/run-123 \
  --json \
  .pi/conductor.yaml \
  "Implement the requested repository change."

--non-interactive makes ask_user fail immediately instead of reading stdin. When --log-dir <path> is omitted, the CLI writes durable run logs to <cwd>/.pi-conductor/runs (creating missing parents); --log-dir <path> selects an explicit persistent run-log directory and creates missing parents. The separate offline advisory report reads every *.jsonl log in a runs directory and does not make network requests or mutate the logs:

conduct advisory-report <runs-dir>
conduct advisory-report <runs-dir> --json

In both text and --json run modes the CLI writes one immediate run_started NDJSON event to stdout once the run handle resolves and before completion: {"schema_version":1,"event":"run_started","run_id":"…","log_dir":"…"} with an absolute log_dir. Under --json, stdout is therefore an NDJSON stream of two documents: that run_started event followed by the existing versioned terminal JSON result; prompts, warnings, and diagnostics use stderr. In text mode the same run_started line is followed by the existing human-readable terminal line. Normal conductor terminal outcomes (done, session_failed, and aborted) retain exit code 0 and are distinguished by exit_reason; setup and unexpected runtime errors remain nonzero. While a run is active, the first SIGINT or SIGTERM requests a graceful abort so terminal state can be persisted; a second signal exits immediately.

Resume a previously started run from its durable log:

conduct resume [options] <manifestPath> <runId>
conduct resume --log-dir /tmp/pi-conductor/run-123 .pi/conductor.yaml <run_id>

resume accepts the same --non-interactive, --log-dir, --json, and approval options as start, resolves the log directory with the same default and override rules, restores the original goal from the durable log, and emits the same immediate run_started event plus the same terminal result.

The engine is the same in all three surfaces — extension, CLI, and library.

Opt-in delegated command sandbox

Delegated children remain file-only unless their subagent profile declares an execution block. The initial command backend is Linux Bubblewrap with no network:

subagents:
  - name: implementer
    models:
      - model: openai-codex:gpt-5.6-luna
        effort: medium
    max_session_cost_usd: 2
    system_prompt: .pi/subagents/implementer.md
    workspace:
      projection:
        allowed_paths: [src, tests, package.json]
        default_paths: [src, tests, package.json]
    execution:
      backend: bubblewrap
      runtime_root: prepared-runtime
      writable_paths: [src, tests]
      network: none
      environment:
        PATH: /usr/bin:/bin
        LANG: C.UTF-8
      max_output_bytes: 67108864

An opted-in run also requires explicit host approval. Pass --sandbox-approval <path> to the standalone CLI or set Pi's --conduct-sandbox-approval <path> flag for /conduct and /conduct:resume. The manifest and model cannot supply this approval. The approval file must be a canonical absolute path to a current-user-owned, single-link regular file with mode 0600.

The approved Bubblewrap executable must be an exact reviewed patched build, and the prepared runtime must contain the complete approved regular-file inventory, /bin/bash, and the fixed compiled capability probe. Admission rechecks binary identity and digest, runtime contents, probe digest, and the required namespace behavior before a command is released. The primary checkout and its Git control files must also have protected ownership and permissions; admission rejects group-writable or other-writable paths rather than changing their modes. See sandboxed delegation for approval preparation, guarantees, limits, and recovery.

Opt-in executable controller

A manifest may replace its orchestrator model session with a fixed-argv, Bubblewrap-sandboxed repository controller. The controller owns scheduling and semantic gates; the host retains admission, persistence, artifacts, costs, cleanup, and final state transitions. Controller mode requires both the sandbox approval and a separate protected controller registry. Pass --controller-approval <path> to the standalone CLI or set Pi's --conduct-controller-approval <path> flag. See sandboxed repository controllers and the examples/controller runnable example. For private native outputs and approved integration/delivery, see the operator guide and delivery example. Opt-in trusted local providers can implement forge publication and CI observation; see the local provider guide for measurement, private credentials, bounded waits, and crash recovery.

Two layers, kept strictly apart

  • Pure core (src/core, src/manifest, src/seam, src/cost, src/persistence) — the deterministic FSM reducer + manifest static checks + TypeBox emission schemas + cost roll-up. Zero pi imports. Enforced by a grep-guard test that scans source as text.
  • SDK host driver (src/host) — owns the orchestration loop, persists records, and enforces caps. Shared roles use the in-process SDK createAgentSession path; isolated worktree and copy roles use a host-owned package-local pi --mode rpc Node process whose current working directory is the provisioned role workspace.

The extension layer (extensions/conduct.ts + src/extension/) is the UX shell that wraps the engine. It does not become the engine: the production Host launches every worker role through the shared SDK or isolated RPC path, never via ctx.newSession() / ctx.fork(). A grep guard on extensions/**/*.ts rejects those two calls — the §9.5 boundary holds. While a conduct run is active in the TUI, press Esc and confirm to abort it; the standalone conduct CLI does not add that Escape interrupt.

For the full architecture rationale, see docs/archive/orchestrator-fsm-spec.md (the authority).

Quick start

1. Install

pi install ./                       # from the checkout, dev install
pi list                             # verify: pi-conductor should appear

2. Declare roles

Roles live in a single YAML manifest, .pi/conductor.yaml. The repo ships an example:

version: 1
end_request_roles: [reviewer]
roles:
  - name: orchestrator
    is_orchestrator: true
    models: [anthropic:claude-sonnet-4-5]
    max_run_cost_usd: 25.0
    system_prompt: .pi/roles/orchestrator.md
    tools: [read, bash, handoff, end]

  - name: implementer
    max_visits: 3
    max_session_cost_usd: 5.0
    models:
      - model: anthropic:claude-opus-4-5
        effort: high                       # explicit; effort defaults to "medium" when omitted
      - openai:gpt-4o                     # legacy shorthand → { model, effort: "medium" }
    system_prompt: .pi/roles/implementer.md
    tools: [read, edit, write, bash, handoff, end]

  - name: reviewer
    max_visits: 3
    system_prompt: .pi/roles/reviewer.md
    tools: [read, grep, handoff, end]

Optional shadow-only delegation advisory

An operator may opt into bounded, advisory-only TypeSafe judgments by adding this strict block to the top level of a manifest that already has a role with a delegation policy:

delegation_advisory:
  schema_version: 1
  provider: typesafe_jev
  model: jev-latest
  mode: shadow
  max_parallel: 4
  request_timeout_ms: 5000
  max_attempts: 1

Add a short description (1–500 characters) to each allowed subagent profile to make it eligible for profile-fit comparison. For example, add this field to an existing subagents: entry:

- name: api-implementer
  description: Implements one bounded API contract and its tests.

Profile fit is asked only when the parent allows at least two profiles and each allowed profile has a description; the host never uses system_prompt as a substitute. The advisory records never affect admission, prompts, scheduling, child-result normalization, or routing. Read the operator disclosure before enabling the block; it details the outbound data boundary and rollback.

3. Write role prompts

Each role's system prompt is a plain-prose .md file at the declared system_prompt path. The host loads it via DefaultResourceLoader({ systemPromptOverride }) and feeds it to the role's session. See the shipped defaults at tests/fixtures/default-conductor/.pi/roles/. A role prompt tells the role which tools it has, what its legal handoff target is, and whether it may request completion. The host force-injects both handoff and end into every role; workers return through handoff, while only the orchestrator can finalize a run.

Accepted handoffs deliver structured fields to the recipient, including returning orchestrators, and retain them across restart. The complete JSON payload is limited to 64 KiB of UTF-8; an oversized payload receives a validation error that the role can correct in the same session. Use concise public artifact locators and hashes for larger evidence. Artifact declarations still require the existing host collection and delivery checks. Older run records retain reason-only delivery where structured fields were not persisted.

A minimal starter bundle is available programmatically:

import { getDefaultBundle } from "pi-conductor";
const { yaml, prompts } = getDefaultBundle(); // default conductor.yaml + orchestrator/worker prompts

4. Run

Inside any pi session in a project with .pi/conductor.yaml:

/conduct ship the changelog for the auth refactor

You'll see the conductor's status line update as the orchestrator dispatches to workers; while a role session is active, the footer also shows model=<provider:id> · effort=<level> (or model=<default> · effort=medium on the system/default model path) for the current worker. The run reaches a terminal state and notifies with the run_id, and /conduct:list shows the same model and effort tokens for active runs. While the run is active, Esc opens a confirmation dialog; confirming aborts the run just like /conduct:abort. Use /conduct:steer to redirect the addressable active role, or /conduct:followup to carry guidance across the next handoff. /conduct:copy copies the latest completed assistant response without tool summaries and remains available for the most recently completed run in the current pi process.

Documentation

The reference material is split into focused pages:

Status & what's left

Full status is tracked in the authoritative specs:

Architecture in brief

checkpoint + event + def (pinned manifest snapshot)
            │
            ▼
        reduce()  ── pure, deterministic, host-agnostic (src/core)
            │
            ▼
   transition record + new checkpoint
            │
            ▼
   host persists record + snapshot, spawns next role (src/host)
            │
            ▼
   ┌────────┴────────┐
   ▼                 ▼
   bin/conduct    extensions/conduct.ts
   (CLI)          (pi extension /commands)

The full architecture rationale and invariants are in docs/architecture.md and the authoritative docs/archive/orchestrator-fsm-spec.md.

Repo layout

src/
  core/         FSM types + reducer + lifecycle + targets + run-memory (no pi)
  manifest/     manifest types + parse + validate + toMachineDefinition
  seam/         TypeBox emission schemas + validateEmission
  cost/         pure usage roll-up + cap predicates
  persistence/  RecordLog interface + InMemoryRecordLog
  host/         SDK driver — the ONLY place that imports pi (engine)
  extension/    UX shell helpers — wraps src/host for the extension
                (may import pi; mirrors src/host/ posture)
  bin/          conduct CLI fallback (built to dist/bin/conduct.js)
  index.ts      public barrel
extensions/
  conduct.ts    pi extension entrypoint (loaded by pi via jiti)
tests/
  *.test.ts              unit + E2E (stub-provider-driven; no API key)
  grep-guard.test.ts     asserts src/core + src/manifest (+seam/cost) have zero pi imports
  package-metadata.test.ts asserts pi extension manifest + peer-dependency posture
docs/
  archive/orchestrator-fsm-spec.md    the spec (authority)
biome.json            # linter + formatter (replaces ESLint + Prettier)
lefthook.yml          # git hooks: pre-push runs lint + typecheck + tests
pnpm-workspace.yaml   # pnpm config + supply-chain hardening (camelCase keys)

License

MIT — see LICENSE.