Files
jason.woltje 623388603b Merge organizations MVP (ui-org-r2) into refactor
BUILD-LOG.md: append-only union, refactor entries first, then the branch's.
docs/SESSIONS.md: append-only union, refactor entries first, then the branch's.
packages/bus/src/runtime.mjs: keep the organization usage timer and refactor's halted/invalid startup-recovery gate.
packages/harness/src/first-output.mjs: keep the branch's harness-neutral markers; refactor's empty-length rejection moves into the Pi monitor (packages/plugins/harness/pi/model-stream.mjs).
packages/harness/tests/runner.test.mjs: keep refactor's length-continuation cases and the branch's output-policy cases.
packages/plugins/harness/pi/index.mjs: keep the documented GLM limits and the branch's transport-based bundle.

Suites: 6356 pass, 32 fail; all 32 fail identically on both parents
(node_modules symlink EISDIR in pinned-copy and stack copy/release tests,
missing Python jsonschema in two conversation tests).
2026-11-08 21:38:42 -06:00

126 lines
7.2 KiB
JavaScript

// 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 <level>` 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 at ${BIN}; 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";