返回源码地图

packages/coding-agent/src/core/session-manager.ts

v1.0.0 · a13d35a742c6 · 07:树、投影和追加存储;非所有格式迁移路径审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import type { AgentMessage } from "@earendil-works/pi-agent-core";
2import {
3 type AssistantMessage,
4 getCurrentSystemMessage,
5 type ImageContent,
6 type Message,
7 type SystemMessage,
8 type TextContent,
9 type ToolResultMessage,
10 type Usage,
11 type UserMessage,
12 uuidv7,
13} from "@earendil-works/pi-ai";
14import { randomUUID } from "crypto";
15import {
16 appendFileSync,
17 closeSync,
18 createReadStream,
19 existsSync,
20 mkdirSync,
21 openSync,
22 readdirSync,
23 readSync,
24 type Stats,
25 statSync,
26 writeFileSync,
27} from "fs";
28import { readdir, stat } from "fs/promises";
29import { basename, join, resolve } from "path";
30import { createInterface } from "readline";
31import { StringDecoder } from "string_decoder";
32import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
33import { normalizePath, resolvePath } from "../utils/paths.ts";
34import {
35 type BashExecutionMessage,
36 type CustomMessage,
37 createBranchSummaryMessage,
38 createCompactionSummaryMessage,
39 createCustomMessage,
40} from "./messages.ts";
41export const CURRENT_SESSION_VERSION = 3;
42
43export interface SessionHeader {
44 type: "session";
45 version?: number; // v1 sessions don't have this
46 id: string;
47 timestamp: string;
48 cwd: string;
49 parentSession?: string;
50}
51
52export interface NewSessionOptions {
53 id?: string;
54 parentSession?: string;
55}
56
57export interface SessionEntryBase {
58 type: string;
59 id: string;
60 parentId: string | null;
61 timestamp: string;
62}
63
64export interface SessionMessageEntry extends SessionEntryBase {
65 type: "message";
66 message: AgentMessage;
67}
68
69export interface ThinkingLevelChangeEntry extends SessionEntryBase {
70 type: "thinking_level_change";
71 thinkingLevel: string;
72}
73
74export interface ModelChangeEntry extends SessionEntryBase {
75 type: "model_change";
76 provider: string;
77 modelId: string;
78}
79
80export interface UsageEntry extends SessionEntryBase {
81 type: "usage";
82 /** Arbitrary usage category, such as "cache_warm". */
83 kind: string;
84 provider: string;
85 model: string;
86 usage: Usage;
87 /** Optional human-readable qualifier for usage notices. */
88 note?: string;
89}
90
91export interface CompactionEntry<T = unknown> extends SessionEntryBase {
92 type: "compaction";
93 summary: string;
94 firstKeptEntryId: string;
95 tokensBefore: number;
96 /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
97 details?: T;
98 /** Usage from the LLM call(s) that generated this summary, if available */
99 usage?: Usage;
100 /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
101 fromHook?: boolean;
102 /** Complete prompt and tool state at this compaction boundary. */
103 systemMessage?: SystemMessage;
104}
105
106export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
107 type: "branch_summary";
108 fromId: string;
109 summary: string;
110 /** Extension-specific data (not sent to LLM) */
111 details?: T;
112 /** Usage from the LLM call that generated this summary, if available */
113 usage?: Usage;
114 /** True if generated by an extension, false if pi-generated */
115 fromHook?: boolean;
116}
117
118/**
119 * Custom entry for extensions to store extension-specific data in the session.
120 * Use customType to identify your extension's entries.
121 *
122 * Purpose: Persist extension state across session reloads. On reload, extensions can
123 * scan entries for their customType and reconstruct internal state.
124 *
125 * Does NOT participate in LLM context (ignored by buildSessionContext).
126 * For injecting content into context, see CustomMessageEntry.
127 */
128export interface CustomEntry<T = unknown> extends SessionEntryBase {
129 type: "custom";
130 customType: string;
131 data?: T;
132}
133
134/** Label entry for user-defined bookmarks/markers on entries. */
135export interface LabelEntry extends SessionEntryBase {
136 type: "label";
137 targetId: string;
138 label: string | undefined;
139}
140
141/** Session metadata entry (e.g., user-defined display name). */
142export interface SessionInfoEntry extends SessionEntryBase {
143 type: "session_info";
144 name?: string;
145}
146
147/**
148 * Custom message entry for extensions to inject messages into LLM context.
149 * Use customType to identify your extension's entries.
150 *
151 * Unlike CustomEntry, this DOES participate in LLM context.
152 * The content is converted to a user message in buildSessionContext().
153 * Use details for extension-specific metadata (not sent to LLM).
154 *
155 * display controls TUI rendering:
156 * - false: hidden entirely
157 * - true: rendered with distinct styling (different from user messages)
158 */
159export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
160 type: "custom_message";
161 customType: string;
162 content: string | (TextContent | ImageContent)[];
163 details?: T;
164 display: boolean;
165}
166
167/** Content that an append-only context edit may replace without changing message metadata. */
168export type ContextEditableContent =
169 | UserMessage["content"]
170 | AssistantMessage["content"]
171 | ToolResultMessage["content"]
172 | CustomMessage["content"];
173
174/** Append-only change to one earlier entry's contribution to model context. */
175export interface ContextEditEntry extends SessionEntryBase {
176 type: "context_edit";
177 targetId: string;
178 /** Null omits the target from model context. A value replaces only its content. */
179 replacement: { content: ContextEditableContent } | null;
180}
181
182/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
183export type SessionEntry =
184 | SessionMessageEntry
185 | ThinkingLevelChangeEntry
186 | ModelChangeEntry
187 | UsageEntry
188 | CompactionEntry
189 | BranchSummaryEntry
190 | CustomEntry
191 | CustomMessageEntry
192 | ContextEditEntry
193 | LabelEntry
194 | SessionInfoEntry;
195
196/** Raw file entry (includes header) */
197export type FileEntry = SessionHeader | SessionEntry;
198
199/** Tree node for getTree() - defensive copy of session structure */
200export interface SessionTreeNode {
201 entry: SessionEntry;
202 children: SessionTreeNode[];
203 /** Resolved label for this entry, if any */
204 label?: string;
205 /** Timestamp of the latest label change for this entry, if any */
206 labelTimestamp?: string;
207}
208
209export interface ProjectedSessionEntry {
210 /** Raw append-only entry that owns this projected contribution. */
211 sourceEntry: SessionEntry;
212 /** Model-visible messages after context edits. Empty for state-only entries and omissions. */
213 messages: AgentMessage[];
214}
215
216export interface SessionProjection {
217 entries: ProjectedSessionEntry[];
218 messages: AgentMessage[];
219 thinkingLevel: string;
220 model: { provider: string; modelId: string } | null;
221}
222
223export interface SessionContext {
224 messages: AgentMessage[];
225 thinkingLevel: string;
226 model: { provider: string; modelId: string } | null;
227}
228
229export interface SessionInfo {
230 path: string;
231 id: string;
232 /** Working directory where the session was started. Empty string for old sessions. */
233 cwd: string;
234 /** User-defined display name from session_info entries. */
235 name?: string;
236 /** Path to the parent session (if this session was forked). */
237 parentSessionPath?: string;
238 created: Date;
239 modified: Date;
240 messageCount: number;
241 firstMessage: string;
242 allMessagesText: string;
243}
244
245export type ReadonlySessionManager = Pick<
246 SessionManager,
247 | "getCwd"
248 | "getSessionDir"
249 | "getSessionId"
250 | "getSessionFile"
251 | "getLeafId"
252 | "getLeafEntry"
253 | "getEntry"
254 | "getLabel"
255 | "getBranch"
256 | "buildContextEntries"
257 | "buildSessionProjection"
258 | "getHeader"
259 | "getEntries"
260 | "getTree"
261 | "getSessionName"
262>;
263
264function createSessionId(): string {
265 return uuidv7();
266}
267
268export function assertValidSessionId(id: string): void {
269 if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {
270 throw new Error(
271 "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character",
272 );
273 }
274}
275
276/** Generate a unique short ID (8 hex chars, collision-checked) */
277function generateId(byId: { has(id: string): boolean }): string {
278 for (let i = 0; i < 100; i++) {
279 const id = randomUUID().slice(0, 8);
280 if (!byId.has(id)) return id;
281 }
282 // Fallback to full UUID if somehow we have collisions
283 return randomUUID();
284}
285
286/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
287function migrateV1ToV2(entries: FileEntry[]): void {
288 const ids = new Set<string>();
289 let prevId: string | null = null;
290
291 for (const entry of entries) {
292 if (entry.type === "session") {
293 entry.version = 2;
294 continue;
295 }
296
297 entry.id = generateId(ids);
298 entry.parentId = prevId;
299 prevId = entry.id;
300
301 // Convert firstKeptEntryIndex to firstKeptEntryId for compaction
302 if (entry.type === "compaction") {
303 const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
304 if (typeof comp.firstKeptEntryIndex === "number") {
305 const targetEntry = entries[comp.firstKeptEntryIndex];
306 if (targetEntry && targetEntry.type !== "session") {
307 comp.firstKeptEntryId = targetEntry.id;
308 }
309 delete comp.firstKeptEntryIndex;
310 }
311 }
312 }
313}
314
315/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
316function migrateV2ToV3(entries: FileEntry[]): void {
317 for (const entry of entries) {
318 if (entry.type === "session") {
319 entry.version = 3;
320 continue;
321 }
322
323 // Update message entries with hookMessage role
324 if (entry.type === "message") {
325 const msgEntry = entry as SessionMessageEntry;
326 if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") {
327 (msgEntry.message as { role: string }).role = "custom";
328 }
329 }
330 }
331}
332
333/**
334 * Run all necessary migrations to bring entries to current version.
335 * Mutates entries in place. Returns true if any migration was applied.
336 */
337function migrateToCurrentVersion(entries: FileEntry[]): boolean {
338 const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
339 const version = header?.version ?? 1;
340
341 if (version >= CURRENT_SESSION_VERSION) return false;
342
343 if (version < 2) migrateV1ToV2(entries);
344 if (version < 3) migrateV2ToV3(entries);
345
346 return true;
347}
348
349/** Exported for testing */
350export function migrateSessionEntries(entries: FileEntry[]): void {
351 migrateToCurrentVersion(entries);
352}
353
354/** Exported for compaction.test.ts */
355export function parseSessionEntries(content: string): FileEntry[] {
356 const entries: FileEntry[] = [];
357 const lines = content.trim().split("\n");
358
359 for (const line of lines) {
360 if (!line.trim()) continue;
361 try {
362 const entry = JSON.parse(line) as FileEntry;
363 entries.push(entry);
364 } catch {
365 // Skip malformed lines
366 }
367 }
368
369 return entries;
370}
371
372export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
373 for (let i = entries.length - 1; i >= 0; i--) {
374 if (entries[i].type === "compaction") {
375 return entries[i] as CompactionEntry;
376 }
377 }
378 return null;
379}
380
381function buildEntryIndex(entries: SessionEntry[], byId?: Map<string, SessionEntry>): Map<string, SessionEntry> {
382 if (byId) return byId;
383 const index = new Map<string, SessionEntry>();
384 for (const entry of entries) {
385 index.set(entry.id, entry);
386 }
387 return index;
388}
389
390function buildSessionPath(
391 entries: SessionEntry[],
392 leafId?: string | null,
393 byId?: Map<string, SessionEntry>,
394): SessionEntry[] {
395 const index = buildEntryIndex(entries, byId);
396 let leaf: SessionEntry | undefined;
397 if (leafId === null) {
398 return [];
399 }
400 if (leafId) {
401 leaf = index.get(leafId);
402 }
403 leaf ??= entries[entries.length - 1];
404 if (!leaf) {
405 return [];
406 }
407
408 const path: SessionEntry[] = [];
409 let current: SessionEntry | undefined = leaf;
410 while (current) {
411 path.push(current);
412 current = current.parentId ? index.get(current.parentId) : undefined;
413 }
414 path.reverse();
415 return path;
416}
417
418function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "thinkingLevel" | "model"> {
419 let thinkingLevel = "off";
420 let model: { provider: string; modelId: string } | null = null;
421
422 for (const entry of path) {
423 if (entry.type === "thinking_level_change") {
424 thinkingLevel = entry.thinkingLevel;
425 } else if (entry.type === "model_change") {
426 model = { provider: entry.provider, modelId: entry.modelId };
427 } else if (entry.type === "message" && entry.message.role === "assistant") {
428 model = { provider: entry.message.provider, modelId: entry.message.model };
429 }
430 }
431
432 return { thinkingLevel, model };
433}
434
435/**
436 * Project one selected session entry into LLM/runtime messages.
437 * Plain custom entries are display/state entries and do not participate in context.
438 */
439export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {
440 if (entry.type === "message") {
441 const message = entry.message;
442 // Session files are parsed without validation; old versions, forks, or
443 // hand-edited files can contain messages with null/missing content.
444 if (message.role === "system" && message.content == null) return [{ ...message, content: "" }];
445 if (
446 (message.role === "user" || message.role === "assistant" || message.role === "toolResult") &&
447 message.content == null
448 ) {
449 return [{ ...message, content: [] }];
450 }
451 return [message];
452 }
453 if (entry.type === "custom_message") {
454 return [
455 createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp),
456 ];
457 }
458 if (entry.type === "branch_summary" && entry.summary) {
459 return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
460 }
461 if (entry.type === "compaction") {
462 const summary = createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
463 return entry.systemMessage ? [entry.systemMessage, summary] : [summary];
464 }
465 return [];
466}
467
468/**
469 * Build the active, compaction-aware session entry list.
470 *
471 * This follows the current leaf path. If the path contains compaction entries,
472 * the latest compaction is represented by the compaction entry itself, followed
473 * by the kept entries starting at firstKeptEntryId and all entries after the
474 * compaction entry. Older summarized entries are omitted.
475 */
476export function buildContextEntries(
477 entries: SessionEntry[],
478 leafId?: string | null,
479 byId?: Map<string, SessionEntry>,
480): SessionEntry[] {
481 const path = buildSessionPath(entries, leafId, byId);
482 let compaction: CompactionEntry | null = null;
483
484 for (const entry of path) {
485 if (entry.type === "compaction") {
486 compaction = entry;
487 }
488 }
489
490 if (!compaction) {
491 return path;
492 }
493
494 const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);
495 if (compactionIdx < 0) {
496 return path;
497 }
498
499 const contextEntries: SessionEntry[] = [compaction];
500 let foundFirstKept = false;
501 for (let i = 0; i < compactionIdx; i++) {
502 const entry = path[i];
503 if (entry.id === compaction.firstKeptEntryId) {
504 foundFirstKept = true;
505 }
506 if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system")) {
507 contextEntries.push(entry);
508 }
509 }
510 contextEntries.push(...path.slice(compactionIdx + 1));
511 return contextEntries;
512}
513
514/**
515 * Build the session context from entries using tree traversal.
516 * If leafId is provided, walks from that entry to root.
517 * Handles compaction and branch summaries along the path.
518 */
519function projectContextEntry(entry: SessionEntry, edit: ContextEditEntry | undefined): AgentMessage[] {
520 const messages = sessionEntryToContextMessages(entry);
521 if (!edit) return messages;
522 const replacement = edit.replacement;
523 if (replacement === null) return [];
524
525 return messages.map((message) => {
526 if (
527 message.role !== "user" &&
528 message.role !== "assistant" &&
529 message.role !== "toolResult" &&
530 message.role !== "custom"
531 ) {
532 return message;
533 }
534 const content =
535 (message.role === "assistant" || message.role === "toolResult") && typeof replacement.content === "string"
536 ? [{ type: "text" as const, text: replacement.content }]
537 : replacement.content;
538 return { ...message, content } as AgentMessage;
539 });
540}
541
542/** Build provenance-preserving, compaction-aware model context. */
543export function buildSessionProjection(
544 entries: SessionEntry[],
545 leafId?: string | null,
546 byId?: Map<string, SessionEntry>,
547): SessionProjection {
548 const path = buildSessionPath(entries, leafId, byId);
549 const { thinkingLevel, model } = getSessionContextSettings(path);
550 const contextEntries = buildContextEntries(entries, leafId, byId);
551 const edits = new Map<string, ContextEditEntry>();
552 for (const entry of contextEntries) {
553 if (entry.type === "context_edit") edits.set(entry.targetId, entry);
554 }
555 const projectedEntries = contextEntries.map(
556 (sourceEntry, index): ProjectedSessionEntry => ({
557 sourceEntry,
558 // buildContextEntries() may retain an older compaction entry because its
559 // raw ID lies inside the newest retained range. Only the newest compaction
560 // at index zero contributes a checkpoint and summary.
561 messages:
562 sourceEntry.type === "compaction" && index > 0
563 ? []
564 : projectContextEntry(sourceEntry, edits.get(sourceEntry.id)),
565 }),
566 );
567 return {
568 entries: projectedEntries,
569 messages: projectedEntries.flatMap((entry) => entry.messages),
570 thinkingLevel,
571 model,
572 };
573}
574
575/** Build the finalized model context from the canonical session projection. */
576export function buildSessionContext(
577 entries: SessionEntry[],
578 leafId?: string | null,
579 byId?: Map<string, SessionEntry>,
580): SessionContext {
581 const { messages, thinkingLevel, model } = buildSessionProjection(entries, leafId, byId);
582 return { messages, thinkingLevel, model };
583}
584
585/**
586 * Compute the default session directory for a cwd.
587 * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
588 */
589function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string {
590 const resolvedCwd = resolvePath(cwd);
591 const resolvedAgentDir = resolvePath(agentDir);
592 const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
593 return join(resolvedAgentDir, "sessions", safePath);
594}
595
596export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {
597 const sessionDir = getDefaultSessionDirPath(cwd, agentDir);
598 if (!existsSync(sessionDir)) {
599 mkdirSync(sessionDir, { recursive: true });
600 }
601 return sessionDir;
602}
603
604const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
605const SESSION_HEADER_READ_BUFFER_SIZE = 4096;
606/** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */
607const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
608
609class SessionHeaderScanLimitError extends Error {
610 constructor(filePath: string) {
611 super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`);
612 this.name = "SessionHeaderScanLimitError";
613 }
614}
615
616function parseSessionEntryLine(line: string): FileEntry | null {
617 if (!line.trim()) return null;
618 try {
619 return JSON.parse(line) as FileEntry;
620 } catch {
621 // Skip malformed lines
622 return null;
623 }
624}
625
626/** Exported for testing */
627export function loadEntriesFromFile(filePath: string): FileEntry[] {
628 const resolvedFilePath = normalizePath(filePath);
629 if (!existsSync(resolvedFilePath)) return [];
630
631 const entries: FileEntry[] = [];
632 let pending = "";
633 const fd = openSync(resolvedFilePath, "r");
634 try {
635 const decoder = new StringDecoder("utf8");
636 const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
637
638 while (true) {
639 const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
640 if (bytesRead === 0) break;
641
642 pending += decoder.write(buffer.subarray(0, bytesRead));
643 let lineStart = 0;
644 let newlineIndex = pending.indexOf("\n", lineStart);
645 while (newlineIndex !== -1) {
646 const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
647 if (entry) entries.push(entry);
648 lineStart = newlineIndex + 1;
649 newlineIndex = pending.indexOf("\n", lineStart);
650 }
651 pending = pending.slice(lineStart);
652 }
653
654 pending += decoder.end();
655 const finalEntry = parseSessionEntryLine(pending);
656 if (finalEntry) entries.push(finalEntry);
657 } finally {
658 closeSync(fd);
659 }
660
661 // Validate session header before repairing the file.
662 if (entries.length === 0) return entries;
663 const header = entries[0];
664 if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {
665 return [];
666 }
667
668 if (pending) appendFileSync(resolvedFilePath, "\n");
669 return entries;
670}
671
672/**
673 * Inspect a physical line while searching for the first parsed session entry.
674 * Blank and malformed lines are skipped to match loadEntriesFromFile().
675 * Returns undefined to keep scanning, null for a parsed non-header entry, or the header.
676 */
677function parseSessionHeaderCandidate(line: string): SessionHeader | null | undefined {
678 if (!line.trim()) return undefined;
679 const entry = parseSessionEntryLine(line);
680 if (!entry) return undefined;
681 if (entry.type !== "session" || typeof (entry as { id?: unknown }).id !== "string") return null;
682 return entry;
683}
684
685function readSessionHeader(filePath: string): SessionHeader | null {
686 const fd = openSync(filePath, "r");
687 try {
688 const decoder = new StringDecoder("utf8");
689 const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE);
690 const lineChunks: string[] = [];
691 let scannedBytes = 0;
692
693 while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {
694 const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);
695 const bytesRead = readSync(fd, buffer, 0, readLength, null);
696 if (bytesRead === 0) {
697 lineChunks.push(decoder.end());
698 return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
699 }
700 scannedBytes += bytesRead;
701
702 const chunk = decoder.write(buffer.subarray(0, bytesRead));
703 let lineStart = 0;
704 let newlineIndex = chunk.indexOf("\n", lineStart);
705 while (newlineIndex !== -1) {
706 lineChunks.push(chunk.slice(lineStart, newlineIndex));
707 const header = parseSessionHeaderCandidate(lineChunks.join(""));
708 if (header !== undefined) return header;
709 lineChunks.length = 0;
710 lineStart = newlineIndex + 1;
711 newlineIndex = chunk.indexOf("\n", lineStart);
712 }
713 lineChunks.push(chunk.slice(lineStart));
714 }
715
716 // Probe for EOF so a final header without a newline is allowed when it ends
717 // exactly at the scan limit. Any additional byte exceeds the bounded scan.
718 const probe = Buffer.allocUnsafe(1);
719 if (readSync(fd, probe, 0, probe.length, null) === 0) {
720 lineChunks.push(decoder.end());
721 return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
722 }
723 throw new SessionHeaderScanLimitError(filePath);
724 } finally {
725 closeSync(fd);
726 }
727}
728
729function readSessionHeaderForDiscovery(filePath: string): SessionHeader | null {
730 try {
731 return readSessionHeader(filePath);
732 } catch {
733 // Discovery is best-effort: unreadable or oversized files are not sessions,
734 // and one corrupt file must not prevent other sessions from being found.
735 return null;
736 }
737}
738
739function getSessionHeaderCwd(header: SessionHeader): string | undefined {
740 const cwd = (header as { cwd?: unknown }).cwd;
741 return typeof cwd === "string" ? cwd : undefined;
742}
743
744function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {
745 return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;
746}
747
748/** Exported for testing */
749export function findMostRecentSession(sessionDir: string, cwd?: string): string | null {
750 const resolvedSessionDir = normalizePath(sessionDir);
751 const resolvedCwd = cwd ? resolvePath(cwd) : undefined;
752 try {
753 const files = readdirSync(resolvedSessionDir)
754 .filter((file) => file.endsWith(".jsonl"))
755 .map((file) => join(resolvedSessionDir, file))
756 .map((path) => ({ path, mtime: statSync(path).mtimeMs }))
757 .sort((a, b) => b.mtime - a.mtime);
758
759 for (const { path } of files) {
760 const header = readSessionHeaderForDiscovery(path);
761 if (header && (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(header), resolvedCwd))) return path;
762 }
763 return null;
764 } catch {
765 // Directory access and stat races make recent-session discovery unavailable.
766 return null;
767 }
768}
769
770function isMessageWithContent(message: AgentMessage): message is Message {
771 return typeof (message as Message).role === "string" && "content" in message;
772}
773
774function extractTextContent(message: Message): string {
775 const content = message.content;
776 if (typeof content === "string") {
777 return content;
778 }
779 return content
780 .filter((block): block is TextContent => block.type === "text")
781 .map((block) => block.text)
782 .join(" ");
783}
784
785function getMessageActivityTime(entry: SessionMessageEntry): number | undefined {
786 const message = entry.message;
787 if (!isMessageWithContent(message)) return undefined;
788 if (message.role !== "user" && message.role !== "assistant") return undefined;
789
790 const msgTimestamp = (message as { timestamp?: number }).timestamp;
791 if (typeof msgTimestamp === "number") {
792 return msgTimestamp;
793 }
794
795 const t = new Date(entry.timestamp).getTime();
796 return Number.isNaN(t) ? undefined : t;
797}
798
799async function buildSessionInfo(
800 filePath: string,
801 signal?: AbortSignal,
802 fileStats?: Stats,
803): Promise<SessionInfo | null> {
804 try {
805 const stats = fileStats ?? (await stat(filePath));
806 let header: SessionHeader | null = null;
807 let messageCount = 0;
808 let firstMessage = "";
809 const allMessages: string[] = [];
810 let name: string | undefined;
811 let lastActivityTime: number | undefined;
812
813 const rl = createInterface({
814 input: createReadStream(filePath, { encoding: "utf8", signal }),
815 crlfDelay: Infinity,
816 });
817
818 for await (const line of rl) {
819 const entry = parseSessionEntryLine(line);
820 if (!entry) continue;
821
822 if (!header) {
823 if (entry.type !== "session") return null;
824 header = entry;
825 continue;
826 }
827
828 // Extract session name (use latest, including explicit clears)
829 if (entry.type === "session_info") {
830 name = entry.name?.trim() || undefined;
831 }
832
833 if (entry.type !== "message") continue;
834 messageCount++;
835
836 const activityTime = getMessageActivityTime(entry);
837 if (typeof activityTime === "number") {
838 lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
839 }
840
841 const message = entry.message;
842 if (!isMessageWithContent(message)) continue;
843 if (message.role !== "user" && message.role !== "assistant") continue;
844
845 const textContent = extractTextContent(message);
846 if (!textContent) continue;
847
848 allMessages.push(textContent);
849 if (!firstMessage && message.role === "user") {
850 firstMessage = textContent;
851 }
852 }
853
854 if (!header) return null;
855
856 const cwd = typeof header.cwd === "string" ? header.cwd : "";
857 const parentSessionPath = header.parentSession;
858 const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
859 const modified =
860 typeof lastActivityTime === "number" && lastActivityTime > 0
861 ? new Date(lastActivityTime)
862 : !Number.isNaN(headerTime)
863 ? new Date(headerTime)
864 : stats.mtime;
865
866 return {
867 path: filePath,
868 id: header.id,
869 cwd,
870 name,
871 parentSessionPath,
872 created: new Date(header.timestamp),
873 modified,
874 messageCount,
875 firstMessage: firstMessage || "(no messages)",
876 allMessagesText: allMessages.join(" "),
877 };
878 } catch {
879 signal?.throwIfAborted();
880 return null;
881 }
882}
883
884export type SessionListProgress = (
885 loaded: number,
886 total: number,
887 /** Sessions loaded so far, sorted by activity. Present on periodic updates. */
888 partialSessions?: readonly SessionInfo[],
889) => void;
890
891const MAX_CONCURRENT_SESSION_INFO_LOADS = 10;
892const MAX_CONCURRENT_SESSION_DISCOVERY_LOADS = 64;
893const CURRENT_SESSION_LIST_PUBLISH_INTERVAL = 10;
894const ALL_SESSION_LIST_PUBLISH_INTERVAL = 100;
895
896interface SessionFileCandidate {
897 path: string;
898 stats?: Stats;
899}
900
901async function mapWithConcurrency<T, R>(
902 items: T[],
903 limit: number,
904 map: (item: T, index: number) => Promise<R>,
905 signal?: AbortSignal,
906): Promise<R[]> {
907 const results = new Array<R>(items.length);
908 let nextIndex = 0;
909 const worker = async (): Promise<void> => {
910 while (nextIndex < items.length) {
911 signal?.throwIfAborted();
912 const index = nextIndex++;
913 results[index] = await map(items[index]!, index);
914 }
915 };
916 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
917 return results;
918}
919
920function sortSessionInfos(sessions: SessionInfo[]): SessionInfo[] {
921 return sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
922}
923
924function buildSessionInfosWithConcurrency(
925 files: SessionFileCandidate[],
926 onLoaded: (info: SessionInfo | null, index: number) => void,
927 signal?: AbortSignal,
928): Promise<(SessionInfo | null)[]> {
929 return mapWithConcurrency(
930 files,
931 MAX_CONCURRENT_SESSION_INFO_LOADS,
932 async (file, index) => {
933 const info = await buildSessionInfo(file.path, signal, file.stats);
934 onLoaded(info, index);
935 return info;
936 },
937 signal,
938 );
939}
940
941async function listSessionsFromDir(
942 dir: string,
943 onProgress?: SessionListProgress,
944 signal?: AbortSignal,
945): Promise<SessionInfo[]> {
946 signal?.throwIfAborted();
947 if (!existsSync(dir)) return [];
948
949 try {
950 const dirEntries = await readdir(dir);
951 const files = dirEntries
952 .filter((file) => file.endsWith(".jsonl"))
953 .sort((a, b) => b.localeCompare(a))
954 .map((file) => ({ path: join(dir, file) }));
955 const total = files.length;
956 const partialSessions: SessionInfo[] = [];
957 let loaded = 0;
958 const results = await buildSessionInfosWithConcurrency(
959 files,
960 (info) => {
961 loaded++;
962 if (info) partialSessions.push(info);
963 const publishPartial =
964 loaded === 1 || loaded % CURRENT_SESSION_LIST_PUBLISH_INTERVAL === 0 || loaded === files.length;
965 onProgress?.(loaded, total, publishPartial ? sortSessionInfos([...partialSessions]) : undefined);
966 },
967 signal,
968 );
969 return results.filter((info): info is SessionInfo => info !== null);
970 } catch {
971 signal?.throwIfAborted();
972 return [];
973 }
974}
975
976/**
977 * Manages conversation sessions as append-only trees stored in JSONL files.
978 *
979 * Each session entry has an id and parentId forming a tree structure. The "leaf"
980 * pointer tracks the current position. Appending creates a child of the current leaf.
981 * Branching moves the leaf to an earlier entry, allowing new branches without
982 * modifying history.
983 *
984 * Use buildSessionContext() to get the resolved message list for the LLM, which
985 * handles compaction summaries and follows the path from root to current leaf.
986 */
987export class SessionManager {
988 private sessionId: string = "";
989 private sessionFile: string | undefined;
990 private sessionDir: string;
991 private cwd: string;
992 private persist: boolean;
993 private flushed: boolean = false;
994 private fileEntries: FileEntry[] = [];
995 private byId: Map<string, SessionEntry> = new Map();
996 private labelsById: Map<string, string> = new Map();
997 private labelTimestampsById: Map<string, string> = new Map();
998 private leafId: string | null = null;
999
1000 private constructor(
1001 cwd: string,
1002 sessionDir: string,
1003 sessionFile: string | undefined,
1004 persist: boolean,
1005 newSessionOptions?: NewSessionOptions,
1006 preloadedFileEntries?: FileEntry[],
1007 ) {
1008 this.cwd = resolvePath(cwd);
1009 this.sessionDir = normalizePath(sessionDir);
1010 this.persist = persist;
1011 if (persist && this.sessionDir && !existsSync(this.sessionDir)) {
1012 mkdirSync(this.sessionDir, { recursive: true });
1013 }
1014
1015 if (sessionFile) {
1016 this._setSessionFile(sessionFile, preloadedFileEntries);
1017 } else if (preloadedFileEntries?.length) {
1018 this._loadEntries(preloadedFileEntries, newSessionOptions);
1019 } else {
1020 this.newSession(newSessionOptions);
1021 }
1022 }
1023
1024 /** Switch to a different session file (used for resume and branching) */
1025 setSessionFile(sessionFile: string): void {
1026 this._setSessionFile(sessionFile);
1027 }
1028
1029 private _setSessionFile(sessionFile: string, preloadedFileEntries?: FileEntry[]): void {
1030 this.sessionFile = resolvePath(sessionFile);
1031 if (existsSync(this.sessionFile)) {
1032 const entries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);
1033
1034 // If file was empty, initialize it with a valid session header. If it was
1035 // non-empty but did not parse as a pi session, fail without modifying it.
1036 if (entries.length === 0) {
1037 const explicitPath = this.sessionFile;
1038 if (statSync(explicitPath).size > 0) {
1039 throw new Error(`Session file is not a valid ${APP_NAME} session: ${explicitPath}`);
1040 }
1041 this.newSession();
1042 this.sessionFile = explicitPath;
1043 this._rewriteFile();
1044 this.flushed = true;
1045 return;
1046 }
1047
1048 this._loadEntries(entries);
1049 this.flushed = true;
1050 } else {
1051 const explicitPath = this.sessionFile;
1052 this.newSession();
1053 this.sessionFile = explicitPath; // preserve explicit path from --session flag
1054 }
1055 }
1056
1057 newSession(options?: NewSessionOptions): string | undefined {
1058 if (options?.id !== undefined) {
1059 assertValidSessionId(options.id);
1060 }
1061 this.sessionId = options?.id ?? createSessionId();
1062 const timestamp = new Date().toISOString();
1063 const header: SessionHeader = {
1064 type: "session",
1065 version: CURRENT_SESSION_VERSION,
1066 id: this.sessionId,
1067 timestamp,
1068 cwd: this.cwd,
1069 parentSession: options?.parentSession,
1070 };
1071 this.fileEntries = [header];
1072 this.byId.clear();
1073 this.labelsById.clear();
1074 this.labelTimestampsById.clear();
1075 this.leafId = null;
1076 this.flushed = false;
1077
1078 if (this.persist) {
1079 const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1080 this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
1081 }
1082 return this.sessionFile;
1083 }
1084
1085 private _loadEntries(entries: FileEntry[], options?: NewSessionOptions): void {
1086 const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
1087
1088 if (header) {
1089 this.fileEntries = entries;
1090 this.sessionId = header.id;
1091
1092 if (migrateToCurrentVersion(this.fileEntries)) {
1093 this._rewriteFile();
1094 }
1095 } else {
1096 this.newSession(options);
1097 this.fileEntries = this.fileEntries.concat(entries);
1098 }
1099
1100 this._buildIndex();
1101 }
1102
1103 private _buildIndex(): void {
1104 this.byId.clear();
1105 this.labelsById.clear();
1106 this.labelTimestampsById.clear();
1107 this.leafId = null;
1108 for (const entry of this.fileEntries) {
1109 if (entry.type === "session") continue;
1110 this.byId.set(entry.id, entry);
1111 this.leafId = entry.id;
1112 if (entry.type === "label") {
1113 if (entry.label) {
1114 this.labelsById.set(entry.targetId, entry.label);
1115 this.labelTimestampsById.set(entry.targetId, entry.timestamp);
1116 } else {
1117 this.labelsById.delete(entry.targetId);
1118 this.labelTimestampsById.delete(entry.targetId);
1119 }
1120 }
1121 }
1122 }
1123
1124 private _rewriteFile(): void {
1125 if (!this.persist || !this.sessionFile) return;
1126 const fd = openSync(this.sessionFile, "w");
1127 try {
1128 for (const entry of this.fileEntries) {
1129 writeFileSync(fd, `${JSON.stringify(entry)}\n`);
1130 }
1131 } finally {
1132 closeSync(fd);
1133 }
1134 }
1135
1136 isPersisted(): boolean {
1137 return this.persist;
1138 }
1139
1140 getCwd(): string {
1141 return this.cwd;
1142 }
1143
1144 getSessionDir(): string {
1145 return this.sessionDir;
1146 }
1147
1148 usesDefaultSessionDir(): boolean {
1149 return this.sessionDir === getDefaultSessionDirPath(this.cwd);
1150 }
1151
1152 getSessionId(): string {
1153 return this.sessionId;
1154 }
1155
1156 getSessionFile(): string | undefined {
1157 return this.sessionFile;
1158 }
1159
1160 /**
1161 * A new session file is created only once the session contains a user or assistant message.
1162 * Setup entries alone (model, thinking level, system prompt) stay in memory so opening and
1163 * closing pi without chatting leaves no file behind. Starting at the user message (not the
1164 * first assistant reply) keeps the prompt on disk if the first turn never completes (#10000).
1165 */
1166 private _hasConversation(): boolean {
1167 return this.fileEntries.some(
1168 (e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
1169 );
1170 }
1171
1172 _persist(entry: SessionEntry): void {
1173 if (!this.persist || !this.sessionFile) return;
1174
1175 if (!this.flushed) {
1176 if (!this._hasConversation()) return;
1177 const fd = openSync(this.sessionFile, "wx");
1178 try {
1179 for (const e of this.fileEntries) {
1180 writeFileSync(fd, `${JSON.stringify(e)}\n`);
1181 }
1182 } finally {
1183 closeSync(fd);
1184 }
1185 this.flushed = true;
1186 } else {
1187 appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1188 }
1189 }
1190
1191 private _appendEntry(entry: SessionEntry): void {
1192 this.fileEntries.push(entry);
1193 this.byId.set(entry.id, entry);
1194 this.leafId = entry.id;
1195 this._persist(entry);
1196 }
1197
1198 /** Append a message as child of current leaf, then advance leaf. Returns entry id.
1199 * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
1200 * Reason: we want these to be top-level entries in the session, not message session entries,
1201 * so it is easier to find them.
1202 * These need to be appended via appendCompaction() and appendBranchSummary() methods.
1203 */
1204 appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {
1205 const entry: SessionMessageEntry = {
1206 type: "message",
1207 id: generateId(this.byId),
1208 parentId: this.leafId,
1209 timestamp: new Date().toISOString(),
1210 message,
1211 };
1212 this._appendEntry(entry);
1213 return entry.id;
1214 }
1215
1216 /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
1217 appendThinkingLevelChange(thinkingLevel: string): string {
1218 const entry: ThinkingLevelChangeEntry = {
1219 type: "thinking_level_change",
1220 id: generateId(this.byId),
1221 parentId: this.leafId,
1222 timestamp: new Date().toISOString(),
1223 thinkingLevel,
1224 };
1225 this._appendEntry(entry);
1226 return entry.id;
1227 }
1228
1229 /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
1230 appendModelChange(provider: string, modelId: string): string {
1231 const entry: ModelChangeEntry = {
1232 type: "model_change",
1233 id: generateId(this.byId),
1234 parentId: this.leafId,
1235 timestamp: new Date().toISOString(),
1236 provider,
1237 modelId,
1238 };
1239 this._appendEntry(entry);
1240 return entry.id;
1241 }
1242
1243 /** Append model-attributed usage that does not participate in LLM context. Returns the appended entry. */
1244 appendUsage(kind: string, provider: string, model: string, usage: Usage, note?: string): UsageEntry {
1245 const entry: UsageEntry = {
1246 type: "usage",
1247 id: generateId(this.byId),
1248 parentId: this.leafId,
1249 timestamp: new Date().toISOString(),
1250 kind,
1251 provider,
1252 model,
1253 usage,
1254 ...(note ? { note } : {}),
1255 };
1256 this._appendEntry(entry);
1257 return entry;
1258 }
1259
1260 /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
1261 appendCompaction<T = unknown>(
1262 summary: string,
1263 firstKeptEntryId: string | null,
1264 tokensBefore: number,
1265 details?: T,
1266 fromHook?: boolean,
1267 usage?: Usage,
1268 ): string {
1269 const timestamp = new Date().toISOString();
1270 const systemMessage = getCurrentSystemMessage(this.buildSessionProjection().messages);
1271 const id = generateId(this.byId);
1272 const entry: CompactionEntry<T> = {
1273 type: "compaction",
1274 id,
1275 parentId: this.leafId,
1276 timestamp,
1277 summary,
1278 firstKeptEntryId: firstKeptEntryId ?? id,
1279 tokensBefore,
1280 details,
1281 usage,
1282 fromHook,
1283 ...(systemMessage ? { systemMessage: { ...systemMessage, timestamp: new Date(timestamp).getTime() } } : {}),
1284 };
1285 this._appendEntry(entry);
1286 return entry.id;
1287 }
1288
1289 /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
1290 appendCustomEntry(customType: string, data?: unknown): string {
1291 const entry: CustomEntry = {
1292 type: "custom",
1293 customType,
1294 data,
1295 id: generateId(this.byId),
1296 parentId: this.leafId,
1297 timestamp: new Date().toISOString(),
1298 };
1299 this._appendEntry(entry);
1300 return entry.id;
1301 }
1302
1303 /** Append a session info entry (e.g., display name). Returns entry id. */
1304 appendSessionInfo(name: string): string {
1305 const sanitizedName = name.replace(/[\r\n]+/g, " ").trim();
1306 const entry: SessionInfoEntry = {
1307 type: "session_info",
1308 id: generateId(this.byId),
1309 parentId: this.leafId,
1310 timestamp: new Date().toISOString(),
1311 name: sanitizedName,
1312 };
1313 this._appendEntry(entry);
1314 return entry.id;
1315 }
1316
1317 /** Get the current session name from the latest session_info entry, if any. */
1318 getSessionName(): string | undefined {
1319 // Walk entries in reverse to find the latest session_info entry.
1320 // Empty names explicitly clear the session title. Reads fileEntries directly: the footer
1321 // calls this on every frame, and getEntries() copies the whole session.
1322 for (let i = this.fileEntries.length - 1; i >= 0; i--) {
1323 const entry = this.fileEntries[i];
1324 if (entry.type === "session_info") {
1325 return entry.name?.trim() || undefined;
1326 }
1327 }
1328 return undefined;
1329 }
1330
1331 /**
1332 * Append a custom message entry (for extensions) that participates in LLM context.
1333 * @param customType Extension identifier for filtering on reload
1334 * @param content Message content (string or TextContent/ImageContent array)
1335 * @param display Whether to show in TUI (true = styled display, false = hidden)
1336 * @param details Optional extension-specific metadata (not sent to LLM)
1337 * @returns Entry id
1338 */
1339 appendCustomMessageEntry<T = unknown>(
1340 customType: string,
1341 content: string | (TextContent | ImageContent)[],
1342 display: boolean,
1343 details?: T,
1344 ): string {
1345 const entry: CustomMessageEntry<T> = {
1346 type: "custom_message",
1347 customType,
1348 content,
1349 display,
1350 details,
1351 id: generateId(this.byId),
1352 parentId: this.leafId,
1353 timestamp: new Date().toISOString(),
1354 };
1355 this._appendEntry(entry);
1356 return entry.id;
1357 }
1358
1359 /** Append a branch-local edit to an earlier model-visible entry. */
1360 appendContextEdit(targetId: string, replacement: ContextEditEntry["replacement"]): string {
1361 if (
1362 replacement !== null &&
1363 (typeof replacement !== "object" ||
1364 !("content" in replacement) ||
1365 (typeof replacement.content !== "string" && !Array.isArray(replacement.content)))
1366 ) {
1367 throw new Error("Context edit replacement must be null or contain string/array content");
1368 }
1369 const target = this.byId.get(targetId);
1370 if (!target) throw new Error(`Entry ${targetId} not found`);
1371 if (!this.getBranch().some((entry) => entry.id === targetId)) {
1372 throw new Error(`Entry ${targetId} is not on the active branch`);
1373 }
1374 const editable =
1375 target.type === "custom_message" ||
1376 (target.type === "message" &&
1377 (target.message.role === "user" ||
1378 target.message.role === "assistant" ||
1379 target.message.role === "toolResult"));
1380 if (!editable) throw new Error(`Entry ${targetId} does not contribute editable model content`);
1381 const targetRole = target.type === "message" ? target.message.role : "custom";
1382 const normalizedReplacement =
1383 replacement !== null &&
1384 (targetRole === "assistant" || targetRole === "toolResult") &&
1385 typeof replacement.content === "string"
1386 ? { content: [{ type: "text" as const, text: replacement.content }] }
1387 : replacement;
1388 const entry: ContextEditEntry = {
1389 type: "context_edit",
1390 id: generateId(this.byId),
1391 parentId: this.leafId,
1392 timestamp: new Date().toISOString(),
1393 targetId,
1394 replacement: normalizedReplacement,
1395 };
1396 this._appendEntry(entry);
1397 return entry.id;
1398 }
1399
1400 // =========================================================================
1401 // Tree Traversal
1402 // =========================================================================
1403
1404 getLeafId(): string | null {
1405 return this.leafId;
1406 }
1407
1408 getLeafEntry(): SessionEntry | undefined {
1409 return this.leafId ? this.byId.get(this.leafId) : undefined;
1410 }
1411
1412 getEntry(id: string): SessionEntry | undefined {
1413 return this.byId.get(id);
1414 }
1415
1416 /**
1417 * Get all direct children of an entry.
1418 */
1419 getChildren(parentId: string): SessionEntry[] {
1420 const children: SessionEntry[] = [];
1421 for (const entry of this.byId.values()) {
1422 if (entry.parentId === parentId) {
1423 children.push(entry);
1424 }
1425 }
1426 return children;
1427 }
1428
1429 /**
1430 * Get the label for an entry, if any.
1431 */
1432 getLabel(id: string): string | undefined {
1433 return this.labelsById.get(id);
1434 }
1435
1436 /**
1437 * Set or clear a label on an entry.
1438 * Labels are user-defined markers for bookmarking/navigation.
1439 * Pass undefined or empty string to clear the label.
1440 */
1441 appendLabelChange(targetId: string, label: string | undefined): string {
1442 if (!this.byId.has(targetId)) {
1443 throw new Error(`Entry ${targetId} not found`);
1444 }
1445 const entry: LabelEntry = {
1446 type: "label",
1447 id: generateId(this.byId),
1448 parentId: this.leafId,
1449 timestamp: new Date().toISOString(),
1450 targetId,
1451 label,
1452 };
1453 this._appendEntry(entry);
1454 if (label) {
1455 this.labelsById.set(targetId, label);
1456 this.labelTimestampsById.set(targetId, entry.timestamp);
1457 } else {
1458 this.labelsById.delete(targetId);
1459 this.labelTimestampsById.delete(targetId);
1460 }
1461 return entry.id;
1462 }
1463
1464 /**
1465 * Walk from entry to root, returning all entries in path order.
1466 * Includes all entry types (messages, compaction, model changes, etc.).
1467 * Use buildSessionContext() to get the resolved messages for the LLM.
1468 */
1469 getBranch(fromId?: string): SessionEntry[] {
1470 const path: SessionEntry[] = [];
1471 const startId = fromId ?? this.leafId;
1472 let current = startId ? this.byId.get(startId) : undefined;
1473 while (current) {
1474 path.push(current);
1475 current = current.parentId ? this.byId.get(current.parentId) : undefined;
1476 }
1477 path.reverse();
1478 return path;
1479 }
1480
1481 /**
1482 * Build the active, compaction-aware entry list for context/rendering.
1483 * Uses tree traversal from current leaf.
1484 */
1485 buildContextEntries(): SessionEntry[] {
1486 return buildContextEntries(this.getEntries(), this.leafId, this.byId);
1487 }
1488
1489 /**
1490 * Build the session context (what gets sent to the LLM).
1491 * Uses tree traversal from current leaf.
1492 */
1493 buildSessionProjection(): SessionProjection {
1494 return buildSessionProjection(this.getEntries(), this.leafId, this.byId);
1495 }
1496
1497 buildSessionContext(): SessionContext {
1498 const { messages, thinkingLevel, model } = this.buildSessionProjection();
1499 return { messages, thinkingLevel, model };
1500 }
1501
1502 /**
1503 * Get session header.
1504 */
1505 getHeader(): SessionHeader | null {
1506 const h = this.fileEntries.find((e) => e.type === "session");
1507 return h ? (h as SessionHeader) : null;
1508 }
1509
1510 /** Number of session entries (excludes header), without copying them like `getEntries()`. */
1511 getEntryCount(): number {
1512 return this.byId.size;
1513 }
1514
1515 /**
1516 * Get all session entries (excludes header). Returns a shallow copy.
1517 * The session is append-only: use appendXXX() to add entries, branch() to
1518 * change the leaf pointer. Entries cannot be modified or deleted.
1519 */
1520 getEntries(): SessionEntry[] {
1521 return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session");
1522 }
1523
1524 /**
1525 * Get the session as a tree structure. Returns a shallow defensive copy of all entries.
1526 * A well-formed session has exactly one root (first entry with parentId === null).
1527 * Orphaned entries (broken parent chain) are also returned as roots.
1528 */
1529 getTree(): SessionTreeNode[] {
1530 const entries = this.getEntries();
1531 const nodeMap = new Map<string, SessionTreeNode>();
1532 const roots: SessionTreeNode[] = [];
1533
1534 // Create nodes with resolved labels
1535 for (const entry of entries) {
1536 const label = this.labelsById.get(entry.id);
1537 const labelTimestamp = this.labelTimestampsById.get(entry.id);
1538 nodeMap.set(entry.id, { entry, children: [], label, labelTimestamp });
1539 }
1540
1541 // Build tree
1542 for (const entry of entries) {
1543 const node = nodeMap.get(entry.id)!;
1544 if (entry.parentId === null || entry.parentId === entry.id) {
1545 roots.push(node);
1546 } else {
1547 const parent = nodeMap.get(entry.parentId);
1548 if (parent) {
1549 parent.children.push(node);
1550 } else {
1551 // Orphan - treat as root
1552 roots.push(node);
1553 }
1554 }
1555 }
1556
1557 // Sort children by timestamp (oldest first, newest at bottom)
1558 // Use iterative approach to avoid stack overflow on deep trees
1559 const stack: SessionTreeNode[] = [...roots];
1560 while (stack.length > 0) {
1561 const node = stack.pop()!;
1562 node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
1563 stack.push(...node.children);
1564 }
1565
1566 return roots;
1567 }
1568
1569 // =========================================================================
1570 // Branching
1571 // =========================================================================
1572
1573 /**
1574 * Start a new branch from an earlier entry.
1575 * Moves the leaf pointer to the specified entry. The next appendXXX() call
1576 * will create a child of that entry, forming a new branch. Existing entries
1577 * are not modified or deleted.
1578 */
1579 branch(branchFromId: string): void {
1580 if (!this.byId.has(branchFromId)) {
1581 throw new Error(`Entry ${branchFromId} not found`);
1582 }
1583 this.leafId = branchFromId;
1584 }
1585
1586 /**
1587 * Reset the leaf pointer to null (before any entries).
1588 * The next appendXXX() call will create a new root entry (parentId = null).
1589 * Use this when navigating to re-edit the first user message.
1590 */
1591 resetLeaf(): void {
1592 this.leafId = null;
1593 }
1594
1595 /**
1596 * Start a new branch with a summary of the abandoned path.
1597 * Same as branch(), but also appends a branch_summary entry that captures
1598 * context from the abandoned conversation path.
1599 */
1600 branchWithSummary(
1601 branchFromId: string | null,
1602 summary: string,
1603 details?: unknown,
1604 fromHook?: boolean,
1605 usage?: Usage,
1606 ): string {
1607 if (branchFromId !== null && !this.byId.has(branchFromId)) {
1608 throw new Error(`Entry ${branchFromId} not found`);
1609 }
1610 const fromId = this.leafId ?? "root";
1611 this.leafId = branchFromId;
1612 const entry: BranchSummaryEntry = {
1613 type: "branch_summary",
1614 id: generateId(this.byId),
1615 parentId: branchFromId,
1616 timestamp: new Date().toISOString(),
1617 fromId,
1618 summary,
1619 details,
1620 usage,
1621 fromHook,
1622 };
1623 this._appendEntry(entry);
1624 return entry.id;
1625 }
1626
1627 /**
1628 * Create a new session file containing only the path from root to the specified leaf.
1629 * Useful for extracting a single conversation path from a branched session.
1630 * Returns the new session file path, or undefined if not persisting.
1631 */
1632 createBranchedSession(leafId: string): string | undefined {
1633 const previousSessionFile = this.sessionFile;
1634 const path = this.getBranch(leafId);
1635 if (path.length === 0) {
1636 throw new Error(`Entry ${leafId} not found`);
1637 }
1638
1639 // Filter out LabelEntry from path - we'll recreate them from the resolved map.
1640 // Because labels are real tree entries, later entries can be children of labels;
1641 // removing labels requires re-chaining the retained path to avoid orphaned subtrees.
1642 const pathWithoutLabels: SessionEntry[] = [];
1643 const replacementByLabelId = new Map<string, string>();
1644 const pendingLabelIds: string[] = [];
1645 let pathParentId: string | null = null;
1646 for (const entry of path) {
1647 if (entry.type === "label") {
1648 pendingLabelIds.push(entry.id);
1649 continue;
1650 }
1651 for (const labelId of pendingLabelIds) {
1652 replacementByLabelId.set(labelId, entry.id);
1653 }
1654 pendingLabelIds.length = 0;
1655 pathWithoutLabels.push(
1656 entry.type === "compaction"
1657 ? {
1658 ...entry,
1659 parentId: pathParentId,
1660 firstKeptEntryId:
1661 entry.firstKeptEntryId === entry.id
1662 ? entry.id
1663 : (replacementByLabelId.get(entry.firstKeptEntryId) ?? entry.firstKeptEntryId),
1664 }
1665 : { ...entry, parentId: pathParentId },
1666 );
1667 pathParentId = entry.id;
1668 }
1669
1670 const newSessionId = createSessionId();
1671 const timestamp = new Date().toISOString();
1672 const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1673 const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
1674
1675 const header: SessionHeader = {
1676 type: "session",
1677 version: CURRENT_SESSION_VERSION,
1678 id: newSessionId,
1679 timestamp,
1680 cwd: this.cwd,
1681 parentSession: this.persist ? previousSessionFile : undefined,
1682 };
1683
1684 // Collect labels for entries in the path
1685 const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));
1686 const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = [];
1687 for (const [targetId, label] of this.labelsById) {
1688 if (pathEntryIds.has(targetId)) {
1689 labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! });
1690 }
1691 }
1692
1693 if (this.persist) {
1694 // Build label entries
1695 const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1696 let parentId = lastEntryId;
1697 const labelEntries: LabelEntry[] = [];
1698 for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
1699 const labelEntry: LabelEntry = {
1700 type: "label",
1701 id: generateId(new Set(pathEntryIds)),
1702 parentId,
1703 timestamp: labelTimestamp,
1704 targetId,
1705 label,
1706 };
1707 pathEntryIds.add(labelEntry.id);
1708 labelEntries.push(labelEntry);
1709 parentId = labelEntry.id;
1710 }
1711
1712 this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1713 this.sessionId = newSessionId;
1714 this.sessionFile = newSessionFile;
1715 this._buildIndex();
1716
1717 // Use the same rule as _persist(): write now if the branched path already
1718 // has a conversation, otherwise let _persist() create the file later.
1719 if (this._hasConversation()) {
1720 this._rewriteFile();
1721 this.flushed = true;
1722 } else {
1723 this.flushed = false;
1724 }
1725
1726 return newSessionFile;
1727 }
1728
1729 // In-memory mode: replace current session with the path + labels
1730 const labelEntries: LabelEntry[] = [];
1731 let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1732 for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
1733 const labelEntry: LabelEntry = {
1734 type: "label",
1735 id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
1736 parentId,
1737 timestamp: labelTimestamp,
1738 targetId,
1739 label,
1740 };
1741 labelEntries.push(labelEntry);
1742 parentId = labelEntry.id;
1743 }
1744 this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1745 this.sessionId = newSessionId;
1746 this._buildIndex();
1747 return undefined;
1748 }
1749
1750 /**
1751 * Create a new session.
1752 * @param cwd Working directory (stored in session header)
1753 * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1754 */
1755 static create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager {
1756 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1757 return new SessionManager(cwd, dir, undefined, true, options);
1758 }
1759
1760 /**
1761 * Open a specific session file.
1762 * @param path Path to session file
1763 * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
1764 * @param cwdOverride Optional cwd override instead of the session header cwd.
1765 */
1766 static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
1767 const resolvedPath = resolvePath(path);
1768 let header: SessionHeader | null = null;
1769 let preloadedFileEntries: FileEntry[] | undefined;
1770 if (cwdOverride === undefined && existsSync(resolvedPath)) {
1771 try {
1772 header = readSessionHeader(resolvedPath);
1773 } catch (error) {
1774 if (!(error instanceof SessionHeaderScanLimitError)) throw error;
1775 // The bounded scan is only a discovery optimization. A full load remains
1776 // authoritative for legacy files with very large headers or prefixes.
1777 preloadedFileEntries = loadEntriesFromFile(resolvedPath);
1778 const firstEntry = preloadedFileEntries[0];
1779 header = firstEntry?.type === "session" ? firstEntry : null;
1780 }
1781 }
1782 const cwd = cwdOverride ?? (header ? getSessionHeaderCwd(header) : undefined) ?? process.cwd();
1783 // If no sessionDir provided, derive from file's parent directory
1784 const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");
1785 return new SessionManager(cwd, dir, resolvedPath, true, undefined, preloadedFileEntries);
1786 }
1787
1788 /**
1789 * Continue the most recent session, or create new if none.
1790 * @param cwd Working directory
1791 * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1792 */
1793 static continueRecent(cwd: string, sessionDir?: string): SessionManager {
1794 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1795 const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
1796 const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);
1797 if (mostRecent) {
1798 return new SessionManager(cwd, dir, mostRecent, true);
1799 }
1800 return new SessionManager(cwd, dir, undefined, true);
1801 }
1802
1803 /** Create an in-memory session (no file persistence), optionally from entries held outside the filesystem. */
1804 static inMemory(cwd: string = process.cwd(), options?: NewSessionOptions, entries?: FileEntry[]): SessionManager {
1805 return new SessionManager(cwd, "", undefined, false, options, entries);
1806 }
1807
1808 /**
1809 * Fork a session from another project directory into the current project.
1810 * Creates a new session in the target cwd with the full history from the source session.
1811 * @param sourcePath Path to the source session file
1812 * @param targetCwd Target working directory (where the new session will be stored)
1813 * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
1814 */
1815 static forkFrom(
1816 sourcePath: string,
1817 targetCwd: string,
1818 sessionDir?: string,
1819 options?: NewSessionOptions,
1820 ): SessionManager {
1821 const resolvedSourcePath = resolvePath(sourcePath);
1822 const resolvedTargetCwd = resolvePath(targetCwd);
1823 const sourceEntries = loadEntriesFromFile(resolvedSourcePath);
1824 if (sourceEntries.length === 0) {
1825 throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);
1826 }
1827
1828 const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined;
1829 if (!sourceHeader) {
1830 throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
1831 }
1832
1833 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
1834 if (!existsSync(dir)) {
1835 mkdirSync(dir, { recursive: true });
1836 }
1837
1838 // Create new session file with new ID but forked content
1839 if (options?.id !== undefined) {
1840 assertValidSessionId(options.id);
1841 }
1842 const newSessionId = options?.id ?? createSessionId();
1843 const timestamp = new Date().toISOString();
1844 const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1845 const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
1846
1847 // Write new header pointing to source as parent, with updated cwd
1848 const newHeader: SessionHeader = {
1849 type: "session",
1850 version: CURRENT_SESSION_VERSION,
1851 id: newSessionId,
1852 timestamp,
1853 cwd: resolvedTargetCwd,
1854 parentSession: resolvedSourcePath,
1855 };
1856 writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });
1857
1858 // Copy all non-header entries from source
1859 for (const entry of sourceEntries) {
1860 if (entry.type !== "session") {
1861 appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
1862 }
1863 }
1864
1865 return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
1866 }
1867
1868 /**
1869 * Find an exact session ID without loading transcript bodies.
1870 * @param cwd Working directory (used to compute default session directory)
1871 * @param id Exact session ID
1872 * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1873 */
1874 static findById(cwd: string, id: string, sessionDir?: string): string | undefined {
1875 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1876 const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
1877 const resolvedCwd = resolvePath(cwd);
1878
1879 try {
1880 for (const file of readdirSync(dir)) {
1881 if (!file.endsWith(".jsonl")) continue;
1882 const path = join(dir, file);
1883 const header = readSessionHeaderForDiscovery(path);
1884 if (header?.id !== id) continue;
1885 if (filterCwd && !sessionCwdMatches(getSessionHeaderCwd(header), resolvedCwd)) continue;
1886 return path;
1887 }
1888 } catch {
1889 // Exact session discovery is best-effort, matching list().
1890 }
1891 return undefined;
1892 }
1893
1894 /**
1895 * List all sessions for a directory.
1896 * @param cwd Working directory (used to compute default session directory)
1897 * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1898 * @param onProgress Optional callback for progress updates (loaded, total)
1899 */
1900 static async list(
1901 cwd: string,
1902 sessionDir?: string,
1903 onProgress?: SessionListProgress,
1904 signal?: AbortSignal,
1905 ): Promise<SessionInfo[]> {
1906 const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1907 const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
1908 const resolvedCwd = resolvePath(cwd);
1909 const includeSession = (session: SessionInfo) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd);
1910 const progress: SessionListProgress | undefined = onProgress
1911 ? (loaded, total, partialSessions) => onProgress(loaded, total, partialSessions?.filter(includeSession))
1912 : undefined;
1913 const sessions = (await listSessionsFromDir(dir, progress, signal)).filter(includeSession);
1914 return sortSessionInfos(sessions);
1915 }
1916
1917 /**
1918 * List all sessions across all project directories.
1919 * @param onProgress Optional callback for progress updates (loaded, total)
1920 */
1921 static async listAll(onProgress?: SessionListProgress, signal?: AbortSignal): Promise<SessionInfo[]>;
1922 static async listAll(
1923 sessionDir?: string,
1924 onProgress?: SessionListProgress,
1925 signal?: AbortSignal,
1926 ): Promise<SessionInfo[]>;
1927 static async listAll(
1928 sessionDirOrOnProgress?: string | SessionListProgress,
1929 onProgressOrSignal?: SessionListProgress | AbortSignal,
1930 signal?: AbortSignal,
1931 ): Promise<SessionInfo[]> {
1932 const customSessionDir =
1933 typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined;
1934 const progress =
1935 typeof sessionDirOrOnProgress === "function"
1936 ? sessionDirOrOnProgress
1937 : typeof onProgressOrSignal === "function"
1938 ? onProgressOrSignal
1939 : undefined;
1940 const abortSignal =
1941 typeof sessionDirOrOnProgress === "string" || typeof onProgressOrSignal === "function"
1942 ? signal
1943 : (onProgressOrSignal ?? signal);
1944 abortSignal?.throwIfAborted();
1945 if (customSessionDir) {
1946 return sortSessionInfos(await listSessionsFromDir(customSessionDir, progress, abortSignal));
1947 }
1948
1949 const sessionsDir = getSessionsDir();
1950
1951 try {
1952 if (!existsSync(sessionsDir)) return [];
1953 const entries = await readdir(sessionsDir, { withFileTypes: true });
1954 const dirs = entries
1955 .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
1956 .map((entry) => join(sessionsDir, entry.name));
1957
1958 const dirFiles = await mapWithConcurrency(
1959 dirs,
1960 MAX_CONCURRENT_SESSION_DISCOVERY_LOADS,
1961 async (dir) => {
1962 try {
1963 return (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));
1964 } catch {
1965 return [];
1966 }
1967 },
1968 abortSignal,
1969 );
1970 const allFiles = dirFiles.flat();
1971 const candidates = await mapWithConcurrency(
1972 allFiles,
1973 MAX_CONCURRENT_SESSION_DISCOVERY_LOADS,
1974 async (path): Promise<SessionFileCandidate> => {
1975 try {
1976 return { path, stats: await stat(path) };
1977 } catch {
1978 return { path };
1979 }
1980 },
1981 abortSignal,
1982 );
1983 candidates.sort(
1984 (a, b) =>
1985 (b.stats?.mtimeMs ?? Number.NEGATIVE_INFINITY) - (a.stats?.mtimeMs ?? Number.NEGATIVE_INFINITY) ||
1986 basename(b.path).localeCompare(basename(a.path)),
1987 );
1988
1989 const totalFiles = candidates.length;
1990 let loaded = 0;
1991 let firstCandidateLoaded = false;
1992 const partialSessions: SessionInfo[] = [];
1993 const results = await buildSessionInfosWithConcurrency(
1994 candidates,
1995 (info, index) => {
1996 loaded++;
1997 if (index === 0) firstCandidateLoaded = true;
1998 if (info) partialSessions.push(info);
1999 const publishPartial =
2000 firstCandidateLoaded &&
2001 (index === 0 || loaded % ALL_SESSION_LIST_PUBLISH_INTERVAL === 0 || loaded === totalFiles);
2002 progress?.(loaded, totalFiles, publishPartial ? sortSessionInfos([...partialSessions]) : undefined);
2003 },
2004 abortSignal,
2005 );
2006
2007 return sortSessionInfos(results.filter((info): info is SessionInfo => info !== null));
2008 } catch {
2009 abortSignal?.throwIfAborted();
2010 return [];
2011 }
2012 }
2013}