Pi
Run uvx jes login first. See Setup.
Install
Put the extension in its own subdirectory, so Pi loads index.ts and doesn't
treat the runner as a second extension:
mkdir -p ~/.pi/agent/extensions/jes
uvx jes pi-settings > ~/.pi/agent/extensions/jes/index.ts
uvx jes runner-settings > ~/.pi/agent/extensions/jes/jes-runner.ts
Project extensions go in .pi/extensions/ the same way. Reload with
/reload.
The runner calls uvx jes@<version> hook, pinned to the release that printed
it, so print both files again after you upgrade jes. If jes takes over 60
seconds, crashes, or prints no decision, the runner blocks with
Blocked: jes did not answer.
What each hook does
| Hook | Stage | If jes blocks |
|---|---|---|
before_agent_start | input | Injects the refusal as a message. |
tool_call | tool_call | Returns block: true and reason. The tool does not run. |
tool_result | tool_result | Replaces content with the refusal. Array content becomes one text block. |
Limits
- Pi doesn't erase a blocked prompt. The model sees the prompt and the refusal.
- jes doesn't check the reply in Pi.
Source
index.ts
// Checks each step with jes through ./jes-runner.ts, which `jes runner-settings` prints.
// before_agent_start checks the user prompt and injects the refusal when it
// is blocked. tool_call returns block: true. tool_result replaces content.
import { sessionOf, spawnJes, type JesRunner } from "./jes-runner.ts";
type PiContext = { sessionId?: string };
export default function jesExtension(
pi: {
on: (
name: string,
handler: (event: Record<string, unknown>, ctx: PiContext) => unknown,
) => void;
},
run: JesRunner = spawnJes,
) {
pi.on("before_agent_start", async (event, ctx) => {
const prompt = typeof event.prompt === "string" ? event.prompt : "";
const checked = run({
stage: "input",
text: prompt,
session_id: sessionOf(ctx.sessionId, "pi"),
});
if (!checked.ok) {
return {
message: { customType: "jes", content: checked.onward, display: true },
};
}
return undefined;
});
pi.on("tool_call", async (event, ctx) => {
const tool = typeof event.toolName === "string" ? event.toolName : "";
const checked = run({
stage: "tool_call",
tool,
arguments: event.input ?? {},
session_id: sessionOf(ctx.sessionId, "pi"),
});
if (!checked.ok) {
return { block: true, reason: checked.onward };
}
return undefined;
});
pi.on("tool_result", async (event, ctx) => {
const tool = typeof event.toolName === "string" ? event.toolName : "tool";
const text =
typeof event.content === "string" ? event.content : JSON.stringify(event.content ?? "");
const checked = run({
stage: "tool_result",
tool,
text,
session_id: sessionOf(ctx.sessionId, "pi"),
});
if (!checked.ok) {
if (Array.isArray(event.content)) {
return { content: [{ type: "text", text: checked.onward }] };
}
return { content: checked.onward };
}
return undefined;
});
}
jes-runner.ts
// Calls `uvx jes@0.0.1 hook` on stdin. uvx ships with uv, so there is no separate jes install.
// `jes runner-settings` prints this file with the version filled in. Print it again after upgrading jes.
import { spawnSync } from "node:child_process";
export type JesDecision = { ok: boolean; onward: string; decision: string };
export type JesRunner = (body: Record<string, unknown>) => JesDecision;
// The agent waits for every check, so a check that hangs blocks after this long.
const TIMEOUT_MS = 60_000;
const UNANSWERED: JesDecision = {
ok: false,
onward: "Blocked: jes did not answer.",
decision: "block",
};
export function spawnJes(body: Record<string, unknown>): JesDecision {
const run = spawnSync("uvx", ["jes@0.0.1", "hook"], {
input: JSON.stringify(body),
encoding: "utf8",
timeout: TIMEOUT_MS,
maxBuffer: 16 * 1024 * 1024,
});
return parseDecision(run.stdout);
}
// A jes that crashed, timed out, or is misconfigured prints nothing usable. That blocks.
export function parseDecision(stdout: string | null | undefined): JesDecision {
const text = (stdout ?? "").trim();
if (!text) {
return UNANSWERED;
}
try {
const parsed = JSON.parse(text) as Partial<JesDecision>;
if (typeof parsed.ok !== "boolean" || typeof parsed.onward !== "string") {
return UNANSWERED;
}
return { ok: parsed.ok, onward: parsed.onward, decision: parsed.ok ? "allow" : "block" };
} catch {
return UNANSWERED;
}
}
export function sessionOf(value: string | undefined, fallback: string): string {
const cleaned = (value ?? fallback).replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 200);
return /^[A-Za-z0-9]/.test(cleaned) ? cleaned : `s${cleaned}`;
}