@nicknisi/pi-codemode
Execute model-written TypeScript that orchestrates subagents compositionally — one codemode tool over the first-party in-process runtime
Package details
Install @nicknisi/pi-codemode from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@nicknisi/pi-codemode- Package
@nicknisi/pi-codemode- Version
0.2.1- Published
- Aug 9, 2026
- Downloads
- 189/mo · 189/wk
- Author
- nicknisi
- License
- MIT
- Types
- extension
- Size
- 98.9 KB
- Dependencies
- 4 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@nicknisi/pi-codemode
Codemode for the first-party subagent platform: the model writes TypeScript that orchestrates subagents compositionally — Promise.all fan-out, sequential pipelines, map/reduce over files — and the codemode tool compiles and runs it in-process, returning the module's default export as the result. Rebuilt on @nicknisi/pi-shared's in-process runtime; no pi-subagents dependency, no pi-mcp-adapter.
It also ships a console: an = editor prefix and /cx named snippets that run the same runtime inline (devtools-console style), with returned values bound to $1, $2, … for later snippets. See Console.
What it adds
codemodetool (model-facing) — params:{ code: string; label?: string; timeoutMs?: number }(default 10 min, capped at 30 min). The snippet gets two injected bindings and mustexport defaultits result.- Runscope orchestration ledger (parent-session side effect) — every
spawn/runWorkflowlifecycle event is appended to the parent session as a typed custom entry (customType: 'codemode-runscope'). See Runscope ledger. =console prefix (TUI editor) —=<snippet>runs a snippet inline and renders the result as a collapsible block. See Console./cxnamed snippets (slash command) — run named snippet files from~/.pi/agent/snippets/or.pi/snippets/. See/cxnamed snippets.
The snippet API
spawn(options: SpawnOptions): Promise<SpawnResult>;
runWorkflow(spec: WorkflowSpec, opts?): Promise<WorkflowResult>;
log(...args: unknown[]): void;
spawnlaunches a hermetic in-process child agent through the shared runtime (namespacecodemode). Children are version-matched to the host, cannot themselves spawn, and honor the ecosystem recursion guard. Run artifacts persist to~/.pi/agent/subagent-runs/codemode/, so codemode children appear in thefleettool //fleetcommand from@nicknisi/pi-subagents.spawnnever rejects. Checkresult.ok; failures carrykind: 'crashed' | 'empty' | 'schema_invalid' | 'aborted'pluserror.- Output contracts (breaking). Every
spawn()must declare its contract explicitly: eitheroutputSchema(validated; parsed JSON lands inresult.data) ortext: true(raw text opt-in). Aspawn()with neither throws immediately with an error naming the missing contract — it does not return unparsed text. This prevents the silent failure mode where a schema-less spawn returns text, a caller reads a field off it (result.someField), getsundefined, and a whole parallel fleet reports success while every result is unusable. A schema-validating spawn that fails validation after one bounded repair attempt (a normalize-and-recheck pass) returns a loud recoverable failure:{ ok: false, kind: 'schema_invalid', error, text }— never a silently-empty string. Migration: addtext: trueto existing text-mode spawns, or define anoutputSchema. runWorkflowruns a declarative multi-stage DAG overspawn(from@nicknisi/pi-shared's engine): per-stageneedsdeps (default linear),foreachfan-out,gaterevise-feedback loops,retries,tokenBudget, andsharesTreestages that never overlap other work and hand their boundedgit diff HEADto dependents. Control artifacts land in~/.pi/agent/workflow-runs/;opts.resumeFromskips previously-ok stages. Also never rejects — per-stage outcomes carryok/kind. Prefer it over hand-rolledPromise.allwhen stages depend on each other, need gates/retries, or edit the working tree.SpawnOptionshighlights:prompt(required),agent(label),model('provider/id'),tools(allowlist —undefineddefaults to read-only['read','grep','find','ls']; pass['read','bash','edit','write']explicitly for builders),systemPrompt,outputSchema(validated; parsed JSON lands inresult.data),text(boolean — opt into raw text mode; required whenoutputSchemais omitted),cwd,timeoutMs,maxTurns,maxToolCalls,thinkingLevel.logoutput comes back in the tool result'sdetails.logs.
The snippet executes in the host process with full Node access — process, require, and fs are all reachable, the same trust boundary as the bash tool (see Security model). spawn, runWorkflow, and log are the injected codemode API. Composition is plain code: that's the point.
Usage examples
Parallel research fan-out:
const questions = [
'How does the auth refresh flow work?',
'Where are sessions persisted?',
'What breaks if the token endpoint 500s?',
];
const results = await Promise.all(
questions.map((q) => spawn({ prompt: q, tools: ['read', 'grep', 'find', 'ls'], text: true })),
);
export default results.map((r, i) => ({
question: questions[i],
answer: r.ok ? r.text.slice(0, 500) : `FAILED (${r.kind}): ${r.error}`,
}));
Map/reduce over files with a pipeline:
const files = ['src/a.ts', 'src/b.ts', 'src/c.ts'];
const summaries = [];
for (const f of files) {
const r = await spawn({ prompt: `Summarize the public API of ${f} in 3 bullets.`, tools: ['read'], text: true });
summaries.push({ file: f, summary: r.ok ? r.text : `failed: ${r.error}` });
}
const rollup = await spawn({
prompt: `Combine these module summaries into one architecture paragraph:\n${JSON.stringify(summaries)}`,
tools: [],
text: true,
});
export default { rollup: rollup.ok ? rollup.text : 'rollup failed', summaries };
Console
The codemode tool is model-facing, but codemode also runs the same runtime inline from the editor, devtools-console style. Returned values bind to $1, $2, … for later snippets in the session, and every run is persisted as a session custom entry so the console history survives reload. The model-facing codemode tool does not bind to $N — only the console does.
= console prefix
pi's editor prefix grammar: ! = bash, !! = silent bash, @ = files. codemode adds the = member:
=const x = await spawn({ prompt: 'list top-level exports', tools: ['read','grep'], text: true });
export default x.ok ? x.text : x.error
=<snippet> at position zero runs the snippet through the same codemode runtime as the codemode tool. The result renders as a collapsible block (toggled with the same app.tools.expand key — ctrl+o by default — as tool output), with the returned value bound to the next $N and the run persisted as a codemode-console custom entry.
The = prefix is intercepted via on("input") with { action: "handled" } — the documented mechanism that skips the agent entirely. Only the TUI editor prefix is intercepted; extension-injected messages and non-tui (rpc/json/print) inputs pass through unchanged. It respects the same schema-or-nothing spawn() contract as the tool.
Reference a previous console result with $1, $2, …:
=export default `previous had ${($1 as string[]).length} items`
/cx named snippets
Named codemode snippets as plain TS/JS files discovered from:
- Global:
~/.pi/agent/snippets/*.{ts,js} - Project:
.pi/snippets/*.{ts,js}(only after the project is trusted)
mirroring pi's prompt-template discovery conventions. Files carry optional frontmatter (description) for the autocomplete dropdown:
---
description: Summarize a file's public API
---
const file = await spawn({ prompt: `Summarize the public API of {{1}}.`, tools: ['read'], text: true });
export default file.ok ? file.text : file.error
/cx <name> [args...] expands {{args}}-style substitution then runs the snippet via codemode:
{{args}}or{{@}}— all args joined{{N}}— positional arg (1-indexed); empty string when missing{{N:-default}}— positional with a default- unknown
{{...}}is left intact so typos are visible
/cx summarize src/index.ts
/cx review src/auth.ts src/session.ts
This is a directory convention ONLY — no registry, no index, no config keys. The registry is ls; the package manager is git; the search engine is grep. Snippets are read on demand each invocation, so discovery rides /reload with no snippet-specific wiring. Completion for the first token lists global snippet names on demand; project snippets (untrusted) are not in the completion list but still run.
Runscope ledger
Every spawn and runWorkflow lifecycle event is appended to the parent session as a custom entry via pi.appendEntry('codemode-runscope', entry). Custom entries persist to the session JSONL but do not enter LLM context, so the ledger is durable and free of context-window cost. Dependency-free — no OpenTelemetry.
Entry shape (all fields except extras are guaranteed):
{
runId: string; // workflow run id, or the spawn's own runId for a standalone spawn
spanId: string; // this span (spawn runId, or stage id)
parentSpanId: string | null; // containing stage span, or the first `needs` dep for a stage, or null
kind: 'spawn_start' | 'spawn_end' | 'stage_start' | 'stage_end' | 'gate_result';
ts: number; // epoch ms
// extras (kind-dependent): ok, failureKind, error, passed, feedback
}
Events:
spawn_start/spawn_end— around everyspawn, standalone or inside a workflow stage.spawn_endcarriesokand, on failure,failureKind+error.stage_start/stage_end— around every workflow stage (including skipped / budget-exceeded ones).stage_endcarriesokand, on failure,failureKind+error.gate_result— emitted when a stagegateverdict is reached. Carriespassed: booleanand, on revise,feedback.
Trace tree. Stage parentSpanId is derived from needs edges (default: the previous stage), so the stage span tree mirrors the workflow DAG. A trace span has one parent, so a multi-need stage attaches to its first need — a spanning tree of the DAG. Spawns inside a workflow are parented to their stage via AsyncLocalStorage, so concurrent stages attribute their spawns correctly. Standalone spawns (no workflow) have parentSpanId: null.
Read the ledger back by scanning ctx.sessionManager.getEntries() for entry.type === 'custom' && entry.customType === 'codemode-runscope' (e.g. on session_start to reconstruct a run view). Entries are not rendered in the transcript unless you register a renderer via pi.registerEntryRenderer('codemode-runscope', ...).
Recipes
Adversarial gauntlet
A self-contained quality gate: one builder stage (sharesTree: true) edits the tree, a synchronous critic gate reads the real resulting git diff HEAD and forces the builder to revise up to maxGateAttempts: 3 times. A second, independent critic stage then reviews the captured treeDiffs['builder'] — the ground-truth diff that sharesTree hands to dependents — and emits a final pass/fail. No new runtime code; this is plain runWorkflow over the existing engine.
import { execSync } from 'node:child_process';
const TARGET = 'src/auth.ts';
const MAX_ADDED_LINES = 400;
function critic(diff: string): string[] {
const issues: string[] = [];
if (!new RegExp(`diff --git a/${TARGET}`).test(diff)) issues.push(`did not touch ${TARGET}`);
if (!/\.test\.ts\b/.test(diff)) issues.push('no test file changed');
if (/console\.log|debugger/.test(diff)) issues.push('contains console.log / debugger');
const added = diff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).length;
if (added > MAX_ADDED_LINES) issues.push(`diff is ${added} added lines (>${MAX_ADDED_LINES})`);
return issues;
}
const result = await runWorkflow({
name: 'adversarial-gauntlet',
stages: [
{
id: 'builder',
agent: 'builder',
sharesTree: true,
tools: ['read', 'bash', 'edit', 'write'],
prompt: `Implement the refresh-token rotation fix in ${TARGET}. Add or update a test. Keep the diff tight.`,
maxGateAttempts: 3,
gate: (outcome) => {
// The builder's spawn has resolved, so the working tree holds its edits.
// Read the REAL resulting diff synchronously and critique it.
let diff = '';
try {
diff = execSync('git diff HEAD', { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
} catch {
return { revise: 'Could not read `git diff HEAD` — is this a git repo?' };
}
if (!diff.trim()) return { revise: 'Working tree is clean — no changes to critique.' };
const issues = critic(diff);
return issues.length === 0 ? true : { revise: 'Critic issues:\n- ' + issues.join('\n- ') };
},
},
{
id: 'critic',
agent: 'critic',
tools: ['read', 'grep', 'find', 'ls'],
needs: ['builder'],
prompt: (ctx) =>
`Review this diff for correctness and security. Report pass/fail with concrete issues.\n\n${ctx.treeDiffs['builder'] ?? '(no diff captured)'}`,
},
],
});
const b = result.outcomes['builder'];
const c = result.outcomes['critic'];
export default {
ok: result.ok,
builderAttempts: b?.attempts,
builder: result.ok ? 'passed the gauntlet' : `failed (${b && !b.ok ? b.kind : '?'}): ${b && !b.ok ? b.error : ''}`,
critic: c && c.ok ? c.output : `critic did not run (${c && !c.ok ? c.kind : '?'}): ${c && !c.ok ? c.error : ''}`,
runDir: result.runDir,
};
When to use. You have a concrete, checkable contract for a change (must touch a file, must add a test, must stay under N lines, must not reintroduce a banned pattern) and want the builder to iterate against it autonomously instead of you hand-holding each round. The sharesTree diff also flows downstream, so the independent critic stage reviews ground truth rather than the builder's self-report.
Pitfalls.
- The gate runs synchronously, before the engine captures
treeDiffs. It cannot be anasyncLLM call —runWorkflowdoes not await the gate, so a Promise would be treated as a non-trueobject with an undefinedrevise. The critic gate is therefore deterministic (regex/structural checks over the real diff). For an LLM critic over the captured diff, use a downstream stage likecriticabove — but note downstream stages do not loop back into the builder, so the autonomous revise loop lives entirely in the builder's owngate/maxGateAttempts. execSyncblocks the host event loop.git diff HEADis fast, but keep the gate's work bounded — never shell out to anything slow. The codemode timeout cannot preempt synchronous JS.- Node builtins import fine;
node_modulesdoes not.import { execSync } from 'node:child_process'is externalized by bundle-require and resolves at runtime; bare package imports fail (nonode_modulesnear the temp dir). The snippet API (spawn/runWorkflow/log) is injected globals, not imports. - The gate sees the real diff, not the bounded 64 KB
treeDiffshandoff. A builder producing a huge diff passes the gate's line cap but still truncates for downstream stages — keepMAX_ADDED_LINESunder the 64 KB budget if downstream consumers need the full diff.
Security model
The snippet executes in-process with the host's full privileges. This is the same trust boundary as any bash tool call and as the original pi-codemode: it's your model, writing code for your session, on your machine. There is no sandbox. Do not point it at untrusted prompt sources.
Configuration
None.
Caveats
- No crash isolation. A pathological snippet (memory bomb, infinite sync loop) hurts the host. The timeout cannot preempt CPU-bound synchronous code — it aborts in-flight subagents and stops awaiting, but JS can't be killed mid-
while(true). Never write busy-wait or long synchronous loops: they block the host event loop and can freeze the session. - Executions are serialized. Runs queue behind each other because snippet bindings are passed through a single global; concurrent
codemodecalls do not run in parallel. The=console and/cxshare this same queue and the same runtime as the tool. $Nbinding is JSON on reload. Within a live session,$1… hold the raw default export (no serialization). On reload,$Nare rebuilt from the persistedcodemode-consoleentries, which are JSON — so non-JSON values (functions, symbols, class instances) are lost or simplified. Errored console runs are not bound to a$N.- Timeout semantics: on expiry, in-flight
spawncalls (including every stage of an in-flightrunWorkflow) are aborted (children stop quickly) and the tool returns a timeout error with captured logs; the detached snippet promise settles later and is discarded. export defaultis required. A missing/undefined default export returns a notice, not an error.- Results are bounded (16 KB), logs are bounded (200 entries × 2 KB), stacks are bounded (4 KB).
- bundle-require/ESM interop quirks: the snippet is esbuild-bundled as ESM with target es2022 (top-level await works); bare package imports fail by design (no
node_modulesnear the temp dir), but Node builtins and globals remain reachable — there is no sandbox. - Runscope volume: every spawn emits two ledger entries, and a
foreachfan-out of N items emits 2N. Entries persist to the session JSONL (never LLM context), so a large fan-out grows the session file — the trade-off for a durable trace. Emission is best-effort and never fails a run.