pi-ptc-subagents
DSH PTC mode (ptc_run_code + ptc_workflow) for pi
Package details
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 astools.read(...),tools.write(...), etc. (all seven built-ins,bashincluded); its return value andconsole.logoutput are reported back.ptc_workflow— structured variant withmeta+ plain-JSONargs, plus the workflow helpers (log,phase,parallel,pipeline). There is noagent()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: foregroundpi.dispatch, the top-levelptc_subagentfront, 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 callpi.dispatchitself; thechildDepth = parentDepth + 1is rejected when it would exceedmaxDispatchDepth. 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 receivesSIGTERMfollowed bySIGKILLafter a 5-second grace window, the same shape as pi'sexamples/extensions/subagent/index.tsreference.
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/ptccommand, no briefing.subagents—ptc_subagentplus the threeptc_task_*tools, with pi's owncodemodedoing the orchestration; warns at startup whencodemodeis not in the active tool set.full— today's set:ptc_run_code/ptc_workflowplus the threeptc_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
subagentssurface, putcodemodein your tool list. Without that you getptc_run_code/ptc_workflowand 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 activatecodemode, so a pinnedsubagentson a session that never configured it still warns, by design (ADR-0025 decision 4).
Turning pi's
codemodeoff —"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 gotptc_subagentwith 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-toolscan only reach those tools from inside a PTC program (a default session hasread,bash,edit,write— enable more to bind more). - Tool calls made from inside a PTC program execute directly and bypass
pi's
tool_callhooks (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.