ππ Agent

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";
29
30export const CURRENT_SESSION_VERSION = 3;
31
32export interface SessionHeader {
33 type: "session";
34 version?: number; // v1 sessions don't have this
35 id: string;
36 timestamp: string;
37 cwd: string;
38 parentSession?: string;
39}
40
41export interface NewSessionOptions {
42 id?: string;
43 parentSession?: string;
44}
45
46export interface SessionEntryBase {
47 type: string;
48 id: string;
49 parentId: string | null;
50 timestamp: string;
51}
52
53export interface SessionMessageEntry extends SessionEntryBase {
54 type: "message";
55 message: AgentMessage;
56}
57
58export interface ThinkingLevelChangeEntry extends SessionEntryBase {
59 type: "thinking_level_change";
60 thinkingLevel: string;
61}
62
63export interface ModelChangeEntry extends SessionEntryBase {
64 type: "model_change";
65 provider: string;
66 modelId: string;
67}
68
69export 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}
81
82export 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}
93
94/**
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 can
99 * 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}
109
110/** 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}
116
117/** Session metadata entry (e.g., user-defined display name). */
118export interface SessionInfoEntry extends SessionEntryBase {
119 type: "session_info";
120 name?: string;
121}
122
123/**
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 entirely
133 * - 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}
142
143/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
144export type SessionEntry =
145 | SessionMessageEntry
146 | ThinkingLevelChangeEntry
147 | ModelChangeEntry
148 | CompactionEntry
149 | BranchSummaryEntry
150 | CustomEntry
151 | CustomMessageEntry
152 | LabelEntry
153 | SessionInfoEntry;
154
155/** Raw file entry (includes header) */
156export type FileEntry = SessionHeader | SessionEntry;
157
158/** 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}
167
168export interface SessionContext {
169 messages: AgentMessage[];
170 thinkingLevel: string;
171 model: { provider: string; modelId: string } | null;
172}
173
174export 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}
189
190export 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>;
207
208function createSessionId(): string {
209 return uuidv7();
210}
211
212export 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}
219
220/** 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 collisions
227 return randomUUID();
228}
229
230/** 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;
234
235 for (const entry of entries) {
236 if (entry.type === "session") {
237 entry.version = 2;
238 continue;
239 }
240
241 entry.id = generateId(ids);
242 entry.parentId = prevId;
243 prevId = entry.id;
244
245 // Convert firstKeptEntryIndex to firstKeptEntryId for compaction
246 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}
258
259/** 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 }
266
267 // Update message entries with hookMessage role
268 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}
276
277/**
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;
284
285 if (version >= CURRENT_SESSION_VERSION) return false;
286
287 if (version < 2) migrateV1ToV2(entries);
288 if (version < 3) migrateV2ToV3(entries);
289
290 return true;
291}
292
293/** Exported for testing */
294export function migrateSessionEntries(entries: FileEntry[]): void {
295 migrateToCurrentVersion(entries);
296}
297
298/** Exported for compaction.test.ts */
299export function parseSessionEntries(content: string): FileEntry[] {
300 const entries: FileEntry[] = [];
301 const lines = content.trim().split("\n");
302
303 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 lines
310 }
311 }
312
313 return entries;
314}
315
316export 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}
324
325function 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}
333
334function 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 }
351
352 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}
361
362function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "thinkingLevel" | "model"> {
363 let thinkingLevel = "off";
364 let model: { provider: string; modelId: string } | null = null;
365
366 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 }
375
376 return { thinkingLevel, model };
377}
378
379/**
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, or
387 // 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 == null
391 ) {
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}
409
410/**
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, followed
415 * by the kept entries starting at firstKeptEntryId and all entries after the
416 * 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;
425
426 for (const entry of path) {
427 if (entry.type === "compaction") {
428 compaction = entry;
429 }
430 }
431
432 if (!compaction) {
433 return path;
434 }
435
436 const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);
437 if (compactionIdx < 0) {
438 return path;
439 }
440
441 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}
455
456/**
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}
471
472/**
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}
482
483export 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}
490
491const 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;
495
496class 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}
502
503function 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 lines
509 return null;
510 }
511}
512
513/** Exported for testing */
514export function loadEntriesFromFile(filePath: string): FileEntry[] {
515 const resolvedFilePath = normalizePath(filePath);
516 if (!existsSync(resolvedFilePath)) return [];
517
518 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 = "";
524
525 while (true) {
526 const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
527 if (bytesRead === 0) break;
528
529 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 }
540
541 pending += decoder.end();
542 const finalEntry = parseSessionEntryLine(pending);
543 if (finalEntry) entries.push(finalEntry);
544 } finally {
545 closeSync(fd);
546 }
547
548 // Validate session header
549 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 }
554
555 return entries;
556}
557
558/**
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}
570
571function 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;
578
579 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;
587
588 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 }
601
602 // Probe for EOF so a final header without a newline is allowed when it ends
603 // 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}
614
615function 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}
624
625function getSessionHeaderCwd(header: SessionHeader): string | undefined {
626 const cwd = (header as { cwd?: unknown }).cwd;
627 return typeof cwd === "string" ? cwd : undefined;
628}
629
630function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {
631 return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;
632}
633
634/** 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());
650
651 return files[0]?.path || null;
652 } catch {
653 // Directory access and stat races make recent-session discovery unavailable.
654 return null;
655 }
656}
657
658function isMessageWithContent(message: AgentMessage): message is Message {
659 return typeof (message as Message).role === "string" && "content" in message;
660}
661
662function extractTextContent(message: Message): string {
663 const content = message.content;
664 if (typeof content === "string") {
665 return content;
666 }
667 return content
668 .filter((block): block is TextContent => block.type === "text")
669 .map((block) => block.text)
670 .join(" ");
671}
672
673function 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;
677
678 const msgTimestamp = (message as { timestamp?: number }).timestamp;
679 if (typeof msgTimestamp === "number") {
680 return msgTimestamp;
681 }
682
683 const t = new Date(entry.timestamp).getTime();
684 return Number.isNaN(t) ? undefined : t;
685}
686
687async 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;
696
697 const rl = createInterface({
698 input: createReadStream(filePath, { encoding: "utf8" }),
699 crlfDelay: Infinity,
700 });
701
702 for await (const line of rl) {
703 const entry = parseSessionEntryLine(line);
704 if (!entry) continue;
705
706 if (!header) {
707 if (entry.type !== "session") return null;
708 header = entry;
709 continue;
710 }
711
712 // Extract session name (use latest, including explicit clears)
713 if (entry.type === "session_info") {
714 name = entry.name?.trim() || undefined;
715 }
716
717 if (entry.type !== "message") continue;
718 messageCount++;
719
720 const activityTime = getMessageActivityTime(entry);
721 if (typeof activityTime === "number") {
722 lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
723 }
724
725 const message = entry.message;
726 if (!isMessageWithContent(message)) continue;
727 if (message.role !== "user" && message.role !== "assistant") continue;
728
729 const textContent = extractTextContent(message);
730 if (!textContent) continue;
731
732 allMessages.push(textContent);
733 if (!firstMessage && message.role === "user") {
734 firstMessage = textContent;
735 }
736 }
737
738 if (!header) return null;
739
740 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 > 0
745 ? new Date(lastActivityTime)
746 : !Number.isNaN(headerTime)
747 ? new Date(headerTime)
748 : stats.mtime;
749
750 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}
766
767export type SessionListProgress = (loaded: number, total: number) => void;
768
769const MAX_CONCURRENT_SESSION_INFO_LOADS = 10;
770
771async 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;
778
779 const startNext = (): void => {
780 const index = nextIndex++;
781 const file = files[index];
782 if (!file) return;
783
784 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 };
798
799 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 }
807
808 return results;
809}
810
811async 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 }
821
822 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;
826
827 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 error
839 }
840
841 return sessions;
842}
843
844/**
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 without
850 * modifying history.
851 *
852 * Use buildSessionContext() to get the resolved message list for the LLM, which
853 * 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;
867
868 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 }
882
883 if (sessionFile) {
884 this._setSessionFile(sessionFile, preloadedFileEntries);
885 } else {
886 this.newSession(newSessionOptions);
887 }
888 }
889
890 /** Switch to a different session file (used for resume and branching) */
891 setSessionFile(sessionFile: string): void {
892 this._setSessionFile(sessionFile);
893 }
894
895 private _setSessionFile(sessionFile: string, preloadedFileEntries?: FileEntry[]): void {
896 this.sessionFile = resolvePath(sessionFile);
897 if (existsSync(this.sessionFile)) {
898 this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);
899
900 // If file was empty, initialize it with a valid session header. If it was
901 // 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 }
913
914 const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
915 this.sessionId = header?.id ?? createSessionId();
916
917 if (migrateToCurrentVersion(this.fileEntries)) {
918 this._rewriteFile();
919 }
920
921 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 flag
927 }
928 }
929
930 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;
950
951 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 }
957
958 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 }
978
979 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 }
990
991 isPersisted(): boolean {
992 return this.persist;
993 }
994
995 getCwd(): string {
996 return this.cwd;
997 }
998
999 getSessionDir(): string {
1000 return this.sessionDir;
1001 }
1002
1003 usesDefaultSessionDir(): boolean {
1004 return this.sessionDir === getDefaultSessionDirPath(this.cwd);
1005 }
1006
1007 getSessionId(): string {
1008 return this.sessionId;
1009 }
1010
1011 getSessionFile(): string | undefined {
1012 return this.sessionFile;
1013 }
1014
1015 _persist(entry: SessionEntry): void {
1016 if (!this.persist || !this.sessionFile) return;
1017
1018 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 written
1024 this.flushed = false;
1025 }
1026 return;
1027 }
1028
1029 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 }
1043
1044 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 }
1050
1051 /** 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 }
1068
1069 /** 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 }
1081
1082 /** 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 }
1095
1096 /** 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 }
1120
1121 /** 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 }
1134
1135 /** 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 }
1148
1149 /** 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 }
1162
1163 /**
1164 * Append a custom message entry (for extensions) that participates in LLM context.
1165 * @param customType Extension identifier for filtering on reload
1166 * @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 id
1170 */
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 }
1190
1191 // =========================================================================
1192 // Tree Traversal
1193 // =========================================================================
1194
1195 getLeafId(): string | null {
1196 return this.leafId;
1197 }
1198
1199 getLeafEntry(): SessionEntry | undefined {
1200 return this.leafId ? this.byId.get(this.leafId) : undefined;
1201 }
1202
1203 getEntry(id: string): SessionEntry | undefined {
1204 return this.byId.get(id);
1205 }
1206
1207 /**
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 }
1219
1220 /**
1221 * Get the label for an entry, if any.
1222 */
1223 getLabel(id: string): string | undefined {
1224 return this.labelsById.get(id);
1225 }
1226
1227 /**
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 }
1254
1255 /**
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 }
1271
1272 /**
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 }
1279
1280 /**
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 }
1287
1288 /**
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 }
1295
1296 /**
1297 * Get all session entries (excludes header). Returns a shallow copy.
1298 * The session is append-only: use appendXXX() to add entries, branch() to
1299 * 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 }
1304
1305 /**
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[] = [];
1314
1315 // Create nodes with resolved labels
1316 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 }
1321
1322 // Build tree
1323 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 root
1333 roots.push(node);
1334 }
1335 }
1336 }
1337
1338 // Sort children by timestamp (oldest first, newest at bottom)
1339 // Use iterative approach to avoid stack overflow on deep trees
1340 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 }
1346
1347 return roots;
1348 }
1349
1350 // =========================================================================
1351 // Branching
1352 // =========================================================================
1353
1354 /**
1355 * Start a new branch from an earlier entry.
1356 * Moves the leaf pointer to the specified entry. The next appendXXX() call
1357 * will create a child of that entry, forming a new branch. Existing entries
1358 * 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 }
1366
1367 /**
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 }
1375
1376 /**
1377 * Start a new branch with a summary of the abandoned path.
1378 * Same as branch(), but also appends a branch_summary entry that captures
1379 * 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 }
1406
1407 /**
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 }
1418
1419 // 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 }
1429
1430 const newSessionId = createSessionId();
1431 const timestamp = new Date().toISOString();
1432 const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1433 const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
1434
1435 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 };
1443
1444 // Collect labels for entries in the path
1445 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 }
1452
1453 if (this.persist) {
1454 // Build label entries
1455 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 }
1471
1472 this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1473 this.sessionId = newSessionId;
1474 this.sessionFile = newSessionFile;
1475 this._buildIndex();
1476
1477 // Only write the file now if it contains an assistant message.
1478 // Otherwise defer to _persist(), which creates the file on the
1479 // first assistant response, matching the newSession() contract
1480 // and avoiding the duplicate-header bug when _persist()'s
1481 // 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 }
1489
1490 return newSessionFile;
1491 }
1492
1493 // In-memory mode: replace current session with the path + labels
1494 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 }
1513
1514 /**
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 }
1523
1524 /**
1525 * Open a specific session file.
1526 * @param path Path to session file
1527 * @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 remains
1540 // 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 directory
1548 const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");
1549 return new SessionManager(cwd, dir, resolvedPath, true, undefined, preloadedFileEntries);
1550 }
1551
1552 /**
1553 * Continue the most recent session, or create new if none.
1554 * @param cwd Working directory
1555 * @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 }
1566
1567 /** 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 }
1571
1572 /**
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 file
1576 * @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 }
1591
1592 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 }
1596
1597 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
1598 if (!existsSync(dir)) {
1599 mkdirSync(dir, { recursive: true });
1600 }
1601
1602 // Create new session file with new ID but forked content
1603 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`);
1610
1611 // Write new header pointing to source as parent, with updated cwd
1612 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" });
1621
1622 // Copy all non-header entries from source
1623 for (const entry of sourceEntries) {
1624 if (entry.type !== "session") {
1625 appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
1626 }
1627 }
1628
1629 return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
1630 }
1631
1632 /**
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 }
1648
1649 /**
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 }
1667
1668 const sessionsDir = getSessionsDir();
1669
1670 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));
1676
1677 // Count total files first for accurate progress
1678 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 }
1689
1690 // Process all files with progress tracking
1691 let loaded = 0;
1692 const sessions: SessionInfo[] = [];
1693 const allFiles = dirFiles.flat();
1694
1695 const results = await buildSessionInfosWithConcurrency(allFiles, () => {
1696 loaded++;
1697 progress?.(loaded, totalFiles);
1698 });
1699
1700 for (const info of results) {
1701 if (info) {
1702 sessions.push(info);
1703 }
1704 }
1705
1706 sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1707 return sessions;
1708 } catch {
1709 return [];
1710 }
1711 }
1712}
1713