/**
* Makes Pi reachable from `agent-app
bridge`, the framework service that
* starts an agent harness when an Agent App queues work for one.
*
* The bridge starts a harness by a route read from `$A2APP_HOME/harnesses.json`
* (default `~/.a2app/harnesses.json`). The framework ships no Pi profile, so
* until this extension writes one the bridge cannot see Pi at all. The route is
* Pi's print mode, `pi -p ""`: one run in the app's directory, then
* exit. Pi's bash tool asks no permission, so the agent can operate the app
* through the a2app CLI unattended.
*
* The file is the machine's, shared by every harness, so this only ever ADDS a
* "pi" profile when none exists. It never edits another entry, never sets
* `default`, and never rewrites a file it cannot parse. A user's edit to the
* entry is kept; deleting it makes this write it again on the next start.
*
* Self-contained on purpose (Node built-ins only): Pi installs this package by
* copying files, so there is no dependency install to rely on. The same rules
* live in `@a2app/integration-starter`'s `registerHarnessProfile`, which the
* other plugins use.
*/
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
const PROFILE = {
id: "pi",
name: "Pi",
routes: [{ mode: "headless", command: "pi", args: ["-p", "{prompt}"] }],
};
type Outcome = { status: "registered" | "kept" | "refused"; detail: string };
function harnessesFile(): string {
const override = process.env["A2APP_HOME"];
const home = override && override.trim() !== "" ? resolve(override) : join(homedir(), ".a2app");
return join(home, "harnesses.json");
}
function writeAtomic(file: string, contents: string): void {
mkdirSync(dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.tmp`;
const fd = openSync(tmp, "w");
try {
writeSync(fd, contents);
fsyncSync(fd);
} finally {
closeSync(fd);
}
try {
renameSync(tmp, file);
} catch (err) {
rmSync(tmp, { force: true });
throw err;
}
}
function register(): Outcome {
const file = harnessesFile();
let doc: Record = { version: 1, harnesses: [] };
if (existsSync(file)) {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(file, "utf8"));
} catch (err) {
return { status: "refused", detail: `${file} is not valid JSON (${(err as Error).message}); left it alone` };
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { status: "refused", detail: `${file} is not a JSON object; left it alone` };
}
doc = parsed as Record;
if (doc["harnesses"] === undefined) doc["harnesses"] = [];
if (!Array.isArray(doc["harnesses"])) {
return { status: "refused", detail: `${file} has a "harnesses" that is not an array; left it alone` };
}
}
const harnesses = doc["harnesses"] as unknown[];
if (harnesses.some((h) => (h as { id?: unknown } | null)?.id === PROFILE.id)) {
return { status: "kept", detail: `a "pi" profile is already in ${file}` };
}
harnesses.push(PROFILE);
try {
writeAtomic(file, JSON.stringify(doc, null, 2) + "\n");
} catch (err) {
return { status: "refused", detail: `could not write ${file}: ${(err as Error).message}` };
}
return { status: "registered", detail: `registered Pi for \`agent-app bridge\` in ${file}` };
}
/** The part of Pi's ExtensionAPI this uses. */
interface PiApi {
on(event: "session_start", handler: (event: unknown, ctx: { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } }) => void): void;
}
export default function (pi: PiApi): void {
const outcome = register();
if (outcome.status === "kept") return;
pi.on("session_start", (_event, ctx) => {
ctx.ui.notify(`agent-app: ${outcome.detail}`, outcome.status === "refused" ? "warning" : "info");
});
}