@bermudi/pi-delegate
Pi extension: subagent dispatch with admission, durable tickets, workspaces, and steering
Package details
Install @bermudi/pi-delegate from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@bermudi/pi-delegate- Package
@bermudi/pi-delegate- Version
0.6.1- Published
- Oct 15, 2026
- Downloads
- 2,711/mo · 978/wk
- Author
- bermudi
- License
- unknown
- Types
- extension
- Size
- 929.5 KB
- Dependencies
- 0 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./delegate.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-delegate
Subagent dispatch for the Pi coding agent.
Delegate registers three tools: delegate runs one task or a batch of tasks;
delegate_ticket operates the durable ticket a backgrounded dispatch returns
(poll, wait, cancel, answer, steer, interrupt); delegate_session lists and
closes pooled subagent sessions. Tasks run against the shared tree, a disposable
copy, or a private Git worktree. SPEC.md is the v3 behavioral contract;
COMPATIBILITY.md records deliberate breaks from earlier versions.
Quickstart
// One task or many — returns a background ticket; results arrive automatically.
delegate({
tasks: [{ agent: "explore", prompt: "Map the authentication flow" }],
});
// A batch — asynchronous by default: returns a ticket immediately. When every
// task settles, the result wakes the parent once (tickets settling in the same
// window share one wake); if the conversation branch moved, it is durably
// appended instead. Either way it stays pollable.
delegate({
tasks: [
{ prompt: "Implement the parser change", workspace: "isolated" },
{ prompt: "Update the parser tests", workspace: "isolated" },
],
});
async: false waits for inline results for any task count. Omitted async
always means background execution. tasks: [] shows the manual.
Delegation is never automatic: the parent model decides whether to call these tools, so a model that never calls them does all the work itself.
One tool surface
There is one tool surface (ADR 0002). Every field and action in this README is
advertised and accepted; there is no compact or full mode to choose. A
delegate.json that still sets surface fails at load — remove the key and
/reload. Removed fields reject with an explanation rather than being ignored
(see Dispatch).
Dispatch
A call resolves profiles, tools, workspaces and write claims before running
work. Task fields are prompt (required unless resumeFrom), id (optional
correlation key; unnamed tasks are task-1, task-2…), agent, cwd,
sessionId, resumeFrom, workspace, and dependsOn. The top-level
workspace is the default for tasks that do not set their own.
Each prompt and the top-level brief are capped at 32,768 characters.
Reference files by path instead of pasting their contents.
Tasks have no wall-clock deadline (#118): deadlineMs is removed and rejects
before execution, even when null. Stall detection remains an inactivity
watchdog (stallTimeoutMs). Cancel and interrupt stop work cooperatively.
Saved historical deadline failures remain readable.
Only canonical field names are accepted. Removed fields reject before any
worker starts, even when null or accompanied by the canonical field:
description, systemPrompt, tools, operationId, steerId, deadlineMs,
tokenBudget (batch spend is bounded by task count and concurrency, not a
caller budget), timeoutMs, and the cross-harness spellings subagent_type,
agent_type, task_name, run_in_background, timeout_ms, and a task-level
message. Each rejection says what to do instead. model, thinking, and
reasoning_effort are rejected wherever they appear: models and effort are
configured in delegate.json, never set by a call.
Top-level brief is shared batch context: it prepends to every task's prompt
inside a --- batch brief --- fence (task sections and headers note it once; it
never merges into the prompt's prose).
Delivery happens once, when the batch fully settles: tickets settling within the
same flush window coalesce into a single steering wake when the parent is still
on the dispatching branch — it starts a turn on an idle parent and merges at the
next turn boundary on a busy one — or a single durable append when the branch
moved or a navigation is in flight. A wait or poll that already returned the
same terminal view consumes the wake, so a result you already saw is not
delivered again. Delivery failure never undoes settlement — the ticket stays
pollable — and delivery suppressed at shutdown leaves it the same way.
Concurrency: maxConcurrent caps simultaneous tasks globally (default 8).
Per-model bounds work per model key — an exact concurrency.models
provider/model entry wins over concurrency.providers.<provider>, which wins
over concurrency.default, which falls back to maxConcurrent. Tasks over a
bound queue pending.
Agents
Built-in profiles:
| Agent | Tools | Purpose |
|---|---|---|
default |
mirrors the parent's delegatable tools | Mirrors the parent's model and thinking; the base prompt composes from the parent's user-authored prompt inputs. |
explore |
read, grep, find, ls |
Read-only investigation; runs fully concurrently. |
coder |
read, write, edit, bash |
Implementation in the shared workspace. |
reviewer |
read, bash |
Review that can run checks — carries bash, so it serializes as a writer. |
verifier |
read, bash |
Rules on a claim; its result carries a parsed VERDICT: line beside file evidence — reporting only, never gating. |
Agent names are exact and case-sensitive: built-ins or authored profiles, without automatic translations. Unknown names list available profiles.
Markdown profiles come from .pi/agents/*.md under the working directory first,
then <agentDir>/agents/*.md — first definition wins; built-ins win name
collisions. Frontmatter requires name and description and may set tools,
model, thinking; the body is the system prompt. .claude/agents is never
imported. An authored general.md or scout.md is an ordinary exact-name
profile, not an alias. Profile tools and body are the only capability and
base-prompt selectors; there are no per-task overrides.
Steering
delegate_ticket({ ticket, action: "steer", message }) sends text to a running
task. taskId picks the target when several run; it defaults to the only running
task, and an ambiguous call errors naming the running ids. Each steer receipts
under a key derived from its own tool call, so a transport-level retry replays
the receipt instead of injecting the message twice. Receipts:
steered— queued on a live run; the child sees it at the next turn boundary.activated— no run in flight (queued, between retries, pre-prompt); the message is parked and opens the task's next turn.duplicate— this steer already applied; nothing re-applies.not-applied— the task settled, the ticket was recovered, or nothing matches.
Steering is boundary delivery: the child's model receives the
message at run start or a turn boundary, never merged into a turn already
streaming. Parked steers void to not-applied if the task settles first;
recovered tickets always refuse steering. A whole-task retry re-supplies
the failed attempt's injected steers through the next attempt's first
turn — a receipted message is not dropped with the session that died.
Safety and admission
Admission resolves physical Git roots and real tool sets before any subagent
starts. Tasks whose tools cannot mutate (e.g. explore) hold no write claims and
run fully concurrently. Shared writers touching the same repository serialize
in task order within one call. A call whose writer overlaps a still-running
dispatch from an earlier call is rejected before anything starts — there is no
unsafe-write bypass.
Workspaces:
shared(default) — writes go to the real tree.scratch— the task runs in a disposable copy of the containing repository (reflink fast path where the filesystem supports it, full copy otherwise), discarded when it settles.isolated— a private detached Git worktree per task. Completed proposals reconcile into the source in task order, all-or-nothing; conflicts retain the proposal ref, full patch, and conflict worktree. Rejects repositories with submodules.
Both non-shared modes are one-shot: they reject sessionId and resumeFrom.
Completion evidence — results name the files each task touched: the union of
write/edit-observed paths and the changes a Git snapshot taken before and
after the run saw in the worker's repository (committed paths included via a
moved HEAD; ignored files are never covered). A shell run outside Git
coverage shows files: unknown (shell used outside git), a covered window
that saw nothing shows no files line, a window that overlapped other writers
names them, and an overlap: note flags batch tasks that claimed the same
file. Evidence records observed changes only; it is not confinement and does
not prove other paths were untouched.
Configuration
delegate.json in the agent directory (DELEGATE_AGENT_DIR, else Pi's
PI_CODING_AGENT_DIR, else ~/.pi/agent in a normal install).
Unknown top-level keys are ignored; unknown keys inside the telemetry,
sessions, and models/modelsByParent blocks are rejected. Malformed
values fail loudly at the dispatch boundary. Retired v1 keys
(agentOverrides, agentOverridesByParentModel, maxAsyncTickets,
allowUnsafeSharedWrites) are among the ignored — a stale v1 config
silently does nothing; see COMPATIBILITY.md for the mapping.
| Key | Default | Meaning |
|---|---|---|
maxConcurrent |
8 |
Global cap on simultaneous tasks. |
concurrency.default |
unset | Fallback in-flight bound. |
concurrency.providers |
{} |
Per-provider bound, e.g. {"anthropic": 2}. |
concurrency.models |
{} |
Per-model bound, e.g. {"openai/gpt-5.2": 1}; wins over provider and default. |
models |
{} |
Model override per canonical agent, e.g. {"coder": "openai/gpt-5.2"}. No default entry — it mirrors the parent. Keys name built-ins and globally defined (<agentDir>/agents) profiles only; project .pi/agents profiles pin via their frontmatter model: instead. |
modelsByParent |
{} |
models scoped by exact normalized parent provider/model-id; wins over models. |
stallTimeoutMs |
900000 (15 min) |
Inactivity watchdog: no session events for this long aborts the task. 0 disables. |
sessions.maxIdle |
4 |
Idle pooled sessions kept resident in memory; beyond the bound the least-recently-idle unloads to its transcript and reloads on next use. 0 unloads every settled session. |
telemetry.enabled |
false |
Record dispatches in a local SQLite file. |
telemetry.dbPath |
unset | Store location; falls back to DELEGATE_TELEMETRY_DB, then <agentDir>/delegate-usage.db. |
providerExtensions |
{"openai-codex": ["npm:@bermudi/pi-codex"]} |
Per-provider user-scope packages injected into that provider's subagents (e.g. {"openai-codex": ["npm:@bermudi/pi-codex"]}). A listed provider's array replaces the shipped default for that provider and its sources are required — missing or unverifiable, the dispatch fails. Providers never listed fall back to shipped defaults, which degrade silently to extension-free children. Empty arrays are ignored. |
output.spillThresholdChars |
8000 |
Characters before a result spills to a file. |
output.spillTailChars |
2000 |
Tail kept inline on spill. |
Telemetry writes a calls row per dispatch plus a tasks row per task —
durations, token counts and cost, tool mix, workspace, outcome — and a
misfires row per dispatch rejected before execution (config-load failures,
validation errors including unknown agent names, and admission rejects) with
the phase, the verbatim caller-visible message, and the batch shape. The store
stays on the local machine; recording is fail-open, so a broken database logs
and disables telemetry without changing delegation results. Inspect with
sqlite3 <db> 'SELECT * FROM calls' (or tasks / misfires).
Tickets
delegate_ticket({ ticket, action, ... }) operates on a ticket. Its actions are
poll, wait, cancel, answer, steer, and interrupt. Any other action
name is rejected with that list.
poll— this session's ticket roster, or one ticket's task list and settled results.wait— parks until the ticket settles, a worker asks a question, or an interruption arrives. Waits are unbounded; detaching never affects the task, and delivered results arrive on their own, so polling is enough if you would rather move on.cancel— the first call previews what would stop and warns that writes and commands are not rolled back;force: trueterminates the ticket and asks workers to abort.answer— reply to a worker question (see below).steer— send text to a running task (see Steering).interrupt— abort one task's in-flight turn cooperatively (taskIddefaults to the only still-running task). The task settlesinterrupted, notcancelled: a pooled session returns reusable, a fresh task keeps its transcript and aresumeFromhint. A settled, already-interrupted, or not-yet-running target receiptsnot-applied.
Pause and resume are not model-facing. They are operator controls in the
/subagents dashboard, and the tool rejects pause and resume with a pointer
to wait and cancel. A paused task keeps its execution slot, session, and
workspace reservation by design — paused remains running (INVARIANTS) — so a
small maxConcurrent can be starved by a long-paused ticket; resuming it frees
the slot.
Tickets are durable journals under <agentDir>/delegate-tickets/ with
owner-only permissions. Settled outcomes are recoverable after a restart via
poll/wait; a snapshot that was running recovers as interrupted — running
work is never resumed automatically. Delivered wakes do not survive restart;
only the recorded outcomes do. The bare roster and unknown-ticket hints list
only the current session's tickets — records owned by sibling sessions stay
readable by explicit id and are counted in a trailing note.
Worker questions
A subagent's ask_parent call parks that task — sibling tasks keep running —
and surfaces the question in the ticket view plus an immediate delivery to the
parent (a wake on the same branch, a durable append otherwise).
delegate_ticket({ action: "answer", ticket, taskId, questionId, answer })
releases it. Only ticket tasks carry ask_parent; on a synchronous run the
tool is not offered. One unanswered question per task. Questions are
in-memory only — a restart interrupts the waiting task like any other running
work.
Sessions
A task with sessionId keeps its subagent conversation pooled; a later task
reusing the id continues the same conversation. Idle residents are bounded by
sessions.maxIdle — over the bound, the least-recently-idle session unloads to
its transcript file and the next reuse transparently reloads it; a running
session is never evicted. delegate_session list shows pooled sessions
(resident and on-disk) and close shuts one down. Pooled sessions end with the
parent process; resumeFrom instead rehydrates a session from a prior .jsonl
transcript.
Parent conversation isolation
Children do not share the parent's conversation. The child system prompt is a
Markdown profile's body, verbatim, when the profile has one; otherwise —
including the built-in profiles — it is composed from the parent's
user-authored prompt inputs plus a subagent framing.
Project context files under the task's cwd are kept; user-global context files
are excluded. Children run with an in-memory settings profile and no parent
extensions, no parent history or transcript tail, and no MCP tools from the
parent. One exception is providerExtensions: a provider's subagents may load
the packages configured for that provider (the shipped default gives
openai-codex children a companion package, best-effort — see Configuration).
No delegate tools are ever available to children — delegate,
delegate_ticket, and delegate_session are stripped from every child toolset
(profile frontmatter and the mirrored parent set alike), so subagents never
nest dispatches.
Develop
bun install
bun run typecheck
bun test
The extension entry point is delegate.ts; package.json exposes it via
pi.extensions for local development.
Operational diagnostics
Delegate does not print diagnostics over Pi's terminal UI. When stderr is
terminal-attached, error/warn/info records append to
<agentDir>/delegate-diagnostics/<pid>.jsonl (directory 0700, file 0600).
agentDir here means nonempty DELEGATE_AGENT_DIR, otherwise Pi's public
getAgentDir() (PI_CODING_AGENT_DIR or ~/.pi/agent), independent of any
context/session/cwd fallback used for configuration. Print mode also logs to
this file if stderr is still attached to the terminal. With redirected/piped
stderr, all levels go to stderr instead; diagnostic stdout is never used.
If the primary path is blocked or insecure, the private fallback is
<system-temp>/pi-delegate-diagnostics-<uid>-<pid>/<pid>.jsonl; its records
include safe primary-failure context. Existing symlinks, insecure permissions,
foreign ownership, and linked log files are rejected, never silently repaired.
If both secure destinations fail, work and cleanup still finish. Delegate
reports a safe warning through Pi's managed notices and the next public tool
result, including the primary path/operation and both failure codes. Failed
notices remain visible in the result warning; logs never fall back to the
attached terminal. Startup, recovery, telemetry and cancellation remain
noninterfering even when neither destination is writable. The warning also rides
cloned per-return details.diagnosticWarning, visible in collapsed/expanded tool
views and history replay; it never contaminates cached operation/ticket results.
Whole directory components delegate-diagnostics and
pi-delegate-diagnostics-<numeric uid>-<numeric pid> are reserved runtime trees
anywhere under source. Both worker-copy kinds, private Git snapshots, and
source-drift/file-attribution evidence omit them and existing symlink aliases,
regardless of the engine's agent directory. Retained fallback trees are covered;
ordinary names such as delegate-diagnostics.md or
pi-delegate-diagnostics-guide remain source. Historical Git objects already in
your repository are not scrubbed.
Secure file routing is currently Linux-only and requires user-id and no-follow filesystem capabilities. Other attached platforms report a managed unsupported warning rather than crashing; foreign filesystem behavior has not been verified. Headless/piped stderr remains available on every platform.
Records are bounded JSON events with operational metadata and safe error class/code, not raw errors/stacks, provider payloads, or task/profile/question/ answer/steering contents. Logs are append-only, with no rotation or automatic cleanup; remove old process logs when no longer needed.