@sweny-ai/core 0.1.147 → 0.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,966 @@
1
+ /**
2
+ * pi harness (#415): runs a node on the pi coding agent (bring any model)
3
+ * through `pi --mode rpc`.
4
+ *
5
+ * Interfaces used, all read from badlogic/pi-mono at v0.99.2
6
+ * (`packages/coding-agent`, now also published as earendil-works/pi):
7
+ * - `docs/cli.md`: `--mode rpc`, `--no-session`, `--tools` (allowlist over
8
+ * built-in, extension and MCP tools), `--exclude-tools`, `--no-tools`,
9
+ * `--no-extensions` with explicit `-e builtin:mcp`, `--no-context-files`,
10
+ * `--no-skills`, `--no-prompt-templates`, `--no-themes`, `--no-approve`,
11
+ * `--model`, `--append-system-prompt`.
12
+ * - `docs/rpc.md`, `docs/rpc-commands.md`: JSONL framing on LF only, the
13
+ * `prompt`, `abort` and `get_session_stats` commands, `agent_settled`.
14
+ * - `docs/json.md`, `docs/message-types.md`: `message_end` (assistant message
15
+ * with `stopReason` and `errorMessage`), `tool_execution_start|end`,
16
+ * `auto_retry_end`.
17
+ * - `docs/mcp.md`, `src/extensions/mcp/`: `mcp.json` in `$PI_CODING_AGENT_DIR`,
18
+ * `exposure: "direct"` so tools are declared to the model, `${VAR}` in
19
+ * `env`, tool names `mcp__<server>__<tool>`.
20
+ * - `docs/security.md`: "no built-in sandbox", so the whole process runs
21
+ * inside the sweny process wrapper (sandbox-wrapper.ts) and strict refuses
22
+ * without it.
23
+ * - `src/core/agent-session.ts` `_refreshToolRegistry`: a `--tools` allowlist
24
+ * filters every registered tool, MCP tools included, by exact name.
25
+ *
26
+ * Every sweny opinion in the pi column of the enforcement matrix is enforced
27
+ * natively, kept by sweny (wrapper, tool bridge, prompt + parse + validate,
28
+ * watchdog), reported in `degraded`, or refused under strict policy by
29
+ * `policyGate`. Nothing is dropped silently.
30
+ */
31
+ import { spawn, execFile } from "node:child_process";
32
+ import { createHash, randomBytes } from "node:crypto";
33
+ import * as fs from "node:fs";
34
+ import * as os from "node:os";
35
+ import * as path from "node:path";
36
+ import { consoleLogger } from "../types.js";
37
+ import { PI_AUTH_VARS, PI_ENV_PREFIXES, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, } from "../agent-env.js";
38
+ import { PI_CAPABILITIES } from "./capabilities.js";
39
+ import { policyGate, resolveHarnessPolicy } from "./policy.js";
40
+ import { buildNodePrompt } from "./prompts.js";
41
+ import { makeAbort } from "./abort.js";
42
+ import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
43
+ import { startToolBridge } from "./tool-bridge/server.js";
44
+ import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandbox-wrapper.js";
45
+ import { TOKEN_ENV } from "./tool-bridge/protocol.js";
46
+ export { PI_CAPABILITIES };
47
+ /**
48
+ * Oldest pi this adapter is written against. The docs and source were read at
49
+ * v0.99.2; older releases are unverified, so they are refused.
50
+ */
51
+ export const MIN_PI_VERSION = "0.99.2";
52
+ /** Same system prompt Claude Code nodes get, appended to pi's own. */
53
+ const SYSTEM_PROMPT = `You are a step in an automated workflow. Execute the instruction precisely using the tools available to you. Be thorough but concise. When you're done, summarize your findings and results.`;
54
+ /** Name of the MCP server that carries sweny's skill tools (the tool bridge). */
55
+ export const PI_BRIDGE_SERVER = "sweny-core";
56
+ /** pi built-in tools that can write or run code, per portable class (docs/cli.md tools table). */
57
+ const BUILTIN_BY_CLASS = {
58
+ shell: ["bash", "powershell"],
59
+ write: ["write"],
60
+ edit: ["edit"],
61
+ };
62
+ /** Built-in tools a dry run may keep: they only read. */
63
+ const READ_ONLY_BUILTINS = ["read", "grep", "find", "ls"];
64
+ /**
65
+ * `disallowed_tools` names a node may use, per portable class. Claude Code
66
+ * names are included because bundled workflows (triage.yml) were written for
67
+ * it; pi names come from its tool table.
68
+ */
69
+ const DENY_NAME_TO_CLASSES = {
70
+ // Claude Code built-ins
71
+ Bash: ["shell"],
72
+ Write: ["write"],
73
+ NotebookEdit: ["write"],
74
+ Edit: ["edit"],
75
+ MultiEdit: ["edit"],
76
+ WebFetch: ["net"],
77
+ WebSearch: ["net"],
78
+ Task: ["subagent"],
79
+ Agent: ["subagent"],
80
+ // pi built-ins
81
+ bash: ["shell"],
82
+ powershell: ["shell"],
83
+ write: ["write"],
84
+ edit: ["edit"],
85
+ };
86
+ /** Read-only pi tools a node may still deny by name (`Read` and friends map to them). */
87
+ const DENY_NAME_TO_PI_TOOL = {
88
+ Read: "read",
89
+ Grep: "grep",
90
+ Glob: "find",
91
+ LS: "ls",
92
+ read: "read",
93
+ grep: "grep",
94
+ find: "find",
95
+ ls: "ls",
96
+ };
97
+ /** Provider API hosts pi needs through the sandbox wrapper, keyed by the provider's API-key variable. */
98
+ const PROVIDER_HOSTS = {
99
+ ANTHROPIC_API_KEY: "api.anthropic.com",
100
+ ANTHROPIC_OAUTH_TOKEN: "api.anthropic.com",
101
+ ANTHROPIC_AUTH_TOKEN: "api.anthropic.com",
102
+ OPENAI_API_KEY: "api.openai.com",
103
+ GEMINI_API_KEY: "generativelanguage.googleapis.com",
104
+ OPENROUTER_API_KEY: "openrouter.ai",
105
+ GROQ_API_KEY: "api.groq.com",
106
+ MISTRAL_API_KEY: "api.mistral.ai",
107
+ XAI_API_KEY: "api.x.ai",
108
+ DEEPSEEK_API_KEY: "api.deepseek.com",
109
+ };
110
+ /** Model provider prefix (`openrouter/...`) to its host, for runs with no provider key in the env. */
111
+ const PROVIDER_PREFIX_HOSTS = {
112
+ anthropic: "api.anthropic.com",
113
+ openai: "api.openai.com",
114
+ google: "generativelanguage.googleapis.com",
115
+ openrouter: "openrouter.ai",
116
+ groq: "api.groq.com",
117
+ mistral: "api.mistral.ai",
118
+ xai: "api.x.ai",
119
+ deepseek: "api.deepseek.com",
120
+ };
121
+ /**
122
+ * Hosts pi itself needs through the sandbox wrapper: the API host of every
123
+ * provider whose key is in the env, and of the provider named in `--model`
124
+ * (`provider/id`). Other providers (custom endpoints in `models.json`, cloud
125
+ * providers) are added with `SWENY_SANDBOX_ALLOWED_DOMAINS`.
126
+ */
127
+ export function piBackendHosts(env = process.env, model) {
128
+ const hosts = new Set();
129
+ for (const [v, host] of Object.entries(PROVIDER_HOSTS))
130
+ if (env[v])
131
+ hosts.add(host);
132
+ const prefix = model && model.includes("/") ? model.split("/")[0].toLowerCase() : "";
133
+ if (prefix && PROVIDER_PREFIX_HOSTS[prefix])
134
+ hosts.add(PROVIDER_PREFIX_HOSTS[prefix]);
135
+ return [...hosts];
136
+ }
137
+ // ─── Helpers ─────────────────────────────────────────────────────
138
+ function compareVersions(a, b) {
139
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
140
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
141
+ for (let i = 0; i < 3; i++) {
142
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
143
+ return (pa[i] ?? 0) - (pb[i] ?? 0);
144
+ }
145
+ return 0;
146
+ }
147
+ /**
148
+ * Split `disallowed_tools` names into portable classes pi can act on, pi tools
149
+ * it can exclude by name, and the names it has no tool for.
150
+ */
151
+ export function translateDenyNames(names = []) {
152
+ const classes = [];
153
+ const piTools = [];
154
+ const unknown = [];
155
+ for (const n of names) {
156
+ const mapped = DENY_NAME_TO_CLASSES[n];
157
+ const tool = DENY_NAME_TO_PI_TOOL[n];
158
+ if (mapped)
159
+ classes.push(...mapped);
160
+ if (tool)
161
+ piTools.push(tool);
162
+ if (!mapped && !tool)
163
+ unknown.push(n);
164
+ }
165
+ return { classes: [...new Set(classes)], piTools: [...new Set(piTools)], unknown };
166
+ }
167
+ /** The name pi registers an MCP tool under (`src/extensions/mcp/tools.ts` createMcpToolName). */
168
+ export function piMcpToolName(server, tool, isTaken = () => false) {
169
+ const max = 64;
170
+ const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_");
171
+ if (name.length <= max && !isTaken(name))
172
+ return name;
173
+ const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8);
174
+ return `${name.slice(0, max - hash.length - 1)}_${hash}`;
175
+ }
176
+ /** pi registered names of the bridge's tools, and the way back to the sweny names. */
177
+ export function bridgeToolNames(tools) {
178
+ const taken = new Set();
179
+ const back = new Map();
180
+ for (const t of tools) {
181
+ const name = piMcpToolName(PI_BRIDGE_SERVER, t.name, (n) => taken.has(n));
182
+ taken.add(name);
183
+ back.set(name, t.name);
184
+ }
185
+ return { names: [...back.keys()], back };
186
+ }
187
+ /** Does pi's config syntax treat this value specially (`!command`, `${VAR}`)? Such a value is never written. */
188
+ function hasPiConfigSyntax(value) {
189
+ return value.startsWith("!") || value.includes("${");
190
+ }
191
+ const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
192
+ /**
193
+ * `mcp.json` entries for the external servers. Returns what pi cannot load
194
+ * (reported in `degraded`). Values using pi's `!command` or `${VAR}` syntax are
195
+ * refused, so a sweny config value can never run a command inside pi.
196
+ */
197
+ export function piMcpServers(servers) {
198
+ const entries = {};
199
+ const unsupported = [];
200
+ const namespaces = new Map([[PI_BRIDGE_SERVER.replace(/-/g, "_"), PI_BRIDGE_SERVER]]);
201
+ for (const [id, s] of Object.entries(servers)) {
202
+ const ns = id.replace(/-/g, "_");
203
+ if (!SERVER_NAME.test(id)) {
204
+ unsupported.push(`${id} (pi server names allow letters, digits, _ and -)`);
205
+ continue;
206
+ }
207
+ if (namespaces.has(ns)) {
208
+ unsupported.push(`${id} (same pi namespace as ${namespaces.get(ns)})`);
209
+ continue;
210
+ }
211
+ const record = (values) => Object.values(values ?? {}).every((v) => !hasPiConfigSyntax(v));
212
+ if (s.type === "http" || (!s.type && s.url && !s.command)) {
213
+ if (!s.url) {
214
+ unsupported.push(`${id} (http without url)`);
215
+ continue;
216
+ }
217
+ if (!record(s.headers)) {
218
+ unsupported.push(`${id} (header value uses pi config syntax)`);
219
+ continue;
220
+ }
221
+ entries[id] = {
222
+ url: s.url,
223
+ ...(s.headers && Object.keys(s.headers).length > 0 ? { headers: s.headers } : {}),
224
+ exposure: "direct",
225
+ };
226
+ }
227
+ else if (s.command) {
228
+ if (hasPiConfigSyntax(s.command) || !(s.args ?? []).every((a) => !hasPiConfigSyntax(a)) || !record(s.env)) {
229
+ unsupported.push(`${id} (value uses pi config syntax)`);
230
+ continue;
231
+ }
232
+ entries[id] = {
233
+ command: s.command,
234
+ ...(s.args && s.args.length > 0 ? { args: s.args } : {}),
235
+ ...(s.env && Object.keys(s.env).length > 0 ? { env: s.env } : {}),
236
+ exposure: "direct",
237
+ };
238
+ }
239
+ else {
240
+ unsupported.push(`${id} (type ${String(s.type)})`);
241
+ continue;
242
+ }
243
+ namespaces.set(ns, id);
244
+ }
245
+ return { entries, unsupported };
246
+ }
247
+ // ─── Process registry (crash cleanup) ────────────────────────────
248
+ const liveChildren = new Set();
249
+ let exitHookInstalled = false;
250
+ /**
251
+ * Stop pi. It stays in sweny's process group (not detached), so a Ctrl-C or a
252
+ * CI cancel reaches it too; pi stops its MCP servers itself when it exits.
253
+ */
254
+ function stopChild(child, signal) {
255
+ if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
256
+ return;
257
+ try {
258
+ child.kill(signal);
259
+ }
260
+ catch {
261
+ // already gone
262
+ }
263
+ }
264
+ function trackChild(child) {
265
+ liveChildren.add(child);
266
+ if (!exitHookInstalled) {
267
+ exitHookInstalled = true;
268
+ // A crashing sweny must not leave pi (and its MCP servers) running.
269
+ process.on("exit", () => {
270
+ for (const c of liveChildren)
271
+ stopChild(c, "SIGKILL");
272
+ });
273
+ }
274
+ }
275
+ /**
276
+ * `get_session_stats` data onto NodeUsage. pi reports 0 for what a provider did
277
+ * not report (and for a model it has no price for), so only positive values are
278
+ * kept: absent stays absent, never a guessed 0 or a fake free run.
279
+ */
280
+ export function mapStats(data) {
281
+ if (!data || typeof data !== "object")
282
+ return undefined;
283
+ const d = data;
284
+ const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
285
+ const t = d.tokens ?? {};
286
+ const usage = {
287
+ costUsd: pos(d.cost),
288
+ inputTokens: pos(t.input),
289
+ outputTokens: pos(t.output),
290
+ cacheReadTokens: pos(t.cacheRead),
291
+ cacheCreationTokens: pos(t.cacheWrite),
292
+ };
293
+ const present = Object.fromEntries(Object.entries(usage).filter(([, v]) => v !== undefined));
294
+ return Object.keys(present).length > 0 ? present : undefined;
295
+ }
296
+ function textOf(content) {
297
+ if (typeof content === "string")
298
+ return content;
299
+ if (!Array.isArray(content))
300
+ return "";
301
+ return content
302
+ .filter((b) => !!b && b.type === "text" && typeof b.text === "string")
303
+ .map((b) => b.text)
304
+ .join("\n");
305
+ }
306
+ // ─── Adapter ─────────────────────────────────────────────────────
307
+ export class PiHarness {
308
+ id = "pi";
309
+ capabilities = PI_CAPABILITIES;
310
+ /**
311
+ * Empty on purpose: judges use the model pi would use anyway. Leaving this
312
+ * undefined would fall back to the Claude judge model, which pi may not have.
313
+ */
314
+ defaultJudgeModel = "";
315
+ logger;
316
+ model;
317
+ maxTurns;
318
+ cwd;
319
+ defaultContext;
320
+ mcpServers;
321
+ defaultMcpServers;
322
+ envPassthrough;
323
+ envScope;
324
+ sandboxMode;
325
+ sandboxWrapper;
326
+ policyMode;
327
+ piCommand;
328
+ modelsFile;
329
+ toolBridgeShim;
330
+ abortGraceMs;
331
+ killGraceMs;
332
+ statsTimeoutMs;
333
+ egressWarned = false;
334
+ preflightResult;
335
+ version = "unknown";
336
+ constructor(opts = {}) {
337
+ this.model = opts.model;
338
+ this.maxTurns = opts.maxTurns ?? 20;
339
+ this.cwd = opts.cwd ?? process.cwd();
340
+ this.logger = opts.logger ?? consoleLogger;
341
+ this.defaultContext = opts.defaultContext ?? { config: {}, logger: this.logger };
342
+ this.mcpServers = opts.mcpServers ?? {};
343
+ this.defaultMcpServers = opts.defaultMcpServers ?? {};
344
+ this.envPassthrough = opts.envPassthrough;
345
+ this.envScope = opts.envScope;
346
+ this.sandboxMode = opts.sandbox;
347
+ this.sandboxWrapper = opts.sandboxWrapper;
348
+ this.policyMode = opts.policy ?? resolveHarnessPolicy(process.env, undefined, this.logger);
349
+ this.piCommand = opts.piCommand ?? { command: process.env.SWENY_PI_PATH || "pi", args: [] };
350
+ this.modelsFile = opts.modelsFile ?? (process.env.SWENY_PI_MODELS_JSON || undefined);
351
+ this.toolBridgeShim = opts.toolBridgeShim;
352
+ this.abortGraceMs = opts.abortGraceMs ?? 3000;
353
+ this.killGraceMs = opts.killGraceMs ?? 2000;
354
+ this.statsTimeoutMs = opts.statsTimeoutMs ?? 5000;
355
+ }
356
+ info() {
357
+ return { id: this.id, version: this.version };
358
+ }
359
+ /** `pi --version`, checked against {@link MIN_PI_VERSION}. Cached per instance. */
360
+ preflight() {
361
+ this.preflightResult ??= new Promise((resolve) => {
362
+ const { command, args } = this.piCommand;
363
+ execFile(command, [...args, "--version"], { timeout: 30_000 }, (err, stdout, stderr) => {
364
+ if (err) {
365
+ const code = err.code;
366
+ resolve({
367
+ ok: false,
368
+ reason: code === "ENOENT"
369
+ ? `pi CLI not found ("${command}"). Install it: npm install -g @earendil-works/pi-coding-agent (>= ${MIN_PI_VERSION}), or set SWENY_PI_PATH`
370
+ : `pi --version failed: ${err.message}`,
371
+ });
372
+ return;
373
+ }
374
+ // pi may print its version on stdout or stderr.
375
+ const m = /(\d+\.\d+\.\d+)/.exec(`${String(stdout)} ${String(stderr)}`);
376
+ if (!m) {
377
+ resolve({ ok: false, reason: `could not read the pi version from "${String(stdout).trim()}"` });
378
+ return;
379
+ }
380
+ if (compareVersions(m[1], MIN_PI_VERSION) < 0) {
381
+ resolve({ ok: false, reason: `pi ${m[1]} is too old; sweny needs >= ${MIN_PI_VERSION}` });
382
+ return;
383
+ }
384
+ this.version = m[1];
385
+ resolve({ ok: true, version: m[1] });
386
+ });
387
+ });
388
+ return this.preflightResult;
389
+ }
390
+ /** Env for the pi process: scoped like Claude Code's, with pi's provider credentials instead of Anthropic's. */
391
+ buildEnv(extraVars = []) {
392
+ const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
393
+ let env = full;
394
+ if (resolveEnvScope(process.env, this.envScope, this.logger)) {
395
+ const scoped = scopeAgentEnv(full, {
396
+ extraVars,
397
+ passthrough: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
398
+ authVars: PI_AUTH_VARS,
399
+ prefixes: PI_ENV_PREFIXES,
400
+ logger: this.logger,
401
+ });
402
+ reportWithheldEnv(scoped.withheld, this.logger);
403
+ env = scoped.env;
404
+ }
405
+ // pi must not phone home or look for updates; the agent dir is set per spawn.
406
+ return { ...env, PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" };
407
+ }
408
+ /** The node policy after `disallowed_tools` names are translated into pi classes. */
409
+ compilePolicy(req, maxTurns) {
410
+ // A node policy can make the run stricter, never looser than the harness policy mode.
411
+ const base = req.policy
412
+ ? { ...req.policy, strict: req.policy.strict || this.policyMode === "strict" }
413
+ : {
414
+ readOnly: !!req.readOnly,
415
+ deny: req.deny ?? [],
416
+ nativeDeny: req.disallowedTools,
417
+ egress: req.agentAccess?.domains ?? [],
418
+ strict: this.policyMode === "strict",
419
+ };
420
+ const { classes, piTools, unknown } = translateDenyNames(base.nativeDeny);
421
+ return {
422
+ policy: {
423
+ ...base,
424
+ deny: [...new Set([...base.deny, ...classes])],
425
+ nativeDeny: unknown,
426
+ maxTurns: base.maxTurns ?? maxTurns,
427
+ },
428
+ piTools,
429
+ };
430
+ }
431
+ /** argv shared by run() and complete(); the prompt travels as an RPC command, never in argv. */
432
+ baseArgs(model, withMcp) {
433
+ const args = [
434
+ ...this.piCommand.args,
435
+ "--mode",
436
+ "rpc",
437
+ "--no-session",
438
+ // Nothing from the operator's or the project's pi setup: no context files,
439
+ // skills, prompt templates, themes, extensions, or trust-gated project files.
440
+ "--no-context-files",
441
+ "--no-skills",
442
+ "--no-prompt-templates",
443
+ "--no-themes",
444
+ "--no-approve",
445
+ "--no-extensions",
446
+ "--append-system-prompt",
447
+ SYSTEM_PROMPT,
448
+ ];
449
+ if (withMcp)
450
+ args.push("-e", "builtin:mcp");
451
+ if (model)
452
+ args.push("--model", model);
453
+ return args;
454
+ }
455
+ /** Spawn pi in rpc mode, send the prompt, and read events until it settles and exits. */
456
+ async exec(opts) {
457
+ const out = {
458
+ settled: false,
459
+ exitCode: null,
460
+ exitSignal: null,
461
+ stderrTail: "",
462
+ toolCalls: [],
463
+ };
464
+ const byId = new Map();
465
+ let toolCount = 0;
466
+ let stopped = false;
467
+ let statsSent = false;
468
+ const abort = makeAbort(opts.timeoutMs, opts.signal);
469
+ if (abort?.controller.signal.aborted) {
470
+ // Aborted before pi started: nothing to spawn or stop.
471
+ abort.clear();
472
+ out.abortReason = abort.reason();
473
+ return out;
474
+ }
475
+ let child;
476
+ try {
477
+ child = spawn(opts.spawn.command, opts.spawn.args, {
478
+ cwd: opts.spawn.cwd,
479
+ env: opts.spawn.env,
480
+ stdio: ["pipe", "pipe", "pipe"],
481
+ });
482
+ }
483
+ catch (err) {
484
+ abort?.clear();
485
+ out.spawnError = err instanceof Error ? err.message : String(err);
486
+ return out;
487
+ }
488
+ trackChild(child);
489
+ const closed = new Promise((resolve) => {
490
+ child.once("close", (code, signal) => {
491
+ out.exitCode = code;
492
+ out.exitSignal = signal;
493
+ resolve();
494
+ });
495
+ child.once("error", (err) => {
496
+ out.spawnError = err.message;
497
+ resolve();
498
+ });
499
+ });
500
+ child.stdin?.on("error", () => {
501
+ // pi exited before reading a command; the exit code tells the story.
502
+ });
503
+ const send = (command) => {
504
+ if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded)
505
+ return;
506
+ child.stdin.write(JSON.stringify(command) + "\n");
507
+ };
508
+ let killTimer;
509
+ let abortTimer;
510
+ let statsTimer;
511
+ /** Close stdin (pi's orderly shutdown), then SIGTERM and SIGKILL if it lingers. */
512
+ const hardStop = () => {
513
+ try {
514
+ child.stdin?.end();
515
+ }
516
+ catch {
517
+ // already closed
518
+ }
519
+ stopChild(child, "SIGTERM");
520
+ killTimer ??= setTimeout(() => stopChild(child, "SIGKILL"), this.killGraceMs);
521
+ killTimer.unref?.();
522
+ };
523
+ /** Ask pi to stop with the `abort` command; kill it if it does not settle in time. */
524
+ const stop = () => {
525
+ if (stopped)
526
+ return;
527
+ stopped = true;
528
+ send({ type: "abort" });
529
+ if (abortTimer)
530
+ clearTimeout(abortTimer);
531
+ abortTimer = setTimeout(hardStop, this.abortGraceMs);
532
+ abortTimer.unref?.();
533
+ };
534
+ /** Orderly end: stdin closes and pi exits by itself. */
535
+ const finish = () => {
536
+ try {
537
+ child.stdin?.end();
538
+ }
539
+ catch {
540
+ // already closed
541
+ }
542
+ // A pi that does not exit after stdin closes is stopped like a hung one.
543
+ abortTimer ??= setTimeout(hardStop, this.abortGraceMs);
544
+ abortTimer.unref?.();
545
+ };
546
+ if (abort) {
547
+ const onAbort = () => {
548
+ out.abortReason = abort.reason();
549
+ stop();
550
+ };
551
+ abort.controller.signal.addEventListener("abort", onAbort, { once: true });
552
+ if (abort.controller.signal.aborted)
553
+ onAbort();
554
+ }
555
+ child.stderr?.setEncoding("utf8");
556
+ child.stderr?.on("data", (chunk) => {
557
+ this.logger.debug(`[pi] ${chunk.trimEnd()}`);
558
+ out.stderrTail = (out.stderrTail + chunk).slice(-2000);
559
+ });
560
+ const PROMPT_ID = "sweny-prompt";
561
+ const STATS_ID = "sweny-stats";
562
+ const onToolStart = (ev) => {
563
+ const id = String(ev.toolCallId ?? "");
564
+ const raw = String(ev.toolName ?? "");
565
+ const tool = opts.toolNames?.get(raw) ?? raw.replace(/^mcp__sweny_core__/, "");
566
+ const call = { tool, input: ev.args };
567
+ out.toolCalls.push(call);
568
+ if (id)
569
+ byId.set(id, call);
570
+ toolCount++;
571
+ opts.onProgress?.(tool.length > 80 ? tool.slice(0, 79) + "…" : tool);
572
+ if (opts.maxToolCalls !== undefined && toolCount > opts.maxToolCalls && !out.overBudget) {
573
+ out.overBudget = { count: toolCount, limit: opts.maxToolCalls };
574
+ stop();
575
+ }
576
+ };
577
+ const onToolEnd = (ev) => {
578
+ const call = byId.get(String(ev.toolCallId ?? ""));
579
+ if (!call || call.status !== undefined)
580
+ return;
581
+ const content = ev.result?.content;
582
+ if (ev.isError === true) {
583
+ const detail = parseToolResultContent(content ?? "");
584
+ call.status = "error";
585
+ call.output = { error: detail };
586
+ this.logger.warn(` tool ${call.tool} failed: ${summarizeToolError(detail)}`);
587
+ }
588
+ else {
589
+ call.status = "success";
590
+ call.output = content === undefined ? null : parseToolResultContent(content);
591
+ }
592
+ };
593
+ const onLine = (line) => {
594
+ const trimmed = line.trim();
595
+ if (!trimmed)
596
+ return;
597
+ let ev;
598
+ try {
599
+ ev = JSON.parse(trimmed);
600
+ }
601
+ catch {
602
+ this.logger.debug(`[pi] non-JSON stdout line ignored`);
603
+ return;
604
+ }
605
+ switch (ev.type) {
606
+ case "response":
607
+ if (ev.id === PROMPT_ID) {
608
+ if (ev.success !== true) {
609
+ out.promptRejected = String(ev.error ?? "prompt rejected");
610
+ finish();
611
+ }
612
+ else if (ev.data?.disposition === "handled") {
613
+ // An extension or input handler consumed the prompt: no agent run will follow.
614
+ out.handled = true;
615
+ finish();
616
+ }
617
+ }
618
+ else if (ev.id === STATS_ID) {
619
+ if (ev.success === true)
620
+ out.usage = mapStats(ev.data);
621
+ else
622
+ this.logger.debug(`[pi] get_session_stats failed: ${String(ev.error ?? "")}`);
623
+ if (statsTimer)
624
+ clearTimeout(statsTimer);
625
+ finish();
626
+ }
627
+ break;
628
+ case "tool_execution_start":
629
+ onToolStart(ev);
630
+ break;
631
+ case "tool_execution_end":
632
+ onToolEnd(ev);
633
+ break;
634
+ case "message_end":
635
+ if (ev.message?.role === "assistant") {
636
+ out.last = {
637
+ text: textOf(ev.message.content),
638
+ stopReason: String(ev.message.stopReason ?? ""),
639
+ ...(typeof ev.message.errorMessage === "string" ? { errorMessage: ev.message.errorMessage } : {}),
640
+ };
641
+ }
642
+ break;
643
+ case "auto_retry_end":
644
+ if (ev.success === false)
645
+ out.retryFailure = String(ev.finalError ?? "retries exhausted");
646
+ break;
647
+ case "extension_error":
648
+ this.logger.warn(` pi extension error: ${String(ev.error ?? "")}`);
649
+ break;
650
+ case "agent_settled":
651
+ out.settled = true;
652
+ if (stopped) {
653
+ // Aborted or over budget: nothing more to read.
654
+ finish();
655
+ }
656
+ else if (!statsSent) {
657
+ statsSent = true;
658
+ send({ id: STATS_ID, type: "get_session_stats" });
659
+ statsTimer = setTimeout(finish, this.statsTimeoutMs);
660
+ statsTimer.unref?.();
661
+ }
662
+ break;
663
+ default:
664
+ break;
665
+ }
666
+ };
667
+ // Strict JSONL: split on LF only (docs/rpc.md), never on Unicode line separators.
668
+ let buf = "";
669
+ child.stdout?.setEncoding("utf8");
670
+ child.stdout?.on("data", (chunk) => {
671
+ buf += chunk;
672
+ let nl;
673
+ while ((nl = buf.indexOf("\n")) !== -1) {
674
+ const line = buf.slice(0, nl).replace(/\r$/, "");
675
+ buf = buf.slice(nl + 1);
676
+ onLine(line);
677
+ }
678
+ });
679
+ send({ id: PROMPT_ID, type: "prompt", message: opts.prompt });
680
+ await closed;
681
+ // A final line without a newline is complete only if it parses.
682
+ if (buf.trim())
683
+ onLine(buf);
684
+ for (const t of [killTimer, abortTimer, statsTimer])
685
+ if (t)
686
+ clearTimeout(t);
687
+ abort?.clear();
688
+ liveChildren.delete(child);
689
+ return out;
690
+ }
691
+ /** Write pi's scratch agent dir: generated `mcp.json` only (plus an operator-supplied `models.json`). */
692
+ writeAgentDir(dir, mcp) {
693
+ if (mcp && Object.keys(mcp).length > 0) {
694
+ // `autoEnableCodemode: false`: tools are declared directly, never behind a script tool.
695
+ const doc = { mcpServers: mcp, autoEnableCodemode: false };
696
+ fs.writeFileSync(path.join(dir, "mcp.json"), JSON.stringify(doc, null, 2), { mode: 0o600 });
697
+ }
698
+ if (this.modelsFile) {
699
+ try {
700
+ fs.copyFileSync(this.modelsFile, path.join(dir, "models.json"));
701
+ fs.chmodSync(path.join(dir, "models.json"), 0o600);
702
+ }
703
+ catch (err) {
704
+ this.logger.warn(` pi: could not copy models.json from ${this.modelsFile}: ${String(err)}`);
705
+ }
706
+ }
707
+ }
708
+ /**
709
+ * Run a node. The gate runs first: in strict mode a node whose opinions pi
710
+ * cannot honor is refused before pi starts. pi has no native sandbox, so the
711
+ * whole process runs inside the host's process sandbox wrapper (#360 step 2)
712
+ * when the sandbox mode is not off; `SWENY_SANDBOX=strict` refuses without one.
713
+ */
714
+ async run(req) {
715
+ const maxTurns = req.maxTurns ?? this.maxTurns;
716
+ const mode = req.policy?.sandbox ?? resolveSandboxMode(process.env, this.sandboxMode, this.logger);
717
+ const compiled = this.compilePolicy(req, maxTurns);
718
+ const policy = { ...compiled.policy, sandbox: mode };
719
+ const needsWrapper = mode !== "off";
720
+ const wrapper = needsWrapper && this.sandboxWrapper !== null
721
+ ? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
722
+ : undefined;
723
+ const gate = policyGate(this.capabilities, policy, wrappersFrom(wrapper));
724
+ let degraded = gate.degraded;
725
+ const extraDegraded = [];
726
+ const tag = (r) => ({
727
+ ...r,
728
+ harness: this.info(),
729
+ degraded: [...degraded, ...extraDegraded],
730
+ });
731
+ const refused = (why) => {
732
+ const msg = `pi refused this node: ${why}`;
733
+ this.logger.error(msg);
734
+ // `refused` keeps fail_soft from softening a policy refusal (executor.ts).
735
+ return tag({ status: "failed", data: { error: msg, refused: true }, toolCalls: [] });
736
+ };
737
+ if (gate.refuse)
738
+ return refused(gate.refuse);
739
+ const pre = await this.preflight();
740
+ if (!pre.ok) {
741
+ this.logger.error(pre.reason);
742
+ return tag({ status: "failed", data: { error: pre.reason }, toolCalls: [] });
743
+ }
744
+ const readOnly = policy.readOnly;
745
+ const deny = new Set(policy.deny);
746
+ if (readOnly)
747
+ for (const c of ["shell", "write", "edit"])
748
+ deny.add(c);
749
+ const env = this.buildEnv(req.agentAccess?.envVars);
750
+ const model = req.model ?? this.model;
751
+ let bridge;
752
+ let agentDir;
753
+ let prepCleanup;
754
+ try {
755
+ // Dry run: external MCP servers cannot be classified per tool (and a
756
+ // `--tools` allowlist cannot name tools it has not seen), so only sweny's
757
+ // own (already read-filtered) skill tools remain.
758
+ const servers = readOnly
759
+ ? {}
760
+ : { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
761
+ const { entries, unsupported } = piMcpServers(servers);
762
+ if (unsupported.length > 0) {
763
+ const note = `mcp: pi cannot load ${unsupported.join(", ")}`;
764
+ this.logger.warn(` ${note}`);
765
+ extraDegraded.push(note);
766
+ }
767
+ let toolNames;
768
+ let bridgeNames = [];
769
+ if (req.tools.length > 0) {
770
+ bridge = await startToolBridge({
771
+ tools: req.tools,
772
+ context: this.defaultContext,
773
+ logger: this.logger,
774
+ ...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
775
+ });
776
+ const shim = bridge.mcpServer;
777
+ // The token reaches the shim by reference (`${VAR}`), never in a file or argv.
778
+ env[TOKEN_ENV] = bridge.token;
779
+ entries[PI_BRIDGE_SERVER] = {
780
+ command: shim.command,
781
+ args: shim.args ?? [],
782
+ env: { SWENY_NO_UPDATE_CHECK: "1", [TOKEN_ENV]: "${" + TOKEN_ENV + "}" },
783
+ exposure: "direct",
784
+ timeout: 600,
785
+ };
786
+ const named = bridgeToolNames(req.tools);
787
+ toolNames = named.back;
788
+ bridgeNames = named.names;
789
+ }
790
+ // Tool selection. A dry run is an allowlist (only reading tools plus
791
+ // sweny's own); otherwise built-ins are denied by name and MCP tools stay.
792
+ const toolArgs = [];
793
+ const excluded = new Set(compiled.piTools);
794
+ for (const c of deny)
795
+ for (const n of BUILTIN_BY_CLASS[c] ?? [])
796
+ excluded.add(n);
797
+ if (readOnly) {
798
+ const allow = [...READ_ONLY_BUILTINS.filter((n) => !excluded.has(n)), ...bridgeNames];
799
+ toolArgs.push(...(allow.length > 0 ? ["--tools", allow.join(",")] : ["--no-tools"]));
800
+ }
801
+ else if (excluded.size > 0) {
802
+ toolArgs.push("--exclude-tools", [...excluded].join(","));
803
+ }
804
+ const args = [...this.baseArgs(model, Object.keys(entries).length > 0), ...toolArgs];
805
+ const harnessEgress = piBackendHosts(process.env, model);
806
+ if (mode !== "off" && harnessEgress.length === 0 && !this.egressWarned) {
807
+ this.egressWarned = true;
808
+ this.logger.warn("pi runs inside the sandbox wrapper with no known model API host; add it with SWENY_SANDBOX_ALLOWED_DOMAINS.");
809
+ }
810
+ const prep = await prepareAgentSpawn({
811
+ caps: this.capabilities,
812
+ policy,
813
+ spawn: { command: this.piCommand.command, args, env, cwd: this.cwd },
814
+ wrapper: wrapper ?? null,
815
+ harnessEgress,
816
+ env: process.env,
817
+ });
818
+ prepCleanup = prep.cleanup;
819
+ degraded = prep.degraded;
820
+ if (prep.refuse)
821
+ return refused(prep.refuse);
822
+ // pi's agent dir must be writable (it writes logs and caches there), so
823
+ // under the wrapper it lives in the wrapper's scratch HOME.
824
+ agentDir = prep.home
825
+ ? path.join(prep.home, `.sweny-pi-${randomBytes(4).toString("hex")}`)
826
+ : fs.mkdtempSync(path.join(os.tmpdir(), "sweny-pi-"));
827
+ fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
828
+ this.writeAgentDir(agentDir, entries);
829
+ const out = await this.exec({
830
+ spawn: { ...prep.spawn, env: { ...prep.spawn.env, PI_CODING_AGENT_DIR: agentDir } },
831
+ prompt: buildNodePrompt(req.instruction, req.context, req.outputSchema),
832
+ timeoutMs: req.timeoutMs,
833
+ signal: req.signal,
834
+ maxToolCalls: maxTurns,
835
+ toolNames,
836
+ onProgress: req.onProgress,
837
+ });
838
+ return tag(this.toResult(out, req.outputSchema, req.timeoutMs));
839
+ }
840
+ catch (err) {
841
+ const msg = `pi run failed: ${err instanceof Error ? err.message : String(err)}`;
842
+ this.logger.error(msg);
843
+ return tag({ status: "failed", data: { error: msg }, toolCalls: [] });
844
+ }
845
+ finally {
846
+ await bridge?.close();
847
+ if (agentDir)
848
+ fs.rmSync(agentDir, { recursive: true, force: true });
849
+ await prepCleanup?.();
850
+ }
851
+ }
852
+ /** Map one rpc outcome onto a NodeResult with Claude Code's semantics. */
853
+ toResult(out, outputSchema, timeoutMs) {
854
+ const { toolCalls, usage } = out;
855
+ const withUsage = (r) => (usage ? { ...r, usage } : r);
856
+ const last = out.last?.text ?? "";
857
+ const fail = (error, partial = false) => {
858
+ this.logger.error(error);
859
+ return withUsage({
860
+ status: "failed",
861
+ data: { error, ...(partial && last.trim() !== "" ? { summary: last.trim() } : {}) },
862
+ toolCalls,
863
+ });
864
+ };
865
+ if (out.spawnError)
866
+ return fail(`pi could not start: ${out.spawnError}`);
867
+ if (out.abortReason === "timeout")
868
+ return fail(`pi run timed out after ${timeoutMs}ms`);
869
+ if (out.abortReason === "signal")
870
+ return fail("pi run aborted");
871
+ if (out.overBudget) {
872
+ // Same shape as Claude Code's max_turns stop: partial text is kept for fail_soft nodes.
873
+ return fail(`pi run terminated early: max_turns (${out.overBudget.limit} tool calls; stopped by the sweny watchdog)`, true);
874
+ }
875
+ if (out.promptRejected)
876
+ return fail(`pi rejected the prompt: ${out.promptRejected}`);
877
+ if (out.handled)
878
+ return fail("pi handled the prompt as a command and ran no agent turn");
879
+ if (!out.settled) {
880
+ const code = out.exitSignal ? `signal ${out.exitSignal}` : `code ${out.exitCode}`;
881
+ const tail = out.stderrTail.trim().split("\n").slice(-3).join(" | ");
882
+ return fail(out.exitCode === 0 && !out.exitSignal
883
+ ? `agent stream ended without a result message (${toolCalls.length} tool calls captured)`
884
+ : `pi exited with ${code}${tail ? `: ${tail}` : ""}`, true);
885
+ }
886
+ const l = out.last;
887
+ if (!l)
888
+ return fail("pi settled without an assistant message");
889
+ if (l.stopReason !== "stop") {
890
+ const why = l.errorMessage ?? out.retryFailure;
891
+ if (l.stopReason === "aborted")
892
+ return fail("pi run aborted", true);
893
+ if (l.stopReason === "length")
894
+ return fail("pi run stopped at the model's output limit", true);
895
+ return fail(`pi run failed (${l.stopReason || "no stop reason"})${why ? `: ${why}` : ""}`, true);
896
+ }
897
+ if (out.exitCode !== 0 || out.exitSignal) {
898
+ const code = out.exitSignal ? `signal ${out.exitSignal}` : `code ${out.exitCode}`;
899
+ return fail(`pi exited with ${code} after completing the run`);
900
+ }
901
+ const parsed = tryParseJSON(last, outputSchema, this.logger, "pi");
902
+ return withUsage({ status: "success", data: { summary: last, ...parsed }, toolCalls });
903
+ }
904
+ /**
905
+ * One completion: no tools (`--no-tools`), no MCP, no extensions, an empty
906
+ * scratch agent dir. Nothing can act, so no process wrapper is needed.
907
+ * null when it failed (callers fail closed).
908
+ */
909
+ async complete(req) {
910
+ const purpose = req.purpose ?? "ask";
911
+ const failClosed = (why) => {
912
+ this.logger.warn(purpose === "evaluate"
913
+ ? `pi.evaluate: ${why}. Failing closed (no route decision).`
914
+ : `pi.ask: ${why}. Returning empty string.`);
915
+ return null;
916
+ };
917
+ const pre = await this.preflight();
918
+ if (!pre.ok)
919
+ return failClosed(pre.reason);
920
+ let agentDir;
921
+ try {
922
+ agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-pi-"));
923
+ this.writeAgentDir(agentDir);
924
+ const env = { ...this.buildEnv(), PI_CODING_AGENT_DIR: agentDir };
925
+ const out = await this.exec({
926
+ spawn: {
927
+ command: this.piCommand.command,
928
+ // An empty model (the judge default) means pi's own default model.
929
+ args: [...this.baseArgs(req.model || this.model, false), "--no-tools"],
930
+ env,
931
+ cwd: this.cwd,
932
+ },
933
+ prompt: req.prompt,
934
+ timeoutMs: req.timeoutMs,
935
+ signal: req.signal,
936
+ // Classification calls use no tools; any tool call is over budget.
937
+ maxToolCalls: 0,
938
+ });
939
+ if (out.spawnError)
940
+ return failClosed(`pi could not start: ${out.spawnError}`);
941
+ if (out.abortReason === "timeout")
942
+ return failClosed(`timed out after ${req.timeoutMs}ms`);
943
+ if (out.abortReason === "signal")
944
+ return failClosed("aborted");
945
+ if (out.overBudget)
946
+ return failClosed("the model tried to call a tool");
947
+ if (out.promptRejected || out.handled)
948
+ return failClosed(out.promptRejected ?? "the prompt was handled as a command");
949
+ if (!out.settled)
950
+ return failClosed(`agent stream ended without a result message (pi exit ${out.exitCode})`);
951
+ if (out.last?.stopReason !== "stop") {
952
+ return failClosed(`pi run failed (${out.last?.stopReason || "no assistant message"})${out.last?.errorMessage ? `: ${out.last.errorMessage}` : ""}`);
953
+ }
954
+ if (out.exitCode !== 0 || out.exitSignal)
955
+ return failClosed(`pi exited with code ${out.exitCode}`);
956
+ return out.last.text;
957
+ }
958
+ catch (err) {
959
+ return failClosed(err instanceof Error ? err.message : String(err));
960
+ }
961
+ finally {
962
+ if (agentDir)
963
+ fs.rmSync(agentDir, { recursive: true, force: true });
964
+ }
965
+ }
966
+ }