pulpod 0.2.0

Pulpo daemon — manages agent sessions via tmux/Docker
/**
 * 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 */
    }
  });
}