pi-ptc-subagents

DSH PTC mode (ptc_run_code + ptc_workflow) for pi

Packages

Package details

extension

Install pi-ptc-subagents from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-ptc-subagents
Package
pi-ptc-subagents
Version
1.6.0
Published
Oct 8, 2026
Downloads
1,783/mo · 1,010/wk
Author
goodali
License
Apache-2.0
Types
extension
Size
291.2 KB
Dependencies
1 dependency · 4 peers
Pi manifest JSON
{
  "extensions": [
    "./dist/index.js"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-ptc-subagents

DSH-style PTC mode (Programmable Tool Calling) for pi: the model writes a JS/TS program that calls pi's tools from inside a worker, and only the program's return value plus its logs come back to the model.

Source is open. This repository is public and the source is here — dist/ on npm is the compiled form of what you read below. Contributions go through pull requests: see CONTRIBUTING.md for the gate your PR has to pass, and SECURITY.md before reporting anything. Releases are cut from main by the maintainer only; if you find something you think needs a release, open an issue and say so.

Status

Functional and actively used: ptc_run_code and ptc_workflow are registered and run programs through the same tested worker machinery (dispatcher, wire protocol, budgets, built-in bindings). The implementation is written clean-room from DeepSeek Harness PTC behaviour — see ADR-0002 for how that boundary is kept, and THIRD_PARTY_NOTICES.md for the attribution that follows from it.

Install

pi install npm:pi-ptc-subagents

From a local checkout: pnpm install && pnpm run build && pi install /abs/path/to/this/repo (or npm install && npm run build && … — the lockfile is pnpm-lock.yaml; with npm you'll need npm i to regenerate package-lock.json).

pi reads the pi.extensions manifest field, so no extra setup steps are required — install it and the extension is on for the next pi startup.

Tools

  • ptc_run_code — run a JS/TS program that composes tool calls; the program reaches tools as tools.read(...), tools.write(...), etc. (all seven built-ins, bash included); its return value and console.log output are reported back.
  • ptc_workflow — structured variant with meta + plain-JSON args, plus the workflow helpers (log, phase, parallel, pipeline). There is no agent() helper on either surface.
  • ptc_task_list / ptc_task_output / ptc_task_stop — manage background dispatches (see Background dispatch). They stay available when PTC mode is off.

Long output follows pi's own truncation contract (ADR-0015):

the text block keeps the tail (50 KB / 2000 lines) and the untruncated text is written to a temp file

the next program can tools.read; the collapsed row then shows truncated in its meta.

Dispatch (fan-out to per-call pi subprocesses)

PTC programs can spawn a fresh pi subprocess per call via the pi.dispatch(...) binding (ADR-0016). Use it to fan out to a specialist agent — the child subprocess loads the named agent's markdown from ~/.pi/agent/agents/<name>.md (or .pi/agents/<name>.md for project-scope agents), runs that agent's tool set and system prompt in isolation, and returns a structured result.

Bindings are reached through the one tools table — there is no pi global in the worker — so the binding named pi.dispatch is called as tools["pi.dispatch"]({ … }) with a single object argument.

// inside a ptc_run_code program
const result = await tools["pi.dispatch"]({
  agent: "scout",
  task: "find all auth code in src/",
  cwd: process.cwd(),
});

// result.status: "fulfilled" | "rejected"  (the binding never throws)
// result.text:   final assistant text
// result.usage:  { input, output, cacheRead, cacheWrite, cost, turns }
// result.exitCode, result.durationMs, result.stderr?, result.errorMessage?

Fan out in parallel with the rest of PTC's tools — dispatch is a binding, not a model-visible lifecycle tool, so the dispatcher handles concurrency the same way it does for any other tool call:

const [read, scoutA, scoutB] = await Promise.all([
  tools.read({ path: "package.json" }),
  tools["pi.dispatch"]({ agent: "scout", task: "review auth" }),
  tools["pi.dispatch"]({ agent: "scout", task: "review db" }),
]);

The child report. A dispatched child returns more than prose. Under the report contract (ADR-0032) a child hands back a child report — a summary in its own words, findings each carrying the independent thing that supports the claim, the files_touched it is sure about, and the token usage the host measured (never a number the child made up). The child's prose is kept alongside the report, never replaced by it.

The report travels one of two channels. It prefers a declared ptc_child_report tool, whose payload the host reads back as JSON. If that tool is not available to the child, the host still reads a fenced JSON block from its final message. Either way the result names the channel that delivered it:

const r = await tools["pi.dispatch"]({ agent: "scout", task: "survey the auth code" });
if (r.reportChannel === "none") {
  // The child ran and did not comply. r.text is its prose; treat it as unbacked.
} else {
  for (const f of r.report?.findings ?? []) console.log(f.what, "←", f.evidence);
}

reportChannel is always present — "tool", "prompt-json" or "none" — because a degradation a caller cannot see is a silent failure, and "ran but did not comply" must not read as "returned nothing". ptc_subagent renders the same report into the text the model reads, bounded at 20 findings with the withheld count stated in-band.

The contract is on by default. An agent opts out with one line of frontmatter, childReport: false, and then its channel reads "opted-out" — nobody was asked, which is a different claim from having been asked and ignored.

Bounded. Three knobs keep fan-out from running away:

  • PtcConfig.dispatchConcurrency (default 8) — hard cap on concurrently in-flight dispatch in one pi session. It is one counter, not one per run: foreground pi.dispatch, the top-level ptc_subagent front, and live background children all spend it, and a background child holds its slot for its whole lifetime. The N+1th concurrent call resolves immediately with { status: "rejected", errorMessage: "dispatch concurrency limit reached" } instead of queuing or spawning — so a call over the cap is not made to wait for a slot to come back.
  • PtcConfig.maxDispatchDepth (default 3) — recursion bound. The child subprocess loads pi-ptc too, so it can write its own PTC programs and call pi.dispatch itself; the childDepth = parentDepth + 1 is rejected when it would exceed maxDispatchDepth. The child sees a <pi-ptc-context depth="N" max-depth="M">…</pi-ptc-context> hint appended to its system prompt so it can budget its recursion.
  • signal — when the parent run is cancelled (deadline, abort, user Esc), every in-flight child receives SIGTERM followed by SIGKILL after a 5-second grace window, the same shape as pi's examples/extensions/subagent/index.ts reference.

What the concurrency cap now governs, and which knob is live. The cap is one counter per pi session (ADR-0016 §2 as amended, ADR-0022 §9), acquired inside dispatch() so a single owner gates every front. Two consequences are worth stating plainly, because both were measured and neither is a rounding difference: two programs running concurrently in one session now share 8 rather than 8 each, and a program sharing a session with eight live background children can be refused every foreground slot. The live control is createBackgroundTaskRuntime({ concurrency }), the call that builds that session counter, and the value it is given is PtcConfig.dispatchConcurrency. It is not a background-only knob: changing it changes how many foreground children a whole session can have in flight.

The dispatchConcurrency a caller passes to runPtcProgram({ config }) sizes the dispatcher's own per-run counter, and that counter is only reached when no session counter is supplied (dispatcher.ts hands the binding options.dispatchDeps?.slots ?? dispatchSlots). In a pi session a session counter always is, so the per-run one is not what enforces the cap you are looking at.

Opt out. Pass an explicit binding subset to createBuiltinBindings to opt out — the parallel binding is mixed in only when the caller accepts the default set:

// in a hypothetical runner that wants to keep reads-only:
createBuiltinBindings({ cwd: "/abs/path", names: ["read", "grep"] });
// `pi.dispatch` is NOT in the resulting `tools` table.

Not a subagent. The term subagent is overloaded in this field (DSH's subagent is a different thing; pi's examples/extensions/subagent/ extension is also a different thing). pi-ptc uses parallel binding and concurrent tool call throughout; see CONTEXT.md for the canonical terms.

Background dispatch

Foreground pi.dispatch blocks the program until the child exits. Pass background: true to spawn the child and return immediately with a DispatchHandle (ADR-0022); the child outlives both the program and the turn:

// inside a ptc_run_code program — bindings are reached as tools["<name>"]
const handle = await tools["pi.dispatch"]({
  agent: "scout",
  task: "audit the auth code",
  background: true,
  label: "auth audit", // defaults to task.slice(0, 64)
});
// handle: { taskId: "01J…", label: "auth audit", status: "running" }

(The binding's name is pi.dispatch; a program reaches it as tools["pi.dispatch"].)

A detached pump drives the task's lifecycle (running -> succeeded / failed / canceled / lost), and the model observes it with three always-on tools — they are not part of the PTC-mode loadout, so /ptc off (which only blocks new spawns) does not remove them. A background task is owned by the dispatching pi process (ADR-0023): it survives programs, turns, and /ptc off, ends when that session ends or the process dies, and no other pi process in the same directory can reap it (background dispatch children share the session's task storage, so pre-ADR-0023 any same-directory pi process — including a dispatch child itself — could reap every task on startup). Known edges: pre-upgrade ownerless records are still reaped by whichever process binds the directory first; a recycled pid can leave a record running after its owner died; and ptc_task_stop from another process can write a stopping state into your record even though the stop signal itself never crosses the process boundary:

  • ptc_task_list({ status?, limit? }) — list this session's tasks, newest first (default limit 100).
  • ptc_task_output({ taskId, sinceBytes? }) — read a task's captured output, tail-truncated to pi's 50 KB / 2000-line contract (ADR-0015).
  • ptc_task_stop({ taskId, reason? }) — ask a running task to stop.

Background tasks count against the same dispatchConcurrency (default 8) for their whole lifetime and share the maxDispatchDepth (default 3) recursion bound — and since the gate moved into dispatch() that cap is the one session counter the foreground path uses too, not a second one held beside it. A session running eight long background children therefore has no foreground dispatch headroom left, and a foreground call over the cap is refused outright rather than queued behind them. A pre-spawn refusal (depth or concurrency cap, unknown agent) still comes back as the familiar DispatchResult with status: "rejected". Full guide: docs/usage/bgdispatch.md.

TUI rendering

Both tools register custom renderCall / renderResult hooks, so a PTC run reads as a program rather than as yet another file operation (ADR-0013):

PTC  Find AssistantMessageComponent instantiations
  ├─ file: "chat-viewport.ts"                 • 1 output line · 1.42s
  ├─ instantiations: Array(3)
  │  ├─ [0] {file: "chat-viewport.ts", line: 23}
  │  ├─ [1] {file: "chat-viewport.ts", line: 47}
  │  └─ [2] {file: "chat-viewport.ts", line: 91}
  └─ totalLines: 47

The call row is the tool label plus the model's description. Under it, the completion value is shown as a tree: an object or array with content gives one row per property (or index), nested containers recurse behind ├─ / └─ / │ connectors, and a small all-scalar container collapses onto one row ({file: "a", line: 12}). Depth caps at 4 levels, 6 children per container and 120 characters per row; whatever is withheld is reported (…+N more keys, a trailing …). A scalar value is one line instead — → 47, → {}, done when the program returned nothing, or failed: <reason> in red. The run's countable facts — output lines, workflow phases, attached images, warnings, duration — stay pinned to the right edge of the area's first row. Nothing is ever printed as escaped JSON. Expanding a row (ctrl+e) adds the code head, phase roll-up, console.log output and plan-drift warnings, each block labelled and capped. renderShell stays at pi's default, so these rows keep the same box and colors as the built-in tools.

The copy above is the human's. The text block the model reads is a separate contract with separate bounds (ADR-0012): a completion value whose compact form fits in 100 characters stays on one line, and every line of the assembled block is capped at 200 characters with a trailing …. Those are not the numbers above, and they are not variants of them. 4 / 6 / 120 bound the on-screen tree — depth, children per container, characters per row, aligned by visible width — because they serve the eye; 100 / 200 bound the model's copy because they serve what the model has to read. Neither set derives from the other, so moving 200 to 120 so they "match" is a behaviour change that needs its own ADR, not an edit to a number on this page.

Images

An image read inside a program — await tools.read({ path: "shot.png" }) — is attached to the PTC tool result as a real image block, so the model sees the picture instead of a marker string or a wall of base64. This is what DSH does by deferring a context message after the run (ADR-0014). Nothing is capped or deduped: every image the program's tool calls produced is attached, in call order, because how much context a run spends is the program's call. The collapsed row's meta shows the count (· 1 image), so the volume is visible without being policed. The program receives the image either way.

PTC default mode

On a TUI start — install, restart, done — the session narrows its tool loadout so the built-in tools are reachable only from inside a program:

PTC  Verify the inserted image file
  → {file, clipNow}                        • 6 output lines · 1 image · 536ms

The model calls ptc_run_code / ptc_workflow, and reaches read / bash / edit / write / grep / find / ls through tools.<name>(args) inside the program. Tools contributed by other extensions (web_search, todo, …) stay directly callable — they cannot become bindings (pi.getAllTools() returns metadata, not execute), so hiding one would make it unreachable for the session. The mode's rationale and rejected alternatives are in ADR-0010.

Turning it off. For one session: /ptc off (and /ptc on, /ptc for status). Permanently:

// ~/.pi/agent/ptc.json
{ "defaultMode": false }

Choosing the surface. defaultMode decides whether the session enters PTC mode; surfaceMode decides which model-facing tools this package registers at all (ADR-0025):

/ptc surface              # report the current surface and where it came from
/ptc surface subagents    # write the key and reload so it applies now
// ~/.pi/agent/ptc.json
{ "surfaceMode": "subagents" }
  • off — a stock pi session: no tool, no /ptc command, no briefing.
  • subagents — ptc_subagent plus the three ptc_task_* tools, with pi's own codemode doing the orchestration; warns at startup when codemode is not in the active tool set.
  • full — today's set: ptc_run_code / ptc_workflow plus the three ptc_task_* tools.

The command exists because the alternative is editing JSON by hand and starting a new session: the surface is read once in the extension factory and pi has no way to unregister a tool, so a change cannot apply in place. /ptc surface writes the key and then performs pi's own /reload, which re-runs every extension factory (ADR-0030). Two things it will not do silently: a malformed ptc.json is reported and left exactly as it was, and an unchanged value does not reload — a reload replaces every extension instance in the session, so retyping the value you already have costs you in-flight state for nothing. Switching to subagents or off also ends a running PTC mode, which the command says before it does it.

The default is detected, and it follows three questions, not one (ADR-0026, ADR-0027, ADR-0029). With no surfaceMode key:

does this pi ship codemode? will pi load it? can the model call it? surface
yes yes (default, or +builtin:codemode) yes (--tools …,codemode, or defaultTools) subagents
yes yes (default, or +builtin:codemode) no — the default on a stock install full
yes no (-builtin:codemode, or --no-extensions) — full
no — — full

The third column is the one that decides most sessions, and it is why the default is full on a pi that has never been configured. pi ships codemode and loads it by default, but registers it inactive (defaultActive: false) — it joins the model's tool list only when a loadout names it. Handing orchestration to a tool the model cannot call is the failure this avoids, so subagents is chosen only on positive evidence.

Setting the key always wins. A probe that cannot answer falls back to full — the safe direction, since subagents as a failure mode would take away the orchestration tool the session was relying on.

To use the subagents surface, put codemode in your tool list. Without that you get ptc_run_code / ptc_workflow and no startup warning, which is the correct answer for a session that never asked for delegation:

// ~/.pi/agent/settings.json
{ "defaultTools": ["read", "bash", "edit", "write", "+codemode"] }

or per launch, pi --tools read,bash,edit,write,codemode. A list made only of modifiers starts from pi's four defaults, so { "defaultTools": ["+codemode"] } means the same thing. If you would rather pin the surface regardless of what pi is doing, set { "surfaceMode": "subagents" } — and note that pinning it does not activate codemode, so a pinned subagents on a session that never configured it still warns, by design (ADR-0025 decision 4).

Turning pi's codemode off — "extensions": ["-builtin:codemode"], or launching with --no-extensions — brings the PTC surfaces back on its own. Before ADR-0027 it did not: the detection asked only whether the extension directory exists, so a pi told not to load it still counted as an orchestrator and you got ptc_subagent with nothing to compose with. The switch is read from the same three places pi reads it — the command line, <cwd>/.pi/settings.json, and <agentDir>/settings.json — in the same order, and the activation probe reads the first two plus --tools.

No file, or a value outside that set, falls back to the detected default and says so at startup rather than half-applying: which tools exist is not something to change on a guess.

A detection you cannot see is the failure this design has, so the result is reported. With no surfaceMode key, the outcome is issued through the TUI notification channel at session start — how the probe came out and which surface the default therefore is — but only when the probe could not answer. A pi that ships codemode, loads it, and has it in the tool list is the expected case and says nothing. A second notice is issued when the probe and pi's own tool registry disagree, which is the case the probe structurally cannot see: it walks the filesystem, so under --exclude-tools codemode it answers present for a tool this session does not have (and the mirror: a restructured dist answers not-found for one pi plainly registers). A third notice covers ADR-0027: a settings file that could not be read, and an explicit surfaceMode that disagrees with the table — the pinned value still wins, and the notice only says so. What is not established is that either line actually paints in a real pi TUI: a pty capture at review time showed neither the notice nor a control marker, and a TUI quits on stdin EOF before a toast renders, so that is an unmeasured end to end rather than a broken one. No test in this repository observes a notice through a real TUI. If you are relying on the notice rather than on your own surfaceMode key, verify it once.

On a --print session, none of it prints. ui.notify is the TUI channel; measured across three --print runs that each emit one of these notices, stdout and stderr received 0 bytes each. That makes this page the only channel on which a --print user learns why they got the surface they got — ADR-0025's decision-4 warning shares the gap, and there the answer is the same one line of JSON: set surfaceMode yourself and the detection no longer matters.

Where it does not run. Print / JSON / RPC sessions are left exactly as launched, and so is a session started with an explicit tool restriction (--tools, --exclude-tools, --no-builtin-tools, --no-extensions) — the extension does not override what you asked for. --no-extensions does, however, change the detected surface: pi's own codemode is a built-in extension, so turning extensions off means it will not load, and ADR-0027's table resolves the default to full — you keep ptc_run_code / ptc_workflow rather than a ptc_subagent with nothing to compose with. If you would rather pin the surface regardless, set "surfaceMode" in ptc.json. If another extension changes the tool set while the mode is on, the mode yields and tells you.

The important consequence: in a TUI session, bindings come from the loadout recorded before the mode narrowed it. That is what keeps tools.read(…) working — and it is why a --tools restriction still holds: the snapshot is read from pi.getActiveTools(), so it can never contain tools your session was not launched with.

Trust posture (read me)

  • Installing this package grants it the same machine access as any pi extension: PTC programs run as you, with no OS-level sandbox (ADR-0007).
  • Bindings mirror the session's enabled built-in tools (pi.getActiveTools()): a session started with --tools … / --no-builtin-tools can only reach those tools from inside a PTC program (a default session has read, bash, edit, write — enable more to bind more).
  • Tool calls made from inside a PTC program execute directly and bypass pi's tool_call hooks (permission gates, path guards) — see ADR-0005. Do not rely on those guards to constrain tool use while this extension is enabled.

Development

pnpm install
pnpm run typecheck    # tsc --noEmit
pnpm run lint         # oxlint          (`pnpm run lint:fix` applies fixes)
pnpm run fmt          # oxfmt (writes); `pnpm run fmt:check` verifies
pnpm test             # vp test --run --coverage (Vitest 4; coverage via @vitest/coverage-v8)
pnpm run test:watch   # vp test (interactive)
pnpm run test:ui      # vp test --ui (local browser UI; not for CI)
pnpm exec vp test --run tests/render-ptc.test.ts  # renderer unit tests only
pnpm run build        # vp pack + declaration emit
pnpm run verify:dist  # exercise renderCall/renderResult through the built dist (no LLM needed)
PI_ROOT=<global-node-modules>/@earendil-works/pi-coding-agent \
  node scripts/preview-ptc-render.mjs   # print the rendered rows with real theme colors

Tooling: oxc — oxlint + oxfmt (official defaults) — alongside rolldown (also oxc-powered), vite-plus (bundles Vitest 4), and TypeScript 7.

See ADR-0009 for the Vitest adoption decision (reopens ADR-0008's earlier deferment).

Credits

Built clean-room from the PTC behaviour of DeepSeek Harness (MIT, Copyright (c) 2026 DeepSeek), read at tag dsh-v0.2.0-rc.2, and hosted by pi (earendil-works/pi, MIT). Full attribution, and what is derived from what, is in THIRD_PARTY_NOTICES.md.

License

Apache-2.0. See LICENSE and THIRD_PARTY_NOTICES.md.