/**
* pulpo.ts — generated by pulpod for one pulpo session. Do not edit by hand;
* pulpod rewrites this file on every spawn/resume.
*
* Reports pi's lifecycle back to the pulpo daemon by spawning
* `pulpo hook pi --event <name>` as a short-lived, detached child process per
* event, feeding it a small JSON payload on stdin. Must never throw and must
* never block the agent loop: every handler is wrapped in try/catch, and the
* child's own "error" event (e.g. ENOENT if `pulpo` isn't on PATH) is
* swallowed instead of becoming an uncaught exception that could crash pi.
*
* Events are serialized through a single promise `chain` instead of being
* fully fire-and-forget: pi awaits every handler in turn (including
* `session_shutdown` before it exits), so a handler that appends to `chain`
* and then awaits it blocks pi's own event loop just long enough for the
* *previous* event's child process to actually finish (or hit a 2.5s safety
* timeout) before the next event's handler returns. Without this, two
* detached children racing the OS process scheduler could report to the
* daemon out of order (reproduced: session_shutdown logged before
* session_start in print-mode runs).
*
* Verified against @earendil-works/pi-coding-agent 0.85.1.
*/
import { spawn } from "node:child_process";
// Templated by pulpod at file-generation time to the resolved absolute path
// of its own binary; falls back to relying on PATH.
const PULPO_BIN = "__PULPO_BIN_PATH__";
// Serializes reportEvent calls so events reach the daemon in the order they
// fired, instead of racing each other as independent detached processes. Each
// handler below does `reportEvent(...); await chain;` — awaiting the tail of
// this chain, which by then includes the promise the call just appended.
let chain = Promise.resolve();
function safe(fn) {
try {
return fn();
} catch {
return null;
}
}
/**
* Appends one event report to `chain`. Never throws and never rejects `chain`
* itself (every failure path calls `resolve()` instead of `reject()`), so
* `await chain` in a handler can never turn into an uncaught rejection.
*/
function reportEvent(eventName, payload) {
chain = chain.then(
() =>
new Promise((resolve) => {
try {
const child = spawn(PULPO_BIN, ["hook", "pi", "--event", eventName], {
stdio: ["pipe", "ignore", "ignore"],
detached: true,
});
// Required: an unhandled "error" (e.g. ENOENT if `pulpo` isn't on
// PATH) on the child process would otherwise surface as an
// uncaught exception in pi.
child.on("error", () => {});
// Required separately from the listener above: EPIPE from writing
// to a closed/broken pipe is emitted on the child's stdin stream,
// not on the child process itself, and would otherwise also
// surface as an uncaught exception.
child.stdin.on("error", () => {});
try {
child.stdin.write(JSON.stringify(payload));
} catch {
/* ignore */
} finally {
try {
child.stdin.end();
} catch {
/* ignore */
}
}
child.unref();
child.once("exit", resolve);
child.once("error", resolve);
// Safety net: never let one slow/stuck child stall the whole chain
// (and every handler awaiting it) indefinitely.
// unref: a still-pending timer must never keep the pi process alive after
// its own event loop has drained (measured: 2.4 s exit delay otherwise).
const timer = setTimeout(resolve, 2500);
if (typeof timer.unref === "function") timer.unref();
} catch {
/* never let a hook failure affect the agent */
resolve();
}
}),
);
}
function truncate(text, max) {
if (typeof text !== "string") return null;
return text.length > max ? `${text.slice(0, max)}...` : text;
}
/** Find the last assistant message's concatenated text in a message list. */
function lastAssistantText(messages) {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m && m.role === "assistant" && Array.isArray(m.content)) {
const text = m.content
.filter((c) => c && c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n");
if (text) return text;
}
}
return null;
}
function lastAssistantMeta(messages) {
if (!Array.isArray(messages)) return { stopReason: null, errorMessage: null };
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m && m.role === "assistant") {
return { stopReason: m.stopReason ?? null, errorMessage: m.errorMessage ?? null };
}
}
return { stopReason: null, errorMessage: null };
}
export default function pulpoExtension(pi) {
// Cache from the most recent agent_end; consumed when agent_settled fires.
// agent_end can fire more than once per user prompt (pi auto-retry,
// auto-compaction-then-retry, or a queued follow-up message all start a
// new low-level run). agent_settled is pi's own "no further automatic
// continuation will run" signal, so that is the point at which pulpo
// should be told the turn is actually over — reporting on every agent_end
// would flap pulpo's status mid-task.
let lastMessages = null;
function base(ctx) {
return {
session_id: safe(() => ctx.sessionManager.getSessionId()) ?? null,
session_file: safe(() => ctx.sessionManager.getSessionFile()) ?? null,
cwd: safe(() => ctx.cwd) ?? null,
};
}
pi.on("session_start", async (event, ctx) => {
try {
reportEvent("session_start", {
event: "session_start",
...base(ctx),
reason: event.reason,
previous_session_file: event.previousSessionFile ?? null,
});
await chain;
} catch {
/* never throw from a hook */
}
});
pi.on("agent_start", async (_event, ctx) => {
try {
reportEvent("agent_start", { event: "agent_start", ...base(ctx) });
await chain;
} catch {
/* never throw from a hook */
}
});
pi.on("agent_end", async (event, _ctx) => {
// Cache only — do not post here. See comment on `lastMessages` above.
try {
lastMessages = event.messages ?? null;
} catch {
lastMessages = null;
}
});
pi.on("agent_settled", async (_event, ctx) => {
try {
const text = lastAssistantText(lastMessages);
const { stopReason, errorMessage } = lastAssistantMeta(lastMessages);
reportEvent("agent_settled", {
event: "agent_settled",
...base(ctx),
last_assistant_message: truncate(text, 500),
stop_reason: stopReason,
error: stopReason === "error" ? errorMessage ?? "unknown error" : null,
});
await chain;
} catch {
/* never throw from a hook */
} finally {
lastMessages = null;
}
});
pi.on("ui_prompt_start", async (event, ctx) => {
try {
reportEvent("ui_prompt_start", {
event: "ui_prompt_start",
...base(ctx),
kind: event.kind ?? null,
title: event.title ?? null,
});
await chain;
} catch {
/* never throw from a hook */
}
});
pi.on("ui_prompt_end", async (event, ctx) => {
try {
reportEvent("ui_prompt_end", {
event: "ui_prompt_end",
...base(ctx),
kind: event.kind ?? null,
title: event.title ?? null,
});
await chain;
} catch {
/* never throw from a hook */
}
});
pi.on("session_shutdown", async (event, ctx) => {
// reason: "quit" | "reload" | "new" | "resume" | "fork". Only "quit"
// means the pi *process* is exiting; the others are in-process session
// replacement (a fresh session_start follows immediately). Forward all
// of them and let pulpo's parse_event ignore non-"quit" reasons.
try {
reportEvent("session_shutdown", {
event: "session_shutdown",
...base(ctx),
reason: event.reason,
});
await chain;
} catch {
/* never throw from a hook */
}
});
}