@nicknisi/pi-codemode

extensionmaintained

Execute model-written TypeScript that orchestrates subagents compositionally — one codemode tool over the first-party in-process runtime

by — · v0.4.1 · published 5d ago

$ pi install npm:@nicknisi/pi-codemode
downloads/mo
0
stars
41
last push
1d ago
open issues
4

Signals

license: MITtestspi manifest: missinginstall size: —deps: 0peer deps: 0

Download trend

No downloads in the last 12 weeks.

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

  • codemode tool (model-facing) — params: { code: string; label?: string; timeoutMs?: number } (default 10 min, capped at 30 min). The snippet gets two injected bindings and must export default its result.
  • Runscope orchestration ledger (parent-session side effect) — every spawn/runWorkflow lifecycle 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.
  • /cx named snippets (slash command) — run named snippet files from ~/.pi/agent/snippets/ or .pi/snippets/. See /cx named snippets.

The snippet API

spawn(options: SpawnOptions): Promise<SpawnResult>;
runWorkflow(spec: WorkflowSpec, opts?): Promise<WorkflowResult>;
log(...args: unknown[]): void;
  • spawn launches a hermetic in-process child agent through the shared runtime (namespace codemode). 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/ with the owning Pi session recorded, so active codemode children appear in that session's default fleet view; settled and machine-wide records remain available through the fleet's explicit all scope.
  • spawn never rejects. Check result.ok; failures carry kind: 'crashed' | 'empty' | 'schema_invalid' | 'aborted' plus error.
  • Output contracts (breaking). Every spawn() must declare its contract explicitly: either outputSchema (validated; parsed JSON lands in result.data) or text: true (raw text opt-in). A spawn() 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), gets undefined, 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: add text: true to existing text-mode spawns, or define an outputSchema.
  • runWorkflow runs a declarative multi-stage DAG over spawn (from @nicknisi/pi-shared's engine): per-stage needs deps (default linear), foreach fan-out, gate revise-feedback loops, retries, tokenBudget, and sharesTree stages that never overlap other work and hand their bounded git diff HEAD to dependents. Control artifacts land in ~/.pi/agent/workflow-runs/; opts.resumeFrom skips previously-ok stages. Also never rejects — per-stage outcomes carry ok/kind. Prefer it over hand-rolled Promise.all when stages depend on each other, need gates/retries, or edit the working tree.
  • SpawnOptions highlights: prompt (required), agent (label), model ('provider/id'), tools (allowlist — undefined defaults to read-only ['read','grep','find','ls']; pass ['read','bash','edit','write'] explicitly for builders), systemPrompt, outputSchema (validated; parsed JSON lands in result.data), text (boolean — opt into raw text mode; required when outputSchema is omitted), cwd, timeoutMs, maxTurns, maxToolCalls, thinkingLevel.
  • log output comes back in the tool result's details.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 every spawn, standalone or inside a workflow stage. spawn_end carries ok and, on failure, failureKind + error.
  • stage_start / stage_end — around every workflow stage (including skipped / budget-exceeded ones). stage_end carries ok and, on failure, failureKind + error.
  • gate_result — emitted when a stage gate verdict is reached. Carries passed: boolean and, 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 an async LLM call — runWorkflow does not await the gate, so a Promise would be treated as a non-true object with an undefined revise. 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 like critic above — but note downstream stages do not loop back into the builder, so the autonomous revise loop lives entirely in the builder's own gate/maxGateAttempts.
  • execSync blocks the host event loop. git diff HEAD is 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_modules does not. import { execSync } from 'node:child_process' is externalized by bundle-require and resolves at runtime; bare package imports fail (no node_modules near 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 treeDiffs handoff. A builder producing a huge diff passes the gate's line cap but still truncates for downstream stages — keep MAX_ADDED_LINES under 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

Optional, via ~/.pi/agent/configs/codemode.json:

{
  "childModel": "provider/model-id",
  "childThinkingLevel": "xhigh"
}
  • childModel — default model for every child spawn (standalone spawn and workflow stages). null (default) falls back to the session's current model. An explicit model: on a spawn/stage overrides it.
  • childThinkingLevel — default thinking level for child spawns. null (default) falls back to the session's current thinking level. An explicit thinkingLevel: overrides it.

Child concurrency is capped at 6 per codemode run (process-wide ceiling 8). While a codemode tool call runs, the TUI streams live progress (running/done/failed spawn counts and the last few log lines) into the tool row.

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 codemode calls do not run in parallel. The = console and /cx share this same queue and the same runtime as the tool.
  • $N binding is JSON on reload. Within a live session, $1… hold the raw default export (no serialization). On reload, $N are rebuilt from the persisted codemode-console entries, 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 spawn calls (including every stage of an in-flight runWorkflow) 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 default is 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_modules near the temp dir), but Node builtins and globals remain reachable — there is no sandbox.
  • Runscope volume: every spawn emits two ledger entries, and a foreach fan-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.