session-manager.ts
52 KB1713 lines
session-manager.ts
1import type { AgentMessage } from "@earendil-works/pi-agent-core";2import { type ImageContent, type Message, type TextContent, type Usage, uuidv7 } from "@earendil-works/pi-ai";3import { randomUUID } from "crypto";4import {5 appendFileSync,6 closeSync,7 createReadStream,8 existsSync,9 mkdirSync,10 openSync,11 readdirSync,12 readSync,13 statSync,14 writeFileSync,15} from "fs";16import { readdir, stat } from "fs/promises";17import { join, resolve } from "path";18import { createInterface } from "readline";19import { StringDecoder } from "string_decoder";20import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";21import { normalizePath, resolvePath } from "../utils/paths.ts";22import {23 type BashExecutionMessage,24 type CustomMessage,25 createBranchSummaryMessage,26 createCompactionSummaryMessage,27 createCustomMessage,28} from "./messages.ts";2930export const CURRENT_SESSION_VERSION = 3;3132export interface SessionHeader {33 type: "session";34 version?: number; // v1 sessions don't have this35 id: string;36 timestamp: string;37 cwd: string;38 parentSession?: string;39}4041export interface NewSessionOptions {42 id?: string;43 parentSession?: string;44}4546export interface SessionEntryBase {47 type: string;48 id: string;49 parentId: string | null;50 timestamp: string;51}5253export interface SessionMessageEntry extends SessionEntryBase {54 type: "message";55 message: AgentMessage;56}5758export interface ThinkingLevelChangeEntry extends SessionEntryBase {59 type: "thinking_level_change";60 thinkingLevel: string;61}6263export interface ModelChangeEntry extends SessionEntryBase {64 type: "model_change";65 provider: string;66 modelId: string;67}6869export interface CompactionEntry<T = unknown> extends SessionEntryBase {70 type: "compaction";71 summary: string;72 firstKeptEntryId: string;73 tokensBefore: number;74 /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */75 details?: T;76 /** Usage from the LLM call(s) that generated this summary, if available */77 usage?: Usage;78 /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */79 fromHook?: boolean;80}8182export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {83 type: "branch_summary";84 fromId: string;85 summary: string;86 /** Extension-specific data (not sent to LLM) */87 details?: T;88 /** Usage from the LLM call that generated this summary, if available */89 usage?: Usage;90 /** True if generated by an extension, false if pi-generated */91 fromHook?: boolean;92}9394/**95 * Custom entry for extensions to store extension-specific data in the session.96 * Use customType to identify your extension's entries.97 *98 * Purpose: Persist extension state across session reloads. On reload, extensions can99 * scan entries for their customType and reconstruct internal state.100 *101 * Does NOT participate in LLM context (ignored by buildSessionContext).102 * For injecting content into context, see CustomMessageEntry.103 */104export interface CustomEntry<T = unknown> extends SessionEntryBase {105 type: "custom";106 customType: string;107 data?: T;108}109110/** Label entry for user-defined bookmarks/markers on entries. */111export interface LabelEntry extends SessionEntryBase {112 type: "label";113 targetId: string;114 label: string | undefined;115}116117/** Session metadata entry (e.g., user-defined display name). */118export interface SessionInfoEntry extends SessionEntryBase {119 type: "session_info";120 name?: string;121}122123/**124 * Custom message entry for extensions to inject messages into LLM context.125 * Use customType to identify your extension's entries.126 *127 * Unlike CustomEntry, this DOES participate in LLM context.128 * The content is converted to a user message in buildSessionContext().129 * Use details for extension-specific metadata (not sent to LLM).130 *131 * display controls TUI rendering:132 * - false: hidden entirely133 * - true: rendered with distinct styling (different from user messages)134 */135export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {136 type: "custom_message";137 customType: string;138 content: string | (TextContent | ImageContent)[];139 details?: T;140 display: boolean;141}142143/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */144export type SessionEntry =145 | SessionMessageEntry146 | ThinkingLevelChangeEntry147 | ModelChangeEntry148 | CompactionEntry149 | BranchSummaryEntry150 | CustomEntry151 | CustomMessageEntry152 | LabelEntry153 | SessionInfoEntry;154155/** Raw file entry (includes header) */156export type FileEntry = SessionHeader | SessionEntry;157158/** Tree node for getTree() - defensive copy of session structure */159export interface SessionTreeNode {160 entry: SessionEntry;161 children: SessionTreeNode[];162 /** Resolved label for this entry, if any */163 label?: string;164 /** Timestamp of the latest label change for this entry, if any */165 labelTimestamp?: string;166}167168export interface SessionContext {169 messages: AgentMessage[];170 thinkingLevel: string;171 model: { provider: string; modelId: string } | null;172}173174export interface SessionInfo {175 path: string;176 id: string;177 /** Working directory where the session was started. Empty string for old sessions. */178 cwd: string;179 /** User-defined display name from session_info entries. */180 name?: string;181 /** Path to the parent session (if this session was forked). */182 parentSessionPath?: string;183 created: Date;184 modified: Date;185 messageCount: number;186 firstMessage: string;187 allMessagesText: string;188}189190export type ReadonlySessionManager = Pick<191 SessionManager,192 | "getCwd"193 | "getSessionDir"194 | "getSessionId"195 | "getSessionFile"196 | "getLeafId"197 | "getLeafEntry"198 | "getEntry"199 | "getLabel"200 | "getBranch"201 | "buildContextEntries"202 | "getHeader"203 | "getEntries"204 | "getTree"205 | "getSessionName"206>;207208function createSessionId(): string {209 return uuidv7();210}211212export function assertValidSessionId(id: string): void {213 if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {214 throw new Error(215 "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character",216 );217 }218}219220/** Generate a unique short ID (8 hex chars, collision-checked) */221function generateId(byId: { has(id: string): boolean }): string {222 for (let i = 0; i < 100; i++) {223 const id = randomUUID().slice(0, 8);224 if (!byId.has(id)) return id;225 }226 // Fallback to full UUID if somehow we have collisions227 return randomUUID();228}229230/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */231function migrateV1ToV2(entries: FileEntry[]): void {232 const ids = new Set<string>();233 let prevId: string | null = null;234235 for (const entry of entries) {236 if (entry.type === "session") {237 entry.version = 2;238 continue;239 }240241 entry.id = generateId(ids);242 entry.parentId = prevId;243 prevId = entry.id;244245 // Convert firstKeptEntryIndex to firstKeptEntryId for compaction246 if (entry.type === "compaction") {247 const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };248 if (typeof comp.firstKeptEntryIndex === "number") {249 const targetEntry = entries[comp.firstKeptEntryIndex];250 if (targetEntry && targetEntry.type !== "session") {251 comp.firstKeptEntryId = targetEntry.id;252 }253 delete comp.firstKeptEntryIndex;254 }255 }256 }257}258259/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */260function migrateV2ToV3(entries: FileEntry[]): void {261 for (const entry of entries) {262 if (entry.type === "session") {263 entry.version = 3;264 continue;265 }266267 // Update message entries with hookMessage role268 if (entry.type === "message") {269 const msgEntry = entry as SessionMessageEntry;270 if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") {271 (msgEntry.message as { role: string }).role = "custom";272 }273 }274 }275}276277/**278 * Run all necessary migrations to bring entries to current version.279 * Mutates entries in place. Returns true if any migration was applied.280 */281function migrateToCurrentVersion(entries: FileEntry[]): boolean {282 const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;283 const version = header?.version ?? 1;284285 if (version >= CURRENT_SESSION_VERSION) return false;286287 if (version < 2) migrateV1ToV2(entries);288 if (version < 3) migrateV2ToV3(entries);289290 return true;291}292293/** Exported for testing */294export function migrateSessionEntries(entries: FileEntry[]): void {295 migrateToCurrentVersion(entries);296}297298/** Exported for compaction.test.ts */299export function parseSessionEntries(content: string): FileEntry[] {300 const entries: FileEntry[] = [];301 const lines = content.trim().split("\n");302303 for (const line of lines) {304 if (!line.trim()) continue;305 try {306 const entry = JSON.parse(line) as FileEntry;307 entries.push(entry);308 } catch {309 // Skip malformed lines310 }311 }312313 return entries;314}315316export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {317 for (let i = entries.length - 1; i >= 0; i--) {318 if (entries[i].type === "compaction") {319 return entries[i] as CompactionEntry;320 }321 }322 return null;323}324325function buildEntryIndex(entries: SessionEntry[], byId?: Map<string, SessionEntry>): Map<string, SessionEntry> {326 if (byId) return byId;327 const index = new Map<string, SessionEntry>();328 for (const entry of entries) {329 index.set(entry.id, entry);330 }331 return index;332}333334function buildSessionPath(335 entries: SessionEntry[],336 leafId?: string | null,337 byId?: Map<string, SessionEntry>,338): SessionEntry[] {339 const index = buildEntryIndex(entries, byId);340 let leaf: SessionEntry | undefined;341 if (leafId === null) {342 return [];343 }344 if (leafId) {345 leaf = index.get(leafId);346 }347 leaf ??= entries[entries.length - 1];348 if (!leaf) {349 return [];350 }351352 const path: SessionEntry[] = [];353 let current: SessionEntry | undefined = leaf;354 while (current) {355 path.push(current);356 current = current.parentId ? index.get(current.parentId) : undefined;357 }358 path.reverse();359 return path;360}361362function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "thinkingLevel" | "model"> {363 let thinkingLevel = "off";364 let model: { provider: string; modelId: string } | null = null;365366 for (const entry of path) {367 if (entry.type === "thinking_level_change") {368 thinkingLevel = entry.thinkingLevel;369 } else if (entry.type === "model_change") {370 model = { provider: entry.provider, modelId: entry.modelId };371 } else if (entry.type === "message" && entry.message.role === "assistant") {372 model = { provider: entry.message.provider, modelId: entry.message.model };373 }374 }375376 return { thinkingLevel, model };377}378379/**380 * Project one selected session entry into LLM/runtime messages.381 * Plain custom entries are display/state entries and do not participate in context.382 */383export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {384 if (entry.type === "message") {385 const message = entry.message;386 // Session files are parsed without validation; old versions, forks, or387 // hand-edited files can contain messages with null/missing content.388 if (389 (message.role === "user" || message.role === "assistant" || message.role === "toolResult") &&390 message.content == null391 ) {392 return [{ ...message, content: [] }];393 }394 return [message];395 }396 if (entry.type === "custom_message") {397 return [398 createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp),399 ];400 }401 if (entry.type === "branch_summary" && entry.summary) {402 return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];403 }404 if (entry.type === "compaction") {405 return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];406 }407 return [];408}409410/**411 * Build the active, compaction-aware session entry list.412 *413 * This follows the current leaf path. If the path contains compaction entries,414 * the latest compaction is represented by the compaction entry itself, followed415 * by the kept entries starting at firstKeptEntryId and all entries after the416 * compaction entry. Older summarized entries are omitted.417 */418export function buildContextEntries(419 entries: SessionEntry[],420 leafId?: string | null,421 byId?: Map<string, SessionEntry>,422): SessionEntry[] {423 const path = buildSessionPath(entries, leafId, byId);424 let compaction: CompactionEntry | null = null;425426 for (const entry of path) {427 if (entry.type === "compaction") {428 compaction = entry;429 }430 }431432 if (!compaction) {433 return path;434 }435436 const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);437 if (compactionIdx < 0) {438 return path;439 }440441 const contextEntries: SessionEntry[] = [compaction];442 let foundFirstKept = false;443 for (let i = 0; i < compactionIdx; i++) {444 const entry = path[i];445 if (entry.id === compaction.firstKeptEntryId) {446 foundFirstKept = true;447 }448 if (foundFirstKept) {449 contextEntries.push(entry);450 }451 }452 contextEntries.push(...path.slice(compactionIdx + 1));453 return contextEntries;454}455456/**457 * Build the session context from entries using tree traversal.458 * If leafId is provided, walks from that entry to root.459 * Handles compaction and branch summaries along the path.460 */461export function buildSessionContext(462 entries: SessionEntry[],463 leafId?: string | null,464 byId?: Map<string, SessionEntry>,465): SessionContext {466 const path = buildSessionPath(entries, leafId, byId);467 const { thinkingLevel, model } = getSessionContextSettings(path);468 const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);469 return { messages, thinkingLevel, model };470}471472/**473 * Compute the default session directory for a cwd.474 * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.475 */476function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string {477 const resolvedCwd = resolvePath(cwd);478 const resolvedAgentDir = resolvePath(agentDir);479 const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;480 return join(resolvedAgentDir, "sessions", safePath);481}482483export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {484 const sessionDir = getDefaultSessionDirPath(cwd, agentDir);485 if (!existsSync(sessionDir)) {486 mkdirSync(sessionDir, { recursive: true });487 }488 return sessionDir;489}490491const SESSION_READ_BUFFER_SIZE = 1024 * 1024;492const SESSION_HEADER_READ_BUFFER_SIZE = 4096;493/** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */494const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;495496class SessionHeaderScanLimitError extends Error {497 constructor(filePath: string) {498 super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`);499 this.name = "SessionHeaderScanLimitError";500 }501}502503function parseSessionEntryLine(line: string): FileEntry | null {504 if (!line.trim()) return null;505 try {506 return JSON.parse(line) as FileEntry;507 } catch {508 // Skip malformed lines509 return null;510 }511}512513/** Exported for testing */514export function loadEntriesFromFile(filePath: string): FileEntry[] {515 const resolvedFilePath = normalizePath(filePath);516 if (!existsSync(resolvedFilePath)) return [];517518 const entries: FileEntry[] = [];519 const fd = openSync(resolvedFilePath, "r");520 try {521 const decoder = new StringDecoder("utf8");522 const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);523 let pending = "";524525 while (true) {526 const bytesRead = readSync(fd, buffer, 0, buffer.length, null);527 if (bytesRead === 0) break;528529 pending += decoder.write(buffer.subarray(0, bytesRead));530 let lineStart = 0;531 let newlineIndex = pending.indexOf("\n", lineStart);532 while (newlineIndex !== -1) {533 const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));534 if (entry) entries.push(entry);535 lineStart = newlineIndex + 1;536 newlineIndex = pending.indexOf("\n", lineStart);537 }538 pending = pending.slice(lineStart);539 }540541 pending += decoder.end();542 const finalEntry = parseSessionEntryLine(pending);543 if (finalEntry) entries.push(finalEntry);544 } finally {545 closeSync(fd);546 }547548 // Validate session header549 if (entries.length === 0) return entries;550 const header = entries[0];551 if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {552 return [];553 }554555 return entries;556}557558/**559 * Inspect a physical line while searching for the first parsed session entry.560 * Blank and malformed lines are skipped to match loadEntriesFromFile().561 * Returns undefined to keep scanning, null for a parsed non-header entry, or the header.562 */563function parseSessionHeaderCandidate(line: string): SessionHeader | null | undefined {564 if (!line.trim()) return undefined;565 const entry = parseSessionEntryLine(line);566 if (!entry) return undefined;567 if (entry.type !== "session" || typeof (entry as { id?: unknown }).id !== "string") return null;568 return entry;569}570571function readSessionHeader(filePath: string): SessionHeader | null {572 const fd = openSync(filePath, "r");573 try {574 const decoder = new StringDecoder("utf8");575 const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE);576 const lineChunks: string[] = [];577 let scannedBytes = 0;578579 while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {580 const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);581 const bytesRead = readSync(fd, buffer, 0, readLength, null);582 if (bytesRead === 0) {583 lineChunks.push(decoder.end());584 return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;585 }586 scannedBytes += bytesRead;587588 const chunk = decoder.write(buffer.subarray(0, bytesRead));589 let lineStart = 0;590 let newlineIndex = chunk.indexOf("\n", lineStart);591 while (newlineIndex !== -1) {592 lineChunks.push(chunk.slice(lineStart, newlineIndex));593 const header = parseSessionHeaderCandidate(lineChunks.join(""));594 if (header !== undefined) return header;595 lineChunks.length = 0;596 lineStart = newlineIndex + 1;597 newlineIndex = chunk.indexOf("\n", lineStart);598 }599 lineChunks.push(chunk.slice(lineStart));600 }601602 // Probe for EOF so a final header without a newline is allowed when it ends603 // exactly at the scan limit. Any additional byte exceeds the bounded scan.604 const probe = Buffer.allocUnsafe(1);605 if (readSync(fd, probe, 0, probe.length, null) === 0) {606 lineChunks.push(decoder.end());607 return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;608 }609 throw new SessionHeaderScanLimitError(filePath);610 } finally {611 closeSync(fd);612 }613}614615function readSessionHeaderForDiscovery(filePath: string): SessionHeader | null {616 try {617 return readSessionHeader(filePath);618 } catch {619 // Discovery is best-effort: unreadable or oversized files are not sessions,620 // and one corrupt file must not prevent other sessions from being found.621 return null;622 }623}624625function getSessionHeaderCwd(header: SessionHeader): string | undefined {626 const cwd = (header as { cwd?: unknown }).cwd;627 return typeof cwd === "string" ? cwd : undefined;628}629630function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {631 return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;632}633634/** Exported for testing */635export function findMostRecentSession(sessionDir: string, cwd?: string): string | null {636 const resolvedSessionDir = normalizePath(sessionDir);637 const resolvedCwd = cwd ? resolvePath(cwd) : undefined;638 try {639 const files = readdirSync(resolvedSessionDir)640 .filter((f) => f.endsWith(".jsonl"))641 .map((f) => join(resolvedSessionDir, f))642 .map((path) => ({ path, header: readSessionHeaderForDiscovery(path) }))643 .filter(644 (file): file is { path: string; header: SessionHeader } =>645 file.header !== null &&646 (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)),647 )648 .map(({ path }) => ({ path, mtime: statSync(path).mtime }))649 .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());650651 return files[0]?.path || null;652 } catch {653 // Directory access and stat races make recent-session discovery unavailable.654 return null;655 }656}657658function isMessageWithContent(message: AgentMessage): message is Message {659 return typeof (message as Message).role === "string" && "content" in message;660}661662function extractTextContent(message: Message): string {663 const content = message.content;664 if (typeof content === "string") {665 return content;666 }667 return content668 .filter((block): block is TextContent => block.type === "text")669 .map((block) => block.text)670 .join(" ");671}672673function getMessageActivityTime(entry: SessionMessageEntry): number | undefined {674 const message = entry.message;675 if (!isMessageWithContent(message)) return undefined;676 if (message.role !== "user" && message.role !== "assistant") return undefined;677678 const msgTimestamp = (message as { timestamp?: number }).timestamp;679 if (typeof msgTimestamp === "number") {680 return msgTimestamp;681 }682683 const t = new Date(entry.timestamp).getTime();684 return Number.isNaN(t) ? undefined : t;685}686687async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {688 try {689 const stats = await stat(filePath);690 let header: SessionHeader | null = null;691 let messageCount = 0;692 let firstMessage = "";693 const allMessages: string[] = [];694 let name: string | undefined;695 let lastActivityTime: number | undefined;696697 const rl = createInterface({698 input: createReadStream(filePath, { encoding: "utf8" }),699 crlfDelay: Infinity,700 });701702 for await (const line of rl) {703 const entry = parseSessionEntryLine(line);704 if (!entry) continue;705706 if (!header) {707 if (entry.type !== "session") return null;708 header = entry;709 continue;710 }711712 // Extract session name (use latest, including explicit clears)713 if (entry.type === "session_info") {714 name = entry.name?.trim() || undefined;715 }716717 if (entry.type !== "message") continue;718 messageCount++;719720 const activityTime = getMessageActivityTime(entry);721 if (typeof activityTime === "number") {722 lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);723 }724725 const message = entry.message;726 if (!isMessageWithContent(message)) continue;727 if (message.role !== "user" && message.role !== "assistant") continue;728729 const textContent = extractTextContent(message);730 if (!textContent) continue;731732 allMessages.push(textContent);733 if (!firstMessage && message.role === "user") {734 firstMessage = textContent;735 }736 }737738 if (!header) return null;739740 const cwd = typeof header.cwd === "string" ? header.cwd : "";741 const parentSessionPath = header.parentSession;742 const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;743 const modified =744 typeof lastActivityTime === "number" && lastActivityTime > 0745 ? new Date(lastActivityTime)746 : !Number.isNaN(headerTime)747 ? new Date(headerTime)748 : stats.mtime;749750 return {751 path: filePath,752 id: header.id,753 cwd,754 name,755 parentSessionPath,756 created: new Date(header.timestamp),757 modified,758 messageCount,759 firstMessage: firstMessage || "(no messages)",760 allMessagesText: allMessages.join(" "),761 };762 } catch {763 return null;764 }765}766767export type SessionListProgress = (loaded: number, total: number) => void;768769const MAX_CONCURRENT_SESSION_INFO_LOADS = 10;770771async function buildSessionInfosWithConcurrency(772 files: string[],773 onLoaded: () => void,774): Promise<(SessionInfo | null)[]> {775 const results: (SessionInfo | null)[] = new Array(files.length).fill(null);776 const inFlight = new Set<Promise<void>>();777 let nextIndex = 0;778779 const startNext = (): void => {780 const index = nextIndex++;781 const file = files[index];782 if (!file) return;783784 let task: Promise<void>;785 task = buildSessionInfo(file)786 .then((info) => {787 results[index] = info;788 })789 .catch(() => {790 results[index] = null;791 })792 .finally(() => {793 inFlight.delete(task);794 onLoaded();795 });796 inFlight.add(task);797 };798799 while (nextIndex < files.length || inFlight.size > 0) {800 while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) {801 startNext();802 }803 if (inFlight.size > 0) {804 await Promise.race(inFlight);805 }806 }807808 return results;809}810811async function listSessionsFromDir(812 dir: string,813 onProgress?: SessionListProgress,814 progressOffset = 0,815 progressTotal?: number,816): Promise<SessionInfo[]> {817 const sessions: SessionInfo[] = [];818 if (!existsSync(dir)) {819 return sessions;820 }821822 try {823 const dirEntries = await readdir(dir);824 const files = dirEntries.filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f));825 const total = progressTotal ?? files.length;826827 let loaded = 0;828 const results = await buildSessionInfosWithConcurrency(files, () => {829 loaded++;830 onProgress?.(progressOffset + loaded, total);831 });832 for (const info of results) {833 if (info) {834 sessions.push(info);835 }836 }837 } catch {838 // Return empty list on error839 }840841 return sessions;842}843844/**845 * Manages conversation sessions as append-only trees stored in JSONL files.846 *847 * Each session entry has an id and parentId forming a tree structure. The "leaf"848 * pointer tracks the current position. Appending creates a child of the current leaf.849 * Branching moves the leaf to an earlier entry, allowing new branches without850 * modifying history.851 *852 * Use buildSessionContext() to get the resolved message list for the LLM, which853 * handles compaction summaries and follows the path from root to current leaf.854 */855export class SessionManager {856 private sessionId: string = "";857 private sessionFile: string | undefined;858 private sessionDir: string;859 private cwd: string;860 private persist: boolean;861 private flushed: boolean = false;862 private fileEntries: FileEntry[] = [];863 private byId: Map<string, SessionEntry> = new Map();864 private labelsById: Map<string, string> = new Map();865 private labelTimestampsById: Map<string, string> = new Map();866 private leafId: string | null = null;867868 private constructor(869 cwd: string,870 sessionDir: string,871 sessionFile: string | undefined,872 persist: boolean,873 newSessionOptions?: NewSessionOptions,874 preloadedFileEntries?: FileEntry[],875 ) {876 this.cwd = resolvePath(cwd);877 this.sessionDir = normalizePath(sessionDir);878 this.persist = persist;879 if (persist && this.sessionDir && !existsSync(this.sessionDir)) {880 mkdirSync(this.sessionDir, { recursive: true });881 }882883 if (sessionFile) {884 this._setSessionFile(sessionFile, preloadedFileEntries);885 } else {886 this.newSession(newSessionOptions);887 }888 }889890 /** Switch to a different session file (used for resume and branching) */891 setSessionFile(sessionFile: string): void {892 this._setSessionFile(sessionFile);893 }894895 private _setSessionFile(sessionFile: string, preloadedFileEntries?: FileEntry[]): void {896 this.sessionFile = resolvePath(sessionFile);897 if (existsSync(this.sessionFile)) {898 this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);899900 // If file was empty, initialize it with a valid session header. If it was901 // non-empty but did not parse as a pi session, fail without modifying it.902 if (this.fileEntries.length === 0) {903 const explicitPath = this.sessionFile;904 if (statSync(explicitPath).size > 0) {905 throw new Error(`Session file is not a valid pi session: ${explicitPath}`);906 }907 this.newSession();908 this.sessionFile = explicitPath;909 this._rewriteFile();910 this.flushed = true;911 return;912 }913914 const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;915 this.sessionId = header?.id ?? createSessionId();916917 if (migrateToCurrentVersion(this.fileEntries)) {918 this._rewriteFile();919 }920921 this._buildIndex();922 this.flushed = true;923 } else {924 const explicitPath = this.sessionFile;925 this.newSession();926 this.sessionFile = explicitPath; // preserve explicit path from --session flag927 }928 }929930 newSession(options?: NewSessionOptions): string | undefined {931 if (options?.id !== undefined) {932 assertValidSessionId(options.id);933 }934 this.sessionId = options?.id ?? createSessionId();935 const timestamp = new Date().toISOString();936 const header: SessionHeader = {937 type: "session",938 version: CURRENT_SESSION_VERSION,939 id: this.sessionId,940 timestamp,941 cwd: this.cwd,942 parentSession: options?.parentSession,943 };944 this.fileEntries = [header];945 this.byId.clear();946 this.labelsById.clear();947 this.labelTimestampsById.clear();948 this.leafId = null;949 this.flushed = false;950951 if (this.persist) {952 const fileTimestamp = timestamp.replace(/[:.]/g, "-");953 this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);954 }955 return this.sessionFile;956 }957958 private _buildIndex(): void {959 this.byId.clear();960 this.labelsById.clear();961 this.labelTimestampsById.clear();962 this.leafId = null;963 for (const entry of this.fileEntries) {964 if (entry.type === "session") continue;965 this.byId.set(entry.id, entry);966 this.leafId = entry.id;967 if (entry.type === "label") {968 if (entry.label) {969 this.labelsById.set(entry.targetId, entry.label);970 this.labelTimestampsById.set(entry.targetId, entry.timestamp);971 } else {972 this.labelsById.delete(entry.targetId);973 this.labelTimestampsById.delete(entry.targetId);974 }975 }976 }977 }978979 private _rewriteFile(): void {980 if (!this.persist || !this.sessionFile) return;981 const fd = openSync(this.sessionFile, "w");982 try {983 for (const entry of this.fileEntries) {984 writeFileSync(fd, `${JSON.stringify(entry)}\n`);985 }986 } finally {987 closeSync(fd);988 }989 }990991 isPersisted(): boolean {992 return this.persist;993 }994995 getCwd(): string {996 return this.cwd;997 }998999 getSessionDir(): string {1000 return this.sessionDir;1001 }10021003 usesDefaultSessionDir(): boolean {1004 return this.sessionDir === getDefaultSessionDirPath(this.cwd);1005 }10061007 getSessionId(): string {1008 return this.sessionId;1009 }10101011 getSessionFile(): string | undefined {1012 return this.sessionFile;1013 }10141015 _persist(entry: SessionEntry): void {1016 if (!this.persist || !this.sessionFile) return;10171018 const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");1019 if (!hasAssistant) {1020 if (this.flushed) {1021 appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);1022 } else {1023 // Mark as not flushed so when assistant arrives, all entries get written1024 this.flushed = false;1025 }1026 return;1027 }10281029 if (!this.flushed) {1030 const fd = openSync(this.sessionFile, "wx");1031 try {1032 for (const e of this.fileEntries) {1033 writeFileSync(fd, `${JSON.stringify(e)}\n`);1034 }1035 } finally {1036 closeSync(fd);1037 }1038 this.flushed = true;1039 } else {1040 appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);1041 }1042 }10431044 private _appendEntry(entry: SessionEntry): void {1045 this.fileEntries.push(entry);1046 this.byId.set(entry.id, entry);1047 this.leafId = entry.id;1048 this._persist(entry);1049 }10501051 /** Append a message as child of current leaf, then advance leaf. Returns entry id.1052 * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.1053 * Reason: we want these to be top-level entries in the session, not message session entries,1054 * so it is easier to find them.1055 * These need to be appended via appendCompaction() and appendBranchSummary() methods.1056 */1057 appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {1058 const entry: SessionMessageEntry = {1059 type: "message",1060 id: generateId(this.byId),1061 parentId: this.leafId,1062 timestamp: new Date().toISOString(),1063 message,1064 };1065 this._appendEntry(entry);1066 return entry.id;1067 }10681069 /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */1070 appendThinkingLevelChange(thinkingLevel: string): string {1071 const entry: ThinkingLevelChangeEntry = {1072 type: "thinking_level_change",1073 id: generateId(this.byId),1074 parentId: this.leafId,1075 timestamp: new Date().toISOString(),1076 thinkingLevel,1077 };1078 this._appendEntry(entry);1079 return entry.id;1080 }10811082 /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */1083 appendModelChange(provider: string, modelId: string): string {1084 const entry: ModelChangeEntry = {1085 type: "model_change",1086 id: generateId(this.byId),1087 parentId: this.leafId,1088 timestamp: new Date().toISOString(),1089 provider,1090 modelId,1091 };1092 this._appendEntry(entry);1093 return entry.id;1094 }10951096 /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */1097 appendCompaction<T = unknown>(1098 summary: string,1099 firstKeptEntryId: string,1100 tokensBefore: number,1101 details?: T,1102 fromHook?: boolean,1103 usage?: Usage,1104 ): string {1105 const entry: CompactionEntry<T> = {1106 type: "compaction",1107 id: generateId(this.byId),1108 parentId: this.leafId,1109 timestamp: new Date().toISOString(),1110 summary,1111 firstKeptEntryId,1112 tokensBefore,1113 details,1114 usage,1115 fromHook,1116 };1117 this._appendEntry(entry);1118 return entry.id;1119 }11201121 /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */1122 appendCustomEntry(customType: string, data?: unknown): string {1123 const entry: CustomEntry = {1124 type: "custom",1125 customType,1126 data,1127 id: generateId(this.byId),1128 parentId: this.leafId,1129 timestamp: new Date().toISOString(),1130 };1131 this._appendEntry(entry);1132 return entry.id;1133 }11341135 /** Append a session info entry (e.g., display name). Returns entry id. */1136 appendSessionInfo(name: string): string {1137 const sanitizedName = name.replace(/[\r\n]+/g, " ").trim();1138 const entry: SessionInfoEntry = {1139 type: "session_info",1140 id: generateId(this.byId),1141 parentId: this.leafId,1142 timestamp: new Date().toISOString(),1143 name: sanitizedName,1144 };1145 this._appendEntry(entry);1146 return entry.id;1147 }11481149 /** Get the current session name from the latest session_info entry, if any. */1150 getSessionName(): string | undefined {1151 // Walk entries in reverse to find the latest session_info entry.1152 // Empty names explicitly clear the session title.1153 const entries = this.getEntries();1154 for (let i = entries.length - 1; i >= 0; i--) {1155 const entry = entries[i];1156 if (entry.type === "session_info") {1157 return entry.name?.trim() || undefined;1158 }1159 }1160 return undefined;1161 }11621163 /**1164 * Append a custom message entry (for extensions) that participates in LLM context.1165 * @param customType Extension identifier for filtering on reload1166 * @param content Message content (string or TextContent/ImageContent array)1167 * @param display Whether to show in TUI (true = styled display, false = hidden)1168 * @param details Optional extension-specific metadata (not sent to LLM)1169 * @returns Entry id1170 */1171 appendCustomMessageEntry<T = unknown>(1172 customType: string,1173 content: string | (TextContent | ImageContent)[],1174 display: boolean,1175 details?: T,1176 ): string {1177 const entry: CustomMessageEntry<T> = {1178 type: "custom_message",1179 customType,1180 content,1181 display,1182 details,1183 id: generateId(this.byId),1184 parentId: this.leafId,1185 timestamp: new Date().toISOString(),1186 };1187 this._appendEntry(entry);1188 return entry.id;1189 }11901191 // =========================================================================1192 // Tree Traversal1193 // =========================================================================11941195 getLeafId(): string | null {1196 return this.leafId;1197 }11981199 getLeafEntry(): SessionEntry | undefined {1200 return this.leafId ? this.byId.get(this.leafId) : undefined;1201 }12021203 getEntry(id: string): SessionEntry | undefined {1204 return this.byId.get(id);1205 }12061207 /**1208 * Get all direct children of an entry.1209 */1210 getChildren(parentId: string): SessionEntry[] {1211 const children: SessionEntry[] = [];1212 for (const entry of this.byId.values()) {1213 if (entry.parentId === parentId) {1214 children.push(entry);1215 }1216 }1217 return children;1218 }12191220 /**1221 * Get the label for an entry, if any.1222 */1223 getLabel(id: string): string | undefined {1224 return this.labelsById.get(id);1225 }12261227 /**1228 * Set or clear a label on an entry.1229 * Labels are user-defined markers for bookmarking/navigation.1230 * Pass undefined or empty string to clear the label.1231 */1232 appendLabelChange(targetId: string, label: string | undefined): string {1233 if (!this.byId.has(targetId)) {1234 throw new Error(`Entry ${targetId} not found`);1235 }1236 const entry: LabelEntry = {1237 type: "label",1238 id: generateId(this.byId),1239 parentId: this.leafId,1240 timestamp: new Date().toISOString(),1241 targetId,1242 label,1243 };1244 this._appendEntry(entry);1245 if (label) {1246 this.labelsById.set(targetId, label);1247 this.labelTimestampsById.set(targetId, entry.timestamp);1248 } else {1249 this.labelsById.delete(targetId);1250 this.labelTimestampsById.delete(targetId);1251 }1252 return entry.id;1253 }12541255 /**1256 * Walk from entry to root, returning all entries in path order.1257 * Includes all entry types (messages, compaction, model changes, etc.).1258 * Use buildSessionContext() to get the resolved messages for the LLM.1259 */1260 getBranch(fromId?: string): SessionEntry[] {1261 const path: SessionEntry[] = [];1262 const startId = fromId ?? this.leafId;1263 let current = startId ? this.byId.get(startId) : undefined;1264 while (current) {1265 path.push(current);1266 current = current.parentId ? this.byId.get(current.parentId) : undefined;1267 }1268 path.reverse();1269 return path;1270 }12711272 /**1273 * Build the active, compaction-aware entry list for context/rendering.1274 * Uses tree traversal from current leaf.1275 */1276 buildContextEntries(): SessionEntry[] {1277 return buildContextEntries(this.getEntries(), this.leafId, this.byId);1278 }12791280 /**1281 * Build the session context (what gets sent to the LLM).1282 * Uses tree traversal from current leaf.1283 */1284 buildSessionContext(): SessionContext {1285 return buildSessionContext(this.getEntries(), this.leafId, this.byId);1286 }12871288 /**1289 * Get session header.1290 */1291 getHeader(): SessionHeader | null {1292 const h = this.fileEntries.find((e) => e.type === "session");1293 return h ? (h as SessionHeader) : null;1294 }12951296 /**1297 * Get all session entries (excludes header). Returns a shallow copy.1298 * The session is append-only: use appendXXX() to add entries, branch() to1299 * change the leaf pointer. Entries cannot be modified or deleted.1300 */1301 getEntries(): SessionEntry[] {1302 return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session");1303 }13041305 /**1306 * Get the session as a tree structure. Returns a shallow defensive copy of all entries.1307 * A well-formed session has exactly one root (first entry with parentId === null).1308 * Orphaned entries (broken parent chain) are also returned as roots.1309 */1310 getTree(): SessionTreeNode[] {1311 const entries = this.getEntries();1312 const nodeMap = new Map<string, SessionTreeNode>();1313 const roots: SessionTreeNode[] = [];13141315 // Create nodes with resolved labels1316 for (const entry of entries) {1317 const label = this.labelsById.get(entry.id);1318 const labelTimestamp = this.labelTimestampsById.get(entry.id);1319 nodeMap.set(entry.id, { entry, children: [], label, labelTimestamp });1320 }13211322 // Build tree1323 for (const entry of entries) {1324 const node = nodeMap.get(entry.id)!;1325 if (entry.parentId === null || entry.parentId === entry.id) {1326 roots.push(node);1327 } else {1328 const parent = nodeMap.get(entry.parentId);1329 if (parent) {1330 parent.children.push(node);1331 } else {1332 // Orphan - treat as root1333 roots.push(node);1334 }1335 }1336 }13371338 // Sort children by timestamp (oldest first, newest at bottom)1339 // Use iterative approach to avoid stack overflow on deep trees1340 const stack: SessionTreeNode[] = [...roots];1341 while (stack.length > 0) {1342 const node = stack.pop()!;1343 node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());1344 stack.push(...node.children);1345 }13461347 return roots;1348 }13491350 // =========================================================================1351 // Branching1352 // =========================================================================13531354 /**1355 * Start a new branch from an earlier entry.1356 * Moves the leaf pointer to the specified entry. The next appendXXX() call1357 * will create a child of that entry, forming a new branch. Existing entries1358 * are not modified or deleted.1359 */1360 branch(branchFromId: string): void {1361 if (!this.byId.has(branchFromId)) {1362 throw new Error(`Entry ${branchFromId} not found`);1363 }1364 this.leafId = branchFromId;1365 }13661367 /**1368 * Reset the leaf pointer to null (before any entries).1369 * The next appendXXX() call will create a new root entry (parentId = null).1370 * Use this when navigating to re-edit the first user message.1371 */1372 resetLeaf(): void {1373 this.leafId = null;1374 }13751376 /**1377 * Start a new branch with a summary of the abandoned path.1378 * Same as branch(), but also appends a branch_summary entry that captures1379 * context from the abandoned conversation path.1380 */1381 branchWithSummary(1382 branchFromId: string | null,1383 summary: string,1384 details?: unknown,1385 fromHook?: boolean,1386 usage?: Usage,1387 ): string {1388 if (branchFromId !== null && !this.byId.has(branchFromId)) {1389 throw new Error(`Entry ${branchFromId} not found`);1390 }1391 this.leafId = branchFromId;1392 const entry: BranchSummaryEntry = {1393 type: "branch_summary",1394 id: generateId(this.byId),1395 parentId: branchFromId,1396 timestamp: new Date().toISOString(),1397 fromId: branchFromId ?? "root",1398 summary,1399 details,1400 usage,1401 fromHook,1402 };1403 this._appendEntry(entry);1404 return entry.id;1405 }14061407 /**1408 * Create a new session file containing only the path from root to the specified leaf.1409 * Useful for extracting a single conversation path from a branched session.1410 * Returns the new session file path, or undefined if not persisting.1411 */1412 createBranchedSession(leafId: string): string | undefined {1413 const previousSessionFile = this.sessionFile;1414 const path = this.getBranch(leafId);1415 if (path.length === 0) {1416 throw new Error(`Entry ${leafId} not found`);1417 }14181419 // Filter out LabelEntry from path - we'll recreate them from the resolved map.1420 // Because labels are real tree entries, later entries can be children of labels;1421 // removing labels requires re-chaining the retained path to avoid orphaned subtrees.1422 const pathWithoutLabels: SessionEntry[] = [];1423 let pathParentId: string | null = null;1424 for (const entry of path) {1425 if (entry.type === "label") continue;1426 pathWithoutLabels.push({ ...entry, parentId: pathParentId });1427 pathParentId = entry.id;1428 }14291430 const newSessionId = createSessionId();1431 const timestamp = new Date().toISOString();1432 const fileTimestamp = timestamp.replace(/[:.]/g, "-");1433 const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);14341435 const header: SessionHeader = {1436 type: "session",1437 version: CURRENT_SESSION_VERSION,1438 id: newSessionId,1439 timestamp,1440 cwd: this.cwd,1441 parentSession: this.persist ? previousSessionFile : undefined,1442 };14431444 // Collect labels for entries in the path1445 const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));1446 const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = [];1447 for (const [targetId, label] of this.labelsById) {1448 if (pathEntryIds.has(targetId)) {1449 labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! });1450 }1451 }14521453 if (this.persist) {1454 // Build label entries1455 const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;1456 let parentId = lastEntryId;1457 const labelEntries: LabelEntry[] = [];1458 for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {1459 const labelEntry: LabelEntry = {1460 type: "label",1461 id: generateId(new Set(pathEntryIds)),1462 parentId,1463 timestamp: labelTimestamp,1464 targetId,1465 label,1466 };1467 pathEntryIds.add(labelEntry.id);1468 labelEntries.push(labelEntry);1469 parentId = labelEntry.id;1470 }14711472 this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];1473 this.sessionId = newSessionId;1474 this.sessionFile = newSessionFile;1475 this._buildIndex();14761477 // Only write the file now if it contains an assistant message.1478 // Otherwise defer to _persist(), which creates the file on the1479 // first assistant response, matching the newSession() contract1480 // and avoiding the duplicate-header bug when _persist()'s1481 // no-assistant guard later resets flushed to false.1482 const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");1483 if (hasAssistant) {1484 this._rewriteFile();1485 this.flushed = true;1486 } else {1487 this.flushed = false;1488 }14891490 return newSessionFile;1491 }14921493 // In-memory mode: replace current session with the path + labels1494 const labelEntries: LabelEntry[] = [];1495 let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;1496 for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {1497 const labelEntry: LabelEntry = {1498 type: "label",1499 id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),1500 parentId,1501 timestamp: labelTimestamp,1502 targetId,1503 label,1504 };1505 labelEntries.push(labelEntry);1506 parentId = labelEntry.id;1507 }1508 this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];1509 this.sessionId = newSessionId;1510 this._buildIndex();1511 return undefined;1512 }15131514 /**1515 * Create a new session.1516 * @param cwd Working directory (stored in session header)1517 * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).1518 */1519 static create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager {1520 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);1521 return new SessionManager(cwd, dir, undefined, true, options);1522 }15231524 /**1525 * Open a specific session file.1526 * @param path Path to session file1527 * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.1528 * @param cwdOverride Optional cwd override instead of the session header cwd.1529 */1530 static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {1531 const resolvedPath = resolvePath(path);1532 let header: SessionHeader | null = null;1533 let preloadedFileEntries: FileEntry[] | undefined;1534 if (cwdOverride === undefined && existsSync(resolvedPath)) {1535 try {1536 header = readSessionHeader(resolvedPath);1537 } catch (error) {1538 if (!(error instanceof SessionHeaderScanLimitError)) throw error;1539 // The bounded scan is only a discovery optimization. A full load remains1540 // authoritative for legacy files with very large headers or prefixes.1541 preloadedFileEntries = loadEntriesFromFile(resolvedPath);1542 const firstEntry = preloadedFileEntries[0];1543 header = firstEntry?.type === "session" ? firstEntry : null;1544 }1545 }1546 const cwd = cwdOverride ?? (header ? getSessionHeaderCwd(header) : undefined) ?? process.cwd();1547 // If no sessionDir provided, derive from file's parent directory1548 const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");1549 return new SessionManager(cwd, dir, resolvedPath, true, undefined, preloadedFileEntries);1550 }15511552 /**1553 * Continue the most recent session, or create new if none.1554 * @param cwd Working directory1555 * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).1556 */1557 static continueRecent(cwd: string, sessionDir?: string): SessionManager {1558 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);1559 const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);1560 const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);1561 if (mostRecent) {1562 return new SessionManager(cwd, dir, mostRecent, true);1563 }1564 return new SessionManager(cwd, dir, undefined, true);1565 }15661567 /** Create an in-memory session (no file persistence) */1568 static inMemory(cwd: string = process.cwd(), options?: NewSessionOptions): SessionManager {1569 return new SessionManager(cwd, "", undefined, false, options);1570 }15711572 /**1573 * Fork a session from another project directory into the current project.1574 * Creates a new session in the target cwd with the full history from the source session.1575 * @param sourcePath Path to the source session file1576 * @param targetCwd Target working directory (where the new session will be stored)1577 * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.1578 */1579 static forkFrom(1580 sourcePath: string,1581 targetCwd: string,1582 sessionDir?: string,1583 options?: NewSessionOptions,1584 ): SessionManager {1585 const resolvedSourcePath = resolvePath(sourcePath);1586 const resolvedTargetCwd = resolvePath(targetCwd);1587 const sourceEntries = loadEntriesFromFile(resolvedSourcePath);1588 if (sourceEntries.length === 0) {1589 throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);1590 }15911592 const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined;1593 if (!sourceHeader) {1594 throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);1595 }15961597 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);1598 if (!existsSync(dir)) {1599 mkdirSync(dir, { recursive: true });1600 }16011602 // Create new session file with new ID but forked content1603 if (options?.id !== undefined) {1604 assertValidSessionId(options.id);1605 }1606 const newSessionId = options?.id ?? createSessionId();1607 const timestamp = new Date().toISOString();1608 const fileTimestamp = timestamp.replace(/[:.]/g, "-");1609 const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);16101611 // Write new header pointing to source as parent, with updated cwd1612 const newHeader: SessionHeader = {1613 type: "session",1614 version: CURRENT_SESSION_VERSION,1615 id: newSessionId,1616 timestamp,1617 cwd: resolvedTargetCwd,1618 parentSession: resolvedSourcePath,1619 };1620 writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });16211622 // Copy all non-header entries from source1623 for (const entry of sourceEntries) {1624 if (entry.type !== "session") {1625 appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);1626 }1627 }16281629 return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);1630 }16311632 /**1633 * List all sessions for a directory.1634 * @param cwd Working directory (used to compute default session directory)1635 * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).1636 * @param onProgress Optional callback for progress updates (loaded, total)1637 */1638 static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {1639 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);1640 const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);1641 const resolvedCwd = resolvePath(cwd);1642 const sessions = (await listSessionsFromDir(dir, onProgress)).filter(1643 (session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd),1644 );1645 sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());1646 return sessions;1647 }16481649 /**1650 * List all sessions across all project directories.1651 * @param onProgress Optional callback for progress updates (loaded, total)1652 */1653 static async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]>;1654 static async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]>;1655 static async listAll(1656 sessionDirOrOnProgress?: string | SessionListProgress,1657 onProgress?: SessionListProgress,1658 ): Promise<SessionInfo[]> {1659 const customSessionDir =1660 typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined;1661 const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress;1662 if (customSessionDir) {1663 const sessions = await listSessionsFromDir(customSessionDir, progress);1664 sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());1665 return sessions;1666 }16671668 const sessionsDir = getSessionsDir();16691670 try {1671 if (!existsSync(sessionsDir)) {1672 return [];1673 }1674 const entries = await readdir(sessionsDir, { withFileTypes: true });1675 const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name));16761677 // Count total files first for accurate progress1678 let totalFiles = 0;1679 const dirFiles: string[][] = [];1680 for (const dir of dirs) {1681 try {1682 const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl"));1683 dirFiles.push(files.map((f) => join(dir, f)));1684 totalFiles += files.length;1685 } catch {1686 dirFiles.push([]);1687 }1688 }16891690 // Process all files with progress tracking1691 let loaded = 0;1692 const sessions: SessionInfo[] = [];1693 const allFiles = dirFiles.flat();16941695 const results = await buildSessionInfosWithConcurrency(allFiles, () => {1696 loaded++;1697 progress?.(loaded, totalFiles);1698 });16991700 for (const info of results) {1701 if (info) {1702 sessions.push(info);1703 }1704 }17051706 sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());1707 return sessions;1708 } catch {1709 return [];1710 }1711 }1712}1713