import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type {
ImageContent,
Message,
MessageAttribution,
ProviderPayload,
ServiceTier,
TextContent,
Usage,
} from "@oh-my-pi/pi-ai";
import { getTerminalId } from "@oh-my-pi/pi-tui";
import {
getBlobsDir,
getAgentDir as getDefaultAgentDir,
getProjectDir,
getSessionsDir,
getTerminalSessionsDir,
hasFsCode,
isEnoent,
logger,
parseJsonlLenient,
pathIsWithin,
resolveEquivalentPath,
Snowflake,
toError,
} from "@oh-my-pi/pi-utils";
import { ArtifactManager } from "./artifacts";
import {
type BlobPutResult,
BlobStore,
externalizeImageData,
externalizeImageDataSync,
externalizeImageDataUrl,
externalizeImageDataUrlSync,
isBlobRef,
isImageDataUrl,
resolveImageData,
resolveImageDataUrl,
} from "./blob-store";
import {
type BashExecutionMessage,
type CustomMessage,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
type FileMentionMessage,
type HookMessage,
type PythonExecutionMessage,
sanitizeRehydratedOpenAIResponsesAssistantMessage,
stripInternalDetailsFields,
} from "./messages";
import type { SessionStorage, SessionStorageWriter } from "./session-storage";
import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
export const CURRENT_SESSION_VERSION = 3;
export interface SessionHeader {
type: "session";
version?: number;
id: string;
title?: string;
titleSource?: "auto" | "user";
timestamp: string;
cwd: string;
parentSession?: string;
}
export interface NewSessionOptions {
parentSession?: string;
drop?: boolean;
}
export interface SessionEntryBase {
type: string;
id: string;
parentId: string | null;
timestamp: string;
}
export interface SessionMessageEntry extends SessionEntryBase {
type: "message";
message: AgentMessage;
}
export interface ThinkingLevelChangeEntry extends SessionEntryBase {
type: "thinking_level_change";
thinkingLevel?: string | null;
}
export interface ModelChangeEntry extends SessionEntryBase {
type: "model_change";
model: string;
role?: string;
}
export interface ServiceTierChangeEntry extends SessionEntryBase {
type: "service_tier_change";
serviceTier: ServiceTier | null;
}
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
type: "compaction";
summary: string;
shortSummary?: string;
firstKeptEntryId: string;
tokensBefore: number;
details?: T;
preserveData?: Record<string, unknown>;
fromExtension?: boolean;
}
export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
type: "branch_summary";
fromId: string;
summary: string;
details?: T;
fromExtension?: boolean;
}
* Custom entry for extensions to store extension-specific data in the session.
* Use customType to identify your extension's entries.
*
* Purpose: Persist extension state across session reloads. On reload, extensions can
* scan entries for their customType and reconstruct internal state.
*
* Does NOT participate in LLM context (ignored by buildSessionContext).
* For injecting content into context, see CustomMessageEntry.
*/
export interface CustomEntry<T = unknown> extends SessionEntryBase {
type: "custom";
customType: string;
data?: T;
}
export interface LabelEntry extends SessionEntryBase {
type: "label";
targetId: string;
label: string | undefined;
}
export interface TtsrInjectionEntry extends SessionEntryBase {
type: "ttsr_injection";
injectedRules: string[];
}
export interface MCPToolSelectionEntry extends SessionEntryBase {
type: "mcp_tool_selection";
selectedToolNames: string[];
}
export interface SessionInitEntry extends SessionEntryBase {
type: "session_init";
systemPrompt: string;
task: string;
tools: string[];
outputSchema?: unknown;
}
export interface ModeChangeEntry extends SessionEntryBase {
type: "mode_change";
mode: string;
data?: Record<string, unknown>;
}
* Custom message entry for extensions to inject messages into LLM context.
* Use customType to identify your extension's entries.
*
* Unlike CustomEntry, this DOES participate in LLM context.
* The content participates in LLM context through convertToLlm().
* Use details for extension-specific metadata (not sent to LLM).
*
* display controls TUI rendering:
* - false: hidden entirely
* - true: rendered with distinct styling (different from user messages)
*/
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
type: "custom_message";
customType: string;
content: string | (TextContent | ImageContent)[];
details?: T;
display: boolean;
attribution?: MessageAttribution;
}
export type SessionEntry =
| SessionMessageEntry
| ThinkingLevelChangeEntry
| ModelChangeEntry
| ServiceTierChangeEntry
| CompactionEntry
| BranchSummaryEntry
| CustomEntry
| CustomMessageEntry
| LabelEntry
| TtsrInjectionEntry
| MCPToolSelectionEntry
| SessionInitEntry
| ModeChangeEntry;
export type FileEntry = SessionHeader | SessionEntry;
export interface SessionTreeNode {
entry: SessionEntry;
children: SessionTreeNode[];
label?: string;
}
export interface SessionContext {
messages: AgentMessage[];
thinkingLevel?: string;
serviceTier?: ServiceTier;
models: Record<string, string>;
injectedTtsrRules: string[];
selectedMCPToolNames: string[];
hasPersistedMCPToolSelection: boolean;
mode: string;
modeData?: Record<string, unknown>;
}
export interface SessionInfo {
path: string;
id: string;
cwd: string;
title?: string;
parentSessionPath?: string;
created: Date;
modified: Date;
messageCount: number;
size: number;
firstMessage: string;
allMessagesText: string;
}
export type ReadonlySessionManager = Pick<
SessionManager,
| "getCwd"
| "getSessionDir"
| "getSessionId"
| "getSessionFile"
| "getSessionName"
| "getArtifactsDir"
| "getArtifactManager"
| "allocateArtifactPath"
| "saveArtifact"
| "getArtifactPath"
| "getLeafId"
| "getLeafEntry"
| "getEntry"
| "getLabel"
| "getBranch"
| "getHeader"
| "getEntries"
| "getTree"
| "getUsageStatistics"
| "putBlob"
>;
function createSessionId(): string {
return Bun.randomUUIDv7();
}
function generateId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = crypto.randomUUID().slice(-8);
if (!byId.has(id)) return id;
}
return Snowflake.next();
}
function migrateV1ToV2(entries: FileEntry[]): void {
const ids = new Set<string>();
let prevId: string | null = null;
for (const entry of entries) {
if (entry.type === "session") {
entry.version = 2;
continue;
}
entry.id = generateId(ids);
entry.parentId = prevId;
prevId = entry.id;
if (entry.type === "compaction") {
const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
if (typeof comp.firstKeptEntryIndex === "number") {
const targetEntry = entries[comp.firstKeptEntryIndex];
if (targetEntry && targetEntry.type !== "session") {
comp.firstKeptEntryId = targetEntry.id;
}
delete comp.firstKeptEntryIndex;
}
}
}
}
function migrateV2ToV3(entries: FileEntry[]): void {
for (const entry of entries) {
if (entry.type === "session") {
entry.version = 3;
continue;
}
if (entry.type === "message") {
const msg = entry.message as { role?: string };
if (msg.role === "hookMessage") {
(entry.message as { role: string }).role = "custom";
}
}
}
}
* Run all necessary migrations to bring entries to current version.
* Mutates entries in place. Returns true if any migration was applied.
*/
function migrateToCurrentVersion(entries: FileEntry[]): boolean {
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
const version = header?.version ?? 1;
if (version >= CURRENT_SESSION_VERSION) return false;
if (version < 2) migrateV1ToV2(entries);
if (version < 3) migrateV2ToV3(entries);
return true;
}
export function migrateSessionEntries(entries: FileEntry[]): void {
migrateToCurrentVersion(entries);
}
const migratedSessionRoots = new Set<string>();
* Merge or rename a legacy session directory into its canonical target.
* Best effort: callers decide whether migration failures should surface.
*/
function migrateSessionDirPath(oldPath: string, newPath: string): void {
const existing = fs.statSync(newPath, { throwIfNoEntry: false });
if (existing?.isDirectory()) {
for (const file of fs.readdirSync(oldPath)) {
const src = path.join(oldPath, file);
const dst = path.join(newPath, file);
if (!fs.existsSync(dst)) {
fs.renameSync(src, dst);
}
}
fs.rmSync(oldPath, { recursive: true, force: true });
return;
}
if (existing) {
fs.rmSync(newPath, { recursive: true, force: true });
}
fs.renameSync(oldPath, newPath);
}
function encodeLegacyAbsoluteSessionDirName(cwd: string): string {
const resolvedCwd = path.resolve(cwd);
return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
function encodeRelativeSessionDirName(prefix: string, root: string, cwd: string): string {
const relative = path.relative(root, cwd).replace(/[/\\:]/g, "-");
return relative ? (prefix.endsWith("-") ? `${prefix}${relative}` : `${prefix}-${relative}`) : prefix;
}
function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } {
const resolvedCwd = path.resolve(cwd);
const canonicalCwd = resolveEquivalentPath(resolvedCwd);
const home = resolveEquivalentPath(os.homedir());
const tempRoot = resolveEquivalentPath(os.tmpdir());
const encodedDirName = pathIsWithin(home, canonicalCwd)
? encodeRelativeSessionDirName("-", home, canonicalCwd)
: pathIsWithin(tempRoot, canonicalCwd)
? encodeRelativeSessionDirName("-tmp", tempRoot, canonicalCwd)
: encodeLegacyAbsoluteSessionDirName(canonicalCwd);
return { encodedDirName, resolvedCwd };
}
* Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
* Runs once per sessions root on first access, best-effort.
*/
function migrateHomeSessionDirs(sessionsRoot: string): void {
if (migratedSessionRoots.has(sessionsRoot)) return;
migratedSessionRoots.add(sessionsRoot);
const home = os.homedir();
const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
const oldPrefix = `--${homeEncoded}-`;
const oldExact = `--${homeEncoded}--`;
let entries: string[];
try {
entries = fs.readdirSync(sessionsRoot);
} catch {
return;
}
for (const entry of entries) {
let remainder: string;
if (entry === oldExact) {
remainder = "";
} else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
remainder = entry.slice(oldPrefix.length, -2);
} else {
continue;
}
const newName = remainder ? `-${remainder}` : "-";
const oldPath = path.join(sessionsRoot, entry);
const newPath = path.join(sessionsRoot, newName);
try {
migrateSessionDirPath(oldPath, newPath);
} catch {
}
}
}
function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void {
const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd));
if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return;
try {
migrateSessionDirPath(legacyDir, sessionDir);
} catch {
}
}
function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined {
const currentDirName = path.basename(sessionDir);
const { encodedDirName } = getDefaultSessionDirName(cwd);
if (currentDirName !== encodedDirName && currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd)) {
return undefined;
}
return path.dirname(sessionDir);
}
export function parseSessionEntries(content: string): FileEntry[] {
return parseJsonlLenient<FileEntry>(content);
}
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
return entries[i] as CompactionEntry;
}
}
return null;
}
* Build the session context from entries using tree traversal.
* If leafId is provided, walks from that entry to root.
* Handles compaction and branch summaries along the path.
*/
export function buildSessionContext(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>,
): SessionContext {
if (!byId) {
byId = new Map<string, SessionEntry>();
for (const entry of entries) {
byId.set(entry.id, entry);
}
}
let leaf: SessionEntry | undefined;
if (leafId === null) {
return {
messages: [],
thinkingLevel: "off",
serviceTier: undefined,
models: {},
injectedTtsrRules: [],
selectedMCPToolNames: [],
hasPersistedMCPToolSelection: false,
mode: "none",
};
}
if (leafId) {
leaf = byId.get(leafId);
}
if (!leaf) {
leaf = entries[entries.length - 1];
}
if (!leaf) {
return {
messages: [],
thinkingLevel: "off",
serviceTier: undefined,
models: {},
injectedTtsrRules: [],
selectedMCPToolNames: [],
hasPersistedMCPToolSelection: false,
mode: "none",
};
}
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
let thinkingLevel: string | undefined = "off";
let serviceTier: ServiceTier | undefined;
const models: Record<string, string> = {};
let compaction: CompactionEntry | null = null;
const injectedTtsrRulesSet = new Set<string>();
let selectedMCPToolNames: string[] = [];
let hasPersistedMCPToolSelection = false;
let mode = "none";
let modeData: Record<string, unknown> | undefined;
let hasExplicitDefaultModel = false;
for (const entry of path) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel ?? "off";
} else if (entry.type === "model_change") {
if (entry.model) {
const role = entry.role ?? "default";
models[role] = entry.model;
if (role === "default") {
hasExplicitDefaultModel = true;
}
}
} else if (entry.type === "service_tier_change") {
serviceTier = entry.serviceTier ?? undefined;
} else if (entry.type === "message" && entry.message.role === "assistant") {
if (!hasExplicitDefaultModel) {
models.default = `${entry.message.provider}/${entry.message.model}`;
}
} else if (entry.type === "compaction") {
compaction = entry;
} else if (entry.type === "ttsr_injection") {
for (const ruleName of entry.injectedRules) {
injectedTtsrRulesSet.add(ruleName);
}
} else if (entry.type === "mcp_tool_selection") {
selectedMCPToolNames = [...entry.selectedToolNames];
hasPersistedMCPToolSelection = true;
} else if (entry.type === "mode_change") {
mode = entry.mode;
modeData = entry.data;
}
}
const injectedTtsrRules = Array.from(injectedTtsrRulesSet);
const messages: AgentMessage[] = [];
const appendMessage = (entry: SessionEntry) => {
if (entry.type === "message") {
messages.push(entry.message);
} else if (entry.type === "custom_message") {
messages.push(
createCustomMessage(
entry.customType,
entry.content,
entry.display,
entry.details,
entry.timestamp,
entry.attribution,
),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
}
};
if (compaction) {
const providerPayload: ProviderPayload | undefined = (() => {
const candidate = compaction.preserveData?.openaiRemoteCompaction;
if (!candidate || typeof candidate !== "object") return undefined;
const remote = candidate as { provider?: unknown; replacementHistory?: unknown };
if (typeof remote.provider !== "string" || remote.provider.length === 0) return undefined;
if (!Array.isArray(remote.replacementHistory)) return undefined;
return {
type: "openaiResponsesHistory",
provider: remote.provider,
items: remote.replacementHistory as Array<Record<string, unknown>>,
};
})();
const remoteReplacementHistory = providerPayload?.items;
messages.push(
createCompactionSummaryMessage(
compaction.summary,
compaction.tokensBefore,
compaction.timestamp,
compaction.shortSummary,
providerPayload,
),
);
const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
if (!remoteReplacementHistory) {
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
appendMessage(entry);
}
}
}
for (let i = compactionIdx + 1; i < path.length; i++) {
const entry = path[i];
appendMessage(entry);
}
} else {
for (const entry of path) {
appendMessage(entry);
}
}
const pairedToolResultIds = new Set<string>();
for (const message of messages) {
if (message.role === "toolResult") pairedToolResultIds.add(message.toolCallId);
}
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role !== "assistant") continue;
const hasDangling = message.content.some(
block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
);
if (!hasDangling) continue;
const normalized = message.content
.filter(
block =>
!(block.type === "toolCall" && !pairedToolResultIds.has(block.id)) && block.type !== "redactedThinking",
)
.map(block =>
block.type === "thinking" && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block,
);
if (normalized.length === 0) {
messages.splice(i, 1);
} else {
messages[i] = { ...message, content: normalized };
}
}
return {
messages,
thinkingLevel,
serviceTier,
models,
injectedTtsrRules,
selectedMCPToolNames,
hasPersistedMCPToolSelection,
mode,
modeData,
};
}
* Compute the default session directory for a cwd.
* Classifies cwd by canonical location so symlink/alias paths resolve to the
* same home-relative or temp-root directory names as their real targets.
*/
function computeDefaultSessionDir(
cwd: string,
storage: SessionStorage,
sessionsRoot: string = getSessionsDir(),
): string {
const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
migrateHomeSessionDirs(sessionsRoot);
const sessionDir = path.join(sessionsRoot, encodedDirName);
migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot);
storage.ensureDirSync(sessionDir);
return sessionDir;
}
* Write a breadcrumb linking the current terminal to a session file.
* The breadcrumb contains the cwd and session path so --continue can
* find "this terminal's last session" even when running concurrent instances.
*/
function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
const terminalId = getTerminalId();
if (!terminalId) return;
const breadcrumbDir = getTerminalSessionsDir();
const breadcrumbFile = path.join(breadcrumbDir, terminalId);
const content = `${cwd}\n${sessionFile}\n`;
Bun.write(breadcrumbFile, content).catch(() => {});
}
* Read the terminal breadcrumb for the current terminal, scoped to a cwd.
* Returns the session file path if it exists and matches the cwd, null otherwise.
*/
async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
const terminalId = getTerminalId();
if (!terminalId) return null;
try {
const breadcrumbFile = path.join(getTerminalSessionsDir(), terminalId);
const content = await Bun.file(breadcrumbFile).text();
const lines = content.trim().split("\n");
if (lines.length < 2) return null;
const breadcrumbCwd = lines[0];
const sessionFile = lines[1];
if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null;
const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
if (stat?.isFile()) return sessionFile;
} catch (err) {
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
}
return null;
}
export async function loadEntriesFromFile(
filePath: string,
storage: SessionStorage = new FileSessionStorage(),
): Promise<FileEntry[]> {
let content: string;
try {
content = await storage.readText(filePath);
} catch (err) {
if (isEnoent(err)) return [];
throw err;
}
const entries = parseJsonlLenient<FileEntry>(content);
if (entries.length === 0) return entries;
const header = entries[0] as SessionHeader;
if (header.type !== "session" || typeof header.id !== "string") {
return [];
}
return entries;
}
* Resolve blob references in loaded entries, restoring both session image blocks and persisted
* provider image URLs back to the inline data expected by downstream transports. Mutates entries in place.
*/
function hasImageUrl(value: unknown): value is { image_url: string } {
return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string";
}
async function resolvePersistedImageUrlRefs(value: unknown, blobStore: BlobStore): Promise<void> {
if (Array.isArray(value)) {
await Promise.all(value.map(item => resolvePersistedImageUrlRefs(item, blobStore)));
return;
}
if (typeof value !== "object" || value === null) return;
if (hasImageUrl(value) && isBlobRef(value.image_url)) {
value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
}
await Promise.all(Object.values(value).map(item => resolvePersistedImageUrlRefs(item, blobStore)));
}
async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
const promises: Promise<void>[] = [];
for (const entry of entries) {
if (entry.type === "session") continue;
let contentArray: unknown[] | undefined;
if (entry.type === "message" && "content" in entry.message && Array.isArray(entry.message.content)) {
contentArray = entry.message.content;
} else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
contentArray = entry.content;
}
if (contentArray) {
for (const block of contentArray) {
if (isImageBlock(block) && isBlobRef(block.data)) {
promises.push(
resolveImageData(blobStore, block.data).then(resolved => {
block.data = resolved;
}),
);
}
}
}
promises.push(resolvePersistedImageUrlRefs(entry, blobStore));
}
await Promise.all(promises);
}
* Lightweight metadata for a session file, used in session picker UI.
* Uses lazy getters to defer string formatting until actually displayed.
*/
function sanitizeSessionName(value: string | undefined): string | undefined {
if (!value) return undefined;
const firstLine = value.split(/\r?\n/)[0] ?? "";
const stripped = firstLine.replace(/[\x00-\x1F\x7F]/g, "");
const trimmed = stripped.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
class RecentSessionInfo {
#fullName: string | undefined;
#timeAgo: string | undefined;
readonly #headerTimestamp: string | undefined;
constructor(
readonly path: string,
readonly mtime: number,
header: Record<string, unknown>,
firstPrompt?: string,
) {
const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
this.#fullName = sanitizeSessionName(trystr(header.title)) ?? sanitizeSessionName(firstPrompt);
this.#headerTimestamp = trystr(header.timestamp);
}
get fullName(): string {
if (this.#fullName) return this.#fullName;
const ts = this.#headerTimestamp ? Date.parse(this.#headerTimestamp) : Number.NaN;
const date = new Date(Number.isFinite(ts) ? ts : this.mtime);
const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
this.#fullName = `Untitled · ${time}`;
return this.#fullName;
}
* Display name without an arbitrary length cap. The renderer is responsible for
* width-aware truncation so adjacent fields (e.g. the relative time) stay visible.
*/
get name(): string {
return this.fullName;
}
get timeAgo(): string {
if (this.#timeAgo) return this.#timeAgo;
this.#timeAgo = formatTimeAgo(new Date(this.mtime));
return this.#timeAgo;
}
}
* Extracts the text content from a user message entry.
* Returns undefined if the entry is not a user message or has no text.
*/
function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string | undefined {
for (const entry of entries) {
if (entry.type !== "message") continue;
const message = entry.message as Record<string, unknown> | undefined;
if (message?.role !== "user") continue;
const content = message.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block === "object" && block !== null && "text" in block) {
const text = (block as { text: unknown }).text;
if (typeof text === "string") return text;
}
}
}
}
return undefined;
}
* Promote orphaned `<basename>.jsonl.<snowflake>.bak` backups created by
* `#replaceSessionFileAfterEperm` back to their primary path when the primary
* is missing. This runs once per session-dir scan, before the main `*.jsonl`
* glob, so a crash between the two renames in the EPERM-rewrite path does not
* leave the user's last good state stranded outside the loader's view.
*
* Exported for testing.
*/
export async function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void> {
let backups: string[];
try {
backups = storage.listFilesSync(sessionDir, "*.bak");
} catch {
return;
}
if (backups.length === 0) return;
const candidates = new Map<string, { backup: string; mtimeMs: number }>();
for (const backup of backups) {
const name = path.basename(backup);
if (!name.endsWith(".bak")) continue;
const trimmed = name.slice(0, -".bak".length);
const dotIdx = trimmed.lastIndexOf(".");
if (dotIdx <= 0) continue;
const primaryName = trimmed.slice(0, dotIdx);
if (!primaryName.endsWith(".jsonl")) continue;
const primaryPath = path.join(sessionDir, primaryName);
let mtimeMs = 0;
try {
mtimeMs = storage.statSync(backup).mtimeMs;
} catch {
continue;
}
const existing = candidates.get(primaryPath);
if (!existing || mtimeMs > existing.mtimeMs) {
candidates.set(primaryPath, { backup, mtimeMs });
}
}
for (const [primaryPath, { backup }] of candidates) {
if (storage.existsSync(primaryPath)) continue;
try {
await storage.rename(backup, primaryPath);
logger.warn("Recovered orphaned session backup", {
sessionFile: primaryPath,
backupPath: backup,
});
} catch (err) {
logger.warn("Failed to recover orphaned session backup", {
sessionFile: primaryPath,
backupPath: backup,
error: toError(err).message,
});
}
}
}
* Reads all session files from the directory and returns them sorted by mtime (newest first).
* Uses low-level file I/O to efficiently read only the first 4KB of each file
* to extract the JSON header and first user message without loading entire session logs into memory.
*/
async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
await recoverOrphanedBackups(sessionDir, storage);
try {
const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
const sessions: RecentSessionInfo[] = [];
await Promise.all(
files.map(async (path: string) => {
try {
const content = await storage.readTextPrefix(path, 4096);
const entries = parseJsonlLenient<Record<string, unknown>>(content);
if (entries.length === 0) return;
const header = entries[0] as Record<string, unknown>;
if (header.type !== "session" || typeof header.id !== "string") return;
const mtime = storage.statSync(path).mtimeMs;
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
} catch {}
}),
);
return sessions.sort((a, b) => b.mtime - a.mtime);
} catch {
return [];
}
}
export async function findMostRecentSession(
sessionDir: string,
storage: SessionStorage = new FileSessionStorage(),
): Promise<string | null> {
const sessions = await getSortedSessions(sessionDir, storage);
return sessions[0]?.path || null;
}
function formatTimeAgo(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
const MAX_PERSIST_CHARS = 500_000;
const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
const BLOB_EXTERNALIZE_THRESHOLD = 1024;
const TEXT_CONTENT_KEY = "content";
* Recursively truncate large strings in an object for session persistence.
* - Truncates any oversized string fields (key-agnostic)
* - Replaces oversized image blocks with text notices
* - Updates lineCount when content is truncated
* - Returns original object if no changes needed (structural sharing)
*/
function truncateString(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
let truncated = value.slice(0, maxLength);
if (truncated.length > 0) {
const last = truncated.charCodeAt(truncated.length - 1);
if (last >= 0xd800 && last <= 0xdbff) {
truncated = truncated.slice(0, -1);
}
}
return truncated;
}
function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
(value as { type?: string }).type === "image" &&
"data" in value &&
typeof (value as { data?: string }).data === "string"
);
}
async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
async function truncateForPersistence(obj: object, blobStore: BlobStore, key?: string): Promise<object>;
async function truncateForPersistence(
obj: null | undefined,
blobStore: BlobStore,
key?: string,
): Promise<null | undefined>;
async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): Promise<unknown> {
if (obj === null || obj === undefined) return obj;
if (typeof obj === "string") {
if (key === "image_url" && isImageDataUrl(obj)) {
return externalizeImageDataUrl(blobStore, obj);
}
if (obj.length > MAX_PERSIST_CHARS) {
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
return "";
}
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
}
return obj;
}
if (Array.isArray(obj)) {
let changed = false;
const result = await Promise.all(
obj.map(async item => {
if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
changed = true;
const blobRef = await externalizeImageData(blobStore, item.data);
return { ...item, data: blobRef };
}
}
const newItem = await truncateForPersistence(item, blobStore, key);
if (newItem !== item) changed = true;
return newItem;
}),
);
return changed ? result : obj;
}
if (typeof obj === "object") {
let changed = false;
const entries: Array<readonly [string, unknown]> = await Promise.all(
Object.entries(obj).flatMap(([childKey, value]) => {
if (childKey === "partialJson" || childKey === "jsonlEvents") {
changed = true;
return [];
}
return [
(async () => {
const newValue = await truncateForPersistence(value, blobStore, childKey);
if (newValue !== value) changed = true;
return [childKey, newValue] as const;
})(),
];
}),
);
if (!changed) return obj;
const contentEntry = entries.find(([childKey]) => childKey === "content");
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
if (
contentEntry &&
typeof contentEntry[1] === "string" &&
lineCountEntry &&
typeof lineCountEntry[1] === "number"
) {
const content = contentEntry[1];
const updatedEntries = entries.map(([childKey, value]) =>
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
);
return Object.fromEntries(updatedEntries);
}
return Object.fromEntries(entries);
}
return obj;
}
async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): Promise<FileEntry> {
return truncateForPersistence(entry, blobStore);
}
* Synchronous variant of {@link truncateForPersistence}.
*
* The async version's overhead — `Promise.all` over `Object.entries`/`Array.prototype.map`,
* one microtask hop per nested node — is pure waste for entries without image blobs
* (the vast majority). The fast path runs in one synchronous tick so an OOM/SIGKILL
* landing right after `_persist` returns cannot lose the entry. Image externalization
* still happens, but via the synchronous blob-store path (`fs.writeFileSync`), so the
* blob bytes are in the kernel page cache before the JSONL line referencing them is
* written.
*/
function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: string): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj === "string") {
if (key === "image_url" && isImageDataUrl(obj)) {
return externalizeImageDataUrlSync(blobStore, obj);
}
if (obj.length > MAX_PERSIST_CHARS) {
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
return "";
}
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
}
return obj;
}
if (Array.isArray(obj)) {
let changed = false;
const result: unknown[] = new Array(obj.length);
for (let i = 0; i < obj.length; i++) {
const item = obj[i];
if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
changed = true;
const blobRef = externalizeImageDataSync(blobStore, item.data);
result[i] = { ...item, data: blobRef };
continue;
}
}
const newItem = truncateForPersistenceSync(item, blobStore, key);
if (newItem !== item) changed = true;
result[i] = newItem;
}
return changed ? result : obj;
}
if (typeof obj === "object") {
let changed = false;
const entries: Array<readonly [string, unknown]> = [];
for (const [childKey, value] of Object.entries(obj)) {
if (childKey === "partialJson" || childKey === "jsonlEvents") {
changed = true;
continue;
}
const newValue = truncateForPersistenceSync(value, blobStore, childKey);
if (newValue !== value) changed = true;
entries.push([childKey, newValue]);
}
if (!changed) return obj;
const contentEntry = entries.find(([childKey]) => childKey === "content");
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
if (
contentEntry &&
typeof contentEntry[1] === "string" &&
lineCountEntry &&
typeof lineCountEntry[1] === "number"
) {
const content = contentEntry[1];
const updatedEntries = entries.map(([childKey, value]) =>
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
);
return Object.fromEntries(updatedEntries);
}
return Object.fromEntries(entries);
}
return obj;
}
function prepareEntryForPersistenceSync(entry: FileEntry, blobStore: BlobStore): FileEntry {
return truncateForPersistenceSync(entry, blobStore) as FileEntry;
}
class NdjsonFileWriter {
#writer: SessionStorageWriter;
#closed = false;
#closing = false;
#error: Error | undefined;
#pendingWrites: Promise<void> = Promise.resolve();
#onError: ((err: Error) => void) | undefined;
constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
this.#onError = options?.onError;
this.#writer = storage.openWriter(path, {
flags: options?.flags ?? "a",
onError: (err: Error) => this.#recordError(err),
});
}
#recordError(err: unknown): Error {
const writeErr = toError(err);
if (!this.#error) this.#error = writeErr;
this.#onError?.(writeErr);
return writeErr;
}
#enqueue(task: () => Promise<void>): Promise<void> {
const run = async () => {
if (this.#error) throw this.#error;
await task();
};
const next = this.#pendingWrites.then(run);
void next.catch((err: unknown) => {
if (!this.#error) this.#error = toError(err);
});
this.#pendingWrites = next;
return next;
}
async #writeLine(line: string): Promise<void> {
if (this.#error) throw this.#error;
try {
await this.#writer.writeLine(line);
} catch (err) {
throw this.#recordError(err);
}
}
write(entry: FileEntry): Promise<void> {
if (this.#closed || this.#closing) throw new Error("Writer closed");
if (this.#error) throw this.#error;
const line = `${JSON.stringify(entry)}\n`;
return this.#enqueue(() => this.#writeLine(line));
}
* Synchronously serialize and append the entry. Returns once `fs.writeSync` has handed
* the bytes to the kernel page cache — durable across OOM/SIGKILL even before fsync.
*
* Callers MUST NOT mix this with pending async `write()` calls on the same writer:
* the async path is queued through `#pendingWrites`, but this method bypasses the
* queue. Use only when no concurrent async write is in flight (the session-manager
* persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`).
*/
writeSync(entry: FileEntry): void {
if (this.#closed || this.#closing) throw new Error("Writer closed");
if (this.#error) throw this.#error;
const line = `${JSON.stringify(entry)}\n`;
try {
this.#writer.writeLineSync(line);
} catch (err) {
throw this.#recordError(err);
}
}
async flush(): Promise<void> {
if (this.#closed) return;
if (this.#error) throw this.#error;
await this.#enqueue(async () => {});
if (this.#error) throw this.#error;
try {
await this.#writer.flush();
} catch (err) {
throw this.#recordError(err);
}
}
async fsync(): Promise<void> {
if (this.#closed) return;
if (this.#error) throw this.#error;
try {
await this.#writer.fsync();
} catch (err) {
throw this.#recordError(err);
}
}
async close(): Promise<void> {
if (this.#closed || this.#closing) return;
this.#closing = true;
let closeError: Error | undefined;
try {
await this.flush();
} catch (err) {
closeError = toError(err);
}
try {
await this.#pendingWrites;
} catch (err) {
if (!closeError) closeError = toError(err);
}
try {
await this.#writer.close();
} catch (err) {
const endErr = this.#recordError(err);
if (!closeError) closeError = endErr;
}
this.#closed = true;
if (!closeError && this.#error) closeError = this.#error;
if (closeError) throw closeError;
}
getError(): Error | undefined {
return this.#error;
}
isOpen(): boolean {
return !this.#closed && !this.#closing;
}
}
export async function getRecentSessions(
sessionDir: string,
limit = 3,
storage: SessionStorage = new FileSessionStorage(),
): Promise<RecentSessionInfo[]> {
const sessions = await getSortedSessions(sessionDir, storage);
return sessions.slice(0, limit);
}
* Manages conversation sessions as append-only trees stored in JSONL files.
*
* Each session entry has an id and parentId forming a tree structure. The "leaf"
* pointer tracks the current position. Appending creates a child of the current leaf.
* Branching moves the leaf to an earlier entry, allowing new branches without
* modifying history.
*
* Use buildSessionContext() to get the resolved message list for the LLM, which
* handles compaction summaries and follows the path from root to current leaf.
*/
export interface UsageStatistics {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
premiumRequests: number;
cost: number;
}
function getTaskToolUsage(details: unknown): Usage | undefined {
if (!details || typeof details !== "object") return undefined;
const record = details as Record<string, unknown>;
const usage = record.usage;
if (!usage || typeof usage !== "object") return undefined;
return usage as Usage;
}
function extractTextFromContent(content: Message["content"]): string {
if (typeof content === "string") return content;
return content
.filter((block): block is TextContent => block.type === "text")
.map(block => block.text)
.join(" ");
}
const SESSION_LIST_PREFIX_BYTES = 4096;
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
const SESSION_LIST_MAX_WORKERS = 16;
const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
if (!(storage instanceof FileSessionStorage)) {
return storage.readTextPrefix(file, buffer.byteLength);
}
const handle = await fs.promises.open(file, "r");
try {
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
return sessionListPrefixDecoder.decode(buffer.subarray(0, bytesRead));
} finally {
await handle.close();
}
}
function decodeJsonStringFragment(value: string): string {
const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
try {
return JSON.parse(`"${safeValue}"`) as string;
} catch {
return safeValue
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\"/g, '"')
.replace(/\\\\/g, "\\");
}
}
function extractStringProperty(source: string, name: string, startIndex = 0): string | undefined {
const propertyIndex = source.indexOf(`"${name}"`, startIndex);
if (propertyIndex === -1) return undefined;
const colonIndex = source.indexOf(":", propertyIndex + name.length + 2);
if (colonIndex === -1) return undefined;
let valueIndex = colonIndex + 1;
while (valueIndex < source.length) {
const char = source.charCodeAt(valueIndex);
if (char !== 32 && char !== 9 && char !== 10 && char !== 13) break;
valueIndex++;
}
if (source.charCodeAt(valueIndex) !== 34) return undefined;
const valueStart = valueIndex + 1;
let escaped = false;
for (let i = valueStart; i < source.length; i++) {
const char = source.charCodeAt(i);
if (escaped) {
escaped = false;
continue;
}
if (char === 92) {
escaped = true;
continue;
}
if (char === 34) {
return decodeJsonStringFragment(source.slice(valueStart, i));
}
}
return decodeJsonStringFragment(source.slice(valueStart));
}
function countMessageMarkers(content: string): number {
let count = 0;
let index = 0;
while (index < content.length) {
const typeIndex = content.indexOf('"type"', index);
if (typeIndex === -1) break;
const colonIndex = content.indexOf(":", typeIndex + 6);
if (colonIndex === -1) break;
const type = extractStringProperty(content, "type", typeIndex);
if (type === "message") count++;
index = colonIndex + 1;
}
return count;
}
function extractFirstUserMessageFromPrefix(content: string): string | undefined {
const roleIndex = content.indexOf('"role"');
if (roleIndex === -1) return undefined;
let index = roleIndex;
while (index !== -1) {
const role = extractStringProperty(content, "role", index);
if (role === "user") {
return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
}
index = content.indexOf('"role"', index + 6);
}
return undefined;
}
interface SessionListHeader {
type: "session";
id: string;
cwd?: string;
title?: string;
parentSession?: string;
timestamp?: string;
}
function parseSessionListHeader(
content: string,
entries: Array<Record<string, unknown>>,
): SessionListHeader | undefined {
const parsedHeader = entries[0];
if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
return {
type: "session",
id: parsedHeader.id,
cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
};
}
const firstLineEnd = content.indexOf("\n");
const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
if (extractStringProperty(firstLine, "type") !== "session") return undefined;
const id = extractStringProperty(firstLine, "id");
if (!id) return undefined;
return {
type: "session",
id,
cwd: extractStringProperty(firstLine, "cwd"),
title: extractStringProperty(firstLine, "title"),
parentSession: extractStringProperty(firstLine, "parentSession"),
timestamp: extractStringProperty(firstLine, "timestamp"),
};
}
function getSessionListWorkerCount(fileCount: number): number {
if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1;
return Math.min(
SESSION_LIST_MAX_WORKERS,
os.availableParallelism(),
Math.ceil(fileCount / SESSION_LIST_PARALLEL_THRESHOLD),
);
}
async function collectSessionFromFile(
file: string,
storage: SessionStorage,
buffer: Buffer,
): Promise<SessionInfo | undefined> {
try {
const content = await readSessionListPrefix(file, storage, buffer);
const entries = parseJsonlLenient<Record<string, unknown>>(content);
const header = parseSessionListHeader(content, entries);
if (!header) return undefined;
let parsedMessageCount = 0;
let firstMessage = "";
const allMessages: string[] = [];
let shortSummary: string | undefined;
for (let i = 1; i < entries.length; i++) {
const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
shortSummary = entry.shortSummary;
}
if (entry.type === "message" && entry.message) {
parsedMessageCount++;
if (entry.message.role === "user" || entry.message.role === "assistant") {
const textContent = extractTextFromContent(entry.message.content);
if (textContent) {
allMessages.push(textContent);
if (!firstMessage && entry.message.role === "user") {
firstMessage = textContent;
}
}
}
}
}
firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
const stats = storage.statSync(file);
return {
path: file,
id: header.id,
cwd: header.cwd ?? "",
title: header.title ?? shortSummary,
parentSessionPath: header.parentSession,
created: new Date(header.timestamp ?? ""),
modified: stats.mtime,
messageCount,
size: stats.size,
firstMessage: firstMessage || "(no messages)",
allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
};
} catch {
return undefined;
}
}
async function collectSessionsFromFileStride(
files: string[],
storage: SessionStorage,
startIndex: number,
stride: number,
): Promise<SessionInfo[]> {
const sessions: SessionInfo[] = [];
const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
for (let i = startIndex; i < files.length; i += stride) {
const session = await collectSessionFromFile(files[i], storage, buffer);
if (session) sessions.push(session);
}
return sessions;
}
async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
const workerCount = getSessionListWorkerCount(files.length);
const sessions =
workerCount === 1
? await collectSessionsFromFileStride(files, storage, 0, 1)
: (
await Promise.all(
Array.from({ length: workerCount }, (_, workerIndex) =>
collectSessionsFromFileStride(files, storage, workerIndex, workerCount),
),
)
).flat();
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return sessions;
}
export interface ResolvedSessionMatch {
session: SessionInfo;
scope: "local" | "global";
}
function sessionMatchesResumeArg(session: SessionInfo, sessionArg: string): boolean {
const normalizedArg = sessionArg.toLowerCase();
const normalizedId = session.id.toLowerCase();
if (normalizedId.startsWith(normalizedArg)) {
return true;
}
const fileName = path.basename(session.path, ".jsonl").toLowerCase();
if (fileName.startsWith(normalizedArg)) {
return true;
}
const separator = fileName.lastIndexOf("_");
if (separator < 0) {
return false;
}
const fileSessionId = fileName.slice(separator + 1);
return fileSessionId.startsWith(normalizedArg);
}
export async function resolveResumableSession(
sessionArg: string,
cwd: string,
sessionDir?: string,
storage: SessionStorage = new FileSessionStorage(),
): Promise<ResolvedSessionMatch | undefined> {
const localSessionDir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
const localSessions = await SessionManager.list(cwd, localSessionDir, storage);
const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
if (localMatch) {
return { session: localMatch, scope: "local" };
}
if (sessionDir) {
return undefined;
}
const globalSessions = await SessionManager.listAll(storage);
const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
if (!globalMatch) {
return undefined;
}
return { session: globalMatch, scope: "global" };
}
interface SessionManagerStateSnapshot {
sessionId: string;
sessionName: string | undefined;
titleSource: "auto" | "user" | undefined;
sessionFile: string | undefined;
flushed: boolean;
needsFullRewriteOnNextPersist: boolean;
fileEntries: FileEntry[];
}
export class SessionManager {
#sessionId: string = "";
#sessionName: string | undefined;
#titleSource: "auto" | "user" | undefined;
#sessionFile: string | undefined;
#flushed: boolean = false;
#needsFullRewriteOnNextPersist: boolean = false;
#ensuredOnDisk: boolean = false;
#fileEntries: FileEntry[] = [];
#byId: Map<string, SessionEntry> = new Map();
#labelsById: Map<string, string> = new Map();
#leafId: string | null = null;
#usageStatistics = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
premiumRequests: 0,
cost: 0,
} satisfies UsageStatistics;
#turnBudget: { total: number | null; hard: boolean } = { total: null, hard: false };
#turnBaselineOutput = 0;
#turnEvalOutput = 0;
#persistWriter: NdjsonFileWriter | undefined;
#persistWriterPath: string | undefined;
#persistChain: Promise<void> = Promise.resolve();
#persistError: Error | undefined;
#persistErrorReported = false;
#artifactManager: ArtifactManager | null = null;
#artifactManagerSessionFile: string | null = null;
#adoptedArtifactManager: ArtifactManager | null = null;
#inMemoryArtifacts: Map<string, string> | null = null;
#inMemoryArtifactCounter = 0;
readonly #blobStore: BlobStore;
private constructor(
private cwd: string,
private sessionDir: string,
private readonly persist: boolean,
private readonly storage: SessionStorage,
) {
this.#blobStore = new BlobStore(getBlobsDir());
if (persist && sessionDir) {
this.storage.ensureDirSync(sessionDir);
}
}
async putBlob(data: Buffer): Promise<BlobPutResult> {
return this.#blobStore.put(data);
}
captureState(): SessionManagerStateSnapshot {
return {
sessionId: this.#sessionId,
sessionName: this.#sessionName,
titleSource: this.#titleSource,
sessionFile: this.#sessionFile,
flushed: this.#flushed,
needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
fileEntries: [...this.#fileEntries],
};
}
restoreState(snapshot: SessionManagerStateSnapshot): void {
this.#sessionId = snapshot.sessionId;
this.#sessionName = snapshot.sessionName;
this.#titleSource = snapshot.titleSource;
this.#sessionFile = snapshot.sessionFile;
this.#flushed = snapshot.flushed;
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
this.#fileEntries = [...snapshot.fileEntries];
this.#persistWriter = undefined;
this.#persistWriterPath = undefined;
this.#persistChain = Promise.resolve();
this.#persistError = undefined;
this.#persistErrorReported = false;
this.#artifactManager = null;
this.#artifactManagerSessionFile = null;
this.#adoptedArtifactManager = null;
this.#buildIndex();
if (this.#sessionFile) {
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
}
}
async #initSessionFile(sessionFile: string): Promise<void> {
await this.setSessionFile(sessionFile);
}
#initNewSession(): void {
this.#newSessionSync();
}
async setSessionFile(sessionFile: string): Promise<void> {
await this.#closePersistWriter();
this.#persistError = undefined;
this.#persistErrorReported = false;
this.#sessionFile = path.resolve(sessionFile);
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
if (this.#fileEntries.length > 0) {
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
this.#sessionId = header?.id ?? createSessionId();
this.#sessionName = header?.title;
this.#titleSource = header?.titleSource;
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
this.sanitizeLoadedOpenAIResponsesReplayMetadata();
this.#buildIndex();
this.#flushed = true;
this.#ensuredOnDisk = true;
} else {
const explicitPath = this.#sessionFile;
this.#newSessionSync();
this.#sessionFile = explicitPath;
await this.#rewriteFile();
this.#flushed = true;
this.#ensuredOnDisk = true;
return;
}
}
async newSession(options?: NewSessionOptions): Promise<string | undefined> {
await this.#closePersistWriter();
return this.#newSessionSync(options);
}
async dropSession(sessionPath: string): Promise<void> {
await this.#closePersistWriter();
try {
await this.storage.deleteSessionWithArtifacts(sessionPath);
} catch (err) {
if (isEnoent(err)) return;
throw err;
}
}
* Fork the current session, creating a new session file with the same entries.
* Returns both the old and new session file paths for artifact copying.
* @returns { oldSessionFile, newSessionFile } or undefined if not persisting
*/
async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
if (!this.persist || !this.#sessionFile) {
return undefined;
}
const oldSessionFile = this.#sessionFile;
const oldSessionId = this.#sessionId;
await this.#closePersistWriter();
this.#persistChain = Promise.resolve();
this.#persistError = undefined;
this.#persistErrorReported = false;
this.#sessionId = createSessionId();
const timestamp = new Date().toISOString();
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
const oldHeader = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
const newHeader: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: this.#sessionId,
title: oldHeader?.title ?? this.#sessionName,
titleSource: oldHeader?.titleSource ?? this.#titleSource,
timestamp,
cwd: this.cwd,
parentSession: oldSessionId,
};
this.#sessionName = newHeader.title;
this.#titleSource = newHeader.titleSource;
const entries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
this.#fileEntries = [newHeader, ...entries];
this.#flushed = false;
await this.#rewriteFile();
return { oldSessionFile, newSessionFile: this.#sessionFile };
}
* Move the session to a new working directory.
* Moves session files and artifacts on disk, updates all internal references,
* and rewrites the session header with the new cwd.
*/
async moveTo(newCwd: string): Promise<void> {
const resolvedCwd = path.resolve(newCwd);
if (resolvedCwd === this.cwd) return;
const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
const newSessionDir = managedSessionsRoot
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
: computeDefaultSessionDir(resolvedCwd, this.storage);
let hadSessionFile = false;
if (this.persist && this.#sessionFile) {
await this.#closePersistWriter();
this.#persistChain = Promise.resolve();
this.#persistError = undefined;
this.#persistErrorReported = false;
const oldSessionFile = this.#sessionFile;
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
const oldArtifactDir = oldSessionFile.slice(0, -6);
const newArtifactDir = newSessionFile.slice(0, -6);
hadSessionFile = this.storage.existsSync(oldSessionFile);
let movedSessionFile = false;
let movedArtifactDir = false;
try {
if (hadSessionFile) {
await fs.promises.rename(oldSessionFile, newSessionFile);
movedSessionFile = true;
}
try {
const stat = await fs.promises.stat(oldArtifactDir);
if (stat.isDirectory()) {
await fs.promises.rename(oldArtifactDir, newArtifactDir);
movedArtifactDir = true;
}
} catch (err) {
if (!isEnoent(err)) throw err;
}
} catch (err) {
if (movedArtifactDir) {
try {
await fs.promises.rename(newArtifactDir, oldArtifactDir);
} catch (rollbackErr) {
throw new Error(
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
);
}
}
if (movedSessionFile) {
try {
await fs.promises.rename(newSessionFile, oldSessionFile);
} catch (rollbackErr) {
throw new Error(
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
);
}
}
throw err;
}
this.#sessionFile = newSessionFile;
}
this.cwd = resolvedCwd;
this.sessionDir = newSessionDir;
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
if (header) {
header.cwd = resolvedCwd;
}
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
if (this.persist && this.#sessionFile && (hadSessionFile || hasAssistant)) {
await this.#rewriteFile();
}
if (this.#sessionFile) {
writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
}
}
#newSessionSync(options?: NewSessionOptions): string | undefined {
this.#persistChain = Promise.resolve();
this.#persistError = undefined;
this.#persistErrorReported = false;
this.#sessionId = createSessionId();
this.#sessionName = undefined;
this.#titleSource = undefined;
const timestamp = new Date().toISOString();
const header: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: this.#sessionId,
timestamp,
cwd: this.cwd,
parentSession: options?.parentSession,
};
this.#fileEntries = [header];
this.#byId.clear();
this.#labelsById.clear();
this.#leafId = null;
this.#flushed = false;
this.#needsFullRewriteOnNextPersist = false;
this.#ensuredOnDisk = false;
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
this.#inMemoryArtifacts = null;
this.#inMemoryArtifactCounter = 0;
if (this.persist) {
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
}
return this.#sessionFile;
}
#buildIndex(): void {
this.#byId.clear();
this.#labelsById.clear();
this.#leafId = null;
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
for (const entry of this.#fileEntries) {
if (entry.type === "session") continue;
this.#byId.set(entry.id, entry);
this.#leafId = entry.id;
if (entry.type === "label") {
if (entry.label) {
this.#labelsById.set(entry.targetId, entry.label);
} else {
this.#labelsById.delete(entry.targetId);
}
}
if (entry.type === "message" && entry.message.role === "assistant") {
const usage = entry.message.usage;
this.#usageStatistics.input += usage.input;
this.#usageStatistics.output += usage.output;
this.#usageStatistics.cacheRead += usage.cacheRead;
this.#usageStatistics.cacheWrite += usage.cacheWrite;
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
this.#usageStatistics.cost += usage.cost.total;
}
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
const usage = getTaskToolUsage(entry.message.details);
if (usage) {
this.#usageStatistics.input += usage.input;
this.#usageStatistics.output += usage.output;
this.#usageStatistics.cacheRead += usage.cacheRead;
this.#usageStatistics.cacheWrite += usage.cacheWrite;
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
this.#usageStatistics.cost += usage.cost.total;
}
}
}
}
#recordPersistError(err: unknown): Error {
const normalized = toError(err);
if (!this.#persistError) this.#persistError = normalized;
if (!this.#persistErrorReported) {
this.#persistErrorReported = true;
logger.error("Session persistence error.", {
sessionFile: this.#sessionFile,
error: normalized.message,
stack: normalized.stack,
});
}
return normalized;
}
#queuePersistTask(task: () => Promise<void>, options?: { ignoreError?: boolean }): Promise<void> {
const next = this.#persistChain.then(async () => {
if (this.#persistError && !options?.ignoreError) throw this.#persistError;
await task();
});
this.#persistChain = next.catch(err => {
this.#recordPersistError(err);
});
return next;
}
#ensurePersistWriter(): NdjsonFileWriter | undefined {
if (!this.persist || !this.#sessionFile) return undefined;
if (this.#persistError) throw this.#persistError;
if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
if (this.#persistWriter.isOpen()) return this.#persistWriter;
return undefined;
}
this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, {
onError: err => {
this.#recordPersistError(err);
},
});
this.#persistWriterPath = this.#sessionFile;
return this.#persistWriter;
}
async #closePersistWriterInternal(): Promise<void> {
if (this.#persistWriter) {
await this.#persistWriter.close();
this.#persistWriter = undefined;
}
this.#persistWriterPath = undefined;
}
async #closePersistWriter(): Promise<void> {
await this.#queuePersistTask(
async () => {
await this.#closePersistWriterInternal();
},
{ ignoreError: true },
);
}
async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
const dir = path.resolve(targetPath, "..");
const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
try {
await this.storage.rename(targetPath, backupPath);
} catch (err) {
if (isEnoent(err)) {
await this.storage.rename(tempPath, targetPath);
return;
}
throw toError(renameError);
}
try {
await this.storage.rename(tempPath, targetPath);
} catch (err) {
const replaceError = toError(err);
const originalError = toError(renameError);
try {
await this.storage.rename(backupPath, targetPath);
} catch (rollbackErr) {
const rollbackError = toError(rollbackErr);
throw new Error(
`Failed to replace session file after EPERM (original: ${originalError.message}; retry: ${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
{ cause: originalError },
);
}
throw replaceError;
}
try {
await this.storage.unlink(backupPath);
} catch (err) {
if (!isEnoent(err)) {
logger.warn("Failed to remove session rewrite backup", {
sessionFile: targetPath,
backupPath,
error: toError(err).message,
});
}
}
}
async #replaceSessionFile(tempPath: string, targetPath: string): Promise<void> {
try {
await this.storage.rename(tempPath, targetPath);
} catch (err) {
if (!hasFsCode(err, "EPERM")) throw toError(err);
await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err);
}
}
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
if (!this.#sessionFile) return;
const dir = path.resolve(this.#sessionFile, "..");
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
try {
for (const entry of entries) {
await writer.write(entry);
}
await writer.flush();
await writer.fsync();
await writer.close();
await this.#replaceSessionFile(tempPath, this.#sessionFile);
} catch (err) {
try {
await writer.close();
} catch {
}
try {
await this.storage.unlink(tempPath);
} catch {
}
throw toError(err);
}
}
async #rewriteFile(): Promise<void> {
if (!this.persist || !this.#sessionFile) return;
await this.#queuePersistTask(async () => {
await this.#closePersistWriterInternal();
const entries = await Promise.all(
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
);
await this.#writeEntriesAtomically(entries);
this.#needsFullRewriteOnNextPersist = false;
this.#flushed = true;
});
}
isPersisted(): boolean {
return this.persist;
}
* Force-persist all current entries to disk, even when no assistant message exists yet.
* Used by ACP mode where session/new must create a discoverable session immediately.
*/
async ensureOnDisk(): Promise<void> {
if (!this.persist || !this.#sessionFile) return;
if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
await this.#rewriteFile();
this.#ensuredOnDisk = true;
}
async flush(): Promise<void> {
await this.#queuePersistTask(async () => {
if (this.#persistWriter) {
await this.#persistWriter.flush();
await this.#persistWriter.fsync();
}
});
if (this.#persistError) throw this.#persistError;
}
async close(): Promise<void> {
if (!this.#persistWriter) return;
await this.#queuePersistTask(async () => {
await this.#closePersistWriterInternal();
this.#flushed = true;
});
if (this.#persistError) throw this.#persistError;
}
getCwd(): string {
return this.cwd;
}
getUsageStatistics(): UsageStatistics {
return this.#usageStatistics;
}
* Open a new per-turn budget window: snapshot the cumulative output baseline,
* reset the eval-subagent counter, and set the (optional) ceiling. Called once
* per real user message; `total` is null when no `+Nk` directive was present.
*/
beginTurnBudget(total: number | null, hard: boolean): void {
this.#turnBudget = { total, hard };
this.#turnBaselineOutput = this.#usageStatistics.output;
this.#turnEvalOutput = 0;
}
recordEvalSubagentOutput(output: number): void {
if (Number.isFinite(output) && output > 0) this.#turnEvalOutput += output;
}
* Current turn budget for the eval `budget` helper: the ceiling (null = none),
* output tokens spent this turn (main loop + eval-spawned subagents, no
* double-count), and whether the ceiling is hard.
*/
getTurnBudget(): { total: number | null; spent: number; hard: boolean } {
const mainDelta = Math.max(0, this.#usageStatistics.output - this.#turnBaselineOutput);
return { total: this.#turnBudget.total, spent: mainDelta + this.#turnEvalOutput, hard: this.#turnBudget.hard };
}
getSessionDir(): string {
return this.sessionDir;
}
getSessionId(): string {
return this.#sessionId;
}
getSessionFile(): string | undefined {
return this.#sessionFile;
}
* Returns the session artifacts directory path (session file path without .jsonl).
* Returns null when the session is not persisted to a file.
* When this session has adopted an external ArtifactManager (subagent case),
* returns that manager's directory so reads/writes land in the shared parent
* dir instead of a private (non-existent) subdir.
*/
getArtifactsDir(): string | null {
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
const sessionFile = this.#sessionFile;
return sessionFile ? sessionFile.slice(0, -6) : null;
}
* Adopt an externally-owned ArtifactManager. Used by subagents to share
* the parent session's artifact directory and ID counter.
*/
adoptArtifactManager(manager: ArtifactManager): void {
this.#adoptedArtifactManager = manager;
}
* Returns the ArtifactManager this session writes through. Lazily creates
* one bound to the current session file unless an external manager was
* adopted via `adoptArtifactManager`. Returns null only for non-persistent
* sessions with no adopted manager.
*/
getArtifactManager(): ArtifactManager | null {
return this.#getOrCreateArtifactManager();
}
* Returns an artifact manager bound to the current session file.
* Recreates the manager when the active session file changes.
*/
#getOrCreateArtifactManager(): ArtifactManager | null {
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
const sessionFile = this.#sessionFile;
if (!sessionFile) {
this.#artifactManager = null;
this.#artifactManagerSessionFile = null;
return null;
}
if (this.#artifactManager && this.#artifactManagerSessionFile === sessionFile) {
return this.#artifactManager;
}
const manager = new ArtifactManager(sessionFile.slice(0, -6));
this.#artifactManager = manager;
this.#artifactManagerSessionFile = sessionFile;
return manager;
}
* Allocate a new artifact path and ID for the current session.
* Returns an empty object when the session is not persisted.
*/
async allocateArtifactPath(toolType: string): Promise<{ id?: string; path?: string }> {
const manager = this.#getOrCreateArtifactManager();
if (!manager) return {};
return manager.allocatePath(toolType);
}
* Save artifact content under the current session and return artifact ID.
* Returns an artifact ID for all sessions (file-backed for persistent, in-memory fallback otherwise).
*/
async saveArtifact(content: string, toolType: string): Promise<string | undefined> {
const manager = this.#getOrCreateArtifactManager();
if (manager) return manager.save(content, toolType);
if (!this.#inMemoryArtifacts) this.#inMemoryArtifacts = new Map();
const id = String(this.#inMemoryArtifactCounter++);
this.#inMemoryArtifacts.set(id, content);
return id;
}
* Resolve an artifact ID to an on-disk path for the current session.
* Returns null when missing or when the session is not persisted.
*/
async getArtifactPath(id: string): Promise<string | null> {
const manager = this.#getOrCreateArtifactManager();
if (!manager) return null;
return manager.getPath(id);
}
* Path to the unsent-input draft sidecar for the current session. Lives inside
* the artifacts directory so it is removed together with the session on
* `dropSession`. Returns null when the session has no on-disk identity.
*/
#getDraftPath(): string | null {
const dir = this.getArtifactsDir();
return dir ? path.join(dir, "draft.txt") : null;
}
* Persist (or clear) the current editor draft so the next resume of this
* session can restore it. Empty text deletes any stale draft. No-op when the
* session is not persisted.
*/
async saveDraft(text: string): Promise<void> {
const draftPath = this.#getDraftPath();
if (!draftPath || !this.persist) return;
if (text.length === 0) {
try {
await this.storage.unlink(draftPath);
} catch (err) {
if (!isEnoent(err)) throw err;
}
return;
}
await this.ensureOnDisk();
await this.storage.writeText(draftPath, text);
}
* Read and remove the saved draft. Returns the previously-saved text, or
* null when no draft is pending. Single-shot: a successful read removes the
* sidecar so a subsequent resume does not re-restore the same text.
*/
async consumeDraft(): Promise<string | null> {
const draftPath = this.#getDraftPath();
if (!draftPath) return null;
let text: string;
try {
text = await this.storage.readText(draftPath);
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
try {
await this.storage.unlink(draftPath);
} catch (err) {
if (!isEnoent(err)) throw err;
}
return text;
}
get titleSource(): "auto" | "user" | undefined {
return this.#titleSource;
}
getSessionName(): string | undefined {
return this.#sessionName;
}
static #sanitizeName(name: string): string {
return name
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
.replace(/ +/g, " ")
.trim();
}
* Set the session display name.
* @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
* Auto-generated titles are silently ignored when the user has already set a name.
*/
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
if (this.#titleSource === "user" && source === "auto") return false;
const sanitized = SessionManager.#sanitizeName(name);
if (!sanitized) return false;
this.#sessionName = sanitized;
this.#titleSource = source;
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
if (header) {
header.title = sanitized;
header.titleSource = source;
}
const sessionFile = this.#sessionFile;
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
await this.#rewriteFile();
}
return true;
}
_persist(entry: SessionEntry): void {
if (!this.persist || !this.#sessionFile) return;
if (this.#persistError) throw this.#persistError;
if (!this.#ensuredOnDisk) {
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
if (!hasAssistant) {
this.#flushed = false;
return;
}
}
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
this.#rewriteFile().catch(() => {});
return;
}
try {
const writer = this.#ensurePersistWriter();
if (!writer) {
this.#rewriteFile().catch(() => {});
return;
}
const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore);
writer.writeSync(persistedEntry);
} catch (err) {
this.#recordPersistError(err);
}
}
#appendEntry(entry: SessionEntry): void {
this.#fileEntries.push(entry);
this.#byId.set(entry.id, entry);
this.#leafId = entry.id;
this._persist(entry);
if (entry.type === "message" && entry.message.role === "assistant") {
const usage = entry.message.usage;
this.#usageStatistics.input += usage.input;
this.#usageStatistics.output += usage.output;
this.#usageStatistics.cacheRead += usage.cacheRead;
this.#usageStatistics.cacheWrite += usage.cacheWrite;
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
this.#usageStatistics.cost += usage.cost.total;
}
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
const usage = getTaskToolUsage(entry.message.details);
if (usage) {
this.#usageStatistics.input += usage.input;
this.#usageStatistics.output += usage.output;
this.#usageStatistics.cacheRead += usage.cacheRead;
this.#usageStatistics.cacheWrite += usage.cacheWrite;
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
this.#usageStatistics.cost += usage.cost.total;
}
}
}
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
* Reason: we want these to be top-level entries in the session, not message session entries,
* so it is easier to find them.
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
*/
appendMessage(
message:
| Message
| CustomMessage
| HookMessage
| BashExecutionMessage
| PythonExecutionMessage
| FileMentionMessage,
): string {
const entry: SessionMessageEntry = {
type: "message",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
message,
};
this.#appendEntry(entry);
return entry.id;
}
appendThinkingLevelChange(thinkingLevel?: string): string {
const entry: ThinkingLevelChangeEntry = {
type: "thinking_level_change",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
thinkingLevel: thinkingLevel ?? null,
};
this.#appendEntry(entry);
return entry.id;
}
appendServiceTierChange(serviceTier: ServiceTier | null): string {
const entry: ServiceTierChangeEntry = {
type: "service_tier_change",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
serviceTier,
};
this.#appendEntry(entry);
return entry.id;
}
appendModeChange(mode: string, data?: Record<string, unknown>): string {
const entry: ModeChangeEntry = {
type: "mode_change",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
mode,
data,
};
this.#appendEntry(entry);
return entry.id;
}
* Append a model change as child of current leaf, then advance leaf. Returns entry id.
* @param model Model in "provider/modelId" format
* @param role Optional role (default: "default")
*/
appendModelChange(model: string, role?: string): string {
const entry: ModelChangeEntry = {
type: "model_change",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
model,
role,
};
this.#appendEntry(entry);
return entry.id;
}
appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
const entry: SessionInitEntry = {
type: "session_init",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
...init,
};
this.#appendEntry(entry);
return entry.id;
}
appendCompaction<T = unknown>(
summary: string,
shortSummary: string | undefined,
firstKeptEntryId: string,
tokensBefore: number,
details?: T,
fromExtension?: boolean,
preserveData?: Record<string, unknown>,
): string {
const entry: CompactionEntry<T> = {
type: "compaction",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
summary,
shortSummary,
firstKeptEntryId,
tokensBefore,
details,
fromExtension,
preserveData,
};
this.#appendEntry(entry);
return entry.id;
}
appendCustomEntry(customType: string, data?: unknown): string {
const entry: CustomEntry = {
type: "custom",
customType,
data,
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
};
this.#appendEntry(entry);
return entry.id;
}
* Rewrite the session file after in-place entry updates.
* Use sparingly (e.g., pruning old tool outputs).
*/
async rewriteEntries(): Promise<void> {
if (!this.persist || !this.#sessionFile) return;
await this.#rewriteFile();
}
* Append a custom message entry (for extensions) that participates in LLM context.
* @param customType Hook identifier for filtering on reload
* @param content Message content (string or TextContent/ImageContent array)
* @param display Whether to show in TUI (true = styled display, false = hidden)
* @param details Optional extension-specific metadata (not sent to LLM)
* @param attribution Who initiated this message for billing/attribution semantics
* @returns Entry id
*/
appendCustomMessageEntry<T = unknown>(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
details?: T,
attribution: MessageAttribution = "agent",
): string {
const entry: CustomMessageEntry<T> = {
type: "custom_message",
customType,
content,
display,
details: stripInternalDetailsFields(details),
attribution,
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
};
this.#appendEntry(entry);
return entry.id;
}
* Append an MCP tool selection entry recording the discovery-selected MCP tools.
* @param selectedToolNames MCP tool names selected for this branch
* @returns Entry id
*/
appendMCPToolSelection(selectedToolNames: string[]): string {
const entry: MCPToolSelectionEntry = {
type: "mcp_tool_selection",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
selectedToolNames: [...selectedToolNames],
};
this.#appendEntry(entry);
return entry.id;
}
* Append a TTSR injection entry recording which rules were injected.
* @param ruleNames Names of rules that were injected
* @returns Entry id
*/
appendTtsrInjection(ruleNames: string[]): string {
const entry: TtsrInjectionEntry = {
type: "ttsr_injection",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
injectedRules: ruleNames,
};
this.#appendEntry(entry);
return entry.id;
}
* Get all unique TTSR rule names that have been injected in the current branch.
* Scans from root to current leaf for ttsr_injection entries.
*/
getInjectedTtsrRules(): string[] {
const path = this.getBranch();
const ruleNames = new Set<string>();
for (const entry of path) {
if (entry.type === "ttsr_injection") {
for (const name of entry.injectedRules) {
ruleNames.add(name);
}
}
}
return Array.from(ruleNames);
}
getLeafId(): string | null {
return this.#leafId;
}
getLeafEntry(): SessionEntry | undefined {
return this.#leafId ? this.#byId.get(this.#leafId) : undefined;
}
* Get the most recent model role from the current session path.
* Returns undefined if no model change has been recorded.
*/
getLastModelChangeRole(): string | undefined {
let current = this.getLeafEntry();
while (current) {
if (current.type === "model_change") {
return current.role ?? "default";
}
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
}
return undefined;
}
getEntry(id: string): SessionEntry | undefined {
return this.#byId.get(id);
}
* Get all direct children of an entry.
*/
getChildren(parentId: string): SessionEntry[] {
const children: SessionEntry[] = [];
for (const entry of this.#byId.values()) {
if (entry.parentId === parentId) {
children.push(entry);
}
}
return children;
}
* Get the label for an entry, if any.
*/
getLabel(id: string): string | undefined {
return this.#labelsById.get(id);
}
* Set or clear a label on an entry.
* Labels are user-defined markers for bookmarking/navigation.
* Pass undefined or empty string to clear the label.
*/
appendLabelChange(targetId: string, label: string | undefined): string {
if (!this.#byId.has(targetId)) {
throw new Error(`Entry ${targetId} not found`);
}
const entry: LabelEntry = {
type: "label",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
targetId,
label,
};
this.#appendEntry(entry);
if (label) {
this.#labelsById.set(targetId, label);
} else {
this.#labelsById.delete(targetId);
}
return entry.id;
}
* Walk from entry to root, returning all entries in path order.
* Includes all entry types (messages, compaction, model changes, etc.).
* Use buildSessionContext() to get the resolved messages for the LLM.
*/
getBranch(fromId?: string): SessionEntry[] {
const path: SessionEntry[] = [];
const startId = fromId ?? this.#leafId;
let current = startId ? this.#byId.get(startId) : undefined;
while (current) {
path.unshift(current);
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
}
return path;
}
* Build the session context (what gets sent to the LLM).
* Uses tree traversal from current leaf.
*/
buildSessionContext(): SessionContext {
return buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
}
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
let didSanitize = false;
for (const entry of this.#fileEntries) {
if (entry.type !== "message" || entry.message.role !== "assistant") {
continue;
}
const sanitizedMessage = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
if (sanitizedMessage === entry.message) {
continue;
}
entry.message = sanitizedMessage;
didSanitize = true;
}
return didSanitize;
}
* Get session header.
*/
getHeader(): SessionHeader | null {
const h = this.#fileEntries.find(e => e.type === "session");
return h ? (h as SessionHeader) : null;
}
* Get all session entries (excludes header). Returns a shallow copy.
* The session is append-only: use appendXXX() to add entries, branch() to
* change the leaf pointer. Entries cannot be modified or deleted.
*/
getEntries(): SessionEntry[] {
return this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
}
* Get the session as a tree structure. Returns a shallow defensive copy of all entries.
* A well-formed session has exactly one root (first entry with parentId === null).
* Orphaned entries (broken parent chain) are also returned as roots.
*/
getTree(): SessionTreeNode[] {
const entries = this.getEntries();
const nodeMap = new Map<string, SessionTreeNode>();
const roots: SessionTreeNode[] = [];
for (const entry of entries) {
const label = this.#labelsById.get(entry.id);
nodeMap.set(entry.id, { entry, children: [], label });
}
for (const entry of entries) {
const node = nodeMap.get(entry.id)!;
if (entry.parentId === null || entry.parentId === entry.id) {
roots.push(node);
} else {
const parent = nodeMap.get(entry.parentId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
}
const stack: SessionTreeNode[] = [...roots];
while (stack.length > 0) {
const node = stack.pop()!;
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
stack.push(...node.children);
}
return roots;
}
* Start a new branch from an earlier entry.
* Moves the leaf pointer to the specified entry. The next appendXXX() call
* will create a child of that entry, forming a new branch. Existing entries
* are not modified or deleted.
*/
branch(branchFromId: string): void {
if (!this.#byId.has(branchFromId)) {
throw new Error(`Entry ${branchFromId} not found`);
}
this.#leafId = branchFromId;
}
* Reset the leaf pointer to null (before any entries).
* The next appendXXX() call will create a new root entry (parentId = null).
* Use this when navigating to re-edit the first user message.
*/
resetLeaf(): void {
this.#leafId = null;
}
* Start a new branch with a summary of the abandoned path.
* Same as branch(), but also appends a branch_summary entry that captures
* context from the abandoned conversation path.
*/
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
if (branchFromId !== null && !this.#byId.has(branchFromId)) {
throw new Error(`Entry ${branchFromId} not found`);
}
this.#leafId = branchFromId;
const entry: BranchSummaryEntry = {
type: "branch_summary",
id: generateId(this.#byId),
parentId: branchFromId,
timestamp: new Date().toISOString(),
fromId: branchFromId ?? "root",
summary,
details,
fromExtension,
};
this.#appendEntry(entry);
return entry.id;
}
* Create a new session file containing only the path from root to the specified leaf.
* Useful for extracting a single conversation path from a branched session.
* Returns the new session file path, or undefined if not persisting.
*/
createBranchedSession(leafId: string): string | undefined {
const previousSessionFile = this.#sessionFile;
const branchPath = this.getBranch(leafId);
if (branchPath.length === 0) {
throw new Error(`Entry ${leafId} not found`);
}
const pathWithoutLabels = branchPath.filter(e => e.type !== "label");
const newSessionId = createSessionId();
const timestamp = new Date().toISOString();
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
const newSessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
const header: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: newSessionId,
timestamp,
cwd: this.cwd,
parentSession: this.persist ? previousSessionFile : undefined,
};
const pathEntryIds = new Set(pathWithoutLabels.map(e => e.id));
const labelsToWrite: Array<{ targetId: string; label: string }> = [];
for (const [targetId, label] of this.#labelsById) {
if (pathEntryIds.has(targetId)) {
labelsToWrite.push({ targetId, label });
}
}
if (this.persist) {
const lines: string[] = [];
lines.push(JSON.stringify(header));
for (const entry of pathWithoutLabels) {
lines.push(JSON.stringify(entry));
}
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
let parentId = lastEntryId;
const labelEntries: LabelEntry[] = [];
for (const { targetId, label } of labelsToWrite) {
const labelEntry: LabelEntry = {
type: "label",
id: generateId(new Set(pathEntryIds)),
parentId,
timestamp: new Date().toISOString(),
targetId,
label,
};
lines.push(JSON.stringify(labelEntry));
pathEntryIds.add(labelEntry.id);
labelEntries.push(labelEntry);
parentId = labelEntry.id;
}
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this.#sessionId = newSessionId;
this.#sessionFile = newSessionFile;
this.#flushed = true;
this.#buildIndex();
return newSessionFile;
}
const labelEntries: LabelEntry[] = [];
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
for (const { targetId, label } of labelsToWrite) {
const labelEntry: LabelEntry = {
type: "label",
id: generateId(new Set([...pathEntryIds, ...labelEntries.map(e => e.id)])),
parentId,
timestamp: new Date().toISOString(),
targetId,
label,
};
labelEntries.push(labelEntry);
parentId = labelEntry.id;
}
this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this.#sessionId = newSessionId;
this.#buildIndex();
return undefined;
}
* Resolve the canonical default session directory for a cwd.
*/
static getDefaultSessionDir(
cwd: string,
agentDir?: string,
storage: SessionStorage = new FileSessionStorage(),
): string {
return computeDefaultSessionDir(cwd, storage, getSessionsDir(agentDir));
}
* Create a new session.
* @param cwd Working directory (stored in session header)
* @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
*/
static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager {
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
const manager = new SessionManager(cwd, dir, true, storage);
manager.#initNewSession();
return manager;
}
* Fork a session into the current project directory.
* Copies history from another session file while creating a new session file in the current sessionDir.
*/
static async forkFrom(
sourcePath: string,
cwd: string,
sessionDir?: string,
storage: SessionStorage = new FileSessionStorage(),
): Promise<SessionManager> {
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
const manager = new SessionManager(cwd, dir, true, storage);
const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
migrateToCurrentVersion(forkEntries);
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
const sourceHeader = forkEntries.find(e => e.type === "session") as SessionHeader | undefined;
const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[];
manager.#newSessionSync({ parentSession: sourceHeader?.id });
const newHeader = manager.#fileEntries[0] as SessionHeader;
newHeader.title = sourceHeader?.title;
newHeader.titleSource = sourceHeader?.titleSource;
manager.#fileEntries = [newHeader, ...historyEntries];
manager.#sessionName = newHeader.title;
manager.#titleSource = newHeader.titleSource;
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
manager.#buildIndex();
await manager.#rewriteFile();
return manager;
}
* Open a specific session file.
* @param path Path to session file
* @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
*/
static async open(
filePath: string,
sessionDir?: string,
storage: SessionStorage = new FileSessionStorage(),
): Promise<SessionManager> {
const entries = await loadEntriesFromFile(filePath, storage);
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
const cwd = header?.cwd ?? getProjectDir();
const dir = sessionDir ?? path.resolve(filePath, "..");
const manager = new SessionManager(cwd, dir, true, storage);
await manager.#initSessionFile(filePath);
return manager;
}
* Continue the most recent session, or create new if none.
* @param cwd Working directory
* @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
*/
static async continueRecent(
cwd: string,
sessionDir?: string,
storage: SessionStorage = new FileSessionStorage(),
): Promise<SessionManager> {
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
const terminalSession = await readTerminalBreadcrumb(cwd);
const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
const manager = new SessionManager(cwd, dir, true, storage);
if (mostRecent) {
await manager.#initSessionFile(mostRecent);
} else {
manager.#initNewSession();
}
return manager;
}
static inMemory(
cwd: string = getProjectDir(),
storage: SessionStorage = new MemorySessionStorage(),
): SessionManager {
const manager = new SessionManager(cwd, "", false, storage);
manager.#initNewSession();
return manager;
}
* List all sessions.
* @param cwd Working directory (used to compute default session directory)
* @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
*/
static async list(
cwd: string,
sessionDir?: string,
storage: SessionStorage = new FileSessionStorage(),
): Promise<SessionInfo[]> {
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
try {
await recoverOrphanedBackups(dir, storage);
const files = storage.listFilesSync(dir, "*.jsonl");
return await collectSessionsFromFiles(files, storage);
} catch {
return [];
}
}
* List all sessions across all project directories.
*/
static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
try {
const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name =>
path.join(sessionsRoot, name),
);
return await collectSessionsFromFiles(files, storage);
} catch {
return [];
}
}
}