A minimal multi-agent rig layer built on @earendil-works/pi-durable.
Started 2026-10-07 as the candidate replacement for OpenRig after a
six-way comparison survey (see ../docs/ORCHESTRATION-COMPARISON.md).
Seats (named agent conversations), a durable cross-seat work queue with an append-only audit trail, org-edge send permissions, capability tags, and a constitution injected into every seat's system prompt. All state lives in pi-durable documents changed atomically with the transcripts that produce them.
cd durian-pi
npm install --ignore-scripts
# offline smoke (no keys, no network)
node demo/offline.mjs # full delegation loop
node demo/offline-v2.mjs # edge + capability gate
# operate a rig (see docs/QUICKSTART.md for the guided walkthrough)
node cli.mjs up rig.v2.json # bootstrap seats (idempotent); a TTY also starts the broker (--no-serve opts out)
node cli.mjs ps # roster
"hello" | node intent.mjs pm --body-file - # steer a seat via the spool; the broker delivers (reply in log/status)
"hello" | node intent.mjs pm --body-file - --request-id r-1 # retry-safe: same id, one submission
node cli.mjs queue # dump work queue
node cli.mjs status # per-seat health + attention notices
node cli.mjs run # drive the queue to quiescence once
node cli.mjs serve [--max-duration <s>] # resident broker: keep waking seats on store changes; --stop reaps it
node cli.mjs watch # live NDJSON stream of queue + attention
node cli.mjs watch --once # one snapshot line, then exit
node cli.mjs log pm 20 # tail a seat transcript
node cli.mjs check rig.v2.json # audit a rig against the research gaps
node cli.mjs metrics rig.v2.json # drift measures (bands, baselines, blind spots)
# real model traffic (needs DEEPINFRA_API_KEY in env)
node demo/online.mjsThe queue keeps moving on its own while you work because a resident broker
(serve) owns the store and wakes each seat that has queued work — no external
clock (D45). How it is switched on and off for the operator:
- A TTY
upauto-starts it. Runningnode cli.mjs up <rig.json>on an interactive terminal starts the broker for that database. Opt out with--no-serve, or with the env varDURIAN_PI_NO_SERVE=1(the script-wide opt-out). A piped or scriptedup, and scripted write verbs, never silently spawn a daemon — the TTYupis the only auto-start. Proof:demo/autostart.test.mjs(TTY auto-starts;--no-serve/piped opt out;runand a secondservefail closed;up --serveis idempotent). serve --stopreaps it —node cli.mjs serve --stopsettles the running broker cooperatively and clears<db>.broker.json, so the nextrunis allowed again. Proof:demo/autostart.test.mjsanddemo/broker-cooperative-stop.test.mjs.- Raising
DURIAN_PI_MAX_CONCURRENT_SEATSis how parallel seat work is enabled. The broker may drive up toNseats at once, butN = DURIAN_PI_MAX_CONCURRENT_SEATS(Ncross-process generation leases viasrc/genlock.mjs). Unset = unlimited;1serializes all generations (e.g. one local model serving the whole rig); a higherNlets more seats generate in parallel, each in its own worktree. Proof:demo/singleflight.test.mjs(two concurrent seats,max 1in flight at the cap). - One broker per database. A second
serverefuses to start, andrunrefuses while a broker drives that database (two drivers double-wake, D46). For genuinely independent work use separate databases. Proof:demo/autostart.test.mjs.
check reads the rig file and the store, because a rig that looks right in
the file can still be running something else: a rig spec declared its
constitution inline while the store held a different one, so its seats ran
without the team culture and nothing anywhere reported it.
node cli.mjs check rig.starter.json --human # readable report
node cli.mjs check rig.starter.json # JSON (the contract)Every finding carries a stable rule id, a subject, and the exact fix. Severity
says who can fix it: error means the rig cannot do its job until the file
changes, and check exits nonzero so a script can gate on it; fix-now is a gap
the operator can close, and never fails a run. Rules: constitution-missing,
constitution-not-applied, unreachable-seat, dangling-edge,
store-missing-seat, stray-seat, no-verifier-seat, single-model-rig. A
rule it cannot judge (no store to declare whether a matched, or an unread
database) is reported as skipped with its reason, never as a pass.
The two seat rules are the two directions of one disagreement, and they are not
symmetric. store-missing-seat is an error because a seat the file declares
and the store does not hold cannot receive work at all. stray-seat is
fix-now because a seat the store holds and the file does not declare still has
an address, and up never removes one.
Start from rig.starter.json rather than an empty file. It is the rig those
rules describe — four seats, an inline constitution, a verifier on a second model
family, an outgoing edge on every seat — so check reports no findings on a fresh
copy. Without it the rules only tell you what you did wrong, which is the half of
the job this file exists to close.
check is a pre-flight linter: you run it because you were already about to
change something. Metrics are ambient - their value is noticing something
without having asked - so they ride the attention channel status and watch
already use instead of claiming a screen of their own. Nothing is drawn while
everything is in band, because nine measures permanently on screen stop being
read by the second day.
node cli.mjs metrics rig.starter.json --human # the deliberate table
node cli.mjs metrics rig.starter.json # JSON (the contract)
node cli.mjs metrics rig.starter.json --window all # widen past the scan boundFour measures ship: step-repetition (direct), unverified-completions (proxy),
queue-ageing (direct) and shared-reviewer-model (config-derived). Every value
travels with its band, baseline, evidence class and blind spot, because a
clean reading whose blind spot is unwritten reads as a pass. A value it cannot
judge says so rather than guessing: no baseline yet and no data are states of
their own, and no tool calls is not "0% repetition".
The rig file is optional but makes the config-derived measure judgeable, since
the store does not record which model a seat runs. Design, the honesty traps and
the admission criteria for the ambient tier: docs/METRICS.md.
A reading also says what was live when it was made — the revision, whether the tree was dirty, and the active extension set:
config rev fb0007e clean · active: durig.router, ./extensions/timing.ts
That is what makes "the config changed and this measure moved" an inference
rather than a coincidence. A dirty tree is called out rather than hidden, because
the revision then does not describe what ran, and no repository at all reads as
unknown rather than clean. Provenance is only reported: git is already the
append-only change log, so this adds no write path and no stored state.
| File / env | Purpose |
|---|---|
rig.starter.json |
Start here for a real rig. The hardened 4-seat starter: inline constitution, a verifier seat on a different model family, and an outgoing edge on every seat — so check reports no findings on a fresh copy. Generic by design; no corpus is baked in. Proof: demo/starter.test.mjs |
rig.v2.json |
Rig spec: seats (name, role, goal, constraints, backstory, capabilities, model — bare id or {provider}/{modelId}, e.g. deepinfra/meta-llama/Llama-3.3-70B-Instruct; a leading segment is the provider only when it names a registered provider, so slash-containing ids like zai-org/GLM-5.3-Flash work bare —, instructions), edges (send permissions), constitution (file path) or constitutionInline (text; wins if both are set) |
constitution.txt |
Priority-ordered team rules; highest applicable rule governs |
DURIAN_PI_DB |
Storage path (default ./rig.sqlite) |
DURIAN_PI_PROVIDER |
deepinfra (default) or faux for offline runs |
DURIAN_PI_MODEL |
Default model id for seats without their own |
DEEPINFRA_API_KEY |
Ambient credential; read from env only, never stored |
DURIAN_PI_OPTIMIZE |
on: up runs each seat's persona through the instruction optimizer (one model call per seat) before storing; provenance recorded. See D13. |
DURIAN_PI_MAX_CONCURRENT_SEATS |
Cap concurrent GENERATIONS across all seats (unset = unlimited). 1 serializes them so one local model (e.g. Qwen on a 24 GB GPU) can serve the whole rig. Waiting-vs-stuck is surfaced via lease files: status/watch report generation_in_flight (holder seat + since) or generation_stale (dead holder). See DECISIONS D12. |
DURIAN_PI_EXTENSIONS |
Comma-separated module paths to install as extensions. See "Extensions" below. |
DURIAN_PI_STREAM_TIMEOUT_MS |
Total request timeout for one generation attempt, in milliseconds. Unset = the platform default (the openai SDK's 600000 ms). Raise it for a slow local model, or lower it to fail fast instead of waiting out ten minutes on a stalled endpoint. A non-positive or non-numeric value fails the command rather than silently taking the default. |
Deprecated aliases (D103). The pre-rebrand DURIG_* env-var names and the old durig.config.json
filename are still accepted by the loader as deprecated aliases; the new DURIAN_PI_* name wins when both are set.
The intent path is retry-safe: node intent.mjs <seat> --body-file <file> --request-id <id>
spools one submission, so a retry carrying the same id reuses the existing submission
instead of starting a second generation (the harness dedupes by conversation + request-id;
src/broker.mjs writeIntent). An id is never derived from the text, because two deliberate
identical intents are two requests. Without an id nothing changes.
durian-pi installs its own Router as a pi-durable extension, so extensions are not
a new mechanism — they are the one this already uses. Point DURIAN_PI_EXTENSIONS at
modules that export an extension:
// extensions/timing.ts — a complete, working extension
import { defineExtension, section } from "@earendil-works/pi-durable";
export default defineExtension({
name: "timing",
// sections reach every request's system prompt
sections: [section("timing-note", () => "Report elapsed ms for slow tools.")],
});demo/fixtures/echo-extension.mjs is a real one of these, and
demo/extensions.test.mjs loads it and proves its section reaches a seat's model.
An extension may declare sections, tools, hooks, tasks and wraps — see
Pi Durable's write-up for what each does.
Because a seat conversation stores no selection, everything installed is active
for every seat; per-seat tool scoping is a planned refinement (ROADMAP item 18).
node cli.mjs up <rig.json> reports the names it installed.
Two things to know before you add one:
- It is code, running in-process. An extension gets the harness API — commits, tasks, conversations, prompts — and its tools inherit this process's OS permissions. Paths are never discovered automatically; listing one is trusting it, exactly as you would a dependency.
- This is a pi-durable extension, not a pi coding-agent one. Those target
the interactive agent (slash commands, a TUI, project-trust prompts) and cannot
load here: durian-pi does not depend on
pi-coding-agentand has no such surface (D21).
A path that cannot load fails the command rather than being skipped. A declared extension quietly missing is the same failure shape as a dropped constitution: the rig looks configured and runs as something else.
Provider options are imported from src/providers/deepinfra-models.ts — a vendored
copy of the module the pi-deepinfra extension feeds to pi, so catalog and
serializer compat stay single-sourced. Node 24 type-strips that TS module on
import. See src/providers/PROVENANCE.md for the source, version and licence.
src/rig.mjs core: Seats/Edges/Constitution/RigQueue docs + Router tools
src/attention.mjs pure blocked-seat detection (feeds status + watch)
src/checkup.mjs pure rig audit rules + the human report (`cli.mjs check`)
src/metrics.mjs pure drift measures + the deliberate table (`cli.mjs metrics`)
src/ansi.mjs the one ANSI palette the human views share
src/config.mjs env > durian-pi.config.json > defaults; `cli.mjs config`
demo/cli.test.mjs the CLI contract: every command, exit code, error path
demo/watchview.test.mjs the human watch view: extraction, layout, wiring
src/rigstory.mjs read each seat's latest words + tool call from storage
src/watchview.mjs render a rig snapshot for a human (TUI pair to watch)
src/chatview.mjs render one seat's conversation for a human
demo/chatlog.test.mjs chat rendering + --follow streaming
demo/constitution.test.mjs a spec's constitution reaches the rig (inline or file)
demo/check.test.mjs rig audit rules + the drift regression + CLI exit codes
demo/metrics.test.mjs drift measures + what a reading refuses to claim
demo/converter.test.mjs OpenRig rig.yaml -> v2 spec: rig-level edges, ids, personas
docs/STATUS.md start here: current state, decisions needed, resume facts
docs/FEATURES.md what durian-pi does today, where each piece lives, and its proof
docs/ROADMAP.md milestones + the research gap analysis
docs/METRICS.md metrics design: measures, surfacing, scope, traps (first cut built)
docs/DOGFOOD.md dogfooding log: what we ran on durian-pi, what broke, where it lands
docs/REVIEW-pi-durable-demos.md what the two pi-durable example projects show
durian-pi: ideas, gap analysis, patterns and hazards
docs/PI-DURABLE-INTEGRATION.md how durian-pi builds on pi-durable: what the harness
owns, what durian-pi adds, and the pin contract
docs/WORKFLOWS.md what durian-pi is for: the workflows, their must-not-break loops,
and the capability each one needs
docs/SEAT-CAPABILITY-PROFILE.md per-seat tool surface, org-edge send targets, and doc-edit
reach — so an assignment names a mechanism the assignee has
docs/Managing_Long_Context_Length.md long-context research: context rot, effective
windows, when to compact — the basis of ROADMAP 27-30
scripts/sync-pi.mjs build + link pi's main branch at a pinned commit
pi-main.pin.json the pinned upstream commit durian-pi is validated against
src/seatmodel.mjs per-seat {provider}/{modelId} resolution
src/optimizer.mjs instruction optimizer + preservation guard (D13, D14)
src/judge.mjs blind pairwise prompt judge (D14)
src/genlock.mjs cross-process generation slot leases (MVP #6, D12)
cli.mjs operator CLI (JSON output; LLM-friendly contract)
demo/*.test.mjs one runnable proof per guarantee:
repeat-call.test.mjs duplicate tool calls stay friendly
persistence.test.mjs state survives storage close/reopen
race.test.mjs concurrent claims: one winner, no double-assign
watch.test.mjs cross-process live stream works
blocked.test.mjs idle-with-work and failed runs surface as attention
singleflight.test.mjs generation cap holds across concurrent seats
metrics.test.mjs drift measures + the honesty states of a reading
demo/offline*.mjs faux-model delegation loops (no keys needed)
demo/online.mjs real DeepInfra end-to-end test
demo/optimizer-eval.mjs optimizer effectiveness benchmark (guard + blind judge)
demo/eval.test.mjs mechanics test for the guard and judge
scripts/from-openrig.mjs OpenRig rig.yaml -> durian-pi v2 converter (pods, per-seat role.md, skills, guidance, rig-level edges)
rig.starter.json the packaged starter rig
docs/ QUICKSTART, FEATURES, MVP burn-down, STATUS, ROADMAP, METRICS, decisions D1-D37, research
MVP complete (2026-10-09) — all six burn-down items done and proven:
seats with personas and constitution injection; durable cross-seat queue with
append-only audit trail; org-edge send permissions and capability tags;
restart-survival; concurrent-claim race safety; operator visibility via
status + streaming watch with blocked-seat attention notices; and the
single-generation-at-a-time mode for serving a whole rig from one local model.
Known gaps vs OpenRig are tracked in ../docs/ORCHESTRATION-COMPARISON.md;
the post-MVP backlog lives in docs/MVP.md.
@earendil-works/pi-durable ships from a fast-moving monorepo (14 commits past
the last release within a day, including API renames). There is no dev/nightly
npm dist-tag, so main is the dev channel and must be built from source.
pi-main.pin.json records the exact commit durian-pi is validated against. See
docs/PI-DURABLE-INTEGRATION.md for what durian-pi takes from the harness, what it
adds around it, and what the pin means for both sides.
node scripts/sync-pi.mjs # drift: is the pin still upstream's tip?
node scripts/sync-pi.mjs --sync # clone+install+build the pinned commit (~40s)
node scripts/sync-pi.mjs --link # point node_modules at the built packages
node scripts/sync-pi.mjs --update # move the pin to main's tip, then rebuild
node scripts/sync-pi.mjs --unlink # back to the released packages--link uses npm install --no-save, so package.json is never modified
and nothing un-installable-by-others can be committed. The build tree lives in
vendor/pi-main/ (gitignored, reproducible from the pin). A plain npm install
reverts to the released packages.
The tool-result field is returned as both content (1.1.0) and output
(current main) by toolText() in src/rig.mjs, so durian-pi is correct on either.
MIT — see LICENSE. The same licence Pi and Pi Durable are under, since durian-pi is
built on them.
Vendored third-party code keeps its own notice:
src/providers/LICENSE.pi-deepinfra (MIT, © 2026 puetsua) covers the DeepInfra
catalogue copied into src/providers/deepinfra-models.ts; the source, version and
the exact local changes are recorded in src/providers/PROVENANCE.md.