/** * 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"; const PATH_SEPARATOR_RE = /[/\\]/g; const WINDOWS_DRIVE_PREFIX_RE = /^[A-Za-z]:-/; const LEADING_DASHES_RE = /^-+/; function getOutputEntryType(role: string): "assistant" | "user" | "toolResult" { if (role === "assistant") { return "assistant"; } if (role === "user") { return "user"; } return "toolResult"; } /** * 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(PATH_SEPARATOR_RE, "-") // both separators → dash .replace(WINDOWS_DRIVE_PREFIX_RE, "") // strip Windows drive prefix ("C:-") .replace(LEADING_DASHES_RE, ""); // 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: getOutputEntryType(msg.role), 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(); } }); return () => { flush(); unsubscribe(); }; }