Skip to content
Merged
9 changes: 9 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,15 @@
grep -q '@earendil-works/pi-coding-agent' hooks/pi-channel.ts

tsc --noEmit -p hooks/typecheck/tsconfig.json

# Runtime smoke: the type gate is provably blind to execution-order defects (a TDZ
# use-before-declaration shipped green through it), so the asset is transpiled and
# actually driven through its open path.
${pkgs.esbuild}/bin/esbuild hooks/pi-channel.ts \
--format=esm --platform=node --target=es2022 \
--outfile=hooks/typecheck/smoke-out/pi-channel.mjs
SMOKE_TRUE_BIN=${pkgs.coreutils}/bin/true \
${pkgs.nodejs}/bin/node hooks/typecheck/smoke.mjs
touch $out
'';

Expand Down
72 changes: 67 additions & 5 deletions hooks/pi-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const PROTOCOL = 1;
const BIN = "ST2_PI_CHANNEL_BIN";
const CATALOG = "ST2_PI_CHANNEL_CATALOG";
const IDENTITY = "ST2_PI_CHANNEL_IDENTITY";
const RUNTIME_ID = "ST2_PI_CHANNEL_RUNTIME_ID";
const SESSION = "ST2_PI_CHANNEL_SESSION";
const SEQ = "ST2_PI_CHANNEL_SEQ";

// pi starts the session even if st2 is slow to answer. Restored context is worth a short wait and
// never worth a hung agent.
Expand All @@ -51,6 +54,9 @@ type Stash = {
bin?: string;
catalog?: string;
identity?: string;
runtimeId?: string;
session?: string;
seq?: string;
child?: childProcess.ChildProcess;
};

Expand All @@ -67,26 +73,46 @@ type Stash = {
const stash = (): Stash => {
const globals = globalThis as { __st2PiChannel?: Stash };
if (!globals.__st2PiChannel) {
// EVERY ST2_PI_CHANNEL_* value is stashed and unexported — the ownership pair included: a
// leaked runtime id or session token would hand a nested pi (or any tool child) this seat's
// registry key and record ownership. The channel subprocess receives them explicitly below.
globals.__st2PiChannel = {
bin: process.env[BIN],
catalog: process.env[CATALOG],
identity: process.env[IDENTITY],
runtimeId: process.env[RUNTIME_ID],
session: process.env[SESSION],
seq: process.env[SEQ],
};
delete process.env[BIN];
delete process.env[CATALOG];
delete process.env[IDENTITY];
delete process.env[RUNTIME_ID];
delete process.env[SESSION];
delete process.env[SEQ];
}
return globals.__st2PiChannel;
};

export default function (pi: ExtensionAPI) {
const state = stash();
const { bin, catalog, identity } = state;
const { bin, catalog, identity, runtimeId, session, seq } = state;

// Always close a NAMED channel, never "whatever is current". A session replacement (/new,
// /resume, /fork) tears the old session down around the new one's start, so a teardown handler
// that closed `current` would reap the successor it just opened — measured, and it silently
// stopped all delivery after `/new`.
const awaitExit = (child: childProcess.ChildProcess | undefined, ms: number) =>
new Promise<void>((resolve) => {
if (!child || child.exitCode !== null || child.signalCode !== null) return resolve();
const timer = setTimeout(resolve, ms);
timer.unref?.();
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
});

const closeChild = (child: childProcess.ChildProcess | undefined) => {
if (!child) return;
if (state.child === child) state.child = undefined;
Expand All @@ -96,7 +122,7 @@ export default function (pi: ExtensionAPI) {
};

/** Open a channel and resolve with the hello's restored context (empty if none, or on timeout). */
const open = (ctx: ExtensionContext): Promise<string> => {
const open = async (ctx: ExtensionContext): Promise<string> => {
if (!bin || !catalog || !identity) return Promise.resolve("");
if (typeof ctx.isIdle !== "function") {
// Refuse rather than degrade. Without a positive idle proof this extension cannot choose
Expand All @@ -109,13 +135,22 @@ export default function (pi: ExtensionAPI) {
);
return Promise.resolve("");
}
// Closes the channel opened by the PREVIOUS session, whichever extension instance opened it.
closeChild(state.child);
// Closes the channel opened by the PREVIOUS session, whichever extension instance opened
// it — and WAITS (bounded) for it to exit before the replacement spawns: the successor
// shares the seat's record, and a predecessor draining its queued frames after the new
// session's seed would land stale state into fresh records.
const previous = state.child;
closeChild(previous);
await awaitExit(previous, 2000);
Comment thread
schickling marked this conversation as resolved.

const channelEnv: NodeJS.ProcessEnv = { ...process.env };
if (runtimeId) channelEnv[RUNTIME_ID] = runtimeId;
if (session) channelEnv[SESSION] = session;
if (seq) channelEnv[SEQ] = seq;
Comment thread
schickling marked this conversation as resolved.
const child = childProcess.spawn(
bin,
["--catalog", catalog, "driver", "pi-channel", "--identity", identity],
{ stdio: ["pipe", "pipe", "inherit"] },
{ stdio: ["pipe", "pipe", "inherit"], env: channelEnv },
);
state.child = child;

Expand All @@ -132,6 +167,12 @@ export default function (pi: ExtensionAPI) {
if (state.child === child) state.child = undefined;
settle("");
});
// An observability pipe must never take pi down: a channel that closed its stdin mid-write
// surfaces EPIPE on the stream, which without a listener is an uncaught exception in the
// host process. Retire the channel instead — frames simply stop, fail-open.
child.stdin.on("error", () => {
if (state.child === child) state.child = undefined;
});
child.on("exit", () => settle(""));

const send = (frame: Record<string, unknown>) => {
Expand Down Expand Up @@ -197,11 +238,32 @@ export default function (pi: ExtensionAPI) {
});
};

// Observed harness state, extension side. pi's own turn boundaries are the positive signal,
// and the idle edge is `agent_settled`, not `agent_end`: measured against the repo's own pi
// captures, `ctx.isIdle()` is still false through `agent_end`, and a queued follow-up turn
// starts exactly at that boundary — an `agent_end` emit would blip a spurious idle before it.
// `agent_settled` is the first point pi is provably idle. The frame is observational — st2
// decides what becomes of it — and a closed channel drops it silently, matching the fail-open
// rule this file already follows. pi 0.84.2 exposes no typed waiting-on-a-human event, so no
// frame here ever claims one.
const sendState = (word: "active" | "idle") => {
const child = state.child;
if (!child || !child.stdin || child.stdin.destroyed) return;
child.stdin.write(JSON.stringify({ type: "state", state: word }) + "\n");
Comment thread
schickling marked this conversation as resolved.
};
pi.on("agent_start", async () => sendState("active"));
pi.on("agent_settled", async () => sendState("idle"));

pi.on("session_start", async (_event, ctx) => {
// Awaited before the session's first turn, which is what makes restored context reach the boot
// prompt rather than the turn after it.
const restored = await open(ctx);
const opened = state.child;
// Seed the observed state with the idle proof's answer at open time, so the record does not
// wait for the first turn boundary to exist.
if (opened && typeof ctx.isIdle === "function") {
sendState(ctx.isIdle() ? "idle" : "active");
}
if (restored.trim()) {
// A custom message participates in LLM context without triggering a turn of its own — the
// closest pi equivalent to the other harnesses' `additionalContext` hook output.
Expand Down
38 changes: 38 additions & 0 deletions hooks/typecheck/smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Runtime smoke of the shipped pi extension: drives the channel-open path far enough that a
// use-before-declaration (TDZ), a broken import, or a top-level throw fails the check — the
// classes a type-only gate is provably blind to. The channel binary is `true`, so the open
// times out its hello and resolves empty; any thrown error fails the smoke.
import assert from "node:assert";

process.env.ST2_PI_CHANNEL_BIN = process.env.SMOKE_TRUE_BIN ?? "/bin/true";
process.env.ST2_PI_CHANNEL_CATALOG = "/tmp/st2-smoke-catalog";
process.env.ST2_PI_CHANNEL_IDENTITY = "smoke.worker";
process.env.ST2_PI_CHANNEL_RUNTIME_ID = "smoke.worker";
process.env.ST2_PI_CHANNEL_SESSION = "smoke-session";
process.env.ST2_PI_CHANNEL_SEQ = "1";

const mod = await import("./smoke-out/pi-channel.mjs");
assert.strictEqual(typeof mod.default, "function", "extension exports its entry point");

const handlers = new Map();
const pi = {
on: (name, handler) => handlers.set(name, handler),
};
mod.default(pi);
for (const name of ["session_start", "session_shutdown", "agent_start", "agent_settled"]) {
assert.ok(handlers.has(name), `extension registers ${name}`);
}

const ctx = {
isIdle: () => true,
ui: { notify: () => {} },
};
// Two session starts in a row: the second exercises the predecessor close-and-await path — the
// exact region the TDZ regression lived in.
await handlers.get("session_start")({}, ctx);
await handlers.get("session_start")({}, ctx);
await handlers.get("agent_start")({}, ctx);
await handlers.get("agent_settled")({}, ctx);
await handlers.get("session_shutdown")({ reason: "smoke" }, ctx);
console.log("pi extension smoke: ok");
process.exit(0);
Loading
Loading