import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; import { dirname, join } from "node:path"; const run = promisify(execFile); // `crtr -h` is the auto-loaded tool guide. Capturing it costs a real subprocess: // bare `crtr -h` is the one invocation that must build the FULL command tree, so // it loads every subtree's module graph — ~250ms on a laptop, ~320ms in the // guest container. The guide's content is static per crouter version + installed // plugins, so that cost must never sit in front of a turn. The TTL only exists // to pick a tree change (an upgrade, a newly installed plugin) up eventually. const TTL_MS = 15_000; // The TTL cache is also persisted to disk, keyed by cwd (skills resolve by // scope, so the guide is cwd-specific). Every broker/node is a FRESH process, so // the in-memory cache alone is always cold at session_start — each boot would // re-spawn `crtr -h`. The disk layer lets a cluster of node boots within one TTL // window (daemon revives, child spawns, rapid relaunches) share a single capture. function helpCacheFile(cwd: string): string { const h = createHash("sha1").update(cwd).digest("hex").slice(0, 16); return join(homedir(), ".crouter", "cache", `crouter-help-${h}.json`); } export default function (pi: ExtensionAPI) { let helpText = ""; let capturedAt = 0; async function refresh(): Promise { if (Date.now() - capturedAt < TTL_MS && helpText) return; // in-memory fresh const file = helpCacheFile(process.cwd()); // Cross-process disk cache: reuse a recent capture from a sibling boot. try { const c = JSON.parse(readFileSync(file, "utf8")) as { text?: string; at?: number }; if (typeof c.text === "string" && c.text && typeof c.at === "number" && Date.now() - c.at < TTL_MS) { helpText = c.text; capturedAt = c.at; return; // fresh on disk — skip the spawn } } catch { // miss / corrupt — fall through to re-probe } try { const { stdout } = await run("crouter", ["-h"], { timeout: 10_000 }); helpText = stdout.trim(); capturedAt = Date.now(); try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify({ text: helpText, at: capturedAt }), "utf8"); } catch { // best-effort: a missing cache just means the next boot re-probes } } catch { // soft-fail: keep last-known text (or empty); never block the turn } } // Boot-time capture: the one place a refresh may block, because it happens at // node mint/revive, never in front of a user's turn. A warm-pool spare has // therefore already paid it before it is ever claimed. pi.on("session_start", refresh); pi.on("before_agent_start", (event) => { // NEVER await here. A stale-by-TTL capture used to re-spawn `crtr -h` // synchronously, putting its full subprocess cost between the delivered // message and agent_start — measured as the single largest term in a // Northlight first-message ack. Serve the last-known text and let the // refresh land for the next turn instead. if (Date.now() - capturedAt >= TTL_MS) void refresh(); if (!helpText) return; const block = `\n${helpText}\n`; // Place the guide in the tool-selection frame, right after pi's native // "Available tools" list rather than at the very bottom — the agent decides // which capability to reach for while reading the tools, so the crtr // commands (agent/skill/job) must be present there, not 3k tokens later. // `\n\nGuidelines:` is the stable seam that closes the tools area. Fall back // to appending if pi ever changes that marker. const anchor = "\n\nGuidelines:"; const idx = event.systemPrompt.indexOf(anchor); if (idx === -1) { return { systemPrompt: `${event.systemPrompt}\n\n${block}` }; } return { systemPrompt: `${event.systemPrompt.slice(0, idx)}\n\n${block}${event.systemPrompt.slice(idx)}`, }; }); }