import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, watch, writeFileSync, } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { researchReportPath, researchReportsDir } from "./paths.js"; import { SOCKET_FILENAME, SocketIPC } from "./research-ipc-socket.js"; import { updateResearchSessionStatus } from "./research-state.js"; // ── Types ─────────────────────────────────────────────────────────────────── export interface ResearchReport { sessionId: string; task: string; output: string; completedAt: string; } /** Dispose function for registered listeners / watchers. */ export type Unsubscribe = () => void; /** Child process role: send a report back to the parent. */ export interface ResearchReporter { reportBack(report: ResearchReport): Promise; } /** Parent process role: receive reports from research children. */ export interface ResearchReceiver { /** * Subscribe to incoming reports. Returns an unsubscribe function * (the caller doesn't need to hold a stable reference for offReport). */ onReport(handler: (report: ResearchReport) => void): Unsubscribe; /** * Activate push delivery — watch for new report files and fire onReport * handlers as soon as they land. Returns a cleanup function to tear down * watchers / listeners. */ start(): Promise; /** * Explicit poll trigger for lifecycle hooks (e.g. before_agent_start). * Fires onReport handlers for any pending reports; deduplicates across * calls, so a report delivered by push won't fire again here. */ poll(): Promise; } // ── File IPC implementation ───────────────────────────────────────────────── export class FileIPC implements ResearchReporter, ResearchReceiver { private readonly handlers = new Set<(report: ResearchReport) => void>(); /** Track processed filenames to avoid re-delivery on repeated poll(). */ private readonly processed = new Set(); private watcher: ReturnType | null = null; // ── ResearchReporter ────────────────────────────────────────────────── async reportBack(report: ResearchReport): Promise { const dir = researchReportsDir(); mkdirSync(dir, { recursive: true }); const path = researchReportPath(report.sessionId); // Write to a temp file and rename into place so poll() (or the watcher) // never parses a partially-written report. poll() skips *.json.tmp. const tmp = `${path}.tmp`; writeFileSync(tmp, JSON.stringify(report, null, 2), "utf-8"); renameSync(tmp, path); } // ── ResearchReceiver ────────────────────────────────────────────────── onReport(handler: (report: ResearchReport) => void): Unsubscribe { this.handlers.add(handler); return () => { this.handlers.delete(handler); }; } async poll(): Promise { const dir = researchReportsDir(); if (!existsSync(dir)) { return; } const delivered: ResearchReport[] = []; for (const file of readdirSync(dir)) { if (!file.endsWith(".json")) { continue; } if (this.processed.has(file)) { continue; } this.processed.add(file); try { const report = JSON.parse( readFileSync(join(dir, file), "utf-8"), ) as ResearchReport; updateResearchSessionStatus(report.sessionId, "completed"); try { unlinkSync(join(dir, file)); } catch { // Unlink failure (EACCES, EPERM, ...): keep the file in // `processed` so a later poll cannot re-read and re-deliver it. continue; } delivered.push(report); } catch { // Malformed file — retry next poll this.processed.delete(file); } } for (const handler of this.handlers) { for (const report of delivered) { handler(report); } } } async start(): Promise { const dir = researchReportsDir(); mkdirSync(dir, { recursive: true }); this.watcher = watch(dir, { persistent: false }, (_event, filename) => { if (typeof filename === "string" && filename.endsWith(".json")) { void this.poll(); // fires onReport handlers + unlinks } }); // persistent:false → watcher doesn't keep the event loop alive, // so process.exit() isn't blocked by the watch handle. return () => { this.watcher?.close(); this.watcher = null; }; } } // Factory: picks SocketIPC when the daemon socket file exists, FileIPC // otherwise. The connectivity check is lazy: SocketIPC.reportBack and // .register fall back transparently on any daemon failure. export function createIPC(): ResearchReporter & ResearchReceiver { const socketPath = join(getAgentDir(), SOCKET_FILENAME); if (existsSync(socketPath)) { const socket = new SocketIPC(socketPath); const file = new FileIPC(); socket.setFallbackReporter((report) => file.reportBack(report)); socket.setFileIPC(file); return socket; } return new FileIPC(); }