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; // v1 sessions don't have this
	id: string;
	title?: string; // Auto-generated title from first message
	titleSource?: "auto" | "user";
	timestamp: string;
	cwd: string;
	parentSession?: string;
}

export interface NewSessionOptions {
	parentSession?: string;
	/** Skip flushing the current session and delete it instead of saving. */
	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 in "provider/modelId" format */
	model: string;
	/** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
	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;
	/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
	details?: T;
	/** Hook-provided data to persist across compaction */
	preserveData?: Record<string, unknown>;
	/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
	fromExtension?: boolean;
}

export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
	type: "branch_summary";
	fromId: string;
	summary: string;
	/** Extension-specific data (not sent to LLM) */
	details?: T;
	/** True if generated by an extension, false if pi-generated */
	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;
}

/** Label entry for user-defined bookmarks/markers on entries. */
export interface LabelEntry extends SessionEntryBase {
	type: "label";
	targetId: string;
	label: string | undefined;
}

/** TTSR injection entry - tracks which time-traveling rules have been injected this session. */
export interface TtsrInjectionEntry extends SessionEntryBase {
	type: "ttsr_injection";
	/** Names of rules that were injected */
	injectedRules: string[];
}

/** Persisted MCP discovery selection state for a session branch. */
export interface MCPToolSelectionEntry extends SessionEntryBase {
	type: "mcp_tool_selection";
	/** MCP tool names selected for visibility in discovery mode. */
	selectedToolNames: string[];
}

/** Session init entry - captures initial context for subagent sessions (debugging/replay). */
export interface SessionInitEntry extends SessionEntryBase {
	type: "session_init";
	/** Full system prompt sent to the model */
	systemPrompt: string;
	/** Initial task/user message */
	task: string;
	/** Tools available to the agent */
	tools: string[];
	/** Output schema if structured output was requested */
	outputSchema?: unknown;
}

/** Mode change entry - tracks agent mode transitions (e.g. plan mode). */
export interface ModeChangeEntry extends SessionEntryBase {
	type: "mode_change";
	/** Current mode name, or "none" when exiting a mode */
	mode: string;
	/** Optional mode-specific data (e.g. plan file path) */
	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;
	/** Who initiated this message for billing/attribution semantics. */
	attribution?: MessageAttribution;
}

/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
export type SessionEntry =
	| SessionMessageEntry
	| ThinkingLevelChangeEntry
	| ModelChangeEntry
	| ServiceTierChangeEntry
	| CompactionEntry
	| BranchSummaryEntry
	| CustomEntry
	| CustomMessageEntry
	| LabelEntry
	| TtsrInjectionEntry
	| MCPToolSelectionEntry
	| SessionInitEntry
	| ModeChangeEntry;

/** Raw file entry (includes header) */
export type FileEntry = SessionHeader | SessionEntry;

/** Tree node for getTree() - defensive copy of session structure */
export interface SessionTreeNode {
	entry: SessionEntry;
	children: SessionTreeNode[];
	/** Resolved label for this entry, if any */
	label?: string;
}

export interface SessionContext {
	messages: AgentMessage[];
	thinkingLevel?: string;
	serviceTier?: ServiceTier;
	/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
	models: Record<string, string>;
	/** Names of TTSR rules that have been injected this session */
	injectedTtsrRules: string[];
	/** MCP tool names selected through discovery for this session branch. */
	selectedMCPToolNames: string[];
	/** Whether this branch contains an explicit persisted MCP selection entry. */
	hasPersistedMCPToolSelection: boolean;
	/** Active mode (e.g. "plan") or "none" if no special mode is active */
	mode: string;
	/** Mode-specific data from the last mode_change entry */
	modeData?: Record<string, unknown>;
}

export interface SessionInfo {
	path: string;
	id: string;
	/** Working directory where the session was started. Empty string for old sessions. */
	cwd: string;
	title?: string;
	/** Path to the parent session (if this session was forked). */
	parentSessionPath?: string;
	created: Date;
	modified: Date;
	messageCount: number;
	/** File size in bytes on disk; used for compact list rendering. */
	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();
}

/** Generate a unique short ID (8 hex chars, collision-checked) */
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(); // fallback to full snowflake id
}

/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
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;

		// Convert firstKeptEntryIndex to firstKeptEntryId for compaction
		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;
			}
		}
	}
}

/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
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;
}

/** Exported for testing */
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 {
			// Best effort
		}
	}
}

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 {
		// Best effort
	}
}

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);
}

/** Exported for compaction.test.ts */
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 {
	// Build uuid index if not available
	if (!byId) {
		byId = new Map<string, SessionEntry>();
		for (const entry of entries) {
			byId.set(entry.id, entry);
		}
	}

	// Find leaf
	let leaf: SessionEntry | undefined;
	if (leafId === null) {
		// Explicitly null - return no messages (navigated to before first entry)
		return {
			messages: [],
			thinkingLevel: "off",
			serviceTier: undefined,
			models: {},
			injectedTtsrRules: [],
			selectedMCPToolNames: [],
			hasPersistedMCPToolSelection: false,
			mode: "none",
		};
	}
	if (leafId) {
		leaf = byId.get(leafId);
	}
	if (!leaf) {
		// Fallback to last entry (when leafId is undefined)
		leaf = entries[entries.length - 1];
	}

	if (!leaf) {
		return {
			messages: [],
			thinkingLevel: "off",
			serviceTier: undefined,
			models: {},
			injectedTtsrRules: [],
			selectedMCPToolNames: [],
			hasPersistedMCPToolSelection: false,
			mode: "none",
		};
	}

	// Walk from leaf to root, collecting path
	const path: SessionEntry[] = [];
	let current: SessionEntry | undefined = leaf;
	while (current) {
		path.unshift(current);
		current = current.parentId ? byId.get(current.parentId) : undefined;
	}

	// Extract settings and find compaction
	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;
	// Track whether an explicit `model_change` with role="default" has been
	// seen on this path. Once a user (or the agent itself) records an
	// explicit default, later assistant-message inference must NOT overwrite
	// it: temporary fallbacks (retry fallback, context promotion) and
	// server-side model downgrades both produce assistant messages tagged
	// with the wrong model id, which previously clobbered the user's pick on
	// resume (issue #849).
	let hasExplicitDefaultModel = false;

	for (const entry of path) {
		if (entry.type === "thinking_level_change") {
			thinkingLevel = entry.thinkingLevel ?? "off";
		} else if (entry.type === "model_change") {
			// New format: { model: "provider/id", role?: string }
			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") {
			// Legacy fallback: infer default model from assistant messages only
			// when no explicit `model_change` (role=default) entry has been
			// recorded yet. Newer sessions always record an explicit default
			// model_change at the start of the conversation, so this branch is
			// only used to keep pre-model_change sessions working.
			if (!hasExplicitDefaultModel) {
				models.default = `${entry.message.provider}/${entry.message.model}`;
			}
		} else if (entry.type === "compaction") {
			compaction = entry;
		} else if (entry.type === "ttsr_injection") {
			// Collect injected TTSR rule names
			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);

	// Build messages and collect corresponding entries
	// When there's a compaction, we need to:
	// 1. Emit summary first (entry = compaction)
	// 2. Emit kept messages (from firstKeptEntryId up to compaction)
	// 3. Emit messages after compaction
	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;

		// Emit summary first
		messages.push(
			createCompactionSummaryMessage(
				compaction.summary,
				compaction.tokensBefore,
				compaction.timestamp,
				compaction.shortSummary,
				providerPayload,
			),
		);

		// Find compaction index in path
		const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);

		if (!remoteReplacementHistory) {
			// Emit kept messages (before compaction, starting from firstKeptEntryId)
			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);
				}
			}
		}

		// Emit messages after compaction
		for (let i = compactionIdx + 1; i < path.length; i++) {
			const entry = path[i];
			appendMessage(entry);
		}
	} else {
		// No compaction - emit all messages, handle branch summaries and custom messages
		for (const entry of path) {
			appendMessage(entry);
		}
	}

	// Strip dangling tool_use blocks — a tool_use with no matching tool_result on the
	// resolved leaf→root path — from ANY assistant turn, not just the trailing one.
	// This happens whenever the leaf (or a branch point) lands such that an assistant
	// turn's tool results are off the selected path: its result children live on a
	// sibling branch, or it is the leaf itself (results are children below it). Left
	// in place, `transformMessages` fabricates one synthetic "aborted"/"No result
	// provided" result per dangling call plus a `<turn-aborted>` developer note, which
	// render as phantom failed calls and re-inject the failed batch into the model's
	// context — the rewind/restore loop.
	//
	// Stripping is necessary but not sufficient: a *modified* assistant turn that still
	// carries signed `thinking`/`redacted_thinking` is rejected by Anthropic — "thinking
	// blocks in the latest assistant message cannot be modified", and signed thinking
	// replayed out of its original turn shape can also fail signature validation (this
	// bites the handoff/branch-summary request). So when we rewrite a turn we also
	// neutralize its protected reasoning: drop `redactedThinking` (encrypted, no
	// plaintext to keep) and clear `thinking` signatures so the provider encoder
	// downgrades them to plain text (verified accepted by the live API), preserving the
	// visible reasoning while removing the immutability/invalid-signature hazard. Drop a
	// turn left with no content. (Live turns never qualify: their results are persisted
	// on the same path before any context rebuild.)
	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;
}

// =============================================================================
// Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
// =============================================================================

/**
 * 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`;
	// Best-effort — don't break session creation if breadcrumb fails
	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];

		// Only return if cwd matches (user might have cd'd)
		if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null;

		// Verify the session file still exists
		const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
		if (stat?.isFile()) return sessionFile;
	} catch (err) {
		if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
		// Breadcrumb doesn't exist or is corrupt — fall through
	}
	return null;
}

/** Exported for testing */
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);

	// Validate session header
	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,
	) {
		// Prefer an explicit title, then the first user prompt. The raw UUID `id` is
		// intentionally not used as a fallback: showing it as a "name" is unfriendly and
		// indistinguishable from neighboring sessions in the UI. The friendly fallback is
		// derived lazily in `fullName` from the session timestamp.
		const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
		this.#fullName = sanitizeSessionName(trystr(header.title)) ?? sanitizeSessionName(firstPrompt);
		this.#headerTimestamp = trystr(header.timestamp);
	}

	/** Display name. Falls back to a timestamp-based label, never the raw UUID. */
	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;
	}

	/** Human-readable relative time (e.g., "2 hours ago") */
	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;
	// For each primary path, pick the newest backup (highest mtime) as the recovery source.
	const candidates = new Map<string, { backup: string; mtimeMs: number }>();
	for (const backup of backups) {
		const name = path.basename(backup);
		// Expect "<primary>.<snowflake>.bak" where <primary> ends in ".jsonl".
		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 [];
	}
}

/** Exported for testing */
export async function findMostRecentSession(
	sessionDir: string,
	storage: SessionStorage = new FileSessionStorage(),
): Promise<string | null> {
	const sessions = await getSortedSessions(sessionDir, storage);
	return sessions[0]?.path || null;
}

/** Format a time difference as a human-readable string */
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]";
/** Minimum base64 length to externalize to blob store (skip tiny inline images) */
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) {
			// Cryptographic signatures must be preserved exactly or cleared entirely — never truncated.
			// Truncation would produce an invalid signature that the API rejects.
			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 => {
				// Special handling: compress oversized images while preserving shape
				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]) => {
				// Strip transient/redundant properties that shouldn't be persisted.
				// - partialJson: streaming accumulator for tool call JSON parsing
				// - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
				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);
		}
	}

	/** Queue a write. Returns a promise so callers can await if needed. */
	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);
		}
	}

	/** Flush all buffered data to disk. Waits for all queued writes. */
	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);
		}
	}

	/** Sync data to persistent storage. */
	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);
		}
	}

	/** Close the writer, flushing all data. */
	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;
	}

	/** Check if there's a stored error. */
	getError(): Error | undefined {
		return this.#error;
	}

	/** True while the writer accepts new writes (not closing or closed). */
	isOpen(): boolean {
		return !this.#closed && !this.#closing;
	}
}

/** Get recent sessions for display in welcome screen */
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;
	/** Per-turn output-token budget set by a `+Nk` directive (total null when none this turn). */
	#turnBudget: { total: number | null; hard: boolean } = { total: null, hard: false };
	/** Cumulative `output` snapshot captured when the current turn budget window opened. */
	#turnBaselineOutput = 0;
	/** Output tokens consumed by eval-spawned subagents in the current turn window. */
	#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;
	// When set, take precedence over the lazily-derived per-session manager.
	// Subagents adopt the parent's manager so artifact IDs are unique across the
	// whole agent tree and all files land in the parent's artifacts dir.
	#adoptedArtifactManager: ArtifactManager | null = null;
	// In-memory artifact fallback for non-persistent sessions (persist=false).
	// Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme.
	#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);
		}
		// Note: call _initSession() or _initSessionFile() after construction
	}

	/** Puts a binary blob into the blob store and returns the blob reference */
	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,
			// Snapshot entry objects by reference: switch/reload replaces the active entry array,
			// so rollback does not need structured cloning of extension/custom details.
			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);
		}
	}

	/** Initialize with a specific session file (used by factory methods) */
	async #initSessionFile(sessionFile: string): Promise<void> {
		await this.setSessionFile(sessionFile);
	}

	/** Initialize with a new session (used by factory methods) */
	#initNewSession(): void {
		this.#newSessionSync();
	}

	/** Switch to a different session file (used for resume and branching) */
	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; // preserve explicit path from --session flag
			await this.#rewriteFile();
			this.#flushed = true;
			this.#ensuredOnDisk = true;
			return;
		}
	}

	/** Start a new session. Closes any existing writer first. */
	async newSession(options?: NewSessionOptions): Promise<string | undefined> {
		await this.#closePersistWriter();
		return this.#newSessionSync(options);
	}

	/** Delete a session file and its artifacts. Drains the persist writer first to avoid EPERM on Windows. ENOENT is treated as success. */
	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;

		// Close the current writer
		await this.#closePersistWriter();
		this.#persistChain = Promise.resolve();
		this.#persistError = undefined;
		this.#persistErrorReported = false;

		// Create new session ID and header
		this.#sessionId = createSessionId();
		const timestamp = new Date().toISOString();
		const fileTimestamp = timestamp.replace(/[:.]/g, "-");
		this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);

		// Update the header with new ID but keep all entries
		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;

		// Replace the header in fileEntries
		const entries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
		this.#fileEntries = [newHeader, ...entries];

		// Write the new session file
		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) {
			// Close the persist writer before moving files
			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); // strip .jsonl
			const newArtifactDir = newSessionFile.slice(0, -6);
			hadSessionFile = this.storage.existsSync(oldSessionFile);
			let movedSessionFile = false;
			let movedArtifactDir = false;

			try {
				// Guard: session file may not exist yet (no assistant messages persisted)
				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;
		}

		// Update cwd and sessionDir after the move succeeds.
		this.cwd = resolvedCwd;
		this.sessionDir = newSessionDir;

		// Update the session header in fileEntries
		const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
		if (header) {
			header.cwd = resolvedCwd;
		}

		// Rewrite the session file at its new location with updated header.
		// hadSessionFile: file existed before move → must rewrite to update cwd
		// hasAssistant: assistant messages in memory but file missing → recreate from memory
		// Neither true → fresh session, never written → preserve lazy-persist
		const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
		if (this.persist && this.#sessionFile && (hadSessionFile || hasAssistant)) {
			await this.#rewriteFile();
		}

		// Update terminal breadcrumb
		if (this.#sessionFile) {
			writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
		}
	}

	/** Sync version for initial creation (no existing writer to close) */
	#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;
			// Cached writer for the current file is mid-close (queued
			// `#closePersistWriterInternal` has flipped `#closing` but not yet
			// cleared `#persistWriter`). Returning it would make `writeSync`
			// throw "Writer closed". Defer to the caller — `_persist` routes
			// the entry through the async rewrite path so it still lands on disk.
			return undefined;
		}
		// Note: caller must await _closePersistWriter() before calling this if switching files
		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 },
		);
	}
	// Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
	// Move the old session file aside first so a failed retry can roll back to the last good file.
	// The backup uses a plain `<basename>.<snowflake>.bak` name (no leading dot) so that if the
	// process crashes between the two renames, `recoverOrphanedBackups` can find it via the
	// shared `*.bak` glob on both real and in-memory storage backends and promote it back to
	// the primary on the next session-dir scan.

	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 {
				// Ignore cleanup errors
			}
			try {
				await this.storage.unlink(tempPath);
			} catch {
				// Ignore cleanup errors
			}
			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;
	}

	/** Flush pending writes to disk. Call before switching sessions or on shutdown. */
	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;
	}

	/** Close the persistent writer after flushing all pending data. */
	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;
	}

	/** Get usage statistics across all assistant messages in the session. */
	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;
	}

	/** Record output tokens consumed by an eval-spawned subagent in the current turn. */
	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);
		// Non-persistent session: store in memory so spill truncation can proceed.
		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;
		}
		// Force the session header onto disk so resume can find the file we are
		// attaching this draft to. Without this, a session whose first message
		// never produced an assistant reply would persist a draft next to a
		// session file that does not exist on disk.
		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;
	}

	/** The source that set the session name: "user" (manual /rename or RPC) or "auto" (generated title). */
	get titleSource(): "auto" | "user" | undefined {
		return this.#titleSource;
	}

	getSessionName(): string | undefined {
		return this.#sessionName;
	}

	/** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
	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> {
		// User-set names take permanent precedence over auto-generated ones.
		if (this.#titleSource === "user" && source === "auto") return false;

		const sanitized = SessionManager.#sanitizeName(name);
		if (!sanitized) return false;

		this.#sessionName = sanitized;
		this.#titleSource = source;

		// Update the in-memory header (so first flush includes title)
		const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
		if (header) {
			header.title = sanitized;
			header.titleSource = source;
		}

		// Update the session file header with the title (if already flushed)
		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;

		// Normally we wait for the first assistant message before persisting to avoid
		// creating files for sessions that never produce output. Once ensureOnDisk() has
		// been called, the session is already on disk and every entry must be flushed.
		if (!this.#ensuredOnDisk) {
			const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
			if (!hasAssistant) {
				// Mark as not flushed so when assistant arrives, all entries get written.
				this.#flushed = false;
				return;
			}
		}

		if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
			// Cold path: rewrite the whole file atomically. Async — the writer is
			// closed/reopened and every entry is re-prepared. Errors flow through
			// `#persistChain` → `#recordPersistError`; we swallow the rejection
			// here to avoid an unhandled rejection when the persist dir races with
			// test-level tempDir cleanup.
			this.#rewriteFile().catch(() => {});
			return;
		}

		// Hot path: synchronously truncate + append. `fs.writeSync` returns once the
		// bytes are in the kernel page cache, so the entry survives an OOM/SIGKILL
		// landing immediately after this call. Image externalization (rare) runs via
		// the synchronous blob-store path so blob bytes are durable before the JSONL
		// line referencing them is written.
		try {
			const writer = this.#ensurePersistWriter();
			if (!writer) {
				// `#ensurePersistWriter` returns undefined here only when the cached
				// writer is mid-close (the `!persist`/`!sessionFile` cases are
				// rejected above). Route through `#rewriteFile` so the entry — which
				// is already in `#fileEntries` — persists once the close drains.
				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;
			}
		}
	}

	/** Append a message as child of current leaf, then advance leaf. Returns entry id.
	 * 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;
	}

	/** Append a thinking level change as child of current leaf, then advance leaf. Returns 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;
	}

	/** Append a mode change as child of current leaf, then advance leaf. Returns 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;
	}

	/** Append session init metadata (for subagent debugging/replay). Returns 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;
	}

	/** Append a compaction summary as child of current leaf, then advance leaf. Returns 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;
	}

	/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns 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,
			// Drop AgentSession-internal transient fields (allowlist in
			// `INTERNAL_DETAILS_FIELDS`) before disk persistence. Single
			// chokepoint covers every CustomMessage write path.
			details: stripInternalDetailsFields(details),
			attribution,
			id: generateId(this.#byId),
			parentId: this.#leafId,
			timestamp: new Date().toISOString(),
		};
		this.#appendEntry(entry);
		return entry.id;
	}

	// =========================================================================
	// TTSR (Time Traveling Stream Rules)
	// =========================================================================

	/**
	 * 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);
	}

	// =========================================================================
	// Tree Traversal
	// =========================================================================

	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);
	}

	/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
	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[] = [];

		// Create nodes with resolved labels
		for (const entry of entries) {
			const label = this.#labelsById.get(entry.id);
			nodeMap.set(entry.id, { entry, children: [], label });
		}

		// Build tree
		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 {
					// Orphan - treat as root
					roots.push(node);
				}
			}
		}

		// Sort children by timestamp (oldest first, newest at bottom)
		// Use iterative approach to avoid stack overflow on deep trees
		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;
	}

	// =========================================================================
	// Branching
	// =========================================================================

	/**
	 * 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`);
		}

		// Filter out LabelEntry from path - we'll recreate them from the resolved map
		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,
		};

		// Collect labels for entries in the path
		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));
			}
			// Write fresh label entries at the end
			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;
		}

		// In-memory mode: replace current session with the path + labels
		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> {
		// Extract cwd from session header if possible, otherwise use getProjectDir()
		const entries = await loadEntriesFromFile(filePath, storage);
		const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
		const cwd = header?.cwd ?? getProjectDir();
		// If no sessionDir provided, derive from file's parent directory
		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);
		// Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
		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;
	}

	/** Create an in-memory session (no file persistence) */
	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 [];
		}
	}
}