/** * task — `pi-task` as a first-class tool, so the isolated rung of the context * ladder competes with `fork` on equal footing. * * WHY. `fork` (L4: child inherits the ENTIRE parent branch) is a tool and is * therefore in the model's face every turn; `pi-task` (L0–L2: child sees only * an immutable spec) was a CLI reached through bash after remembering that it * exists and hand-writing a JSON file. Models choose among the affordances in * front of them, so the prose rule "prefer pi-task for work with prohibitions * or write boundaries" lost to the tool list — repeatedly, and by the agent * that had just read the rule. This registers the same CLI as `task`, with the * decision rule in the description AND in `promptGuidelines` (which pi appends * to the system prompt, so it survives compaction; the skill text does not). * * WHAT IT WRAPS. `/opt/pi-toolkit/bin/pi-task run ` (pi-toolkit). * The wrapper adds nothing to the contract; it just builds the spec from flat * parameters, validates the two things the CLI cannot catch until the run is * over (write_allowed ⊆ roots; no writable root nested in a watched one — both * make a violation certain, i.e. a guaranteed false FAIL), serialises tasks * whose roots overlap (parallel siblings see each other's writes as violations; * measured 2026-09-17), and returns the CLI's own parent-facing report plus the * parsed result.json as details. * * WHAT IT DOES NOT FIX. Isolation removes the *narrative* failures (parent * voice, invented continuity, ignored prohibitions). It does not remove * confabulation: the envelope proves shape, not truth. Spot-check the evidence * pointers — the report says so at the top of every result. * * Companion: `fork-gate.ts` blocks forks whose brief needs this tool. */ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; // ── Locating the CLI ───────────────────────────────────────────────────────── function findPiTask(): string { const env = process.env.PI_TASK_BIN; if (env && existsSync(env)) return env; const home = homedir(); const candidates = [ "/opt/pi-toolkit/bin/pi-task", path.join(home, "src/pi-toolkit/bin/pi-task"), path.join(home, "src/src_local/pi-toolkit/bin/pi-task"), "/workspace/pi-toolkit/bin/pi-task", ]; for (const c of candidates) if (existsSync(c)) return c; for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { const c = path.join(dir, "pi-task"); if (dir && existsSync(c)) return c; } throw new Error( "pi-task CLI not found (looked at $PI_TASK_BIN, /opt/pi-toolkit/bin, ~/src/pi-toolkit/bin, /workspace/pi-toolkit/bin, $PATH). " + "Install pi-toolkit or set PI_TASK_BIN.", ); } // ── Root overlap (exported for test/task.test.mjs) ─────────────────────────── /** True when one path is the other or lies inside it. */ export function rootsOverlap(a: string, b: string): boolean { const na = path.resolve(a); const nb = path.resolve(b); return na === nb || na.startsWith(nb + path.sep) || nb.startsWith(na + path.sep); } export function anyOverlap(as: string[], bs: string[]): boolean { return as.some((a) => bs.some((b) => rootsOverlap(a, b))); } /** * The two spec errors that make a boundary violation CERTAIN, checked before * spending a model run on them. Mirrors pi-task's own check: a delta line is * keyed by the root it was observed in, and is allowed only if that exact root * string is in write_allowed. So a writable subdirectory of a watched repo root * trips the repo root's porcelain diff every time. */ export function validateRoots(roots: string[], writeAllowed: string[] | undefined, readOnly: boolean): string[] { const problems: string[] = []; if (roots.length === 0) problems.push("roots must name at least one absolute path to watch"); for (const r of roots) { if (!path.isAbsolute(r)) problems.push(`root is not absolute: ${r}`); else if (!existsSync(r)) problems.push(`root does not exist: ${r}`); } if (readOnly) { if (writeAllowed && writeAllowed.length) problems.push("read_only=true but write_allowed is set — pick one"); return problems; } if (!writeAllowed || writeAllowed.length === 0) { problems.push( "read_only=false requires write_allowed (an exact subset of roots). Without it every root is writable and a violation is undetectable.", ); return problems; } for (const w of writeAllowed) { if (!roots.includes(w)) { problems.push(`write_allowed entry is not one of roots (must match a root string exactly): ${w}`); continue; } for (const r of roots) { if (r !== w && !writeAllowed.includes(r) && rootsOverlap(w, r)) { problems.push( `writable root ${w} is nested in (or contains) watched-only root ${r}: any write would register as a violation there. ` + "List the writable part as its own root and drop the enclosing one.", ); } } } return problems; } // ── Extension ──────────────────────────────────────────────────────────────── const ID_RE = /^[a-z0-9][a-z0-9-]{1,48}$/; const MAX_REPORT_BYTES = 24_000; export default function (pi: ExtensionAPI) { /** Tasks in flight, in registration order; a newcomer waits for earlier overlapping ones. */ const running: { id: string; roots: string[]; done: Promise }[] = []; pi.registerTool({ name: "task", label: "Task (pi-task, isolated child)", description: "Run a delegated task in an ISOLATED child agent (pi-task; context ladder L0–L2). Unlike `fork`, the child sees ONLY this spec — " + "not this conversation — so prohibitions and boundaries in the brief are actually obeyed. Returns a machine-checked PASS/FAIL: " + "the envelope must parse; every root is diffed before/after and a change outside write_allowed FAILS the task; cost and wall-clock " + "budgets apply; a full audit dir is kept under ~/.pi/agent/pi-task/. Use `task` for any delegated work that changes files, any " + "brief containing do-not/only/never, or whenever you want a checkable result rather than prose. Use `fork` only for read-only " + "exploration that needs this conversation's context, or N parallel opinions on one question. Roots are watched individually: " + "write_allowed must be an exact subset of roots, and a writable root must not sit inside another listed root (list the writable " + "part on its own). Tasks whose roots overlap are run one at a time automatically. Spot-check the evidence pointers in the result.", promptSnippet: "Delegate work to an isolated child agent with a PASS/FAIL envelope and write-boundary diff (prefer over fork for anything that writes or has prohibitions)", promptGuidelines: [ "Use task, not fork, when a delegated brief will change files, contains a prohibition (do not / only / never), or when a machine-checked PASS/FAIL is wanted; fork inherits this entire conversation and does not reliably obey such briefs.", "Treat a task result's envelope as shape, not truth: spot-check its evidence pointers before building on them.", ], parameters: Type.Object({ id: Type.String({ description: "Short slug (a-z, 0-9, -). Used in the session id and the audit dir name." }), goal: Type.String({ description: "Verbatim task statement. The child has NO other context — say everything." }), deliverable: Type.String({ description: "The exact shape of answer wanted (e.g. 'a table of file:line pointers', 'the edited files plus a one-line summary')." }), effort: Type.Optional( StringEnum(["fast", "balanced", "deep"] as const, { description: "Model tier from settings pi-fork.effortProfiles: fast=mechanical/lookups, balanced=default, deep=architecture/security/ambiguity.", }), ), read_only: Type.Optional(Type.Boolean({ description: "Default true. Any change under roots then FAILS the task." })), roots: Type.Array(Type.String(), { description: "Absolute paths WATCHED for changes (git porcelain --ignored, or a file manifest for non-git dirs). Each is diffed on its own.", }), write_allowed: Type.Optional( Type.Array(Type.String(), { description: "Exact subset of roots the child may CHANGE. Required when read_only=false. Must not be nested inside a watched-only root.", }), ), facts: Type.Optional(Type.Array(Type.String(), { description: "Verified facts the child may rely on without re-deriving (L2 context)." })), files: Type.Optional(Type.Array(Type.String(), { description: "Absolute paths the child should read itself (L1 context)." })), commands: Type.Optional(Type.Array(Type.String(), { description: "Exact commands the child may run." })), wall_s: Type.Optional(Type.Integer({ description: "Wall-clock budget in seconds (default 600). The child is killed at this point and the task FAILS." })), usd: Type.Optional(Type.Number({ description: "Cost budget in USD (default 1.0). Exceeding it FAILS the task after the fact." })), }), async execute(_toolCallId, params, signal, onUpdate) { const bin = findPiTask(); if (!ID_RE.test(params.id)) throw new Error(`id must match ${ID_RE} (got ${JSON.stringify(params.id)})`); const readOnly = params.read_only ?? true; const roots = params.roots.map((r) => path.resolve(r)); const writeAllowed = params.write_allowed?.map((r) => path.resolve(r)); const problems = validateRoots(roots, writeAllowed, readOnly); if (problems.length) throw new Error(`task spec rejected before launch:\n- ${problems.join("\n- ")}`); const wall = params.wall_s ?? 600; const spec = { id: params.id, goal: params.goal, deliverable: params.deliverable, effort: params.effort ?? "balanced", read_only: readOnly, roots, ...(writeAllowed ? { write_allowed: writeAllowed } : {}), context: { ...(params.facts?.length ? { facts: params.facts } : {}), ...(params.files?.length ? { files: params.files } : {}), ...(params.commands?.length ? { commands: params.commands } : {}), }, budget: { wall_s: wall, usd: params.usd ?? 1.0 }, }; // Serialise against earlier in-flight tasks whose roots overlap ours. const blockers = running.filter((t) => anyOverlap(t.roots, roots)); let release!: () => void; const me = { id: params.id, roots, done: new Promise((res) => (release = res)) }; running.push(me); try { if (blockers.length) { onUpdate?.({ content: [{ type: "text", text: `waiting for overlapping task(s) ${blockers.map((b) => b.id).join(", ")} — overlapping roots run sequentially` }], }); await Promise.all(blockers.map((b) => b.done)); } if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled before launch" }], details: { cancelled: true } }; const dir = mkdtempSync(path.join(tmpdir(), "pi-task-spec-")); const specPath = path.join(dir, `${params.id}.json`); writeFileSync(specPath, JSON.stringify(spec, null, 2)); onUpdate?.({ content: [{ type: "text", text: `pi-task run ${params.id} (${spec.effort}, ${readOnly ? "read-only" : `writes: ${writeAllowed!.join(", ")}`}, ≤${wall}s)` }] }); let res: { stdout: string; stderr: string; code: number | null; killed?: boolean }; try { res = await pi.exec(bin, ["run", specPath], { signal, timeout: (wall + 90) * 1000 }); } finally { rmSync(dir, { recursive: true, force: true }); } // 0 = PASS, 1 = FAIL verdict — both are results. Anything else is the CLI refusing. if (res.code !== 0 && res.code !== 1) { throw new Error( `pi-task did not produce a verdict (exit ${res.code}${res.killed ? ", killed" : ""}):\n${(res.stderr || res.stdout).slice(-4000)}`, ); } const auditDir = /^\s*audit:\s*(\S+)\s*$/m.exec(res.stdout)?.[1]; let result: Record | undefined; if (auditDir && existsSync(path.join(auditDir, "result.json"))) { try { result = JSON.parse(readFileSync(path.join(auditDir, "result.json"), "utf8")); } catch {} } let report = res.stdout.trimEnd(); if (report.length > MAX_REPORT_BYTES) report = `${report.slice(0, MAX_REPORT_BYTES)}\n… (report truncated; full result at ${auditDir ?? "audit dir"}/result.json)`; return { content: [{ type: "text", text: report }], details: { verdict: res.code === 0 ? "PASS" : "FAIL", audit_dir: auditDir, result }, }; } finally { release(); const i = running.indexOf(me); if (i >= 0) running.splice(i, 1); } }, }); }