@bermudi/pi-delegate
Pi extension: subagent dispatch with compact/full controls, 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.3.7- Published
- Oct 8, 2026
- Downloads
- 2,273/mo · 893/wk
- Author
- bermudi
- License
- unknown
- Types
- extension
- Size
- 904.9 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, interrupt, pause, resume, answer, steer, tail);
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 v2→v3 breaks.
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.
Compact and full interfaces
Compact is the default: batch tasks, async, workspace, brief; each task
has prompt, agent, cwd, workspace. Named Markdown profiles supply the
usual tools and base instructions.
To enable all advanced controls, set "surface": "full" in user-global
delegate.json, then /reload. Both interfaces use the same engine. Hidden
advanced inputs reject with instructions rather than silently executing. This
is an operator setting, not a model-family switch.
Choose before launching work: /reload cancels active workers and waits for
safe cleanup, as it does for any extension reload.
The manual (tasks: []) documents only the selected surface's controls and,
on compact, ends with the list of what full adds.
Full mode adds task ids/display labels, explicit tools/base-prompt overrides, session reuse, transcript resume and dependencies; batch token budgets/retry keys; ticket pause/resume/tail, wait-any, timed waits and explicit steering retry keys. Safety guarantees are the same in both modes.
Dispatch
A call resolves profiles, tools, workspaces and write claims before running
work. Compact mode covers ordinary calls. Full-mode task fields are prompt
(required unless resumeFrom), id (auto task-1…), description (display
label, ≤200 chars), agent, cwd, systemPrompt, tools, sessionId,
resumeFrom, workspace, dependsOn. Batch workspace
is the default for tasks without their own; full-mode operationId is a
host-lifetime dispatch retry key.
Tasks have no wall-clock deadline (#118): deadlineMs has been removed and
rejects before execution in both modes, even when null. Stall detection remains
an inactivity watchdog. Cancel/interrupt stop work cooperatively; ticket wait
and tail timeouts detach only the waiter. Saved historical deadline failures
remain readable.
Only canonical field names are accepted. Removed cross-harness spellings
reject before any worker starts, even when null or accompanied by the
canonical field. Models/effort remain operator-configured, never call fields.
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). Top-level tokenBudget (positive integer) caps the
batch's recorded token usage: settled tasks charge their usage to it, and
once the ceiling is reached queued tasks settle budget-exhausted
instead of starting — tasks already running always finish, and dependents
of an exhausted task block naming the budget. Result and ticket headers
report token budget: consumed/limit, details.tokenBudget carries
{limit, consumed, exhaustedAt}, and the dispatch's telemetry row
records the account.
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/body are defaults; full-mode task
overrides retain precedence.
Steering
delegate_ticket({ ticket, action: "steer", message, steerId }) 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).
steerId is optional — omitted, the receipt names a steer:<tool-call-id>
key derived from the tool call itself, so a transport-level retry dedupes
instead of re-injecting.
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— thissteerIdalready applied an identical steer; nothing re-applies.not-applied— the task settled, the ticket was recovered, or nothing matches.
Reusing steerId with a different task or message is a conflict error naming
both attempts. 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 |
|---|---|---|
surface |
"compact" |
Advertised and validated interface: "compact" or "full"; changes take effect on /reload. |
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
Compact supports poll/wait/cancel/answer/steer/interrupt. The optional timed wait, wait-any, pause/resume and tail controls below require full mode.
delegate_ticket({ ticket, action, ... }) operates on a ticket:
poll— this session's ticket roster, or one ticket's task list and settled results.wait— block until the ticket settles;timeoutMsbounds the wait (unset means wait for settlement) and aticketsarray watches several, returning when the first watched ticket settles — a one-id list folds into the single-ticket wait. BothtimeoutMsandticketsare full-mode controls. Timing out or detaching never affects the task.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.pause/resume— cooperative pause at a turn boundary; the current model response and tool calls finish first. A requested-but-not-yet-parked task showspausing;pausedmeans parked between turns (or held while queued). A paused task keeps its execution slot, session, and workspace reservation by design — paused remains running (INVARIANTS) — so a smallmaxConcurrentcan be starved by a long-paused ticket; resume frees it.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.tail— a bounded, incremental read of one task's assistant output (taskIddefaults to the ticket's only unsettled or only task and is required when several ran).offsetis a char cursor that clamps when out of range; the reply carries the chunk plus anextOffsetcursor for the following read.waitMsbounds a park that resolves early when new output lands or the task settles — omitted or0is a pure snapshot.
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. operationId deduplication and 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 an
authored systemPrompt or Markdown profile body verbatim when present;
otherwise 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 no extensions and an in-memory settings
profile: no parent extensions or MCP tools, no parent history or transcript
tail, and no delegate tools of their own — delegate, delegate_ticket, and
delegate_session are stripped from every child toolset (explicit tools,
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.