@agent-controller/runtime 0.3.1

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,980 @@
1
+ import { createAgentSession, DefaultResourceLoader, SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { getModel } from "@earendil-works/pi-ai";
3
+ import { join, basename, resolve, dirname } from "node:path";
4
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, copyFileSync, realpathSync, readdirSync, unlinkSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { fileURLToPath } from "node:url";
7
+ import { spawnSync } from "node:child_process";
8
+ import { stamp } from "./wire.js";
9
+ import { CORRECTION_PROMPT, HONESTY_PREAMBLE, detectHallucinatedToolCalls, stripHallucinationXml, wrapSkillBody, } from "./honesty.js";
10
+ import { resolveFakeModelIfRequested } from "./testing/fake-provider.js";
11
+ /**
12
+ * Resolve the effective hallucination-detector mode for this session.
13
+ * Defaults to "block" when the spec omits the guardrails block or the field.
14
+ * Unknown string values fall back to "block" with a stderr warning so a
15
+ * typo in the spec fails safe rather than silently downgrading guardrails.
16
+ */
17
+ function resolveHallucinationMode(spec) {
18
+ const raw = spec.guardrails?.hallucinationDetector;
19
+ if (!raw)
20
+ return "block";
21
+ if (raw === "warn" || raw === "block" || raw === "correct")
22
+ return raw;
23
+ process.stderr.write(`[agent-controller] WARNING: unknown spec.guardrails.hallucinationDetector value ` +
24
+ `"${raw}"; falling back to "block".\n`);
25
+ return "block";
26
+ }
27
+ // Use createRequire so we can resolve CommonJS/ESM packages by name from the
28
+ // runtime package root, regardless of whether the runtime itself is ESM.
29
+ const _require = createRequire(import.meta.url);
30
+ /**
31
+ * Build the object that goes into <cwd>/.pi/mcp.json.
32
+ * pi-mcp-extension expects the servers keyed by name (not an array).
33
+ */
34
+ function buildMcpJson(servers) {
35
+ const mcpServers = {};
36
+ for (const s of servers) {
37
+ // Build a minimal server config — omit undefined/empty fields so
38
+ // pi-mcp-extension's Zod validation doesn't complain.
39
+ const entry = { transport: s.transport };
40
+ if (s.lifecycle)
41
+ entry.lifecycle = s.lifecycle;
42
+ if (s.command)
43
+ entry.command = s.command;
44
+ if (s.args && s.args.length > 0)
45
+ entry.args = s.args;
46
+ if (s.env && Object.keys(s.env).length > 0)
47
+ entry.env = s.env;
48
+ if (s.url)
49
+ entry.url = s.url;
50
+ if (s.headers && Object.keys(s.headers).length > 0)
51
+ entry.headers = s.headers;
52
+ mcpServers[s.name] = entry;
53
+ }
54
+ return {
55
+ settings: { toolPrefix: "mcp" },
56
+ mcpServers,
57
+ };
58
+ }
59
+ /**
60
+ * Write <cwd>/.pi/mcp.json from the ADL mcpServers list.
61
+ *
62
+ * If the file already exists and its contents differ from what we'd write,
63
+ * this throws an error to avoid silently clobbering user config. When the
64
+ * contents are identical (idempotent re-run), we skip the write.
65
+ */
66
+ function writeMcpJson(cwd, servers) {
67
+ const dir = join(cwd, ".pi");
68
+ const filePath = join(dir, "mcp.json");
69
+ const payload = buildMcpJson(servers);
70
+ const newContent = JSON.stringify(payload, null, 2);
71
+ if (existsSync(filePath)) {
72
+ const existing = readFileSync(filePath, "utf8");
73
+ if (existing.trim() === newContent.trim()) {
74
+ // Identical — idempotent, nothing to do.
75
+ return;
76
+ }
77
+ throw new Error(`Cannot write MCP config: ${filePath} already exists with different contents.\n` +
78
+ `Remove or reconcile the file before running an agent with spec.mcpServers.`);
79
+ }
80
+ mkdirSync(dir, { recursive: true });
81
+ writeFileSync(filePath, newContent, "utf8");
82
+ }
83
+ /**
84
+ * Materialize subagent .md files into <cwd>/.pi/agents/ so Pi's subagent
85
+ * extension can discover them via discoverAgents(cwd, "both"|"project").
86
+ *
87
+ * Each subagent's entrypoint is the absolute path to a .md file in our
88
+ * agents/ directory. We copy it to <cwd>/.pi/agents/<slug>.md.
89
+ *
90
+ * If the destination already exists with identical content, we no-op
91
+ * (idempotent). If different, we overwrite — .pi/agents/ is project-local
92
+ * and fully owned by agent-controller.
93
+ */
94
+ function writeAgentFiles(cwd, subagents) {
95
+ if (subagents.length === 0)
96
+ return;
97
+ const agentsDir = join(cwd, ".pi", "agents");
98
+ mkdirSync(agentsDir, { recursive: true });
99
+ // Build the set of filenames we are about to write so we can identify stale entries.
100
+ const declaredBasenames = new Set(subagents.map((s) => basename(s.entrypoint)));
101
+ // Remove any pre-existing .md files in agentsDir that are NOT in the declared set.
102
+ // This enforces the ADL allowlist: only spec.subagents[] agents are present in
103
+ // .pi/agents/, preventing the subagent extension (scoped to "project") from
104
+ // discovering stale or injected agent files.
105
+ if (existsSync(agentsDir)) {
106
+ let entries;
107
+ try {
108
+ entries = readdirSync(agentsDir);
109
+ }
110
+ catch {
111
+ entries = [];
112
+ }
113
+ for (const entry of entries) {
114
+ if (!entry.endsWith(".md"))
115
+ continue;
116
+ if (!declaredBasenames.has(entry)) {
117
+ try {
118
+ unlinkSync(join(agentsDir, entry));
119
+ }
120
+ catch (err) {
121
+ // Fail closed: if we can't remove a stale agent file, the project
122
+ // scope still exposes it to the subagent discovery, which defeats
123
+ // the ADL allowlist enforcement. Better to abort the run with a
124
+ // clear error than silently leave an undeclared agent reachable.
125
+ const path = join(agentsDir, entry);
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ throw new Error(`Failed to remove stale agent file ${path}: ${msg}. ` +
128
+ `agent-controller cannot guarantee the subagent allowlist while ` +
129
+ `undeclared .md files remain in .pi/agents/. Delete the file ` +
130
+ `manually or fix the permissions, then re-run.`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ for (const subagent of subagents) {
136
+ const destPath = join(agentsDir, basename(subagent.entrypoint));
137
+ if (existsSync(destPath)) {
138
+ const existing = readFileSync(destPath, "utf8");
139
+ const incoming = readFileSync(subagent.entrypoint, "utf8");
140
+ if (existing === incoming) {
141
+ // Identical — idempotent, skip.
142
+ continue;
143
+ }
144
+ // Overwrite — agent-controller owns this directory.
145
+ }
146
+ copyFileSync(subagent.entrypoint, destPath);
147
+ }
148
+ }
149
+ /**
150
+ * Write <cwd>/.pi/agent/models.json so that child `pi` processes spawned by
151
+ * the subagent extension route through the same Anthropic gateway as the
152
+ * parent adapter session.
153
+ *
154
+ * When ANTHROPIC_BASE_URL is set, standalone `pi` does not pick it up
155
+ * automatically — pi's anthropic provider always passes `baseURL: model.baseUrl`
156
+ * (hardcoded to api.anthropic.com) explicitly to the SDK, overriding any env
157
+ * var. Pi's `models.json` supports a `providers.<name>.baseUrl` override that
158
+ * IS applied at model-resolution time, so we use that mechanism.
159
+ *
160
+ * We write the file to <cwd>/.pi/agent/ (not the global ~/.pi/agent/) and tell
161
+ * child pi processes to use that directory via PI_CODING_AGENT_DIR, keeping
162
+ * the override project-local and non-destructive to global user config.
163
+ *
164
+ * Returns the local agent-dir path (for PI_CODING_AGENT_DIR), or undefined
165
+ * when ANTHROPIC_BASE_URL is not set.
166
+ */
167
+ function writeSubagentModelsJson(cwd) {
168
+ const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
169
+ if (!anthropicBaseUrl)
170
+ return undefined; // nothing to override
171
+ const localAgentDir = join(cwd, ".pi", "agent");
172
+ const filePath = join(localAgentDir, "models.json");
173
+ // Pi's models.json format: providers.<name>.baseUrl overrides the built-in
174
+ // model baseUrl. An empty models list means "override-only" (no custom models
175
+ // added, just the provider URL replaced). The auth.json must also exist so pi
176
+ // doesn't try to write auth to a missing location.
177
+ const payload = {
178
+ providers: {
179
+ anthropic: {
180
+ baseUrl: anthropicBaseUrl,
181
+ },
182
+ },
183
+ };
184
+ const newContent = JSON.stringify(payload, null, 2);
185
+ mkdirSync(localAgentDir, { recursive: true });
186
+ if (existsSync(filePath)) {
187
+ const existing = readFileSync(filePath, "utf8");
188
+ if (existing.trim() === newContent.trim()) {
189
+ return localAgentDir; // idempotent
190
+ }
191
+ }
192
+ writeFileSync(filePath, newContent, "utf8");
193
+ // Ensure auth.json exists (pi writes to it at startup; if missing it will
194
+ // fail to start when PI_CODING_AGENT_DIR points to an empty directory).
195
+ const authPath = join(localAgentDir, "auth.json");
196
+ if (!existsSync(authPath)) {
197
+ writeFileSync(authPath, "{}", "utf8");
198
+ }
199
+ return localAgentDir;
200
+ }
201
+ /**
202
+ * Copy tool extensions declared in the CompiledSpec into the local agent dir's
203
+ * extensions/ folder so child `pi` processes (spawned by the subagent extension)
204
+ * can load them via the default PI_CODING_AGENT_DIR discovery path.
205
+ *
206
+ * Pi's DefaultResourceLoader auto-discovers extensions from
207
+ * <agentDir>/extensions/<name>/index.ts (or index.js). When we redirect
208
+ * PI_CODING_AGENT_DIR to our local .pi/agent/, we copy each tool's entrypoint
209
+ * there named as index.ts so Pi's package-manager discovery picks it up.
210
+ *
211
+ * Each tool's entrypoint is copied to:
212
+ * <localAgentDir>/extensions/<name>/index.ts
213
+ */
214
+ function copyToolExtensionsToLocalAgentDir(localAgentDir, tools) {
215
+ if (tools.length === 0)
216
+ return;
217
+ const extDir = join(localAgentDir, "extensions");
218
+ mkdirSync(extDir, { recursive: true });
219
+ for (const tool of tools) {
220
+ const toolDir = join(extDir, tool.name);
221
+ mkdirSync(toolDir, { recursive: true });
222
+ // Pi discovers extensions by looking for index.ts or index.js in each subdir
223
+ // under <agentDir>/extensions/. Always write as index.ts regardless of the
224
+ // original entrypoint filename so auto-discovery works.
225
+ const destPath = join(toolDir, "index.ts");
226
+ if (existsSync(destPath)) {
227
+ const existing = readFileSync(destPath, "utf8");
228
+ const incoming = readFileSync(tool.entrypoint, "utf8");
229
+ if (existing === incoming)
230
+ continue; // idempotent
231
+ }
232
+ copyFileSync(tool.entrypoint, destPath);
233
+ }
234
+ }
235
+ /**
236
+ * Build the session's system prompt. Always starts with the honesty
237
+ * preamble (see runtime/src/honesty.ts) so the model has clear rules
238
+ * against fabricated tool calls before it sees anything else. Persona
239
+ * role and instructions are appended after when present.
240
+ *
241
+ * Returns a non-empty string in every case — there is no scenario where
242
+ * an agent-controller session should run without the honesty rules.
243
+ */
244
+ function buildSystemPrompt(persona) {
245
+ const parts = [HONESTY_PREAMBLE];
246
+ if (persona?.role)
247
+ parts.push(`# Role\n${persona.role}`);
248
+ if (persona?.instructions)
249
+ parts.push(`# Instructions\n${persona.instructions}`);
250
+ return parts.join("\n\n");
251
+ }
252
+ /**
253
+ * Resolve the pi binary for the runtime process.
254
+ *
255
+ * Precedence:
256
+ * 1. AC_PI_BIN env var (set by adapter when spawning subagents)
257
+ * 2. PI_BIN env var (user override)
258
+ * 3. runtime/node_modules/.bin/pi (sibling of THIS file's node_modules)
259
+ * 4. System pi on PATH via `which`
260
+ */
261
+ function resolvePiBinForRuntime() {
262
+ if (process.env.AC_PI_BIN && existsSync(process.env.AC_PI_BIN)) {
263
+ return process.env.AC_PI_BIN;
264
+ }
265
+ if (process.env.PI_BIN && existsSync(process.env.PI_BIN)) {
266
+ return process.env.PI_BIN;
267
+ }
268
+ // Walk up from this file to find node_modules/.bin/pi
269
+ const __filename2 = fileURLToPath(import.meta.url);
270
+ const __dirname2 = dirname(__filename2);
271
+ const candidates = [
272
+ join(__dirname2, "..", "node_modules", ".bin", "pi"),
273
+ join(__dirname2, "..", "..", "node_modules", ".bin", "pi"),
274
+ ];
275
+ for (const c of candidates) {
276
+ try {
277
+ const real = realpathSync(c);
278
+ if (existsSync(real))
279
+ return real;
280
+ }
281
+ catch { /* not found */ }
282
+ }
283
+ // Fall back to system PATH
284
+ const which = spawnSync("which", ["pi"], { encoding: "utf8" });
285
+ if (which.status === 0) {
286
+ const p = which.stdout.trim();
287
+ if (p && existsSync(p))
288
+ return p;
289
+ }
290
+ return undefined;
291
+ }
292
+ /**
293
+ * Resolve a source-bound extension: install it if needed, read its
294
+ * pi.extensions manifest, and return the absolute entrypoint path.
295
+ *
296
+ * @param source — e.g. "npm:pi-mcp-extension"
297
+ * @returns absolute path to the extension entrypoint
298
+ * @throws when source scheme is unsupported, pi is missing, install fails,
299
+ * or the package declares no extensions
300
+ */
301
+ export function resolveSourceBoundExtension(source) {
302
+ // ── Parse source ──────────────────────────────────────────────────────────
303
+ if (!source.startsWith("npm:")) {
304
+ throw new Error(`Unsupported source scheme in "${source}". ` +
305
+ `Only "npm:" is supported at v0.1.6.`);
306
+ }
307
+ const pkgName = source.slice("npm:".length);
308
+ if (!pkgName) {
309
+ throw new Error(`source "${source}" has an empty package name.`);
310
+ }
311
+ // ── Locate installed package ───────────────────────────────────────────────
312
+ // Pi installs npm packages to ~/.pi/agent/npm/node_modules/<name>/
313
+ // OR they may already be in the runtime's own node_modules (e.g. pi-mcp-extension).
314
+ const piAgentDir = getAgentDir();
315
+ const piManagedPath = join(piAgentDir, "npm", "node_modules", pkgName);
316
+ // Runtime's own node_modules (resolved via require)
317
+ let runtimeNodeModulesPath;
318
+ try {
319
+ // createRequire-based resolver anchored to this file
320
+ const req = createRequire(import.meta.url);
321
+ const resolved = req.resolve(`${pkgName}/package.json`);
322
+ // resolved is /abs/.../pkgName/package.json → dirname is the package root
323
+ runtimeNodeModulesPath = dirname(resolved);
324
+ }
325
+ catch { /* not found in runtime node_modules */ }
326
+ // Pick the first path that has a package.json
327
+ let pkgRoot;
328
+ if (runtimeNodeModulesPath && existsSync(join(runtimeNodeModulesPath, "package.json"))) {
329
+ pkgRoot = runtimeNodeModulesPath;
330
+ }
331
+ else if (existsSync(join(piManagedPath, "package.json"))) {
332
+ pkgRoot = piManagedPath;
333
+ }
334
+ // ── Install if missing ────────────────────────────────────────────────────
335
+ if (!pkgRoot) {
336
+ // Safety valve: if auto-installation is disabled, emit a clear error.
337
+ if (process.env.AGENT_CONTROLLER_NO_AUTO_INSTALL === "1") {
338
+ throw new Error(`Auto-installation is disabled (AGENT_CONTROLLER_NO_AUTO_INSTALL=1). ` +
339
+ `Run \`agentctl install ${source}\` first, then re-run.`);
340
+ }
341
+ const piBin = resolvePiBinForRuntime();
342
+ if (!piBin) {
343
+ throw new Error(`pi binary not found. Cannot auto-install "${source}".\n` +
344
+ `Run \`agentctl install ${source}\` manually, or install pi and retry.\n` +
345
+ `Alternatively, set the PI_BIN environment variable to point at pi.`);
346
+ }
347
+ const result = spawnSync(piBin, ["install", source], {
348
+ stdio: ["ignore", "inherit", "inherit"],
349
+ encoding: "utf8",
350
+ });
351
+ if (result.error) {
352
+ throw new Error(`Failed to spawn pi to install "${source}": ${result.error.message}`);
353
+ }
354
+ if (result.status !== 0) {
355
+ throw new Error(`pi install ${source} failed with exit code ${result.status ?? "(signal)"}. ` +
356
+ `Check pi output above for details.`);
357
+ }
358
+ // After install, Pi places the package at ~/.pi/agent/npm/node_modules/<name>/
359
+ if (existsSync(join(piManagedPath, "package.json"))) {
360
+ pkgRoot = piManagedPath;
361
+ }
362
+ else {
363
+ throw new Error(`pi install ${source} appeared to succeed but the package was not found at ` +
364
+ `${piManagedPath}. Check Pi's installation output.`);
365
+ }
366
+ }
367
+ // ── Read pi.extensions from package.json ─────────────────────────────────
368
+ const pkgJsonPath = join(pkgRoot, "package.json");
369
+ let pkgJson;
370
+ try {
371
+ pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
372
+ }
373
+ catch (err) {
374
+ throw new Error(`Failed to read package.json for "${pkgName}" at ${pkgJsonPath}: ${err.message}`);
375
+ }
376
+ const piBlock = pkgJson.pi;
377
+ const extensionsArr = piBlock?.extensions;
378
+ if (!Array.isArray(extensionsArr) || extensionsArr.length === 0) {
379
+ throw new Error(`Package "${pkgName}" declares no Pi extensions (pi.extensions is absent or empty in its package.json). ` +
380
+ `Cannot use it as a source-bound extension.`);
381
+ }
382
+ const relEntrypoint = extensionsArr[0];
383
+ return resolve(pkgRoot, relEntrypoint);
384
+ }
385
+ /**
386
+ * runSession assembles a Pi session from the CompiledSpec, subscribes to
387
+ * its events, submits the task as the initial prompt, and resolves when
388
+ * the session ends. emit is invoked once per outgoing wire event.
389
+ *
390
+ * Extension configuration is passed to extensions via the
391
+ * AGENT_CONTROLLER_EXT_CONFIG env var (JSON object keyed by extension name).
392
+ * This is a deliberate MVP convention; v0.2 will migrate to Pi's
393
+ * settingsManager once that API stabilizes (tracked in spec §12 research
394
+ * item 5).
395
+ */
396
+ export async function runSession(spec, emit) {
397
+ // Emit deprecation warning if spec.installs[] is non-empty.
398
+ // spec.installs[] is deprecated in favour of spec.extensions[].source.
399
+ if (spec.installs && spec.installs.length > 0) {
400
+ process.stderr.write("[agent-controller] DEPRECATION WARNING: `spec.installs[]` is deprecated; " +
401
+ "prefer `spec.extensions[].source` instead.\n");
402
+ }
403
+ // Resolve source-bound extensions (spec.extensions[].source is set).
404
+ // For each such entry, install the package if needed and determine its
405
+ // entrypoint; then treat it exactly like a locally-resolved extension.
406
+ const resolvedExtensionPaths = spec.extensions.map((e) => {
407
+ if (e.source) {
408
+ // Source-bound: install if needed and resolve entrypoint.
409
+ return resolveSourceBoundExtension(e.source);
410
+ }
411
+ // Normal registry-resolved extension.
412
+ return e.entrypoint;
413
+ });
414
+ const entrypointPaths = [
415
+ // Pi-builtin tools (bash/read/edit/write) have no entrypoint — they
416
+ // contribute only to the active-tool allowlist (handled below where
417
+ // toolAllowlist is built from spec.tools[].name). Filter them out of
418
+ // additionalExtensionPaths so we don't try to load nonexistent paths.
419
+ ...spec.tools.filter((t) => !t.builtin && t.entrypoint).map((t) => t.entrypoint),
420
+ ...resolvedExtensionPaths,
421
+ ];
422
+ // If spec.mcpServers is non-empty, write <cwd>/.pi/mcp.json and add the
423
+ // pi-mcp-extension entrypoint to the loader paths. This must happen BEFORE
424
+ // DefaultResourceLoader is constructed so the extension is loaded during reload().
425
+ if (spec.mcpServers && spec.mcpServers.length > 0) {
426
+ writeMcpJson(process.cwd(), spec.mcpServers);
427
+ // Only add pi-mcp-extension if it wasn't already loaded via a source-bound
428
+ // spec.extensions[] entry (e.g. { name: "pi-mcp-extension", source: "npm:..." }).
429
+ // Deduplication prevents the extension from loading twice, which would cause
430
+ // a "ResourceCollision" error from DefaultResourceLoader.
431
+ const mcpAlreadyLoaded = spec.extensions.some((e) => e.source === "npm:pi-mcp-extension" || e.name === "pi-mcp-extension");
432
+ if (!mcpAlreadyLoaded) {
433
+ // Resolve the pi-mcp-extension entrypoint from the runtime's own node_modules.
434
+ // The package.json "pi.extensions" field declares "./src/index.ts" as the
435
+ // extension entrypoint — Pi loads .ts files via jiti (no build step needed).
436
+ const mcpPkg = _require.resolve("pi-mcp-extension/src/index.ts");
437
+ entrypointPaths.push(mcpPkg);
438
+ }
439
+ }
440
+ // If spec.subagents is non-empty, materialize the .md files into
441
+ // <cwd>/.pi/agents/ so Pi's subagent extension discovers them, then
442
+ // add the vendored subagent extension entrypoint to the loader paths.
443
+ // The subagent extension registers the "subagent" tool which the parent
444
+ // agent uses to invoke child agents.
445
+ if (spec.subagents && spec.subagents.length > 0) {
446
+ // Subagents always have an entrypoint (the .md path). Narrow the type
447
+ // for writeAgentFiles so it can use entrypoint directly without
448
+ // having to guard for undefined.
449
+ const resolvedSubagents = spec.subagents
450
+ .filter((s) => Boolean(s.entrypoint));
451
+ writeAgentFiles(process.cwd(), resolvedSubagents);
452
+ // Write <cwd>/.pi/agent/models.json with providers.anthropic.baseUrl so
453
+ // child `pi` processes use the same gateway as the parent session.
454
+ // This only makes sense when ANTHROPIC_BASE_URL is set; when using the
455
+ // default Anthropic SDK (just ANTHROPIC_API_KEY), skip the model override.
456
+ const localAgentDir = writeSubagentModelsJson(process.cwd());
457
+ if (localAgentDir) {
458
+ process.env.AC_SUBAGENT_AGENT_DIR = localAgentDir;
459
+ }
460
+ // Copy parent's tool extensions into the local agent dir so child pi
461
+ // sessions can discover and activate them (e.g. get_time).
462
+ // This must run whenever subagents are declared, regardless of whether
463
+ // ANTHROPIC_BASE_URL is set — tools must be available even with a bare
464
+ // ANTHROPIC_API_KEY setup.
465
+ {
466
+ // Use the localAgentDir if available (custom gateway path) or fall back
467
+ // to a default local agent dir so child pi can always find the tools.
468
+ const agentDirForTools = localAgentDir ?? join(process.cwd(), ".pi", "agent");
469
+ // Only entrypoint-backed tools can be copied (builtins are Pi-shipped).
470
+ const resolvedTools = spec.tools
471
+ .filter((t) => !t.builtin && Boolean(t.entrypoint));
472
+ copyToolExtensionsToLocalAgentDir(agentDirForTools, resolvedTools);
473
+ }
474
+ // Expose the `pi` CLI binary path via AC_PI_BIN so the vendored subagent
475
+ // extension can spawn the correct `pi` when `pi` is not on the system PATH.
476
+ // The package's exports map blocks deep-path _require.resolve, so we look
477
+ // for the .bin/pi symlink relative to node_modules, then fall back to the
478
+ // known conventional path for our vendored package.
479
+ //
480
+ // Strategy: walk up from our own file to find a node_modules/.bin/pi that
481
+ // resolves to a real file, or construct the known path from the package we
482
+ // import from. We use __filename (adapter.ts/adapter.js) as the anchor.
483
+ {
484
+ const __filename2 = fileURLToPath(import.meta.url);
485
+ const __dirname2 = dirname(__filename2);
486
+ // Candidate paths to check, from most-local to most-global:
487
+ const piCandidates = [
488
+ join(__dirname2, "..", "node_modules", ".bin", "pi"), // runtime/node_modules/.bin/pi
489
+ join(__dirname2, "..", "..", "node_modules", ".bin", "pi"), // root/node_modules/.bin/pi
490
+ join(__dirname2, "..", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js"),
491
+ join(__dirname2, "..", "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js"),
492
+ ];
493
+ for (const candidate of piCandidates) {
494
+ try {
495
+ // Use realpathSync to resolve symlinks (like .bin/pi -> ../pkg/dist/cli.js)
496
+ const real = realpathSync(candidate);
497
+ if (existsSync(real)) {
498
+ process.env.AC_PI_BIN = real;
499
+ break;
500
+ }
501
+ }
502
+ catch {
503
+ // not found, try next
504
+ }
505
+ }
506
+ }
507
+ // Resolve the vendored subagent extension entrypoint relative to THIS
508
+ // compiled file. After tsc, adapter.js is at runtime/dist/adapter.js and
509
+ // the vendored extension is at extensions/subagent/entrypoint.ts (two dirs
510
+ // up from runtime/dist → runtime/ → root/). Pi loads .ts files via jiti.
511
+ const __filename = fileURLToPath(import.meta.url);
512
+ const __dirname = dirname(__filename);
513
+ const subagentExtPath = resolve(__dirname, "../../extensions/subagent/entrypoint.ts");
514
+ entrypointPaths.push(subagentExtPath);
515
+ }
516
+ // Populate the extension-config env var BEFORE constructing the loader
517
+ // or session — extensions read it inside their default export, which
518
+ // executes during session construction.
519
+ //
520
+ // Include both spec.tools[].config and spec.extensions[].config entries,
521
+ // keyed by name. Tools are loaded as Pi extension entrypoints and have no
522
+ // other config channel. Extensions win on name collision (last-write), but
523
+ // we emit a warning so the conflict is visible.
524
+ const extConfig = {};
525
+ for (const t of spec.tools) {
526
+ if (t.config)
527
+ extConfig[t.name] = t.config;
528
+ }
529
+ for (const e of spec.extensions) {
530
+ if (e.config) {
531
+ if (e.name in extConfig) {
532
+ process.stderr.write(`[agent-controller] WARNING: tool and extension share the name "${e.name}"; extension config wins.\n`);
533
+ }
534
+ extConfig[e.name] = e.config;
535
+ }
536
+ }
537
+ process.env.AGENT_CONTROLLER_EXT_CONFIG = JSON.stringify(extConfig);
538
+ // Skill paths: each skill entrypoint is the absolute path to SKILL.md.
539
+ // Pi's loadSkillsFromDir treats a directory containing SKILL.md as a skill
540
+ // root, and additionalSkillPaths accepts either files or directories.
541
+ // We pass the parent directory of each SKILL.md so Pi uses its standard
542
+ // directory-based discovery rule.
543
+ //
544
+ // Use path.dirname() (already imported from node:path) for platform-safe
545
+ // parent-dir derivation — avoids manual split("/") which breaks on Windows.
546
+ const skillDirs = spec.skills
547
+ // Skills always have an entrypoint (the SKILL.md path) — no source/builtin
548
+ // shortcut applies to skills. Filter defensively in case the compiler ever
549
+ // emits a malformed ResolvedRef.
550
+ .filter((s) => Boolean(s.entrypoint))
551
+ .map((s) =>
552
+ // s.entrypoint = "<root>/skills/<name>/SKILL.md"
553
+ // dirname gives "<root>/skills/<name>" which Pi treats as a skill root.
554
+ dirname(s.entrypoint));
555
+ // Pi's formatSkillsForPrompt only injects each skill's frontmatter
556
+ // {name, description, filePath} into the system prompt. The body is
557
+ // lazy-loaded by the agent via the `read` tool when it decides the skill
558
+ // matches the task — but our ADL allowlist typically excludes `read`,
559
+ // and even when it doesn't, the model frequently won't bother loading
560
+ // skills it could be following.
561
+ //
562
+ // For ADL-declared skills the user clearly opted in, so we additionally
563
+ // inline each skill's body (everything after the YAML frontmatter) into
564
+ // appendSystemPrompt. The body is then unconditionally active for the
565
+ // session, matching the declarative-governance semantics users expect.
566
+ // The lazy/read-tool path still works for skills the model wants to
567
+ // re-read or inspect mid-task.
568
+ const skillBodies = [];
569
+ for (const s of spec.skills) {
570
+ if (!s.entrypoint)
571
+ continue; // defensive: skip malformed skill refs
572
+ try {
573
+ const raw = readFileSync(s.entrypoint, "utf8");
574
+ // Strip YAML frontmatter: leading "---\n...\n---\n".
575
+ const stripped = raw.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
576
+ if (stripped.trim().length > 0) {
577
+ skillBodies.push(wrapSkillBody(s.name, stripped.trim()));
578
+ }
579
+ }
580
+ catch (err) {
581
+ const msg = err instanceof Error ? err.message : String(err);
582
+ process.stderr.write(`[agent-controller] WARNING: could not read skill ${s.name} at ${s.entrypoint}: ${msg}\n`);
583
+ }
584
+ }
585
+ // appendSystemPrompt is string[] (Pi joins with "\n\n"); pass an array.
586
+ const skillsAppend = skillBodies.length > 0 ? skillBodies : undefined;
587
+ // DefaultResourceLoader requires cwd and agentDir to resolve paths;
588
+ // we use the process working directory and a local .agent-controller dir
589
+ // as sensible defaults for the MVP runner.
590
+ //
591
+ // systemPrompt is derived from spec.persona (if present) and passed here so
592
+ // the resource loader serves it to Pi — the resourceLoader.getSystemPrompt()
593
+ // method is called internally by createAgentSession when building the agent.
594
+ //
595
+ // noExtensions: true prevents DefaultResourceLoader.reload() from scanning
596
+ // ~/.pi/agent/extensions/ and <cwd>/.pi/extensions/ for ambient extensions.
597
+ // Without this, extensions outside the ADL allowlist would silently load even
598
+ // when noTools: "builtin" is set. We still pass additionalExtensionPaths so
599
+ // the spec-declared tools and extensions are registered normally.
600
+ //
601
+ // noSkills: true suppresses Pi's default skill scan from ~/.pi/agent/skills/
602
+ // and <cwd>/.pi/skills/. Only ADL-declared skills (via additionalSkillPaths)
603
+ // are loaded, keeping the environment hermetic and consistent with the ADL
604
+ // allowlist principle used for extensions.
605
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
606
+ const resourceLoader = new DefaultResourceLoader({
607
+ cwd: process.cwd(),
608
+ agentDir: process.cwd() + "/.agent-controller",
609
+ additionalExtensionPaths: entrypointPaths,
610
+ additionalSkillPaths: skillDirs,
611
+ systemPrompt: buildSystemPrompt(spec.persona),
612
+ // appendSystemPrompt: each declared skill's body is concatenated and
613
+ // appended to the system prompt verbatim. This makes skills *active by
614
+ // default* rather than lazy-loadable via the read tool — matches what
615
+ // users expect when they declare a skill in spec.skills[].
616
+ appendSystemPrompt: skillsAppend,
617
+ noExtensions: true,
618
+ // noSkills suppresses the default skill scan from ~/.pi/agent/skills/ and
619
+ // <cwd>/.pi/skills/. We always set it to keep the environment hermetic;
620
+ // ADL-declared skills reach the loader exclusively via additionalSkillPaths.
621
+ noSkills: true,
622
+ });
623
+ // Must call reload() explicitly: createAgentSession only calls reload() when
624
+ // it constructs DefaultResourceLoader itself. When we pass a pre-built loader
625
+ // it assumes the caller has already loaded it. Without this call no extensions
626
+ // (get_time, audit-log) are active.
627
+ await resourceLoader.reload();
628
+ // Fail fast if any ADL-declared entrypoint failed to load. Pi records load
629
+ // failures in extensionsResult.errors as { path, error } pairs but continues
630
+ // silently — without this check, Pi's tool allowlist would simply contain a
631
+ // name that maps to no implementation, and the run would proceed without
632
+ // the declared tools.
633
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
634
+ const extensionsResult = resourceLoader.getExtensions?.();
635
+ if (extensionsResult?.errors?.length) {
636
+ const declared = new Set(entrypointPaths);
637
+ const relevant = extensionsResult.errors.filter((e) => declared.has(e.path));
638
+ if (relevant.length > 0) {
639
+ const summary = relevant
640
+ .map((e) => ` - ${e.path}: ${e.error}`)
641
+ .join("\n");
642
+ throw new Error(`Failed to load ADL-declared extensions:\n${summary}\n` +
643
+ `These are listed in spec.tools[]/spec.extensions[] but the runtime ` +
644
+ `could not load their entrypoints. Check the manifest's entrypoint path.`);
645
+ }
646
+ }
647
+ // E2E test hook: when AGENT_CONTROLLER_USE_FAKE_PROVIDER=1 and a fake
648
+ // has been installed via the testing helper, use it in place of the
649
+ // real provider. The env var alone does nothing without a script.
650
+ // See runtime/src/testing/fake-provider.ts.
651
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
652
+ let model = resolveFakeModelIfRequested();
653
+ if (!model) {
654
+ // getModel uses branded literal generics; cast provider/name to `any`
655
+ // since at runtime they come from user YAML and cannot be statically typed.
656
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
657
+ model = getModel(spec.model.provider, spec.model.name);
658
+ }
659
+ if (!model) {
660
+ throw new Error(`Model ${spec.model.provider}/${spec.model.name} not found. ` +
661
+ `Check provider and model name; pi-ai may not support this combination.`);
662
+ }
663
+ // If ANTHROPIC_BASE_URL is set (e.g. a corporate LLM gateway or local
664
+ // dev proxy), override the model's hardcoded baseUrl so Pi routes
665
+ // requests there. Skipped for the fake provider (its baseUrl points
666
+ // to http://fake.invalid which is never dialed). We also ensure
667
+ // ANTHROPIC_API_KEY is non-empty so Pi's env-key check passes; the
668
+ // proxy itself handles auth, so any placeholder value works.
669
+ if (process.env.ANTHROPIC_BASE_URL && spec.model.provider === "anthropic" && model.api !== "fake-test") {
670
+ model.baseUrl = process.env.ANTHROPIC_BASE_URL;
671
+ if (!process.env.ANTHROPIC_API_KEY) {
672
+ process.env.ANTHROPIC_API_KEY = "proxy-managed";
673
+ }
674
+ }
675
+ const sessionId = `s_${Date.now().toString(36)}`;
676
+ // When spec.sessionId is set (via --resume <id>), open/continue the named
677
+ // persistent session under <agentDir>/sessions/agentctl/<id>/.
678
+ // On first run the dir is empty so continueRecent creates a new session;
679
+ // on subsequent runs it picks up the most-recently-modified file in that dir.
680
+ // When spec.sessionId is absent, use an in-memory session (default Pi behaviour).
681
+ const sessionManager = spec.sessionId
682
+ ? SessionManager.continueRecent(process.cwd(), join(getAgentDir(), "sessions", "agentctl", spec.sessionId))
683
+ : SessionManager.inMemory(process.cwd());
684
+ // Enforce the ADL tool allowlist by passing the spec-declared tool names as
685
+ // Pi's `tools` option. This is *both* the activation list and the allowlist:
686
+ // - Pi's built-in tools (read, bash, edit, write) are NOT in the list, so
687
+ // they are filtered out at the registry level.
688
+ // - Only tools whose names appear in this array become active in the
689
+ // model's tool catalog. Extension-registered tools whose names match
690
+ // `spec.tools[].name` get activated; others stay registered but inactive.
691
+ //
692
+ // IMPORTANT — MCP interaction:
693
+ // When spec.mcpServers is non-empty, pi-mcp-extension registers MCP tools
694
+ // AFTER session start (during the session_start event, after connecting to
695
+ // the MCP server). Pi's _refreshToolRegistry filters tools against
696
+ // `allowedToolNames` (derived from the `tools` option) when adding them to
697
+ // `_toolRegistry`. With `tools: []`, `allowedToolNames` becomes an empty
698
+ // Set (truthy), so `isAllowedTool(name)` always returns false — MCP tools
699
+ // never enter `_toolRegistry` and `setActiveTools(["mcp_…"])` silently
700
+ // no-ops. The model receives zero tools and hallucinates XML <invoke> tags.
701
+ //
702
+ // Fix: when mcpServers is non-empty, use `noTools: "builtin"` to suppress
703
+ // Pi's built-in read/bash/edit/write tools without setting `allowedToolNames`
704
+ // to an empty Set. With `allowedToolNames = undefined`, every registered
705
+ // tool can enter `_toolRegistry`, and Pi's "new tools" auto-activation logic
706
+ // (the `!options?.activeToolNames` branch in _refreshToolRegistry) activates
707
+ // MCP tools as they register. Declared spec.tools entrypoints also load via
708
+ // additionalExtensionPaths and auto-activate the same way.
709
+ //
710
+ // Security note: `noExtensions: true` + `additionalExtensionPaths` (only
711
+ // declared entrypoints) already enforce the ADL allowlist at the loading
712
+ // level. The `tools:` option is redundant for that purpose when mcpServers
713
+ // is present; `noTools: "builtin"` is sufficient.
714
+ //
715
+ // When subagents are declared, automatically include the "subagent" tool
716
+ // (registered by the vendored subagent extension) in the parent's allowlist.
717
+ // Without this, the parent model would not be able to call the subagent tool
718
+ // even though the extension is loaded.
719
+ const hasMcpServers = spec.mcpServers && spec.mcpServers.length > 0;
720
+ const toolAllowlist = [
721
+ ...spec.tools.map((t) => t.name),
722
+ ...(spec.subagents && spec.subagents.length > 0 ? ["subagent"] : []),
723
+ ];
724
+ const { session } = await createAgentSession({
725
+ model,
726
+ resourceLoader,
727
+ // When MCP servers are declared: omit `tools` so allowedToolNames stays
728
+ // undefined (MCP tools can enter the registry and auto-activate), and use
729
+ // noTools: "builtin" to suppress Pi's built-in read/bash/edit/write tools.
730
+ // When no MCP servers: pass tools: toolAllowlist as before (explicit
731
+ // allowlist that blocks builtins and activates only declared tool names).
732
+ ...(hasMcpServers
733
+ ? { noTools: "builtin" }
734
+ : { tools: toolAllowlist }),
735
+ sessionManager,
736
+ });
737
+ // Forward spec.model.temperature to the provider via session.agent.onPayload.
738
+ // Pi's CreateAgentSessionOptions has no temperature field; onPayload is the
739
+ // documented hook for injecting per-request provider parameters. The hook
740
+ // receives the raw provider payload (e.g. the Anthropic messages body) and
741
+ // returns a (possibly modified) copy.
742
+ //
743
+ // IMPORTANT: Anthropic's API rejects requests where `temperature` is set to
744
+ // anything other than 1 while `thinking` (extended thinking) is enabled.
745
+ // Pi enables thinking by default for Claude Sonnet. We therefore *only*
746
+ // apply spec.model.temperature when thinking is NOT in the payload — when
747
+ // it is, Pi's default temperature (which is compatible with thinking) wins
748
+ // and the ADL temperature is silently ignored. This is a documented MVP
749
+ // limitation; v0.2 will add a way to disable thinking from ADL.
750
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
751
+ if (spec.model.temperature !== undefined) {
752
+ const specTemperature = spec.model.temperature;
753
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
754
+ const prevOnPayload = session.agent.onPayload;
755
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
756
+ session.agent.onPayload = async (payload, m) => {
757
+ const base = prevOnPayload ? await prevOnPayload(payload, m) : payload;
758
+ if (typeof base === "object" && base !== null && !base.thinking) {
759
+ base.temperature = specTemperature;
760
+ }
761
+ return base;
762
+ };
763
+ }
764
+ // Track terminal failure conditions so the final session.ended reflects
765
+ // whether Pi actually completed or errored. Pi reports provider/runtime
766
+ // failures via assistant message_end with stopReason: "error" (and via
767
+ // agent_end with an error field). Without this tracking, session.prompt
768
+ // resolves normally and we'd report a failed run as "completed".
769
+ let errorMessage;
770
+ // Hallucination-guardrail state. `hallucinationMode` is fixed for the
771
+ // session; `correctionRequested` is flipped when the subscriber sees a
772
+ // hallucinated message_end and mode is "correct"; `correctionSent` is
773
+ // flipped once the corrective re-prompt has actually been dispatched
774
+ // post-turn, so we cap at one correction per outer task invocation.
775
+ const hallucinationMode = resolveHallucinationMode(spec);
776
+ let correctionRequested = false;
777
+ let correctionSent = false;
778
+ // Register the subscriber BEFORE the first emit and BEFORE prompting,
779
+ // so no Pi event is missed.
780
+ session.subscribe((piEvent) => {
781
+ // Hallucinated tool-call detection runs ahead of translatePiEvent for
782
+ // message_end so warn/correct modes can scrub the offending XML out of
783
+ // the user-visible `message` event before it's emitted.
784
+ //
785
+ // The detector must be gated on role === "assistant". In correct mode
786
+ // we send CORRECTION_PROMPT back via session.prompt(); that prompt
787
+ // mentions <invoke>/<function_calls>/<Skill> by name as part of its
788
+ // instructions to the model, so Pi's user-role message_end carries
789
+ // exactly the XML patterns we detect. Without the role gate, the
790
+ // runtime flags its own corrective re-prompt as a hallucination
791
+ // (caught by codex review of v0.1.10).
792
+ let scrubbedAssistantText;
793
+ let hallucinationFindings = [];
794
+ if (piEvent.type === "message_end" && piEvent.message?.role === "assistant") {
795
+ const assistantText = extractAssistantText(piEvent.message);
796
+ if (assistantText) {
797
+ hallucinationFindings = detectHallucinatedToolCalls(assistantText);
798
+ if (hallucinationFindings.length > 0 && hallucinationMode !== "block") {
799
+ scrubbedAssistantText = stripHallucinationXml(assistantText).text;
800
+ }
801
+ }
802
+ }
803
+ // Emit the translated wire event. For warn/correct mode we substitute
804
+ // a scrubbed `message` event in place of the default translation so
805
+ // downstream consumers see clean assistant prose.
806
+ //
807
+ // Note: we deliberately do NOT mutate piEvent.message.content. Pi's
808
+ // conversation state retains the original (unscrubbed) assistant
809
+ // message. Two reasons:
810
+ // (a) The audit log + persisted session must reflect what actually
811
+ // happened. Scrubbing is a display concern, not a rewrite-history
812
+ // concern.
813
+ // (b) In correct mode the corrective re-prompt explicitly tells the
814
+ // model "your previous message contained fabricated tool-call
815
+ // XML." If we scrubbed the conversation history, the model would
816
+ // see a clean previous message and be unable to understand what
817
+ // it's being asked to correct.
818
+ // Codex pass 4 flagged this as a possible issue; the design is
819
+ // intentional. See examples/guardrails-correct.yaml for the flow.
820
+ if (scrubbedAssistantText !== undefined) {
821
+ const role = piEvent.message?.role ?? "unknown";
822
+ emit(stamp(sessionId, "message", { text: scrubbedAssistantText, role }));
823
+ }
824
+ else {
825
+ const translated = translatePiEvent(sessionId, piEvent);
826
+ if (translated)
827
+ emit(translated);
828
+ }
829
+ if (piEvent.type === "message_end") {
830
+ const stop = piEvent.message?.stopReason;
831
+ if (stop === "error" || stop === "aborted") {
832
+ const detail = piEvent.message?.errorMessage
833
+ ? `: ${piEvent.message.errorMessage}`
834
+ : "";
835
+ errorMessage ??= `Pi message ended with stopReason=${stop}${detail}`;
836
+ }
837
+ if (hallucinationFindings.length > 0) {
838
+ const baseMessage = `Assistant message contains fabricated tool-call XML: ${hallucinationFindings.join(", ")}. ` +
839
+ `The model is hallucinating tool invocations instead of using the runtime's ` +
840
+ `tool channel. Consider strengthening the persona, narrowing the active skill ` +
841
+ `set, or adding the missing tool to spec.tools[].`;
842
+ if (hallucinationMode === "block") {
843
+ // Legacy v0.1.8 behavior: hard failure. Wire `error`, errorMessage
844
+ // set so session ends with reason=error and the CLI exits non-zero.
845
+ emit(stamp(sessionId, "error", {
846
+ kind: "hallucinated_tool_call",
847
+ mode: hallucinationMode,
848
+ message: baseMessage,
849
+ patterns: hallucinationFindings,
850
+ }));
851
+ errorMessage ??= `Assistant message contained fabricated tool-call XML (${hallucinationFindings.join(", ")})`;
852
+ }
853
+ else {
854
+ // warn / correct: non-fatal. Emit a `warning` event so listeners
855
+ // can surface the finding without ending the session.
856
+ emit(stamp(sessionId, "warning", {
857
+ kind: "hallucinated_tool_call",
858
+ mode: hallucinationMode,
859
+ message: baseMessage,
860
+ patterns: hallucinationFindings,
861
+ }));
862
+ if (hallucinationMode === "correct" && !correctionSent) {
863
+ // The actual re-prompt happens after session.prompt() returns
864
+ // from the current turn — see the post-prompt block below.
865
+ correctionRequested = true;
866
+ }
867
+ }
868
+ }
869
+ }
870
+ if (piEvent.type === "agent_end" && piEvent.error) {
871
+ errorMessage ??= String(piEvent.error?.message ?? piEvent.error);
872
+ }
873
+ });
874
+ emit(stamp(sessionId, "session.started", {
875
+ agentName: spec.metadata.name,
876
+ model: spec.model,
877
+ }));
878
+ // Fire the Pi `session_start` lifecycle event by calling bindExtensions().
879
+ // This is REQUIRED for extensions to initialise — without it pi-mcp-extension
880
+ // never connects to MCP servers and never registers tools.
881
+ //
882
+ // bindExtensions() accepts an ExtensionBindings object whose fields are all
883
+ // optional. We cast through `any` to avoid reconstructing the full
884
+ // ExtensionUIContext interface (which has ~30 methods). We only provide
885
+ // `notify` so that MCP start-up messages and errors are visible on stderr
886
+ // without disturbing the wire stream. All other UI methods stay as Pi's
887
+ // built-in noOpUIContext (set during ExtensionRunner construction).
888
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
889
+ await session.bindExtensions({
890
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
891
+ uiContext: { notify: (msg) => process.stderr.write(`[pi-mcp] ${msg}\n`) },
892
+ });
893
+ try {
894
+ // session.prompt() is async and resolves when the agent finishes the
895
+ // turn. This is the correct way to run a single-turn task per the
896
+ // Pi SDK examples (see examples/sdk/01-minimal.ts).
897
+ await session.prompt(spec.task);
898
+ // Correct mode: if the model fabricated tool-call XML during the
899
+ // primary turn, send one corrective re-prompt and let it redo its
900
+ // last message without the XML. Pi doesn't expose a mid-stream
901
+ // injection API, so the corrective turn shows up as a separate
902
+ // message in the wire stream — that's intentional and visible.
903
+ //
904
+ // Skip the correction if a terminal failure already happened during
905
+ // the primary turn (errorMessage set). The session is going to end
906
+ // with reason=error regardless; making another model call would
907
+ // burn tokens after a cancellation/provider-error without changing
908
+ // the outcome. Codex pass 5 flagged this.
909
+ if (correctionRequested && !correctionSent && !errorMessage) {
910
+ correctionSent = true;
911
+ await session.prompt(CORRECTION_PROMPT);
912
+ }
913
+ }
914
+ catch (err) {
915
+ errorMessage ??= err instanceof Error ? err.message : String(err);
916
+ }
917
+ finally {
918
+ session.dispose();
919
+ }
920
+ if (errorMessage) {
921
+ emit(stamp(sessionId, "error", { message: errorMessage }));
922
+ emit(stamp(sessionId, "session.ended", { reason: "error", message: errorMessage }));
923
+ }
924
+ else {
925
+ emit(stamp(sessionId, "session.ended", { reason: "completed" }));
926
+ }
927
+ }
928
+ function translatePiEvent(sessionId, piEvent) {
929
+ switch (piEvent.type) {
930
+ // Pi emits tool_execution_start / tool_execution_end to session subscribers.
931
+ // The tool_call / tool_result labels are extension-hook events, not subscriber events.
932
+ case "tool_execution_start":
933
+ return stamp(sessionId, "tool.call", {
934
+ toolName: piEvent.toolName,
935
+ callId: piEvent.toolCallId,
936
+ args: piEvent.args,
937
+ });
938
+ case "tool_execution_end":
939
+ return stamp(sessionId, "tool.result", {
940
+ callId: piEvent.toolCallId,
941
+ isError: piEvent.isError,
942
+ content: piEvent.result,
943
+ });
944
+ case "message_end": {
945
+ const text = extractAssistantText(piEvent.message);
946
+ const role = piEvent.message?.role ?? "unknown";
947
+ return stamp(sessionId, "message", { text, role });
948
+ }
949
+ case "before_provider_request":
950
+ return stamp(sessionId, "model.request", { messageCount: piEvent.messageCount });
951
+ case "after_provider_response":
952
+ return stamp(sessionId, "model.response", {
953
+ tokensIn: piEvent.tokensIn,
954
+ tokensOut: piEvent.tokensOut,
955
+ finishReason: piEvent.finishReason,
956
+ });
957
+ default:
958
+ return undefined;
959
+ }
960
+ }
961
+ /**
962
+ * Extract the plain assistant text from a Pi AppMessage. Pi's message
963
+ * content can be a string or an array of {type,text} blocks; we collect
964
+ * the text blocks. Returns "" when there is no text content.
965
+ */
966
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
967
+ function extractAssistantText(message) {
968
+ const raw = message?.content;
969
+ if (typeof raw === "string")
970
+ return raw;
971
+ if (Array.isArray(raw)) {
972
+ return raw
973
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
974
+ .filter((c) => c.type === "text")
975
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
976
+ .map((c) => c.text)
977
+ .join("");
978
+ }
979
+ return "";
980
+ }