// The "pi" harness plug-in (row 65, REQ-PLUG-2, Q156): a managed session on the pinned // @earendil-works/pi-coding-agent CLI, the repository's node_modules/.bin/pi. The tool gate is the // Pi extension, which stays in packages/harness (a module can't change that boundary). The // launcher, the bundle and the usage reader call these on the host; the runner gets the answers // as data in session.json. packages/plugins/README.md lists the interface. import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; export const interfaceVersion = 1; const REPO = fileURLToPath(new URL("../../../../", import.meta.url)); const PACKAGE = "@earendil-works/pi-coding-agent"; const BIN = join(REPO, "node_modules", ".bin", "pi"); const EXTENSION = fileURLToPath(new URL("../../../harness/src/pi-extension.mjs", import.meta.url)); // Row 66 (#1559): the unslop check, loaded as a second extension. It logs to MOSAIC_UNSLOP_LOG, // which the runner sets, and the runner tells the operator. const UNSLOP = Object.freeze({ unslopExtension: join(REPO, "tools", "unslop-hook", "extension.ts"), unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"), unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"), }); // `pi --thinking ` on 0.85.1 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md). const LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); // docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5. const RELIES_ON = Object.freeze(["S0-1 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start", "S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"]); export const adapter = () => join(REPO, "adapters", "pi", "adapter.sh"); export const levels = () => LEVELS; export const outputPolicy = ({ proxy }) => proxy ? "non-newline-stdout" : "stdout"; // The session runs pi from the repository's node_modules, which the sandbox mounts. export function preflight({ env = {}, proxy = null } = {}) { if (!(typeof proxy?.key === "string" && proxy.key) && !["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY"].some(name => Boolean(env[name]))) { return { code: "no-session-credential", message: "pi needs its own provider key or a session proxy key" }; } if (!existsSync(BIN)) return { code: "harness-binary-missing", message: "pi isn't installed in the repository's node_modules; run npm ci in the repository" }; return null; } // The exact pin from the repository's package.json. export function version() { try { const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8")); return pkg.dependencies?.[PACKAGE] ?? pkg.devDependencies?.[PACKAGE] ?? null; } catch { return null; } } // Pi sessions run on a provider key, never the Claude account limit. export const countsAccount = () => false; export const mounts = () => [ { src: join(REPO, "packages/plugins/harness/pi/model-stream.mjs"), why: "the managed model event stream" }, { src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" }, ...Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's second extension runs" })), ]; // Pi has no web tool of its own, so a granted role under harness-builtin is refused. export const builtinWeb = () => false; export const builtins = ({ tools }) => [...tools]; export const typedName = (name) => name; export const reliesOn = () => RELIES_ON; export const gateTimeout = () => null; // https://docs.z.ai/guides/overview/concept-param // https://docs.z.ai/guides/vlm/glm-5.3-flash // Pi may compact and retry for length stops below maxTokens (pi-ai // overflow.js:163 and pi-coding-agent agent-session.js:1659): once in a row, // re-armed by a new prompt or a successful reply, so tool rounds can repeat it. const DOCUMENTED_MODEL_LIMITS = { 'glm-5.3-flash': { maxTokens: 65536, contextWindow: 128000 }, }; export function bundle({ dir, models, proxy = null, transports = {} } = {}) { const sources = { piExtension: EXTENSION, ...UNSLOP }; if (!proxy) return { files: {}, digests: [], sources }; const providers = {}; for (const entry of Object.values(models ?? {})) { const transport = transports[Object.keys(models).find(key => models[key] === entry)]; if (!transport) continue; const { piProvider: provider, api, endpoint, compat } = transport; if (typeof entry.model !== "string" || !entry.model || entry.model.startsWith("!")) throw new Error("unsafe proxy model id"); const baseUrl = proxy.baseUrl + (endpoint === 'v1' ? '/v1' : ''); const compatibility = compat ?? undefined; if (providers[provider] && (providers[provider].baseUrl !== baseUrl || providers[provider].api !== api || JSON.stringify(providers[provider].compat) !== JSON.stringify(compatibility))) throw new Error('conflicting proxy transports'); providers[provider] ??= { baseUrl, api, apiKey: "$ANTHROPIC_API_KEY", ...(compatibility ? { compat: compatibility } : {}), models: [] }; if (!providers[provider].models.some(model => model.id === entry.model)) { providers[provider].models.push({ id: entry.model, reasoning: true, ...(Object.hasOwn(DOCUMENTED_MODEL_LIMITS, entry.model) ? DOCUMENTED_MODEL_LIMITS[entry.model] : {}) }); } } const text = JSON.stringify({ providers }, null, 2) + "\n"; // Even an accidental collision between a configured model id and the key fails closed. if (typeof proxy.key !== "string" || !proxy.key || text.includes(proxy.key)) throw new Error("unsafe proxy model configuration"); const runDir = dirname(dir); for (const part of ["home", "home/.pi", "home/.pi/agent"]) { const path = join(runDir, part); mkdirSync(path, { recursive: true, mode: 0o700 }); const stat = lstatSync(path); if (!stat.isDirectory() || (stat.mode & 0o777) !== 0o700) throw new Error("pi proxy config needs private run-local directories"); } const file = join(runDir, "home", ".pi", "agent", "models.json"); writeFileSync(file, text, { mode: 0o600, flag: "wx" }); return { files: { proxyModels: file }, digests: [["../home/.pi/agent/models.json", createHash("sha256").update(text).digest("hex")]], sources }; } // The tool limit is the built-ins plus the typed tools, which the extension serves. export function env({ policy, tools, session, proxy = null }) { return { PI_OFFLINE: "1", MOSAIC_TOOLS: [...policy.tools, ...tools.map((t) => t.name)].join(","), MOSAIC_EXTENSIONS: [EXTENSION, UNSLOP.unslopExtension].join(","), PI_PROVIDER: proxy ? session.proxyTransport.piProvider : session.provider ?? "", PI_MODEL: session.model, MOSAIC_THINKING: session.thinking, ...(proxy ? { ANTHROPIC_API_KEY: proxy.key } : {}), }; } // The extension writes the turn marker at agent_end; an exit without it is a failed turn. export const turnEnd = () => "marker"; export const transcript = ({ runDir, store = null }) => (store ? store.session : join(runDir, "session")); // Row 307 (#1814): the session store's compactions, read from its pi streams (compactions.mjs). export { compactions } from "./compactions.mjs";