import { SessionManager, buildContextEntries as piBuildContextEntries, buildSessionContext as piBuildSessionContext, getAgentDir, } from "@earendil-works/pi-coding-agent"; import { closeSync, openSync, readSync } from "fs"; import { normalize as normalizePath } from "path"; import type { AgentMessage, SessionEntry, SessionHeader, SessionInfo, SessionContext } from "./types"; import type { SessionEntry as PiSessionEntry, SessionInfo as PiSessionInfo } from "@earendil-works/pi-coding-agent"; import { normalizeToolCalls } from "./normalize"; import { sessionPathKey } from "./session-path"; import { resolveProject, type ProjectInfo } from "./worktree"; export { getAgentDir }; export async function attachSessionProjectInfo(sessions: SessionInfo[]): Promise { const uniqueCwds = [...new Set(sessions.map((s) => s.cwd).filter(Boolean))]; const projectByCwd = new Map(); await Promise.all(uniqueCwds.map(async (cwd) => { projectByCwd.set(cwd, await resolveProject(cwd)); })); return sessions.map((session) => { const project = session.cwd ? projectByCwd.get(session.cwd) : undefined; return { ...session, projectRoot: project?.projectRoot ?? session.cwd, ...(project?.isWorktree && project.branch ? { worktreeBranch: project.branch } : {}), }; }); } export function mergeSessionLists( persistedSessions: SessionInfo[], supplementalSessions: SessionInfo[], ): SessionInfo[] { const byId = new Map(supplementalSessions.map((session) => [session.id, session])); // A disk scan is authoritative once the JSONL exists. In particular, this // replaces a transient registry snapshot without briefly rendering two rows. for (const session of persistedSessions) byId.set(session.id, session); return [...byId.values()].sort((a, b) => b.modified.localeCompare(a.modified)); } async function loadAllSessions(): Promise { const piSessions: PiSessionInfo[] = await SessionManager.listAll(); const pathToId = new Map(); for (const s of piSessions) pathToId.set(sessionPathKey(s.path), s.id); const sessions = piSessions.map((s) => { cacheSessionPath(s.id, s.path); return { path: s.path, id: s.id, cwd: s.cwd, name: s.name, created: s.created instanceof Date ? s.created.toISOString() : String(s.created), modified: s.modified instanceof Date ? s.modified.toISOString() : String(s.modified), messageCount: s.messageCount, firstMessage: s.firstMessage || "(no messages)", parentSessionId: s.parentSessionPath ? pathToId.get(sessionPathKey(s.parentSessionPath)) : undefined, transient: false, }; }); return attachSessionProjectInfo(sessions); } export async function listAllSessions(options: { force?: boolean } = {}): Promise { if (options.force) invalidateSessionListCache(); const generation = globalThis.__piSessionListGeneration ?? 0; // Return cached result if still fresh (avoids re-scanning session files // and re-spawning git processes on every page load). if (globalThis.__piSessionListCache && Date.now() - globalThis.__piSessionListCache.ts < SESSION_LIST_CACHE_TTL_MS) { return globalThis.__piSessionListCache.data; } // Coalescing dedup: concurrent callers share the same in-flight promise // only while it belongs to the current cache generation. if (globalThis.__piSessionListPromise && globalThis.__piSessionListPromiseGeneration === generation) { return globalThis.__piSessionListPromise; } const loadPromise = loadAllSessions().then((data) => { // If a mutation invalidated this scan, make this caller join (or start) a // scan for the current generation. Returning the stale result here made a // refresh race indistinguishable from a successful refresh. if ((globalThis.__piSessionListGeneration ?? 0) !== generation) { return listAllSessions(); } globalThis.__piSessionListCache = { data, ts: Date.now() }; return data; }); const trackedPromise = loadPromise.finally(() => { if (globalThis.__piSessionListPromise === trackedPromise) { globalThis.__piSessionListPromise = undefined; globalThis.__piSessionListPromiseGeneration = undefined; } }); globalThis.__piSessionListPromise = trackedPromise; globalThis.__piSessionListPromiseGeneration = generation; return trackedPromise; } // ============================================================================ // Session path caches, stored in globalThis for hot-reload safety. // ============================================================================ declare global { var __piSessionPathCache: Map | undefined; var __piPathToSessionIdCache: Map | undefined; var __piSessionListPromise: Promise | undefined; var __piSessionListPromiseGeneration: number | undefined; var __piSessionListGeneration: number | undefined; var __piSessionListCache: { data: SessionInfo[]; ts: number } | undefined; } const SESSION_LIST_CACHE_TTL_MS = 30_000; export function invalidateSessionListCache(): void { globalThis.__piSessionListGeneration = (globalThis.__piSessionListGeneration ?? 0) + 1; globalThis.__piSessionListCache = undefined; } function getPathCache(): Map { if (!globalThis.__piSessionPathCache) globalThis.__piSessionPathCache = new Map(); return globalThis.__piSessionPathCache; } function getPathToIdCache(): Map { if (!globalThis.__piPathToSessionIdCache) globalThis.__piPathToSessionIdCache = new Map(); return globalThis.__piPathToSessionIdCache; } export async function resolveSessionPath(sessionId: string): Promise { const cached = getPathCache().get(sessionId); if (cached) return cached; // Cache miss: scan all sessions to populate cache, then retry await listAllSessions(); return getPathCache().get(sessionId) ?? null; } export async function resolveSessionIdByPath(filePath: string): Promise { const pathKey = sessionPathKey(filePath); const cached = getPathToIdCache().get(pathKey); if (cached) return cached; await listAllSessions(); return getPathToIdCache().get(pathKey); } export function cacheSessionPath(sessionId: string, filePath: string): void { const normalizedPath = normalizePath(filePath); const pathKey = sessionPathKey(normalizedPath); const pathCache = getPathCache(); const reverseCache = getPathToIdCache(); const previousPath = pathCache.get(sessionId); const previousPathKey = previousPath ? sessionPathKey(previousPath) : undefined; const previousSessionId = reverseCache.get(pathKey); const previousOwnerPath = previousSessionId ? pathCache.get(previousSessionId) : undefined; if (previousPathKey && previousPathKey !== pathKey && reverseCache.get(previousPathKey) === sessionId) { reverseCache.delete(previousPathKey); } if ( previousSessionId && previousSessionId !== sessionId && previousOwnerPath && sessionPathKey(previousOwnerPath) === pathKey ) { pathCache.delete(previousSessionId); } pathCache.set(sessionId, normalizedPath); reverseCache.set(pathKey, sessionId); } export function invalidateSessionPathCache(sessionId: string): void { const pathCache = getPathCache(); const reverseCache = getPathToIdCache(); const filePath = pathCache.get(sessionId); pathCache.delete(sessionId); const pathKey = filePath ? sessionPathKey(filePath) : undefined; if (pathKey && reverseCache.get(pathKey) === sessionId) { reverseCache.delete(pathKey); } } export function readSessionHeader(filePath: string): SessionHeader | null { const fd = openSync(filePath, "r"); try { const chunks: Buffer[] = []; const maxHeaderBytes = 64 * 1024; let position = 0; let foundNewline = false; while (position < maxHeaderBytes && !foundNewline) { const buffer = Buffer.allocUnsafe(Math.min(4096, maxHeaderBytes - position)); const bytesRead = readSync(fd, buffer, 0, buffer.length, position); if (bytesRead === 0) break; const data = buffer.subarray(0, bytesRead); const newlineIndex = data.indexOf(0x0a); chunks.push(newlineIndex === -1 ? data : data.subarray(0, newlineIndex)); position += bytesRead; foundNewline = newlineIndex !== -1; } if (!foundNewline && position >= maxHeaderBytes) return null; const firstLine = Buffer.concat(chunks).toString("utf8").trimEnd(); if (!firstLine) return null; try { const header = JSON.parse(firstLine) as SessionHeader; return header.type === "session" ? header : null; } catch { return null; } } finally { closeSync(fd); } } export function getSessionEntries(filePath: string): SessionEntry[] { const entries = SessionManager.open(filePath).getEntries(); return entries as unknown as SessionEntry[]; } export function buildSessionContext( entries: SessionEntry[], leafId?: string | null, options: { deferThinking?: boolean; deferToolResultImages?: boolean } = {}, ): SessionContext { const byId = new Map(); for (const e of entries) byId.set(e.id, e); const piEntries = entries as unknown as PiSessionEntry[]; const piCtx = piBuildSessionContext(piEntries, leafId, byId as unknown as Map); const contextEntries = piBuildContextEntries( piEntries, leafId, byId as unknown as Map, ); // Convert the SDK-selected context entries and their IDs together. This keeps // fork/navigation targets aligned while preserving pi's compaction ordering. const messages: AgentMessage[] = []; const entryIds: string[] = []; for (const entry of contextEntries) { const localEntry = entry as unknown as SessionEntry; const m = entryToUiMessage(localEntry, options); if (m) { messages.push(m); entryIds.push(localEntry.id); } } return { messages, entryIds, thinkingLevel: piCtx.thinkingLevel, model: piCtx.model, }; } function parseEntryTimestamp(timestamp: string): number | undefined { const parsed = Date.parse(timestamp); return Number.isNaN(parsed) ? undefined : parsed; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function base64ImageInfo(block: unknown): { bytes: number; mime?: string } | null { if (!isRecord(block) || block.type !== "image") return null; let data: string | undefined; let mime: string | undefined; if (typeof block.data === "string") { data = block.data; mime = typeof block.mimeType === "string" ? block.mimeType : undefined; } else if (isRecord(block.source) && block.source.type === "base64" && typeof block.source.data === "string") { data = block.source.data; mime = typeof block.source.media_type === "string" ? block.source.media_type : undefined; } if (!data) return null; const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; return { bytes: Math.max(0, Math.floor(data.length * 3 / 4) - padding), mime }; } function omitToolResultBase64Images(message: AgentMessage): AgentMessage { if (message.role !== "toolResult") return message; let omitted = 0; let bytes = 0; const mimes = new Set(); const content = message.content.filter((block) => { const image = base64ImageInfo(block); if (!image) return true; omitted += 1; bytes += image.bytes; if (image.mime) mimes.add(image.mime); return false; }); if (omitted === 0) return message; const mimeText = mimes.size > 0 ? `: ${[...mimes].join(", ")}` : ""; content.push({ type: "text", text: `[${omitted} tool result image${omitted === 1 ? "" : "s"} omitted from initial history payload${mimeText}, ~${bytes} bytes]`, }); return { ...message, content }; } // Convert a session entry on the active branch into a UI message. // Returns null for entries that do not map to chat history (metadata, non-message types). function entryToUiMessage( entry: SessionEntry, options: { deferThinking?: boolean; deferToolResultImages?: boolean }, ): AgentMessage | null { // Supported message roles: user, assistant, toolResult, bashExecution. // bashExecution messages enter the case "message" branch (entry.type === "message"). // The early return at line below ("!options.deferThinking || message.role !== "assistant"") // passes non-assistant messages — including bashExecution — through unchanged. // normalizeToolCalls is a secondary guard (returns non-assistant messages as-is). switch (entry.type) { case "message": { const message = options.deferToolResultImages ? omitToolResultBase64Images(normalizeToolCalls(entry.message)) : normalizeToolCalls(entry.message); if (!options.deferThinking || message.role !== "assistant") return message; return { ...message, content: message.content.map((block) => ( block.type === "thinking" && block.thinking.trim() !== "" ? { ...block, thinking: "", deferred: true } : block )), }; } case "compaction": return { role: "custom", customType: "compaction", content: entry.summary, display: true, details: { tokensBefore: entry.tokensBefore, firstKeptEntryId: entry.firstKeptEntryId, }, timestamp: parseEntryTimestamp(entry.timestamp), }; case "branch_summary": if (!entry.summary) return null; return { role: "user", content: `*The conversation briefly explored another branch and returned with this summary:*\n\n${entry.summary}`, timestamp: parseEntryTimestamp(entry.timestamp), }; case "custom_message": return { role: "custom", customType: entry.customType, content: entry.content, display: entry.display, details: entry.details, timestamp: parseEntryTimestamp(entry.timestamp), }; default: return null; } }