import * as fs from "node:fs"; import * as path from "node:path"; /** * Session files can contain very large tool results and thinking payloads. Result * recovery is therefore deliberately a tail read, not an unbounded readFileSync. * Four MiB leaves ample room for the final assistant entry while keeping process * exit work bounded even for a multi-gigabyte long-horizon session. */ export const MAX_SESSION_SALVAGE_BYTES = 4 * 1024 * 1024; export interface SalvagedAssistantMessage { text: string | null; stopReason: string | null; errorMessage: string | null; } function assistantMessage(entry: unknown): SalvagedAssistantMessage | null { if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null; const record = entry as Record; if (record.type !== "message") return null; const message = record.message; if (message === null || typeof message !== "object" || Array.isArray(message)) return null; const fields = message as Record; if (fields.role !== "assistant" || !Array.isArray(fields.content)) return null; const text = fields.content .filter((block): block is { type: string; text: string } => block !== null && typeof block === "object" && (block as Record).type === "text" && typeof (block as Record).text === "string", ) .map((block) => block.text) .join(""); return { text: text.trim().length > 0 ? text : null, stopReason: typeof fields.stopReason === "string" ? fields.stopReason : null, errorMessage: typeof fields.errorMessage === "string" && fields.errorMessage.length > 0 ? fields.errorMessage : null, }; } /** * Recover the newest assistant message from the recorded Pi session, including an * empty provider-error/abort message. Falling back to an earlier non-empty message * would let partial prose mask the actual terminal provider verdict. * * The final line may be half-written when a process is being stopped. Each line is * parsed independently, so malformed/truncated records are ignored while earlier * durable assistant output remains available. When the tail begins in the middle * of a JSON object, that first fragment is discarded as well. */ export interface SessionSalvageOptions { maxBytes?: number; /** Descriptor-bound directory, when the worker recorded one. */ expectedDirectory?: string; } export function salvageFinalAssistantMessage( sessionFile: string | null, options: SessionSalvageOptions = {}, ): SalvagedAssistantMessage | null { const maxBytes = options.maxBytes ?? MAX_SESSION_SALVAGE_BYTES; if (sessionFile === null || !path.isAbsolute(sessionFile) || !Number.isFinite(maxBytes) || maxBytes <= 0) return null; if (options.expectedDirectory !== undefined && path.resolve(path.dirname(sessionFile)) !== path.resolve(options.expectedDirectory)) return null; let fd: number | undefined; try { // lstat rejects symlinks and non-regular nodes before open. O_NONBLOCK makes a // replacement with a FIFO/device non-blocking, while O_NOFOLLOW (where Node's // platform constants expose it) closes the symlink race between lstat/open. const before = fs.lstatSync(sessionFile); if (!before.isFile() || before.isSymbolicLink()) return null; const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const nonBlock = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; fd = fs.openSync(sessionFile, fs.constants.O_RDONLY | noFollow | nonBlock); const stat = fs.fstatSync(fd); if (!stat.isFile()) return null; const after = fs.lstatSync(sessionFile); if ( before.dev !== stat.dev || before.ino !== stat.ino || !after.isFile() || after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino ) return null; const length = Math.min(stat.size, Math.floor(maxBytes)); if (length <= 0) return null; const start = stat.size - length; const buffer = Buffer.allocUnsafe(length); const read = fs.readSync(fd, buffer, 0, length, start); let raw = buffer.subarray(0, read).toString("utf8"); if (start > 0) { const firstNewline = raw.indexOf("\n"); if (firstNewline === -1) return null; raw = raw.slice(firstNewline + 1); } let latest: SalvagedAssistantMessage | null = null; for (const line of raw.split("\n")) { if (line.trim().length === 0) continue; try { const message = assistantMessage(JSON.parse(line) as unknown); if (message !== null) latest = message; } catch { // A session append interrupted by process shutdown is expected evidence, // not corruption of the earlier complete records in the same bounded tail. } } return latest; } catch { return null; } finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { // Best-effort salvage must never interfere with terminalization. } } } } export function salvageFinalAssistantText(sessionFile: string | null, options: SessionSalvageOptions = {}): string | null { return salvageFinalAssistantMessage(sessionFile, options)?.text ?? null; }