diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 512ccfe54..18ce915d9 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -1,9 +1,18 @@
# Changelog
## [Unreleased]
+### Breaking Changes
+
+- Removed the `writeLine` and `writeLineSync` methods from the public `SessionStorageWriter` contract, requiring custom `SessionStorage` backends to switch to the `append` API
+
+### Added
+
+- Added package-level exports for `SessionContext`, session entry types, session listing/loader helpers, and migration APIs via `session/session-context`, `session/session-entries`, `session/session-listing`, `session/session-loader`, and `session/session-migrations`
+- Added asynchronous session-write `append(...)`-based persistence API in session storage implementations so callers can stream writes without sync line-appending methods
### Changed
+- Changed session persistence internals to expose `writeTextAtomic(...)` on session storage writers for atomic whole-file replacements
- Changed online session-title generation to support tool-choice-less title models. Providers/models that cannot be forced to call a tool (chat-completions hosts without `tool_choice` support such as DeepSeek V4, and Claude Fable/Mythos) are now prompted to wrap the title in `
...` markers instead of the `set_title` tool call; extraction is lenient, accepting a plain sentence or a truncated/unclosed tag. A `TITLE_SYSTEM.md` override is reused in this mode with the marker instruction appended.
### Fixed
@@ -12,6 +21,10 @@
- Fixed submitted user messages emitting OSC 133 command-start markers without a matching command-finished marker, which made some terminals group later transcript output under the first prompt instead of appending it normally.
- Fixed queued forced tool choices being rejected and requeued or dropped when their named tool is no longer active for the upcoming turn, preventing eager todo and pending-action reminders from forcing unavailable tools. ([#1701](https://github.com/can1357/oh-my-pi/issues/1701))
+### Removed
+
+- Removed the `re-roots past a cwd-less legacy session in a shared explicit sessionDir` relocation test case and the `stores symlink-equivalent home cwd sessions under home-relative directories` file-operations test case.
+
## [15.12.5] - 2026-06-13
### Changed
diff --git a/packages/coding-agent/src/autoresearch/state.ts b/packages/coding-agent/src/autoresearch/state.ts
index 667f9e8be..38bd98758 100644
--- a/packages/coding-agent/src/autoresearch/state.ts
+++ b/packages/coding-agent/src/autoresearch/state.ts
@@ -1,4 +1,4 @@
-import type { SessionEntry } from "../session/session-manager";
+import type { SessionEntry } from "../session/session-entries";
import { inferMetricUnitFromName, isBetter } from "./helpers";
import type { RunRow, SessionRow } from "./storage";
import type {
diff --git a/packages/coding-agent/src/autoresearch/types.ts b/packages/coding-agent/src/autoresearch/types.ts
index f442166fe..4bba9bdff 100644
--- a/packages/coding-agent/src/autoresearch/types.ts
+++ b/packages/coding-agent/src/autoresearch/types.ts
@@ -1,6 +1,6 @@
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import type { ExtensionAPI, ExtensionContext } from "../extensibility/extensions";
-import type { SessionEntry } from "../session/session-manager";
+import type { SessionEntry } from "../session/session-entries";
import type { TruncationResult } from "../session/streaming-output";
export type MetricDirection = "lower" | "higher";
diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts
index f83cf5a34..cebb0a8cf 100644
--- a/packages/coding-agent/src/cli/session-picker.ts
+++ b/packages/coding-agent/src/cli/session-picker.ts
@@ -2,7 +2,8 @@ import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import { SessionSelectorComponent } from "../modes/components/session-selector";
import { HistoryStorage } from "../session/history-storage";
-import { type SessionInfo, SessionManager } from "../session/session-manager";
+import type { SessionInfo } from "../session/session-listing";
+import { SessionManager } from "../session/session-manager";
import { FileSessionStorage } from "../session/session-storage";
/**
diff --git a/packages/coding-agent/src/collab/host.ts b/packages/coding-agent/src/collab/host.ts
index 7a30e4aec..f4b952a88 100644
--- a/packages/coding-agent/src/collab/host.ts
+++ b/packages/coding-agent/src/collab/host.ts
@@ -20,7 +20,7 @@ import { AgentLifecycleManager } from "../registry/agent-lifecycle";
import { AgentRegistry } from "../registry/agent-registry";
import type { AgentSessionEvent } from "../session/agent-session";
import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages";
-import type { SessionEntry as StoredSessionEntry } from "../session/session-manager";
+import type { SessionEntry as StoredSessionEntry } from "../session/session-entries";
import { TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL } from "../task";
import { generateRoomKey, generateWriteToken, importRoomKey } from "./crypto";
import {
diff --git a/packages/coding-agent/src/collab/protocol.ts b/packages/coding-agent/src/collab/protocol.ts
index fd46c3b02..7b6513511 100644
--- a/packages/coding-agent/src/collab/protocol.ts
+++ b/packages/coding-agent/src/collab/protocol.ts
@@ -25,7 +25,7 @@ import {
} from "@oh-my-pi/pi-wire";
import type { ContextUsage } from "../extensibility/extensions/types";
import type { AgentSessionEvent } from "../session/agent-session";
-import type { SessionEntry, SessionHeader } from "../session/session-manager";
+import type { SessionEntry, SessionHeader } from "../session/session-entries";
export type {
CollabPromptDetails,
diff --git a/packages/coding-agent/src/eval/__tests__/budget-bridge.test.ts b/packages/coding-agent/src/eval/__tests__/budget-bridge.test.ts
index e54345b8f..9e3af45e3 100644
--- a/packages/coding-agent/src/eval/__tests__/budget-bridge.test.ts
+++ b/packages/coding-agent/src/eval/__tests__/budget-bridge.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import type { GoalModeState } from "../../goals/state";
-import type { UsageStatistics } from "../../session/session-manager";
+import type { UsageStatistics } from "../../session/session-entries";
import type { ToolSession } from "../../tools";
import { runEvalBudget } from "../budget-bridge";
diff --git a/packages/coding-agent/src/export/html/index.ts b/packages/coding-agent/src/export/html/index.ts
index 6f4bccad8..e2f7222d3 100644
--- a/packages/coding-agent/src/export/html/index.ts
+++ b/packages/coding-agent/src/export/html/index.ts
@@ -3,12 +3,9 @@ import * as path from "node:path";
import type { AgentState } from "@oh-my-pi/pi-agent-core";
import { APP_NAME, isEnoent } from "@oh-my-pi/pi-utils";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/theme";
-import {
- loadEntriesFromFile,
- type SessionEntry,
- type SessionHeader,
- SessionManager,
-} from "../../session/session-manager";
+import type { SessionEntry, SessionHeader } from "../../session/session-entries";
+import { loadEntriesFromFile } from "../../session/session-loader";
+import { SessionManager } from "../../session/session-manager";
import templateCss from "./template.css" with { type: "text" };
import templateHtml from "./template.html" with { type: "text" };
import templateJs from "./template.js" with { type: "text" };
diff --git a/packages/coding-agent/src/extensibility/hooks/index.ts b/packages/coding-agent/src/extensibility/hooks/index.ts
index 101337bdf..ec614de93 100644
--- a/packages/coding-agent/src/extensibility/hooks/index.ts
+++ b/packages/coding-agent/src/extensibility/hooks/index.ts
@@ -1,4 +1,5 @@
-export type { ReadonlySessionManager, UsageStatistics } from "../../session/session-manager";
+export type { UsageStatistics } from "../../session/session-entries";
+export type { ReadonlySessionManager } from "../../session/session-manager";
export * from "./loader";
export * from "./runner";
export * from "./tool-wrapper";
diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts
index 713fd54bf..624073297 100644
--- a/packages/coding-agent/src/extensibility/shared-events.ts
+++ b/packages/coding-agent/src/extensibility/shared-events.ts
@@ -17,7 +17,7 @@ import type { CompactionPreparation, CompactionResult } from "@oh-my-pi/pi-agent
import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { Rule } from "../capability/rule";
import type { Goal, GoalModeState } from "../goals/state";
-import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-manager";
+import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-entries";
import type { TodoItem } from "../tools/todo";
// ============================================================================
diff --git a/packages/coding-agent/src/goals/state.ts b/packages/coding-agent/src/goals/state.ts
index eac2be727..fb4170731 100644
--- a/packages/coding-agent/src/goals/state.ts
+++ b/packages/coding-agent/src/goals/state.ts
@@ -1,4 +1,4 @@
-import type { UsageStatistics } from "../session/session-manager";
+import type { UsageStatistics } from "../session/session-entries";
export type GoalStatus = "active" | "paused" | "budget-limited" | "complete" | "dropped";
diff --git a/packages/coding-agent/src/hindsight/transcript.ts b/packages/coding-agent/src/hindsight/transcript.ts
index df4c7f4b1..b7d109221 100644
--- a/packages/coding-agent/src/hindsight/transcript.ts
+++ b/packages/coding-agent/src/hindsight/transcript.ts
@@ -8,7 +8,7 @@
*/
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
-import type { SessionEntry } from "../session/session-manager";
+import type { SessionEntry } from "../session/session-entries";
import type { HindsightMessage } from "./content";
export interface ReadonlySessionManagerLike {
diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts
index 46789d15e..a28f0c0a6 100644
--- a/packages/coding-agent/src/index.ts
+++ b/packages/coding-agent/src/index.ts
@@ -42,8 +42,13 @@ export * from "./session/auth-storage";
export * from "./session/indexed-session-storage";
export * from "./session/messages";
export * from "./session/redis-session-storage";
+export * from "./session/session-context";
export * from "./session/session-dump-format";
+export * from "./session/session-entries";
+export * from "./session/session-listing";
+export * from "./session/session-loader";
export * from "./session/session-manager";
+export * from "./session/session-migrations";
export * from "./session/session-storage";
export * from "./session/sql-session-storage";
export * from "./task/executor";
diff --git a/packages/coding-agent/src/internal-urls/history-protocol.ts b/packages/coding-agent/src/internal-urls/history-protocol.ts
index 576af1ac1..89e96714e 100644
--- a/packages/coding-agent/src/internal-urls/history-protocol.ts
+++ b/packages/coding-agent/src/internal-urls/history-protocol.ts
@@ -12,7 +12,7 @@
import type { AgentRef } from "../registry/agent-registry";
import { AgentRegistry } from "../registry/agent-registry";
import { formatSessionHistoryMarkdown } from "../session/session-history-format";
-import { loadSessionMessagesReadOnly } from "../session/session-manager";
+import { loadSessionMessagesReadOnly } from "../session/session-loader";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
/** Humanize a last-activity timestamp as `Ns/Nm/Nh/Nd ago`. */
diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts
index e22741244..b566813df 100644
--- a/packages/coding-agent/src/main.ts
+++ b/packages/coding-agent/src/main.ts
@@ -64,7 +64,8 @@ import {
} from "./sdk";
import type { AgentSession } from "./session/agent-session";
import type { AuthStorage } from "./session/auth-storage";
-import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
+import { resolveResumableSession, type SessionInfo } from "./session/session-listing";
+import { SessionManager } from "./session/session-manager";
import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt";
import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts
index 33c58fa21..a2fc34824 100644
--- a/packages/coding-agent/src/modes/acp/acp-agent.ts
+++ b/packages/coding-agent/src/modes/acp/acp-agent.ts
@@ -63,11 +63,9 @@ import { theme } from "../../modes/theme/theme";
import { type PlanApprovalDetails, resolveApprovedPlan } from "../../plan-mode/approved-plan";
import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
-import {
- SessionManager,
- type SessionInfo as StoredSessionInfo,
- type UsageStatistics,
-} from "../../session/session-manager";
+import type { UsageStatistics } from "../../session/session-entries";
+import type { SessionInfo as StoredSessionInfo } from "../../session/session-listing";
+import { SessionManager } from "../../session/session-manager";
import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands";
import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts
index 5651b9b20..b54974d08 100644
--- a/packages/coding-agent/src/modes/components/agent-hub.ts
+++ b/packages/coding-agent/src/modes/components/agent-hub.ts
@@ -35,8 +35,8 @@ import {
type SkillPromptDetails,
USER_INTERRUPT_LABEL,
} from "../../session/messages";
-import type { SessionMessageEntry } from "../../session/session-manager";
-import { parseSessionEntries } from "../../session/session-manager";
+import type { SessionMessageEntry } from "../../session/session-entries";
+import { parseSessionEntries } from "../../session/session-loader";
import { createIrcMessageCard } from "../../tools/irc";
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
import { hasVisibleThinking } from "../../utils/thinking-display";
diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts
index e4fede6cb..41765f6be 100644
--- a/packages/coding-agent/src/modes/components/session-selector.ts
+++ b/packages/coding-agent/src/modes/components/session-selector.ts
@@ -15,7 +15,7 @@ import {
import { formatBytes } from "@oh-my-pi/pi-utils";
import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
-import type { SessionInfo, SessionStatus } from "../../session/session-manager";
+import type { SessionInfo, SessionStatus } from "../../session/session-listing";
import { shortenPath } from "../../tools/render-utils";
import { DynamicBorder } from "./dynamic-border";
import { HookSelectorComponent } from "./hook-selector";
diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts
index 8d659cb1d..bb539bd0e 100644
--- a/packages/coding-agent/src/modes/components/tool-execution.ts
+++ b/packages/coding-agent/src/modes/components/tool-execution.ts
@@ -8,6 +8,7 @@ import {
Image,
ImageProtocol,
imageFallback,
+ type NativeScrollbackLiveRegion,
Spacer,
TERMINAL,
Text,
@@ -160,7 +161,7 @@ let toolExecutionInstanceSeq = 0;
/**
* Component that renders a tool call with its result (updateable)
*/
-export class ToolExecutionComponent extends Container {
+export class ToolExecutionComponent extends Container implements NativeScrollbackLiveRegion {
#contentBox: Box; // Used for custom tools and bash visual truncation
#contentText: Text; // For built-in tools (with its own padding/bg)
#multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
@@ -568,6 +569,17 @@ export class ToolExecutionComponent extends Container {
}
}
+ /**
+ * Standalone harnesses may mount a tool component directly under `TUI`
+ * instead of inside `TranscriptContainer`. In that shape the component must
+ * report its own live-region seam for provisional previews, or the core
+ * renderer treats it like shell output and commits tail-window edit/eval/bash
+ * previews to immutable native scrollback before the result replaces them.
+ */
+ getNativeScrollbackLiveRegionStart(): number | undefined {
+ return !this.isTranscriptBlockFinalized() && !this.isTranscriptBlockCommitStable() ? 0 : undefined;
+ }
+
/**
* Whether this block has reached a terminal state for transcript freezing.
* Reports `false` while it can still visually change so the
@@ -591,28 +603,20 @@ export class ToolExecutionComponent extends Container {
/**
* Whether this still-live block's settled rows may enter native scrollback
- * (see `FinalizableBlock.isTranscriptBlockCommitStable`). Classification is
- * per renderer (`ToolRenderer.provisionalPendingPreview`): tail-window
- * streaming views (edit's streamed-diff tail, bash/ssh command caps, eval
- * cells) are re-anchored top-first by the result render, so promoting
- * their visually static head — e.g. an edit preview idling on its last
- * frame while the apply + LSP pass runs — would strand a stale copy of
- * the call box above the final block the moment the result lands. Every
- * other pending preview streams top-anchored append-shaped rows the
- * result render preserves (a task call's context/assignment markdown, a
- * write's content), so it stays commit-eligible — a call taller than the
- * viewport scrolls into native history mid-stream instead of reading as
- * cut off until the result. Expanded blocks always stream top-anchored
- * (the over-tall write/eval scrollback contract). Displaceable waiting
- * polls are removed wholesale by the next poll and must never commit.
+ * (see `FinalizableBlock.isTranscriptBlockCommitStable`). Renderers classify
+ * pending views by durability instead of by tool name: a provisional view is
+ * allowed to be useful on screen, but finalization may replace or re-anchor
+ * it wholesale, so committing any of its rows would strand stale preview
+ * bytes in immutable scrollback. Non-provisional views stream rows whose
+ * committed prefix survives the remaining transitions.
*/
isTranscriptBlockCommitStable(): boolean {
if (this.#displaceable) return false;
- if (this.#expanded || this.isTranscriptBlockFinalized()) return true;
- if ((this.#tool as { provisionalPendingPreview?: boolean } | undefined)?.provisionalPendingPreview) {
- return false;
- }
- return !toolRenderers[this.#toolName]?.provisionalPendingPreview;
+ if (this.isTranscriptBlockFinalized()) return true;
+ const tool = this.#tool as { provisionalPendingPreview?: boolean | "collapsed" } | undefined;
+ const provisionalPendingPreview =
+ tool?.provisionalPendingPreview ?? toolRenderers[this.#toolName]?.provisionalPendingPreview;
+ return provisionalPendingPreview !== true && (provisionalPendingPreview !== "collapsed" || this.#expanded);
}
/**
diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts
index 616f6d515..639bbae16 100644
--- a/packages/coding-agent/src/modes/components/tree-selector.ts
+++ b/packages/coding-agent/src/modes/components/tree-selector.ts
@@ -15,7 +15,7 @@ import {
import type { TreeFilterMode } from "../../config/settings-schema";
import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
-import type { SessionTreeNode } from "../../session/session-manager";
+import type { SessionTreeNode } from "../../session/session-entries";
import { shortenPath } from "../../tools/render-utils";
import { toPathList } from "../../tools/search";
import { DynamicBorder } from "./dynamic-border";
diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts
index 8d10d01fd..1e4874294 100644
--- a/packages/coding-agent/src/modes/controllers/command-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/command-controller.ts
@@ -38,7 +38,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
import type { AsyncJobSnapshotItem } from "../../session/agent-session";
import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
-import type { NewSessionOptions } from "../../session/session-manager";
+import type { NewSessionOptions } from "../../session/session-entries";
import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
import { outputMeta } from "../../tools/output-meta";
diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts
index 5fc2dd526..50aae0aa0 100644
--- a/packages/coding-agent/src/modes/controllers/selector-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts
@@ -28,7 +28,8 @@ import {
} from "../../modes/theme/theme";
import type { InteractiveModeContext } from "../../modes/types";
import type { ResetCreditRedeemOutcome } from "../../session/auth-storage";
-import { type SessionInfo, SessionManager } from "../../session/session-manager";
+import type { SessionInfo } from "../../session/session-listing";
+import { SessionManager } from "../../session/session-manager";
import { FileSessionStorage } from "../../session/session-storage";
import { type LogoutAccount, toLogoutAccounts } from "../../slash-commands/helpers/logout";
import {
diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts
index 221787c27..ac6731d6c 100644
--- a/packages/coding-agent/src/modes/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive-mode.ts
@@ -80,8 +80,9 @@ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compa
};
import type { AgentSession, AgentSessionEvent, ResolvedRoleModel } from "../session/agent-session";
import { HistoryStorage } from "../session/history-storage";
-import type { SessionContext, SessionManager } from "../session/session-manager";
-import { getRecentSessions } from "../session/session-manager";
+import type { SessionContext } from "../session/session-context";
+import { getRecentSessions } from "../session/session-listing";
+import type { SessionManager } from "../session/session-manager";
import type { ShakeMode } from "../session/shake-types";
import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES, BUILTIN_SLASH_COMMANDS } from "../slash-commands/builtin-registry";
import { formatDuration } from "../slash-commands/helpers/format";
diff --git a/packages/coding-agent/src/modes/rpc/rpc-subagents.ts b/packages/coding-agent/src/modes/rpc/rpc-subagents.ts
index 8a4446a63..6d39becc2 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-subagents.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-subagents.ts
@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import { isEnoent } from "@oh-my-pi/pi-utils";
-import type { FileEntry, SessionMessageEntry } from "../../session/session-manager";
-import { parseSessionEntries } from "../../session/session-manager";
+import type { FileEntry, SessionMessageEntry } from "../../session/session-entries";
+import { parseSessionEntries } from "../../session/session-loader";
import {
type AgentProgress,
type SubagentEventPayload,
diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts
index efbbada43..431490039 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-types.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts
@@ -10,7 +10,7 @@ import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai";
import type { BashResult } from "../../exec/bash-executor";
import type { ContextUsage } from "../../extensibility/extensions/types";
import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
-import type { FileEntry } from "../../session/session-manager";
+import type { FileEntry } from "../../session/session-entries";
import type { AvailableSlashCommandSource } from "../../slash-commands/available-commands";
import type {
AgentProgress,
diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts
index dbf5a7aca..cad9b8485 100644
--- a/packages/coding-agent/src/modes/types.ts
+++ b/packages/coding-agent/src/modes/types.ts
@@ -18,7 +18,8 @@ import type { MCPManager } from "../mcp";
import type { PlanApprovalDetails } from "../plan-mode/approved-plan";
import type { AgentSession } from "../session/agent-session";
import type { HistoryStorage } from "../session/history-storage";
-import type { SessionContext, SessionManager } from "../session/session-manager";
+import type { SessionContext } from "../session/session-context";
+import type { SessionManager } from "../session/session-manager";
import type { ShakeMode } from "../session/shake-types";
import type { LspStartupServerInfo } from "../tools";
import type { EventBus } from "../utils/event-bus";
diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts
index 4bc9540f4..b14655b4c 100644
--- a/packages/coding-agent/src/modes/utils/ui-helpers.ts
+++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts
@@ -36,7 +36,7 @@ import {
SKILL_PROMPT_MESSAGE_TYPE,
type SkillPromptDetails,
} from "../../session/messages";
-import type { SessionContext } from "../../session/session-manager";
+import type { SessionContext } from "../../session/session-context";
import { createIrcMessageCard } from "../../tools/irc";
import { formatBytes, formatDuration } from "../../tools/render-utils";
import { hasVisibleThinking } from "../../utils/thinking-display";
diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts
index 65631157c..faf977da2 100644
--- a/packages/coding-agent/src/sdk.ts
+++ b/packages/coding-agent/src/sdk.ts
@@ -128,7 +128,8 @@ import {
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
wrapSteeringForModel,
} from "./session/messages";
-import { getRestorableSessionModels, SessionManager } from "./session/session-manager";
+import { getRestorableSessionModels } from "./session/session-context";
+import { SessionManager } from "./session/session-manager";
import { SnapcompactInlineTransformer } from "./session/snapcompact-inline";
import { closeAllConnections } from "./ssh/connection-manager";
import { unmountAll } from "./ssh/sshfs-mount";
diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts
index ef0845f79..f34c3b7b1 100644
--- a/packages/coding-agent/src/secrets/obfuscator.ts
+++ b/packages/coding-agent/src/secrets/obfuscator.ts
@@ -1,6 +1,6 @@
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
-import type { SessionContext } from "../session/session-manager";
+import type { SessionContext } from "../session/session-context";
import { compileSecretRegex } from "./regex";
// ═══════════════════════════════════════════════════════════════════════════
diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts
index 055f3f2bd..ce4ce170c 100644
--- a/packages/coding-agent/src/session/agent-session.ts
+++ b/packages/coding-agent/src/session/agent-session.ts
@@ -258,15 +258,12 @@ import {
SKILL_PROMPT_MESSAGE_TYPE,
stripImagesFromMessage,
} from "./messages";
+import type { SessionContext } from "./session-context";
+import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
import { formatSessionDumpText } from "./session-dump-format";
-import type {
- BranchSummaryEntry,
- CompactionEntry,
- NewSessionOptions,
- SessionContext,
- SessionManager,
-} from "./session-manager";
-import { EPHEMERAL_MODEL_CHANGE_ROLE, getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
+import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
+import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
+import type { SessionManager } from "./session-manager";
import type { ShakeMode, ShakeResult } from "./shake-types";
import { ToolChoiceQueue } from "./tool-choice-queue";
import { YieldQueue } from "./yield-queue";
@@ -931,6 +928,7 @@ export class AgentSession {
/** Messages queued to be included with the next user prompt as context ("asides"). */
#pendingNextTurnMessages: CustomMessage[] = [];
#scheduledHiddenNextTurnGeneration: number | undefined = undefined;
+ #queuedMessageDrainScheduled = false;
#planModeState: PlanModeState | undefined;
#goalModeState: GoalModeState | undefined;
#goalRuntime: GoalRuntime;
@@ -1085,6 +1083,7 @@ export class AgentSession {
#streamingEditFileCache = new Map();
#promptInFlightCount = 0;
+ #abortInProgress = false;
// Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
// Internal extension hooks and post-emit work (auto-retry, auto-compaction, todo
// checks in #handleAgentEvent) still fire on the original schedule — only the
@@ -1160,10 +1159,8 @@ export class AgentSession {
* Runs whenever the session settles; the guard makes it a no-op when the
* queue was consumed normally or a new turn already started. */
#drainStrandedQueuedMessages(): void {
- if (!this.agent.hasQueuedMessages()) return;
- this.#scheduleAgentContinue({
- shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
- });
+ if (this.#abortInProgress) return;
+ this.#scheduleQueuedMessageDrain();
}
#resetInFlight(): void {
@@ -1916,6 +1913,11 @@ export class AgentSession {
return;
}
+ // A deliberate abort should settle the current turn, not trigger queued continuations.
+ if (msg.stopReason === "aborted") {
+ this.#resolveRetry();
+ return;
+ }
// Check for retryable errors first (overloaded, rate limit, server errors)
if (this.#isRetryableError(msg)) {
const didRetry = await this.#handleRetryableError(msg);
@@ -1938,7 +1940,7 @@ export class AgentSession {
if (compactionDeferredHandoff) {
return;
}
- if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
+ if (msg.stopReason !== "error") {
if (this.#enforceRewindBeforeYield()) {
return;
}
@@ -2034,13 +2036,13 @@ export class AgentSession {
onError?: () => void;
}): void {
this.#schedulePostPromptTask(
- async () => {
+ async signal => {
// Defense in depth: if compaction/handoff slipped onto the post-prompt queue
// alongside us (e.g. via a scheduler we don't own), refuse to start a fresh
// streaming turn — agent.continue() here would race the handoff's session
// reset. The first-class fix is in #checkCompaction/the agent_end handler,
// but this guard catches anything that bypasses that path.
- if (this.isCompacting || this.isGeneratingHandoff) {
+ if (signal.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) {
options?.onSkip?.();
return;
}
@@ -2051,6 +2053,10 @@ export class AgentSession {
this.#beginInFlight();
try {
await this.#maybeRestoreRetryFallbackPrimary();
+ if (signal.aborted || this.#isDisposed) {
+ options?.onSkip?.();
+ return;
+ }
await this.agent.continue();
} catch (error) {
logger.warn("agent.continue failed after scheduling", {
@@ -3162,8 +3168,9 @@ export class AgentSession {
// session's dispose.
this.abortRetry();
this.abortCompaction();
+ const postPromptDrain = this.#cancelPostPromptTasks();
this.agent.abort();
- await this.#cancelPostPromptTasks();
+ await postPromptDrain;
// Cancel jobs this agent registered so a subagent's teardown doesn't
// leak its background bash/task work into the parent's manager. Only
// the session that owns the manager goes on to dispose it (which itself
@@ -5061,9 +5068,25 @@ export class AgentSession {
}
#scheduleIdleQueueDrain(): void {
- if (!this.#canAutoContinueForFollowUp()) return;
+ this.#scheduleQueuedMessageDrain();
+ }
+
+ #scheduleQueuedMessageDrain(): void {
+ if (this.#queuedMessageDrainScheduled || !this.#canAutoContinueForFollowUp() || !this.agent.hasQueuedMessages()) {
+ return;
+ }
+ this.#queuedMessageDrainScheduled = true;
this.#scheduleAgentContinue({
- shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
+ shouldContinue: () => {
+ this.#queuedMessageDrainScheduled = false;
+ return this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages();
+ },
+ onSkip: () => {
+ this.#queuedMessageDrainScheduled = false;
+ },
+ onError: () => {
+ this.#queuedMessageDrainScheduled = false;
+ },
});
}
@@ -5391,28 +5414,37 @@ export class AgentSession {
* abort. Omit it for internal/lifecycle aborts.
*/
async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string }): Promise {
- this.abortRetry();
- this.#promptGeneration++;
- this.#scheduledHiddenNextTurnGeneration = undefined;
- this.abortCompaction();
- this.abortHandoff();
- this.abortBash();
- this.abortEval();
- const postPromptDrain = this.#cancelPostPromptTasks();
- this.agent.abort(options?.reason);
- await postPromptDrain;
- await this.agent.waitForIdle();
- await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
- // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
- // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
- // a subsequent prompt() can incorrectly observe the session as busy after an abort.
- this.#resetInFlight();
- // Safety net: if the agent loop aborted without producing an assistant
- // message (e.g. failed before the first stream), the in-flight yield was
- // never resolved or rejected by the normal message_end path. Reject it now
- // so any requeue callback still fires and the queue stays consistent.
- if (this.#toolChoiceQueue.hasInFlight) {
- this.#toolChoiceQueue.reject("aborted");
+ // Session switch/compact paths disconnect first; explicit aborts should
+ // leave any queued steer/follow-up visible for the user rather than
+ // auto-starting a fresh turn during cleanup.
+ this.#abortInProgress = true;
+ try {
+ this.abortRetry();
+ this.#promptGeneration++;
+ this.#scheduledHiddenNextTurnGeneration = undefined;
+ this.abortCompaction();
+ this.abortHandoff();
+ this.abortBash();
+ this.abortEval();
+ const postPromptDrain = this.#cancelPostPromptTasks();
+ this.agent.abort(options?.reason);
+ await postPromptDrain;
+ await this.agent.waitForIdle();
+ await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
+ // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
+ // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
+ // a subsequent prompt() can incorrectly observe the session as busy after an abort.
+ this.#resetInFlight();
+ // Safety net: if the agent loop aborted without producing an assistant
+ // message (e.g. failed before the first stream), the in-flight yield was
+ // never resolved or rejected by the normal message_end path. Reject it now
+ // so any requeue callback still fires and the queue stays consistent.
+ if (this.#toolChoiceQueue.hasInFlight) {
+ this.#toolChoiceQueue.reject("aborted");
+ }
+ } finally {
+ this.#abortInProgress = false;
+ this.#drainStrandedQueuedMessages();
}
}
diff --git a/packages/coding-agent/src/session/indexed-session-storage.ts b/packages/coding-agent/src/session/indexed-session-storage.ts
index 36d5508fa..27d15098b 100644
--- a/packages/coding-agent/src/session/indexed-session-storage.ts
+++ b/packages/coding-agent/src/session/indexed-session-storage.ts
@@ -173,6 +173,10 @@ export class IndexedSessionStorage implements SessionStorage {
}
}
+ writeTextAtomic(path: string, content: string): Promise {
+ return this.writeText(path, content);
+ }
+
async rename(src: string, dst: string): Promise {
await this.#awaitPath(src);
await this.#awaitPath(dst);
@@ -390,14 +394,7 @@ class IndexedSessionStorageWriter implements SessionStorageWriter {
return next;
}
- writeLineSync(line: string): void {
- if (this.#closed) throw new Error("Writer closed");
- if (this.#error) throw this.#error;
- const mtimeMs = this.#storage._appendForWriter(this.#path, line);
- this.#trackPromise(this.#storage._queueAppend(this.#path, line, mtimeMs, () => this.#error));
- }
-
- async writeLine(line: string): Promise {
+ async append(line: string): Promise {
if (this.#closed) throw new Error("Writer closed");
if (this.#error) throw this.#error;
const mtimeMs = this.#storage._appendForWriter(this.#path, line);
@@ -410,15 +407,8 @@ class IndexedSessionStorageWriter implements SessionStorageWriter {
if (this.#error) throw this.#error;
}
- async fsync(): Promise {
- await this.flush();
- }
-
- fsyncSync(): void {
- // Indexed storage has no real fd to fsync; drain the pending chain
- // synchronously is not possible, so this is a no-op. The async flush()
- // above already ensures durability for the indexed backend.
- if (this.#error) throw this.#error;
+ isOpen(): boolean {
+ return !this.#closed;
}
async close(): Promise {
diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts
new file mode 100644
index 000000000..dd46f079d
--- /dev/null
+++ b/packages/coding-agent/src/session/session-context.ts
@@ -0,0 +1,352 @@
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { ProviderPayload, ServiceTier } from "@oh-my-pi/pi-ai";
+import * as snapcompact from "@oh-my-pi/snapcompact";
+import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "./messages";
+import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry } from "./session-entries";
+
+export interface SessionContext {
+ messages: AgentMessage[];
+ thinkingLevel?: string;
+ serviceTier?: ServiceTier;
+ /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
+ models: Record;
+ /** 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;
+}
+
+/** Lists session model strings to try when restoring, in fallback order. */
+export function getRestorableSessionModels(
+ models: Readonly>,
+ lastModelChangeRole: string | undefined,
+): string[] {
+ const defaultModel = models.default;
+ if (
+ !lastModelChangeRole ||
+ lastModelChangeRole === "default" ||
+ lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
+ ) {
+ return defaultModel ? [defaultModel] : [];
+ }
+
+ const roleModel = models[lastModelChangeRole];
+ if (!roleModel) return defaultModel ? [defaultModel] : [];
+ if (!defaultModel || roleModel === defaultModel) return [roleModel];
+ return [roleModel, defaultModel];
+}
+
+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;
+}
+
+export interface BuildSessionContextOptions {
+ /**
+ * Build the full-history display transcript instead of the LLM context:
+ * every path entry in chronological order, with each compaction emitted
+ * inline as a `compactionSummary` message at the position it fired rather
+ * than replacing the history before it. Display-only — never send the
+ * result to a provider.
+ */
+ transcript?: boolean;
+}
+
+/**
+ * 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,
+ options?: BuildSessionContextOptions,
+): SessionContext {
+ // Build uuid index if not available
+ if (!byId) {
+ byId = new Map();
+ 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 = {};
+ let compaction: CompactionEntry | null = null;
+ const injectedTtsrRulesSet = new Set();
+ let selectedMCPToolNames: string[] = [];
+ let hasPersistedMCPToolSelection = false;
+ let mode = "none";
+ let modeData: Record | 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 (options?.transcript) {
+ // Display transcript: every entry in chronological order. Compactions do
+ // not erase prior history here — each renders inline (as a divider in the
+ // TUI) at the point it fired, with any snapcompact frames re-attached so
+ // the component can report them.
+ for (const entry of path) {
+ if (entry.type === "compaction") {
+ const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
+ messages.push(
+ createCompactionSummaryMessage(
+ entry.summary,
+ entry.tokensBefore,
+ entry.timestamp,
+ entry.shortSummary,
+ undefined,
+ snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
+ ),
+ );
+ } else {
+ appendMessage(entry);
+ }
+ }
+ } else 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>,
+ };
+ })();
+ const remoteReplacementHistory = providerPayload?.items;
+
+ // Emit summary first; re-attach any archived snapcompact frames so the
+ // model can keep reading the archived history after every context rebuild.
+ const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
+ messages.push(
+ createCompactionSummaryMessage(
+ compaction.summary,
+ compaction.tokensBefore,
+ compaction.timestamp,
+ compaction.shortSummary,
+ providerPayload,
+ snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
+ ),
+ );
+
+ // 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, 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();
+ 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,
+ };
+}
diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts
new file mode 100644
index 000000000..099a15938
--- /dev/null
+++ b/packages/coding-agent/src/session/session-entries.ts
@@ -0,0 +1,194 @@
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { ImageContent, MessageAttribution, ServiceTier, TextContent } from "@oh-my-pi/pi-ai";
+
+export const CURRENT_SESSION_VERSION = 3;
+
+export const EPHEMERAL_MODEL_CHANGE_ROLE = "fallback";
+
+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 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;
+ /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
+ fromExtension?: boolean;
+}
+
+export interface BranchSummaryEntry 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 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;
+}
+
+/**
+ * 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 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 UsageStatistics {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ premiumRequests: number;
+ cost: number;
+}
diff --git a/packages/coding-agent/src/session/session-listing.ts b/packages/coding-agent/src/session/session-listing.ts
new file mode 100644
index 000000000..aa8ed5fae
--- /dev/null
+++ b/packages/coding-agent/src/session/session-listing.ts
@@ -0,0 +1,588 @@
+import * as os from "node:os";
+import * as path from "node:path";
+import type { Message, TextContent } from "@oh-my-pi/pi-ai";
+import { getAgentDir as getDefaultAgentDir, logger, parseJsonlLenient, toError } from "@oh-my-pi/pi-utils";
+import { computeDefaultSessionDir } from "./session-paths";
+import { FileSessionStorage, type SessionStorage } from "./session-storage";
+
+/**
+ * Coarse lifecycle status of a session, derived from its last persisted message.
+ *
+ * - `complete` — the last assistant turn ended with no unanswered tool calls, i.e.
+ * the agent yielded control back to the user.
+ * - `interrupted` — work was cut off mid-flight: a trailing assistant turn with
+ * pending tool calls, a trailing tool result the agent never continued from, or
+ * a length-truncated turn.
+ * - `aborted` — the last assistant turn was cancelled by the user.
+ * - `error` — the last assistant turn ended in an error.
+ * - `pending` — a trailing user message with no assistant reply persisted after it.
+ * - `unknown` — status could not be determined (empty/header-only session, or the
+ * final message was larger than the tail window that was read).
+ */
+export type SessionStatus = "complete" | "interrupted" | "aborted" | "error" | "pending" | "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;
+ /**
+ * Coarse lifecycle status from the session's last persisted message. Optional:
+ * synthesized {@link SessionInfo}s (cross-project stubs, tests) leave it unset.
+ */
+ status?: SessionStatus;
+}
+
+export interface ResolvedSessionMatch {
+ session: SessionInfo;
+ scope: "local" | "global";
+}
+
+/** Lightweight metadata for a recent session, used in welcome/picker UI. */
+export interface RecentSessionInfo {
+ path: string;
+ name: string;
+ timeAgo: string;
+}
+
+const SESSION_LIST_PREFIX_BYTES = 4096;
+/**
+ * Tail window read to derive {@link SessionStatus}. Large enough to capture a
+ * typical final assistant turn (thinking + text); when the final message exceeds
+ * it the status falls back to `unknown` rather than misreporting.
+ */
+const SESSION_LIST_SUFFIX_BYTES = 32_768;
+const SESSION_LIST_PARALLEL_THRESHOLD = 64;
+const SESSION_LIST_MAX_WORKERS = 16;
+
+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;
+}
+
+/** 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();
+}
+
+/**
+ * Friendly display name for a session: explicit title, then first user prompt,
+ * then a timestamp-based label. The raw UUID `id` is intentionally never used —
+ * it is unfriendly and indistinguishable from neighboring sessions in the UI.
+ */
+function sessionDisplayName(info: SessionInfo): string {
+ const title = sanitizeSessionName(info.title);
+ if (title) return title;
+ const first =
+ info.firstMessage && info.firstMessage !== "(no messages)" ? sanitizeSessionName(info.firstMessage) : undefined;
+ if (first) return first;
+ const created = info.created.getTime();
+ const ts = Number.isFinite(created) ? created : info.modified.getTime();
+ const date = new Date(ts);
+ const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
+ return `Untitled · ${time}`;
+}
+
+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(" ");
+}
+
+/**
+ * Derive a {@link SessionStatus} from a tail window of a session file. Entries are
+ * newline-terminated on write, so within the window only the first line can be a
+ * partial fragment — it simply fails to parse and is skipped. We walk backwards to
+ * the last `message` entry and classify by its role / stop reason.
+ */
+function deriveSessionStatus(suffix: string): SessionStatus {
+ if (!suffix) return "unknown";
+ const lines = suffix.split("\n");
+ for (let i = lines.length - 1; i >= 0; i--) {
+ const line = lines[i];
+ // Every persisted entry is `JSON.stringify(obj)` → starts with `{`. This
+ // cheaply rejects blank lines and the leading partial fragment without
+ // attempting to parse a multi-KB tail of a truncated line.
+ if (line.charCodeAt(0) !== 123) continue;
+ let entry: { type?: string; message?: TailMessage };
+ try {
+ entry = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ if (entry.type === "message" && entry.message) {
+ return statusFromTailMessage(entry.message);
+ }
+ }
+ return "unknown";
+}
+
+interface TailMessage {
+ role?: string;
+ stopReason?: string;
+ content?: unknown;
+}
+
+function isToolCallBlock(block: unknown): boolean {
+ return typeof block === "object" && block !== null && (block as { type?: unknown }).type === "toolCall";
+}
+
+function statusFromTailMessage(message: TailMessage): SessionStatus {
+ switch (message.role) {
+ case "assistant": {
+ switch (message.stopReason) {
+ case "error":
+ return "error";
+ case "aborted":
+ return "aborted";
+ case "length":
+ return "interrupted";
+ }
+ // A turn that ends without unanswered tool calls means the agent yielded
+ // control back to the user — complete. Trailing tool calls (no tool
+ // results after) mean the loop was cut off before running them.
+ const content = message.content;
+ if (Array.isArray(content) && content.some(isToolCallBlock)) return "interrupted";
+ return "complete";
+ }
+ case "toolResult":
+ // Tools ran but the agent never produced the following assistant turn.
+ return "interrupted";
+ case "user":
+ // User message with no assistant reply persisted after it.
+ return "pending";
+ default:
+ return "unknown";
+ }
+}
+
+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>,
+): 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),
+ );
+}
+
+/**
+ * Scan a single session file into a {@link SessionInfo}. Always reads the 4 KB
+ * header/first-message prefix; only reads the 32 KB tail window (and derives
+ * {@link SessionStatus}) when `withStatus` is set — the recent/most-recent
+ * lookups skip it.
+ */
+async function scanSessionFile(
+ file: string,
+ storage: SessionStorage,
+ withStatus: boolean,
+): Promise {
+ try {
+ const stat = storage.statSync(file);
+ const [content, suffix] = await storage.readTextSlices(
+ file,
+ SESSION_LIST_PREFIX_BYTES,
+ withStatus ? SESSION_LIST_SUFFIX_BYTES : 0,
+ );
+ const { size, mtime } = stat;
+ const entries = parseJsonlLenient>(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));
+ return {
+ path: file,
+ id: header.id,
+ cwd: header.cwd ?? "",
+ title: header.title ?? shortSummary,
+ parentSessionPath: header.parentSession,
+ created: new Date(header.timestamp ?? ""),
+ modified: mtime,
+ messageCount,
+ size,
+ firstMessage: firstMessage || "(no messages)",
+ allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
+ status: withStatus ? deriveSessionStatus(suffix) : undefined,
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+async function collectSessionsFromFileStride(
+ files: string[],
+ storage: SessionStorage,
+ startIndex: number,
+ stride: number,
+ withStatus: boolean,
+): Promise {
+ const sessions: SessionInfo[] = [];
+
+ for (let i = startIndex; i < files.length; i += stride) {
+ const session = await scanSessionFile(files[i], storage, withStatus);
+ if (session) sessions.push(session);
+ }
+
+ return sessions;
+}
+
+async function collectSessionsFromFiles(
+ files: string[],
+ storage: SessionStorage,
+ withStatus: boolean,
+): Promise {
+ const workerCount = getSessionListWorkerCount(files.length);
+ const sessions =
+ workerCount === 1
+ ? await collectSessionsFromFileStride(files, storage, 0, 1, withStatus)
+ : (
+ await Promise.all(
+ Array.from({ length: workerCount }, (_, workerIndex) =>
+ collectSessionsFromFileStride(files, storage, workerIndex, workerCount, withStatus),
+ ),
+ )
+ ).flat();
+
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
+ return sessions;
+}
+
+/**
+ * Promote orphaned `.jsonl..bak` backups created by the
+ * EPERM-rewrite path 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 {
+ 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();
+ for (const backup of backups) {
+ const name = path.basename(backup);
+ // Expect "..bak" where 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,
+ });
+ }
+ }
+}
+
+async function scanSessionDir(
+ sessionDir: string,
+ storage: SessionStorage,
+ withStatus: boolean,
+): Promise {
+ try {
+ await recoverOrphanedBackups(sessionDir, storage);
+ const files = storage.listFilesSync(sessionDir, "*.jsonl");
+ return await collectSessionsFromFiles(files, storage, withStatus);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * List sessions in a resolved session directory (newest first), reading each
+ * file's lifecycle {@link SessionStatus}.
+ */
+export function listSessions(sessionDir: string, storage: SessionStorage): Promise {
+ return scanSessionDir(sessionDir, storage, true);
+}
+
+/** List all sessions across all project directories (newest first). */
+export async function listAllSessions(storage: SessionStorage = new FileSessionStorage()): Promise {
+ 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, true);
+ } catch {
+ return [];
+ }
+}
+
+/** Exported for testing */
+export async function findMostRecentSession(
+ sessionDir: string,
+ storage: SessionStorage = new FileSessionStorage(),
+): Promise {
+ const sessions = await scanSessionDir(sessionDir, storage, false);
+ return sessions[0]?.path ?? null;
+}
+
+/** Get recent sessions for display in the welcome screen. */
+export async function getRecentSessions(
+ sessionDir: string,
+ limit = 4,
+ storage: SessionStorage = new FileSessionStorage(),
+): Promise {
+ const sessions = await scanSessionDir(sessionDir, storage, false);
+ const recent: RecentSessionInfo[] = [];
+ for (let i = 0; i < sessions.length && i < limit; i++) {
+ const info = sessions[i];
+ recent.push({ path: info.path, name: sessionDisplayName(info), timeAgo: formatTimeAgo(info.modified) });
+ }
+ return recent;
+}
+
+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 {
+ const localSessionDir = sessionDir ?? computeDefaultSessionDir(cwd, storage);
+ const localSessions = await listSessions(localSessionDir, storage);
+ const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
+ if (localMatch) {
+ return { session: localMatch, scope: "local" };
+ }
+
+ if (sessionDir) {
+ return undefined;
+ }
+
+ const globalSessions = await listAllSessions(storage);
+ const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
+ if (!globalMatch) {
+ return undefined;
+ }
+
+ return { session: globalMatch, scope: "global" };
+}
diff --git a/packages/coding-agent/src/session/session-loader.ts b/packages/coding-agent/src/session/session-loader.ts
new file mode 100644
index 000000000..702eb16cf
--- /dev/null
+++ b/packages/coding-agent/src/session/session-loader.ts
@@ -0,0 +1,106 @@
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils";
+import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store";
+import { buildSessionContext } from "./session-context";
+import type { FileEntry, SessionEntry, SessionHeader } from "./session-entries";
+import { migrateToCurrentVersion } from "./session-migrations";
+import { isImageBlock } from "./session-persistence";
+import { FileSessionStorage, type SessionStorage } from "./session-storage";
+
+/** Exported for compaction.test.ts */
+export function parseSessionEntries(content: string): FileEntry[] {
+ return parseJsonlLenient(content);
+}
+
+/** Exported for testing */
+export async function loadEntriesFromFile(
+ filePath: string,
+ storage: SessionStorage = new FileSessionStorage(),
+): Promise {
+ let content: string;
+ try {
+ content = await storage.readText(filePath);
+ } catch (err) {
+ if (isEnoent(err)) return [];
+ throw err;
+ }
+ const entries = parseJsonlLenient(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 {
+ 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)));
+}
+
+export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise {
+ const promises: Promise[] = [];
+
+ 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);
+}
+
+/**
+ * Read-only message view of a session file: load entries, migrate to the
+ * current version, resolve blob refs, and build the context along the
+ * persisted leaf path (last entry). Does NOT create a writer or take the
+ * session lock — safe to call against a file another session is writing.
+ */
+export async function loadSessionMessagesReadOnly(filePath: string): Promise {
+ const entries = await loadEntriesFromFile(filePath);
+ if (entries.length === 0) return [];
+ migrateToCurrentVersion(entries);
+ await resolveBlobRefsInEntries(entries, new BlobStore(getBlobsDir()));
+ const sessionEntries = entries.filter((e): e is SessionEntry => e.type !== "session");
+ return buildSessionContext(sessionEntries).messages;
+}
diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts
index e5568b80f..c919d90f9 100644
--- a/packages/coding-agent/src/session/session-manager.ts
+++ b/packages/coding-agent/src/session/session-manager.ts
@@ -1,319 +1,242 @@
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 * as snapcompact from "@oh-my-pi/snapcompact";
+import type { ImageContent, Message, MessageAttribution, ServiceTier, TextContent, Usage } from "@oh-my-pi/pi-ai";
+import { getBlobsDir, getProjectDir, getSessionsDir, isEnoent, logger, toError } from "@oh-my-pi/pi-utils";
import { ArtifactManager } from "./artifacts";
-import {
- type BlobPutOptions,
- type BlobPutResult,
- BlobStore,
- externalizeImageData,
- externalizeImageDataSync,
- externalizeImageDataUrl,
- externalizeImageDataUrlSync,
- isBlobRef,
- isImageDataUrl,
- resolveImageData,
- resolveImageDataUrl,
-} from "./blob-store";
+import { type BlobPutOptions, type BlobPutResult, BlobStore } 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";
+import { type BuildSessionContextOptions, buildSessionContext, type SessionContext } from "./session-context";
+import {
+ type BranchSummaryEntry,
+ type CompactionEntry,
+ CURRENT_SESSION_VERSION,
+ type CustomEntry,
+ type CustomMessageEntry,
+ type FileEntry,
+ type LabelEntry,
+ type MCPToolSelectionEntry,
+ type ModeChangeEntry,
+ type ModelChangeEntry,
+ type NewSessionOptions,
+ type ServiceTierChangeEntry,
+ type SessionEntry,
+ type SessionHeader,
+ type SessionInitEntry,
+ type SessionMessageEntry,
+ type SessionTreeNode,
+ type ThinkingLevelChangeEntry,
+ type TtsrInjectionEntry,
+ type UsageStatistics,
+} from "./session-entries";
+import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing";
+import { loadEntriesFromFile, resolveBlobRefsInEntries } from "./session-loader";
+import { generateId, migrateToCurrentVersion } from "./session-migrations";
+import {
+ computeDefaultSessionDir,
+ readTerminalBreadcrumbEntry,
+ resolveManagedSessionRoot,
+ writeTerminalBreadcrumb,
+} from "./session-paths";
+import { prepareEntryForPersistence } from "./session-persistence";
+import {
+ FileSessionStorage,
+ MemorySessionStorage,
+ type SessionStorage,
+ type SessionStorageWriter,
+} from "./session-storage";
-export const CURRENT_SESSION_VERSION = 3;
+const JSONL_SUFFIX_LENGTH = ".jsonl".length;
-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;
+function mintSessionId(): string {
+ return Bun.randomUUIDv7();
}
-export interface NewSessionOptions {
- parentSession?: string;
- /** Skip flushing the current session and delete it instead of saving. */
- drop?: boolean;
+function nowIso(): string {
+ return new Date().toISOString();
}
-export interface SessionEntryBase {
- type: string;
- id: string;
- parentId: string | null;
- timestamp: string;
+function fileSafeTimestamp(iso: string): string {
+ return iso.replace(/[:.]/g, "-");
}
-export interface SessionMessageEntry extends SessionEntryBase {
- type: "message";
- message: AgentMessage;
+function artifactsDirectoryFor(sessionFile: string | undefined): string | null {
+ return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null;
}
-export interface ThinkingLevelChangeEntry extends SessionEntryBase {
- type: "thinking_level_change";
- thinkingLevel?: string | null;
+function emptyUsageStatistics(): UsageStatistics {
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
}
-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;
+function taskUsageFrom(details: unknown): Usage | undefined {
+ if (details === null || typeof details !== "object") return undefined;
+ const maybeUsage = (details as Record).usage;
+ return maybeUsage !== null && typeof maybeUsage === "object" ? (maybeUsage as Usage) : undefined;
}
-export interface ServiceTierChangeEntry extends SessionEntryBase {
- type: "service_tier_change";
- serviceTier: ServiceTier | null;
+function entryUsage(entry: SessionEntry): Usage | undefined {
+ if (entry.type !== "message") return undefined;
+ const message = entry.message;
+ if (message.role === "assistant") return message.usage;
+ if (message.role === "toolResult" && message.toolName === "task") return taskUsageFrom(message.details);
+ return undefined;
}
-export interface CompactionEntry 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;
- /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
- fromExtension?: boolean;
+function addUsage(target: UsageStatistics, usage: Usage | undefined): void {
+ if (!usage) return;
+ target.input += usage.input;
+ target.output += usage.output;
+ target.cacheRead += usage.cacheRead;
+ target.cacheWrite += usage.cacheWrite;
+ target.premiumRequests += usage.premiumRequests ?? 0;
+ target.cost += usage.cost.total;
}
-export interface BranchSummaryEntry 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;
+function isAssistantEntry(entry: SessionEntry): boolean {
+ return entry.type === "message" && entry.message.role === "assistant";
+}
+
+function orderedByTimestamp(a: SessionTreeNode, b: SessionTreeNode): number {
+ return new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime();
}
/**
- * 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.
+ * Maintains the derived views over a session's entry list: id lookup, the
+ * parent→children adjacency, the resolved label map, the active leaf, and the
+ * running usage totals. Kept in lockstep with the manager's `#entries` so reads
+ * stay O(1)/O(children) instead of rescanning the whole journal.
*/
-export interface CustomEntry extends SessionEntryBase {
- type: "custom";
- customType: string;
- data?: T;
-}
+class SessionEntryIndex {
+ #entriesById = new Map();
+ #children = new Map();
+ #labels = new Map();
+ #leaf: string | null = null;
+ #usage = emptyUsageStatistics();
-/** 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;
-}
-
-/**
- * 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 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;
- /** 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;
-}
-
-export const EPHEMERAL_MODEL_CHANGE_ROLE = "fallback";
-
-/** Lists session model strings to try when restoring, in fallback order. */
-export function getRestorableSessionModels(
- models: Readonly>,
- lastModelChangeRole: string | undefined,
-): string[] {
- const defaultModel = models.default;
- if (
- !lastModelChangeRole ||
- lastModelChangeRole === "default" ||
- lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
- ) {
- return defaultModel ? [defaultModel] : [];
+ clear(): void {
+ this.#entriesById.clear();
+ this.#children.clear();
+ this.#labels.clear();
+ this.#leaf = null;
+ this.#usage = emptyUsageStatistics();
}
- const roleModel = models[lastModelChangeRole];
- if (!roleModel) return defaultModel ? [defaultModel] : [];
- if (!defaultModel || roleModel === defaultModel) return [roleModel];
- return [roleModel, defaultModel];
-}
+ rebuild(entries: readonly SessionEntry[]): void {
+ this.clear();
+ for (const entry of entries) this.insert(entry);
+ }
-/**
- * Coarse lifecycle status of a session, derived from its last persisted message.
- *
- * - `complete` — the last assistant turn ended with no unanswered tool calls, i.e.
- * the agent yielded control back to the user.
- * - `interrupted` — work was cut off mid-flight: a trailing assistant turn with
- * pending tool calls, a trailing tool result the agent never continued from, or
- * a length-truncated turn.
- * - `aborted` — the last assistant turn was cancelled by the user.
- * - `error` — the last assistant turn ended in an error.
- * - `pending` — a trailing user message with no assistant reply persisted after it.
- * - `unknown` — status could not be determined (empty/header-only session, or the
- * final message was larger than the tail window that was read).
- */
-export type SessionStatus = "complete" | "interrupted" | "aborted" | "error" | "pending" | "unknown";
+ insert(entry: SessionEntry): void {
+ this.#entriesById.set(entry.id, entry);
+ this.#leaf = entry.id;
+
+ const bucket = this.#children.get(entry.parentId);
+ if (bucket) bucket.push(entry);
+ else this.#children.set(entry.parentId, [entry]);
+
+ if (entry.type === "label") {
+ if (entry.label) this.#labels.set(entry.targetId, entry.label);
+ else this.#labels.delete(entry.targetId);
+ }
+
+ addUsage(this.#usage, entryUsage(entry));
+ }
+
+ has(id: string): boolean {
+ return this.#entriesById.has(id);
+ }
+
+ get(id: string): SessionEntry | undefined {
+ return this.#entriesById.get(id);
+ }
-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;
/**
- * Coarse lifecycle status from the session's last persisted message. Optional:
- * synthesized {@link SessionInfo}s (cross-project stubs, tests) leave it unset.
+ * The live id→entry map. Read-only for callers (lookups + `generateId`
+ * collision checks); never mutate it directly — go through `insert`/`rebuild`.
*/
- status?: SessionStatus;
+ entriesById(): Map {
+ return this.#entriesById;
+ }
+
+ leafId(): string | null {
+ return this.#leaf;
+ }
+
+ leafEntry(): SessionEntry | undefined {
+ return this.#leaf ? this.#entriesById.get(this.#leaf) : undefined;
+ }
+
+ setLeaf(id: string | null): void {
+ this.#leaf = id;
+ }
+
+ childrenOf(parentId: string): SessionEntry[] {
+ return [...(this.#children.get(parentId) ?? [])];
+ }
+
+ labelFor(id: string): string | undefined {
+ return this.#labels.get(id);
+ }
+
+ labelsInEffect(): IterableIterator<[string, string]> {
+ return this.#labels.entries();
+ }
+
+ usageSnapshot(): UsageStatistics {
+ return { ...this.#usage };
+ }
+
+ pathTo(id: string | null | undefined = this.#leaf): SessionEntry[] {
+ const branch: SessionEntry[] = [];
+ const seen = new Set();
+ let cursor = id ? this.#entriesById.get(id) : undefined;
+
+ while (cursor && !seen.has(cursor.id)) {
+ seen.add(cursor.id);
+ branch.unshift(cursor);
+ cursor = cursor.parentId ? this.#entriesById.get(cursor.parentId) : undefined;
+ }
+
+ return branch;
+ }
+
+ tree(entries: readonly SessionEntry[]): SessionTreeNode[] {
+ const nodes = new Map();
+ const roots: SessionTreeNode[] = [];
+
+ for (const entry of entries) {
+ nodes.set(entry.id, { entry, children: [], label: this.#labels.get(entry.id) });
+ }
+
+ for (const entry of entries) {
+ const node = nodes.get(entry.id)!;
+ const parentId = entry.parentId;
+ if (parentId === null || parentId === entry.id) {
+ roots.push(node);
+ continue;
+ }
+
+ const parent = nodes.get(parentId);
+ if (parent) parent.children.push(node);
+ else roots.push(node);
+ }
+
+ const stack = [...roots];
+ while (stack.length > 0) {
+ const node = stack.pop()!;
+ node.children.sort(orderedByTimestamp);
+ stack.push(...node.children);
+ }
+
+ return roots;
+ }
}
export type ReadonlySessionManager = Pick<
@@ -341,1646 +264,6 @@ export type ReadonlySessionManager = Pick<
| "putBlobSync"
>;
-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();
- 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();
-
-/**
- * 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 `---*--` 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(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;
-}
-
-export interface BuildSessionContextOptions {
- /**
- * Build the full-history display transcript instead of the LLM context:
- * every path entry in chronological order, with each compaction emitted
- * inline as a `compactionSummary` message at the position it fired rather
- * than replacing the history before it. Display-only — never send the
- * result to a provider.
- */
- transcript?: boolean;
-}
-
-/**
- * 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,
- options?: BuildSessionContextOptions,
-): SessionContext {
- // Build uuid index if not available
- if (!byId) {
- byId = new Map();
- 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 = {};
- let compaction: CompactionEntry | null = null;
- const injectedTtsrRulesSet = new Set();
- let selectedMCPToolNames: string[] = [];
- let hasPersistedMCPToolSelection = false;
- let mode = "none";
- let modeData: Record | 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 (options?.transcript) {
- // Display transcript: every entry in chronological order. Compactions do
- // not erase prior history here — each renders inline (as a divider in the
- // TUI) at the point it fired, with any snapcompact frames re-attached so
- // the component can report them.
- for (const entry of path) {
- if (entry.type === "compaction") {
- const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
- messages.push(
- createCompactionSummaryMessage(
- entry.summary,
- entry.tokensBefore,
- entry.timestamp,
- entry.shortSummary,
- undefined,
- snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
- ),
- );
- } else {
- appendMessage(entry);
- }
- }
- } else 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>,
- };
- })();
- const remoteReplacementHistory = providerPayload?.items;
-
- // Emit summary first; re-attach any archived snapcompact frames so the
- // model can keep reading the archived history after every context rebuild.
- const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
- messages.push(
- createCompactionSummaryMessage(
- compaction.summary,
- compaction.tokensBefore,
- compaction.timestamp,
- compaction.shortSummary,
- providerPayload,
- snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
- ),
- );
-
- // 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, 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();
- 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(() => {});
-}
-
-interface TerminalBreadcrumb {
- cwd: string;
- sessionFile: string;
-}
-
-/**
- * Read the raw terminal breadcrumb for the current terminal.
- * Returns the recorded cwd + session file (verified to exist) regardless of
- * whether the recorded cwd still matches the current one. Callers decide how
- * to interpret a cwd mismatch (e.g. a moved/renamed worktree).
- */
-async function readTerminalBreadcrumbEntry(): Promise {
- 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];
-
- // Verify the session file still exists
- const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
- if (stat?.isFile()) return { cwd: breadcrumbCwd, 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 {
- let content: string;
- try {
- content = await storage.readText(filePath);
- } catch (err) {
- if (isEnoent(err)) return [];
- throw err;
- }
- const entries = parseJsonlLenient(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 {
- 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 {
- const promises: Promise[] = [];
-
- 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);
-}
-
-/**
- * Read-only message view of a session file: load entries, migrate to the
- * current version, resolve blob refs, and build the context along the
- * persisted leaf path (last entry). Does NOT create a writer or take the
- * session lock — safe to call against a file another session is writing.
- */
-export async function loadSessionMessagesReadOnly(filePath: string): Promise {
- const entries = await loadEntriesFromFile(filePath);
- if (entries.length === 0) return [];
- migrateToCurrentVersion(entries);
- await resolveBlobRefsInEntries(entries, new BlobStore(getBlobsDir()));
- const sessionEntries = entries.filter((e): e is SessionEntry => e.type !== "session");
- return buildSessionContext(sessionEntries).messages;
-}
-
-/**
- * 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,
- 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>): string | undefined {
- for (const entry of entries) {
- if (entry.type !== "message") continue;
- const message = entry.message as Record | 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 `.jsonl..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 {
- 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();
- for (const backup of backups) {
- const name = path.basename(backup);
- // Expect "..bak" where 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 {
- 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.readTextSlices(path, 4096, 0);
- const entries = parseJsonlLenient>(content);
- if (entries.length === 0) return;
- const header = entries[0] as Record;
- 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 {
- 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;
-async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise;
-async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise;
-async function truncateForPersistence(obj: object, blobStore: BlobStore, key?: string): Promise