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.18.0
Published
Aug 26, 2026
Downloads
1,344/mo · 393/wk
Author
lynellf
License
MIT
Types
extension
Size
2.8 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.


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

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 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. --log-dir <path> selects the persistent run-log directory and creates missing parents. --json reserves stdout for one versioned terminal JSON document; prompts, warnings, and diagnostics use stderr. 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.

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


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]

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.

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.

RoleConfig fields

Field Applies to Meaning
name all roles Role identity (the Role the reducer keys on).
is_orchestrator exactly one role Marks the hub. Workers hand back to it; only it may emit end.
max_visits workers Per-worker visit cap (finite). Uncapped workers are a hard manifest error (§13).
models any role Ordered [primary, ...fallbacks]. Each entry is a provider:id string (shorthand for { model, effort: "medium" }) or an object { model, effort }. Bare aliases are rejected (§13). Effort values: `off
max_session_cost_usd any role Per-invocation cap, shared across model fallbacks within that invocation (§8.1, §11.7).
max_run_cost_usd orchestrator only Run-level cap. Rejected on workers (§13).
system_prompt any role Path to a per-role system-prompt file the host loads. Plain prose, not frontmatter.
tools any role Declared tool allowlist. handoff and end are force-injected by the host regardless; omitting them emits a §13 warning. delegate is available only when it is listed here and the role declares delegation. See Tools available to roles below for the full tool model and the tools:-omission footgun.
delegation parent roles only Enables bounded worktree subagents for this role. Requires tools: [..., delegate]; see Worktree subagent delegation below.

The optional top-level end_request_roles list enables gated completion. It must contain one or more unique declared worker roles—never the orchestrator. When omitted, legacy behavior is preserved: the orchestrator may call end without a pending request. When configured, an authorized worker must first handoff to the orchestrator with status: complete and request_end: true. That approval is single-use: it is consumed by end and cleared if the orchestrator dispatches more work. Run-cost-cap forced closure remains legal without a request and still passes through the reducer.

version is a human-bumped integer, pinned at run-start and never mutated mid-run (spec §10). resumeRun rejects a manifest whose version disagrees with the snapshot's pinned version.

Tools available to roles

Each role session gets tools from two sources, and the manifest's tools: field is an explicit allowlist, not an extension of pi's defaults:

1. Conductor-defined machine-event tools — always on, force-injected.

handoff and end are defined by pi-conductor (TypeBox schemas in src/seam/, factories in src/host/tools.ts) and registered as customTools on every role session. They are added to the allowlist regardless of what tools: declares (§8.1); omitting them from tools: emits a §13 warning but does not disable them.

  • handoff — terminate this role's session and route to another declared role. Workers may only hand off to the orchestrator; the orchestrator may hand off to any declared worker (subject to visit caps, §7.3). Every model-emitted handoff must include a non-empty actionable envelope: status (ready, blocked, or complete), objective, summary, and requested_action, alongside target_role: Role. reason and suggests_next: Role remain optional (the latter is workers-only and non-binding). request_end?: boolean defaults to false; it is valid only for a role named in end_request_roles handing back to the orchestrator with status: complete. An incomplete or unauthorized envelope returns an actionable error without advancing, persisting an accepted transition, or sealing the role session, so the role can correct it immediately.
  • end — terminate this role's session and declare the run complete. Legal only from the orchestrator (§7.2). With end_request_roles configured, a normal end additionally requires a pending authorized request. A worker calling end produces a transition_rejected record with legal_targets surfaced. Args: optional reason: string.

Both tools only validate and record intent into a per-session capture buffer and return a terminating message after a valid capture; they do not call reduce and do not persist — the loop owns those exclusively (§12.1). An incomplete handoff is the exception: it records a host-observable validation failure and returns a non-terminating correction prompt. After a role's first valid handoff/end capture, the session is sealed: every other tool short-circuits, so work-after-handoff cannot mutate the workspace.

2. Shared SDK pass-through and isolated machine tools.

For shared roles only, Pi-registry names selected by tools: are passed to createAgentSession({ tools: [...] }); pi-conductor separately registers its conductor-owned tools.

Isolated worktree and copy roles instead run a package-local pi --mode rpc process with pi's built-in tools disabled. A host-loaded static machine-tools extension provides handoff and end, plus only declared, path-confined file tools; it also provides the host-mediated delegate bridge when authorized. Other declared names do not receive the shared SDK pass-through registry.

For shared SDK roles, pi's built-in tool set (the authoritative reference is pi's own documentation — see the links below; pi-conductor does not redefine it) is, as of pi 0.79.x:

  • On by default (4): read, write, edit, bash.
  • Additional built-in read-only tools, opt-in via tools: (3): grep, find, ls.

Extension-registered or custom tool names the shared host pi session makes available may also be named in tools:.

Note the interaction with the shared-SDK tools:-allowlist footgun below: because pi-conductor treats tools: as an explicit allowlist (not an extension of pi's defaults), a shared role that wants standard file/shell access must name read/write/edit/bash explicitly — they are not inherited just because pi enables them by default in a plain pi session. grep/find/ls likewise must be named to be available.

Reference — pi's tool documentation (the authority on the built-in set; pi-conductor is a shared-SDK pass-through consumer):

  • pi Quickstart — tools (the "By default, pi gives the model four tools" statement + the opt-in read-only tools).
  • pi SDK reference — tools (the createReadTool / createWriteTool / createEditTool / createBashTool / createGrepTool / createFindTool / createLsTool factories, the tools / excludeTools / noTools options, and custom-tool registration via customTools / pi.registerTool).

The same files ship inside the installed @earendil-works/pi package at packages/coding-agent/docs/quickstart.md and packages/coding-agent/docs/sdk.md.

Footgun — shared-SDK tools: is an explicit allowlist, not a default-extension. It selects the shared role's non-machine Pi tool names; it does not add pi's four-tool default (read/write/edit/bash). A shared role that omits tools: has no file or shell access, and no §13 warning fires (the §13 check only triggers when tools: is present but missing handoff/end). handoff and end remain conductor machine tools. Declare every tool a role actually needs.


Worktree subagent delegation

Yes: delegate is a host-provided tool, but only a role that explicitly opts in receives it. It is not an FSM transition and subagents are not conductor roles: the parent remains responsible for reviewing the result and deciding whether to integrate a child branch.

Configure a parent and profiles

Add delegate and a delegation policy to the parent role, then define the named child profiles at top level:

version: 1
roles:
  - name: implementer
    max_visits: 3
    models: [anthropic:claude-sonnet-4-5]
    system_prompt: .pi/roles/implementer.md
    tools: [read, grep, edit, write, bash, handoff, end, delegate]
    delegation:
      allowed_subagents: [api-implementer, test-writer]
      max_children_per_session: 6
      max_parallel: 2

subagents:
  - name: api-implementer
    models:
      - model: anthropic:claude-sonnet-4-5
        effort: high
    max_session_cost_usd: 2.00
    system_prompt: .pi/subagents/api-implementer.md

  - name: test-writer
    models: [anthropic:claude-sonnet-4-5]
    max_session_cost_usd: 1.00
    system_prompt: .pi/subagents/test-writer.md

allowed_subagents must name declared profiles without duplicates. max_children_per_session is the total child-task allowance for one parent session; completed children do not free a slot. max_parallel bounds concurrent children and cannot exceed that allowance. Profile names cannot collide with FSM role names. Bump version when changing this policy or a profile.

The child profile's system_prompt is a normal prompt file. Tell it to make a focused change, run appropriate verification, commit a clean result in its worktree, and call report_result. The host supplies the child task and its worktree path; do not put parent transcripts or FSM routing instructions in the child prompt.

Ask the parent to delegate

The enabled parent calls delegate with one or more independent tasks:

{
  "tasks": [
    {
      "id": "api",
      "subagent": "api-implementer",
      "objective": "Add the endpoint validation described in issue 42.",
      "expected_output": "A committed implementation and relevant unit tests."
    },
    {
      "id": "tests",
      "subagent": "test-writer",
      "objective": "Add edge-case coverage for the endpoint contract.",
      "expected_output": "Committed tests and the test command used."
    }
  ]
}

Task IDs match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$; objective and expected_output must each be 1–8,192 characters. The entire batch is validated before any worktree is created. Delegation requires a clean primary checkout (git status --porcelain=v1 --untracked-files=all) and a resolvable HEAD; commit or stash ordinary and untracked changes first.

The tool waits for all children and returns results in input order. Each result contains its authoritative status, branch, worktree path, base/head commits, session file, usage, summary, and any failure reason. completed requires verified uncommitted changes in the child worktree; no_changes requires a clean worktree at the batch base. A completed report without changes becomes no_changes; an unexpected commit or invalid Git state becomes failed.

Projection-aware child authority (Issue #52)

Delegation remains concurrency inside one active parent role; it does not create parallel FSM roles. A task may narrow its child workspace to exact files that are currently materialized in the clean parent workspace:

{
  "tasks": [
    {
      "id": "parser",
      "subagent": "api-implementer",
      "objective": "Implement the parser change.",
      "expected_output": "A focused parser diff.",
      "projection_paths": ["src/parser.ts", "tests/parser.test.ts"]
    }
  ]
}

projection_paths is an optional array of 1–64 safe repository-relative file paths. Conductor captures the parent’s Git H (materialized) paths at its clean base commit, rejects duplicates, unsafe paths, and paths outside that captured set, and creates no child worktree when batch validation fails. For a sparse parent, omitting projection_paths explicitly inherits its full materialized set; for a non-sparse parent, omission retains the legacy full-child-worktree behavior. An explicit subset is always applied and rechecked before the child session starts.

A child cannot use request_files, delegate, or a shell. It cannot expand its own projection: the parent must decide whether to disclose more context before or in a later delegated batch. This keeps child authority monotonic even when siblings run concurrently.

The run log records each accepted child's parent_role, parent_visit_index, and effective projection_paths in subagent_started. Rejected batches append a delegation_validation_rejected record with the parent identity, task IDs, and typed validation errors; no child lifecycle record is created for such a batch.

Child boundary and branch integration

Each child receives only read, grep, find, ls, edit, write, and report_result, rooted in its generated worktree. Every child file tool rejects absolute paths, .. traversal, and paths that resolve through a symlink outside that worktree; this is path confinement, not an OS or credential sandbox. Children cannot call run, bash, handoff, end, ask_user, or delegate.

The parent receives the worktree path and branch, then owns testing, formatting, builds, Git inspection, commits, and integration. For example, it may run pnpm --dir <worktree_path> test, inspect git -C <worktree_path> diff, and commit accepted changes. The conductor never performs those actions automatically.

The host creates conductor/<runId>/<childId> and keeps both branch and worktree under the run state directory. It never merges, cherry-picks, resets, deletes, or automatically cleans up a child branch. After reviewing a successful result, the parent or operator explicitly verifies, commits, and integrates it, for example:

pnpm --dir <worktree_path> test
git -C <worktree_path> diff
git -C <worktree_path> add --all
git -C <worktree_path> commit -m "Implement delegated task"
git cherry-pick conductor/<runId>/<childId>

Worktree confinement is a path-control boundary, not an OS, network, credential, or process sandbox. Child failures do not cancel siblings. A run abort cancels active children and then the parent; resume marks in-flight children as cancelled (recovered_child_lost) rather than relaunching them.


Per-role isolated workspaces (Issue #48)

Conductor supports three workspace modes: shared (the default), worktree, and copy. A role with an isolated workspace has file tools rooted in its provisioned role workspace; it can reach other paths only through declared mounts.

Configuration reference

roles:
  - name: implementer
    max_visits: 3
    tools: [read, grep, edit, write, handoff, end]
    workspace:
      backend: worktree            # worktree | copy; omit the block for shared
      mounts:
        - path: .campaign          # relative to the role's snapshot checkout
          writable: false
        - path: /data/out          # absolute host path
          writable: true
    artifacts:
      auto_patch: true             # default true for worktree; false for copy
      max_file_bytes: 1048576      # default 1 MiB per declared file
      max_files: 32                # default declared files per handoff

shared roles use the integration checkout. worktree creates a per-role Git worktree, while copy creates an isolated filesystem copy.

Guarantees and unavailable backend

Mode Guarantee
shared none (full integration-checkout access)
worktree / copy confined (role workspace plus declared mounts)

confined is a process and tool-surface boundary, not OS, credential, or network isolation. No available backend provides an OS-isolation guarantee.

container is unavailable. A manifest that selects backend: container is rejected with a typed WorkspaceError before host construction or run persistence; it does not fall back to another backend.

Artifact lifecycle

On an accepted handoff from an isolated role, the host collects declared, workspace-relative files from the emitting provisioned workspace. It enforces projection containment and the max_file_bytes and max_files caps, then persists artifact_collected or artifact_rejected records. Artifacts remain host-owned in the run artifact store.

The host routes only collected declared artifacts. It materializes them beneath artifacts/<emitting-role>-v<visit>/ in an isolated receiver; a shared receiver gets host-store paths in a host-generated seed inventory instead. That inventory is generated only by the host and reports unavailable declared artifacts from rejection records; its available entries are only host-collected artifacts.

Worktree roles retain host-generated auto-patches only when artifacts.auto_patch is enabled (the worktree default), but auto-patches are never routed to a receiver. The host never automatically applies an artifact or patch to the integration checkout.

Progressive file disclosure (Issue #51)

An isolated worktree role can begin with a deliberately incomplete sparse projection and ask Conductor for named, policy-approved files as it discovers a missing dependency. This is opt-in: a role without progressive_disclosure behaves exactly as the ordinary worktree configuration above.

roles:
  - name: implementer
    tools: [read, grep, edit, write, request_files, handoff, end]
    workspace:
      backend: worktree
      source: snapshot
      progressive_disclosure:
        # Present at role startup.
        initial_paths:
          - src/schema/card.ts
          - tests/schema/card.test.ts
        # Exact files or roots under which later exact-file requests are allowed.
        allowed_paths:
          - src/schema
          - tests/schema

Both path lists are required, repository-relative literal paths. Absolute paths, traversal, backslashes, duplicate entries, and Git-pattern syntax are rejected when the manifest loads. Progressive disclosure requires backend: worktree; shared and copy roles cannot enable it. The role gets request_files only when it declares both the policy and the tool.

While active, the role calls the host-provided TypeBox tool:

request_files({
  paths: ["src/schema/card.ts", "tests/schema/card.test.ts"],
  reason: "conditions.ts imports CardSchema, which is absent from this projection.",
});

Each request names one or more exact regular files. Conductor checks every path against the policy and the run's pinned Git snapshot before changing the workspace. An allowed_paths root permits exact file requests below that root, but does not disclose sibling files or a directory tree. A request returns a typed result: approved (with disclosed_paths), denied (invalid or unauthorized path), or unavailable (absent from the pin or a workspace failure). Denied and unavailable requests leave the existing projection unchanged.

Disclosure does not expand write authority. Existing confined file-tool and mount write rules still apply: a read-only role receives disclosed files read-only, and disclosure never grants access to unrequested files. Every request is appended to the run log as a progressive_disclosure record with the role, visit, requested paths, reason, outcome, and paths actually disclosed.

Retention

Workspaces, snapshot checkouts, and artifacts are retained for inspection: there is no automatic cleanup, merge, or deletion. The operator may remove a worktree manually when space is needed.


Advanced: library use

The pure FSM core + SDK host driver are importable as a library. The public API:

import {
  startRun,
  resumeRun,
  listRuns,
  createProductionHost,
  type Host,
  type HostFactoryContext,
  getDefaultBundle,
} from "pi-conductor";

const handle = await startRun(".pi/conductor.yaml", {
  goal: "Ship a changelog for the auth refactor.",
  hostFactory: (ctx: HostFactoryContext) =>
    createProductionHost({
      extension: { modelRegistry: /* pi's ModelRegistry */, cwd: process.cwd() },
      run: { log: ctx.log, loadedManifest: ctx.loadedManifest, runId: ctx.runId },
    }),
});

const { finalCheckpoint, exitReason } = await handle.completion();

While the run is live, library consumers can use the same control state as the extension:

await handle.steer("Check the migration rollback path before continuing.");
await handle.followUp("Include the final verification commands in the response.");

const latest = handle.latestResponse();
console.log(latest?.role, latest?.text);

steer targets an addressable active role or queues at a role boundary. followUp always queues for the next conductor prompt, so it follows a handoff. latestResponse() returns assistant text and readable displayed reasoning while excluding tool summaries. Clipboard access remains a UI concern.

Host is the seam between the pure loop and the pi SDK. It owns session creation, event subscription + usage accumulation, the run-keyed log, and per-session state. You can also implement a custom Host against the interface in src/host/host.ts (six methods: spawnRole, captureUsage, persistRecord, seedRunMemory, abortSession, sealSession, plus nextVisitIndex, sessionTerminalReason, getNextModel, runCostSoFar).

The CLI is a thin example of this: src/bin/conduct.ts calls startRun with a hostFactory that builds a ProductionHost from a fresh ModelRegistry. Read it for a self-contained integration example.


Hooking into the record stream

pi-conductor persists every machine event, lifecycle event, and checkpoint snapshot to a per-run JSONL log on disk — the durable system of record. It also exposes a typed, in-process emitter that fans out the same records to separately installed extensions in the same pi process. The emitter is a read-side extension point: pi-conductor ships zero upload code, zero network code, zero server config — just a public function a consumer can call to register a listener and receive every record the host persists.

The intended consumer is a separately installed pi extension living in ~/.pi/agent/extensions/ (per the pi extensions spec, "Extensions" — auto- discovery). It is not part of pi-conductor and is not published alongside it. The consumer owns auth, retry, batching, backpressure, and the durable replay of anything the listener missed. The spec's only contract is subscribeToRecords and the durable log.

The public API

A single module-level function exported from pi-conductor's public barrel:

import {
  subscribeToRecords,
  type PersistedRecord,
} from "pi-conductor";

const unsubscribe = subscribeToRecords((record: PersistedRecord) => {
  // Do whatever you want with the record. The host fires listeners
  // fire-and-forget — async listeners are NOT awaited. Errors thrown
  // from a listener (sync or async) are isolated and do not affect
  // the engine or other listeners.
});

PersistedRecord is the union from src/persistence/log.ts: transition_accepted, transition_rejected, session_started / session_ended / session_failed, model_fallback, checkpoint_snapshot, and delegation's subagent_started, subagent_completed, and subagent_failed records. The emitter is a transparent fan-out of what the host persists.

The contract — FIFO subscription order, fire-and-forget async delivery, sync-throw and async-rejection isolation, re-entrant subscribe / unsubscribe (effects take place on the next record), idempotent unsubscribe, and the durable backstop pattern — is in src/host/record-emitter.ts (the authority).

A consumer extension

A minimal separately-installed extension at ~/.pi/agent/extensions/conductor-uploader.ts:

import {
  subscribeToRecords,
  type PersistedRecord,
} from "pi-conductor";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  const serverUrl = process.env.CONDUCTOR_UPLOAD_URL;
  if (serverUrl === undefined) {
    pi.events.once("session_start", (_e, ctx) => {
      ctx.ui.notify(
        "conductor-uploader: CONDUCTOR_UPLOAD_URL not set; extension disabled",
        "warning",
      );
    });
    return;
  }

  // Live delivery: every record the host persists goes to the server.
  subscribeToRecords(async (record: PersistedRecord) => {
    const res = await fetch(serverUrl, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(record),
    });
    if (!res.ok) throw new Error(`upload failed: ${res.status}`);
  });

  // Backstop: on session_start, walk the log dir to ship records the
  // listener might have missed (consumer's watermark strategy lives in
  // this extension's own state file; the durable log is read via the
  // FileRecordLog implementation in pi-conductor's persistence module).
  pi.events.on("session_start", async () => {
    /* replayMissedRecords(serverUrl) — consumer-private */
  });
}

This sketch is informative only. The full consumer concern — auth, retry, batching, watermark, and error policy — is the consumer's responsibility. The spec's only commitment is that subscribeToRecords and the durable log are sufficient to recover any record the emitter might have missed.

The optional pi.events bridge

The pi-conductor extension additionally re-emits every record to pi's documented event bus:

pi.events.on("conductor:record", (record) => { /* ... */ });

This is a thin wrapper over subscribeToRecords in extensions/conduct.ts for consumers that prefer the pi.events API. Consumers that import subscribeToRecords directly do not need the bridge. See the spec §8.5 note in src/host/record-emitter.ts for the rationale.

What this is not

  • No upload code in pi-conductor. No HTTP, no fetch, no network primitives. The grep-guard test (tests/grep-guard.test.ts) scans src/host/ for the @earendil-works/pi-coding-agent import allowlist; the emitter does not change that surface.
  • No server config, no auth, no URL. The consumer owns all of that.
  • No batching, no debouncing, no rate-limiting in the host. The host fires every record to every listener. The consumer is free to batch on its side.
  • No promise of guaranteed delivery. The emitter is best-effort; the durable JSONL log is the system of record for missed-record recovery.
  • No cross-process or cross-host coordination. Each pi process has its own registry. The consumer is responsible for cross-process de-duplication (typically by record index or hash, using the per-run JSONL file as the source).
  • No emitter-specific record types. The emitter is a transparent fan-out of the existing PersistedRecord union, including delegation records when delegation is enabled.
  • No change to the orchestration loop. The host's persistRecord is the chokepoint; the loop does not need to know the emitter exists.

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)
  • def: MachineDefinition is the pinned manifest snapshot. reduce / reduceLifecycle are pure functions of (checkpoint, event, def, meta) — no ambient config, no I/O. meta.role === checkpoint.current_role is asserted inside reduce; a mismatch is thrown, not trusted.
  • Every state change goes through reduce. Cost-cap forced-close synthesizes a machine end event fed to reduce; the checkpoint is never mutated to done directly.
  • Checkpoint is snapshot-appended, never mutated in place. Resume reads the latest snapshot from the host-owned run_id-keyed log. SDK branch scoping is not used.
  • handoff/end tools only validate + record intent into a capture buffer and return a terminating message; they do not call reduce and do not persist. The loop owns reduce + persistence + spawning.
  • Post-emission sealing: once a role's first valid handoff/end capture is recorded, the session is sealed — wrapped tools refuse to execute, so work-after-handoff cannot mutate the workspace.

The full authority is docs/archive/orchestrator-fsm-spec.md.


Contributing

Prerequisites

  • Node.js ≥ 22.19.0, pnpm (matches the pi ecosystem). No npm/yarn.
  • Install: pnpm install (also installs Lefthook git hooks via an allowlisted postinstall).

Verification commands

pnpm typecheck        # tsc --noEmit (strict + noUncheckedIndexedAccess), incl. tests
pnpm build            # emits dist/ with .d.ts
pnpm test             # vitest run (incl. the grep-guard test)
pnpm lint             # biome check .  (lint + format check)
pnpm format:check     # biome format .
pnpm audit --prod     # supply-chain audit

pre-push (Lefthook) runs pnpm lint, pnpm typecheck, pnpm test sequentially; any failure blocks the push. CI runs the same three directly.

Invariants you must not break

  • No pi imports in the core. src/core, src/manifest, src/seam, src/cost, src/persistence must not import @earendil-works/pi-coding-agent. tests/grep-guard.test.ts scans source as text and will fail pnpm test (and the pre-push hook) on a violation — a TS error can never mask it. src/host, src/extension, and extensions/ may import pi (they're the only layers that bridge to the SDK). If you reach for the SDK in the core, stop; that code belongs in src/host.
  • Reducer purity. reduce / reduceLifecycle take def and read roles + caps only from it — never from imports or globals.
  • One schema, TypeBox. No Zod. The handoff/end TypeBox schemas are the single source of truth for tool args, seam validation, and the derived TS type. The peer-dependency declaration for typebox in package.json matches the name pi bundles — don't swap back to @sinclair/typebox (different package, would break tool-arg validation at runtime).
  • TypeScript strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax, ESM + NodeNext. No any. Named exports only.
  • Module size ~400 LOC ceiling. Split by responsibility before a file gets large. Readability over cleverness.
  • No silent fallbacks. Ambiguity → throw a typed error or surface a warning.
  • No ctx.newSession() / ctx.fork in extensions/. Shared roles use standalone createAgentSession; isolated worktree and copy roles use host-owned package-local pi --mode rpc processes. Neither path uses the extension session tree. tests/extension/no-role-spawn-via-session-tree.test.ts greps for these calls.

Phases gate each other

Work is sequenced in phases; don't start the next phase until the current one is green and its plan checkboxes are ticked. Per-phase human review is not a gate — the overseer reviews specs up front and gives feedback at the end of the loop (see Operating model in AGENTS.md). Touch only what your task asks for; surface assumptions before implementing; if a task is non-trivial and no spec exists, write one. See AGENTS.md for the full working agreement.

Supply chain (pnpm)

Project config lives in pnpm-workspace.yaml (camelCase keys, pnpm 10 canonical). Hardening: minimumReleaseAge: 10080 (only versions ≥ 7 days old), strictDepBuilds: true (build scripts must be allowlisted under onlyBuiltDependencies), excludeLinksFromLockfile: true. pnpm-lock.yaml is committed; CI installs with --frozen-lockfile. Never set dangerouslyAllowAllBuilds; adding a dep with a build script requires an allowlist entry with a one-line justification.


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.