@bermudi/pi-delegate

Pi extension: subagent dispatch with compact/full controls, admission, durable tickets, workspaces, and steering

Packages

Package details

extension

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 — this steerId already 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; timeoutMs bounds the wait (unset means wait for settlement) and a tickets array watches several, returning when the first watched ticket settles — a one-id list folds into the single-ticket wait. Both timeoutMs and tickets are 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: true terminates 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 shows pausing; paused means 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 small maxConcurrent can 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 (taskId defaults to the only still-running task). The task settles interrupted, not cancelled: a pooled session returns reusable, a fresh task keeps its transcript and a resumeFrom hint. A settled, already-interrupted, or not-yet-running target receipts not-applied.
  • tail — a bounded, incremental read of one task's assistant output (taskId defaults to the ticket's only unsettled or only task and is required when several ran). offset is a char cursor that clamps when out of range; the reply carries the chunk plus a nextOffset cursor for the following read. waitMs bounds a park that resolves early when new output lands or the task settles — omitted or 0 is 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.