#!/usr/bin/env node /** * Phase 14 manual repro (B-002 pre-fix): RPC flow with skillReinject.debug. * Captures diag lines from extension stderr (console.error) and extension_ui notify. */ import { spawn } from "node:child_process"; import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const skillPath = resolve(process.env.B002_SKILL_PATH ?? `${process.env.HOME}/.cursor/skills/fup-blame-commits`); const extensionPath = join(repoRoot, "src/index.ts"); const globalSettingsPath = join(process.env.HOME ?? "", ".pi/agent/settings.json"); const globalBackupPath = `${globalSettingsPath}.b002-backup`; function patchGlobalSettings() { if (!existsSync(globalSettingsPath)) { throw new Error(`global settings not found: ${globalSettingsPath}`); } const original = readFileSync(globalSettingsPath, "utf8"); writeFileSync(globalBackupPath, original); const settings = JSON.parse(original); settings.skillReinject = { ...settings.skillReinject, enabled: true, debug: true, autoCompactIntegration: "defer", }; writeFileSync(globalSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); return original; } function restoreGlobalSettings(original) { writeFileSync(globalSettingsPath, original); rmSync(globalBackupPath, { force: true }); } const sessionDir = mkdtempSync(join(tmpdir(), "b002-session-")); let originalSettings; try { originalSettings = patchGlobalSettings(); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exit(2); } let nextId = 1; const pending = new Map(); const diagNotifies = []; let compactResponse; let statusBeforeCompact; let statusAfterCompact; function send(child, cmd) { const id = `req-${nextId++}`; child.stdin.write(`${JSON.stringify({ id, ...cmd })}\n`); return new Promise((resolvePromise, reject) => { const timer = setTimeout(() => { pending.delete(id); reject(new Error(`timeout waiting for ${cmd.type} (${id})`)); }, Number(process.env.B002_STEP_TIMEOUT_MS ?? 180_000)); pending.set(id, { resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); }, }); }); } function captureDiagLine(line) { if (line.includes("skill-reinject [session_compact]:") || line.includes("skill-reinject [before_agent_start]:")) { diagNotifies.push(line.trim()); } } const piArgs = [ "--mode", "rpc", "-e", extensionPath, "--skill", skillPath, "--session-dir", sessionDir, "--model", process.env.B002_MODEL ?? "Eltex-Coder-Senior", "--no-session", ]; const child = spawn("pi", piArgs, { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"], env: process.env, }); let stdoutBuffer = ""; child.stdout.on("data", (chunk) => { stdoutBuffer += chunk.toString("utf8"); let newlineIndex = stdoutBuffer.indexOf("\n"); while (newlineIndex >= 0) { const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, ""); stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); if (!line.trim()) { newlineIndex = stdoutBuffer.indexOf("\n"); continue; } try { const record = JSON.parse(line); if (record.type === "extension_ui_request" && record.method === "notify") { if (typeof record.message === "string") { captureDiagLine(record.message); } } if (record.type === "message" && record.message?.role === "assistant") { const text = JSON.stringify(record.message.content ?? ""); if (text.includes("skill-reinject:")) { if (!statusBeforeCompact && text.includes("tracked:")) { statusBeforeCompact = text; } else if (text.includes("tracked:")) { statusAfterCompact = text; } } } if (record.type === "response" && record.id && pending.has(record.id)) { pending.get(record.id).resolve(record); pending.delete(record.id); } } catch { // ignore non-JSON stdout } newlineIndex = stdoutBuffer.indexOf("\n"); } }); const stderrChunks = []; child.stderr.on("data", (chunk) => { const text = chunk.toString("utf8"); stderrChunks.push(chunk); for (const line of text.split("\n")) { captureDiagLine(line); } }); child.on("close", () => { for (const { reject } of pending.values()) { reject(new Error("pi process exited")); } pending.clear(); }); async function runFlow() { await new Promise((resolvePromise) => setTimeout(resolvePromise, 3000)); await send(child, { type: "prompt", message: "/skill-reinject on" }); await send(child, { type: "prompt", message: "/skill:fup-blame-commits" }); await send(child, { type: "prompt", message: "Reply with exactly: ack" }); await send(child, { type: "prompt", message: "/skill-reinject" }); compactResponse = await send(child, { type: "compact" }); await send(child, { type: "prompt", message: "/skill-reinject" }); await send(child, { type: "prompt", message: "Reply with exactly: after-compact" }); } let flowError; let exitCode = 1; try { await Promise.race([ runFlow().then(() => child.stdin.end()), new Promise((_, reject) => setTimeout( () => reject(new Error(`global timeout after ${process.env.B002_TIMEOUT_MS ?? 360_000}ms`)), Number(process.env.B002_TIMEOUT_MS ?? 360_000), ), ), ]); exitCode = await new Promise((resolvePromise) => { child.on("close", (code) => resolvePromise(code ?? 1)); }); } catch (error) { flowError = error instanceof Error ? error.message : String(error); child.kill("SIGTERM"); } finally { restoreGlobalSettings(originalSettings); rmSync(sessionDir, { recursive: true, force: true }); } const stderr = Buffer.concat(stderrChunks).toString("utf8"); const parsedDiag = diagNotifies.map((line) => { const match = line.match(/skill-reinject \[(session_compact|before_agent_start)\]: (.+)$/); if (!match) { return { raw: line }; } try { return { phase: match[1], snapshot: JSON.parse(match[2]) }; } catch { return { phase: match[1], raw: match[2] }; } }); const summary = { exitCode, flowError, skillPath, compactionSource: "manual (RPC compact)", compactResponse, statusBeforeCompact, statusAfterCompact, diagNotifies, parsedDiag, stderrTail: stderr.trim().split("\n").filter((l) => !l.includes("Llama.cpp")).slice(-15).join("\n"), }; console.log(JSON.stringify(summary, null, 2)); process.exit(diagNotifies.length > 0 && !flowError ? 0 : 1);