/** * output-file.ts — Streaming JSONL output file for agent transcripts. * * Creates a per-agent output file that streams conversation turns as JSONL, * matching Claude Code's task output file format. */ import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent"; /** * Encode a cwd path as a filesystem-safe directory name. Handles: * - POSIX: "/home/user/project" → "home-user-project" * - Windows: "C:\Users\foo\project" → "Users-foo-project" * - UNC: "\\\\server\\share\\project" → "server-share-project" */ export function encodeCwd(cwd: string): string { return cwd .replace(/[/\\]/g, "-") // both separators → dash .replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-") .replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC) } /** Create the output file path, ensuring the directory exists. * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */ export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string { const encoded = encodeCwd(cwd); const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`); mkdirSync(root, { recursive: true, mode: 0o700 }); // chmod is a no-op on Windows and throws on some Windows filesystems. // On Unix we still want to enforce 0o700 past umask, so only swallow on Windows. try { chmodSync(root, 0o700); } catch (err) { if (process.platform !== "win32") throw err; } const dir = join(root, encoded, sessionId, "tasks"); mkdirSync(dir, { recursive: true }); return join(dir, `${agentId}.output`); } /** Write the initial user prompt entry. */ export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void { const entry = { isSidechain: true, agentId, type: "user", message: { role: "user", content: prompt }, timestamp: new Date().toISOString(), cwd, }; writeFileSync(path, JSON.stringify(entry) + "\n", "utf-8"); } /** * Subscribe to session events and flush new messages to the output file on each turn_end. * Returns a cleanup function that does a final flush and unsubscribes. */ export function streamToOutputFile( session: AgentSession, path: string, agentId: string, cwd: string, ): () => void { let writtenCount = 1; // initial user prompt already written const flush = () => { const messages = session.messages; while (writtenCount < messages.length) { const msg = messages[writtenCount]; const entry = { isSidechain: true, agentId, type: msg.role === "assistant" ? "assistant" : msg.role === "user" ? "user" : "toolResult", message: msg, timestamp: new Date().toISOString(), cwd, }; try { appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8"); } catch { /* ignore write errors */ } writtenCount++; } }; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type === "turn_end") flush(); // Compaction replaces session.messages with a shorter, summarized array, // leaving writtenCount past the new end — without re-anchoring, the flush // loop would never match again and streaming would halt for good (#145). // Flush before it runs so any not-yet-flushed tail still reaches the file, // then re-anchor to the rebuilt array once it lands. The re-anchor is // deferred a microtask because on the overflow-retry path pi trims the // trailing error assistant message AFTER emitting compaction_end — // anchoring synchronously would sit one past the trimmed array and skip // the first post-compaction message. Aborted/failed compactions leave // session.messages untouched, so only successful ones re-anchor. if (event.type === "compaction_start") flush(); if (event.type === "compaction_end" && !event.aborted && event.result) { queueMicrotask(() => { writtenCount = session.messages.length; }); } }); return () => { flush(); unsubscribe(); }; }