diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index bcb921b2..f1ff837b 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -3,17 +3,17 @@ */ import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; -import type { SessionManager } from "../core/session-manager.js"; +import type { SessionInfo } from "../core/session-manager.js"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js"; /** Show TUI session selector and return selected session path or null if cancelled */ -export async function selectSession(sessionManager: SessionManager): Promise { +export async function selectSession(sessions: SessionInfo[]): Promise { return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); let resolved = false; const selector = new SessionSelectorComponent( - sessionManager, + sessions, (path: string) => { if (!resolved) { resolved = true; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 1dd44834..9b2f3220 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -106,12 +106,8 @@ export interface CreateAgentSessionOptions { /** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */ slashCommands?: FileSlashCommand[]; - /** Session file path, or false to disable persistence. Default: auto in agentDir/sessions/ */ - sessionFile?: string | false; - /** Continue most recent session for cwd. */ - continueSession?: boolean; - /** Restore model/thinking from session (default: true when continuing). */ - restoreFromSession?: boolean; + /** Session manager. Default: SessionManager.create(cwd) */ + sessionManager?: SessionManager; /** Settings overrides (merged with agentDir/settings.json) */ settings?: Partial; @@ -411,7 +407,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa * tools: [readTool, bashTool], * hooks: [], * skills: [], - * sessionFile: false, + * sessionManager: SessionManager.inMemory(), * }); * ``` */ @@ -420,34 +416,27 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const agentDir = options.agentDir ?? getDefaultAgentDir(); const settingsManager = new SettingsManager(agentDir); + const sessionManager = options.sessionManager ?? SessionManager.create(cwd); - const sessionManager = new SessionManager(options.continueSession ?? false, undefined); - if (options.sessionFile === false) { - sessionManager.disable(); - } else if (typeof options.sessionFile === "string") { - sessionManager.setSessionFile(options.sessionFile); - } + // Check if session has existing data to restore + const existingSession = sessionManager.loadSession(); + const hasExistingSession = existingSession.messages.length > 0; let model = options.model; let modelFallbackMessage: string | undefined; - const shouldRestoreFromSession = options.restoreFromSession ?? (options.continueSession || options.sessionFile); - // If continuing/restoring, try to get model from session first - if (!model && shouldRestoreFromSession) { - const savedModel = sessionManager.loadModel(); - if (savedModel) { - const restoredModel = findModel(savedModel.provider, savedModel.modelId); - if (restoredModel) { - const key = await getApiKeyForModel(restoredModel); - if (key) { - model = restoredModel; - } - } - // If we couldn't restore, we'll fall back below and set fallback message - if (!model) { - modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}`; + // If session has data, try to restore model from it + if (!model && hasExistingSession && existingSession.model) { + const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId); + if (restoredModel) { + const key = await getApiKeyForModel(restoredModel); + if (key) { + model = restoredModel; } } + if (!model) { + modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`; + } } // If still no model, try settings default @@ -482,12 +471,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} let thinkingLevel = options.thinkingLevel; - // If continuing/restoring, try to get thinking level from session - if (thinkingLevel === undefined && shouldRestoreFromSession) { - const savedThinking = sessionManager.loadThinkingLevel(); - if (savedThinking) { - thinkingLevel = savedThinking as ThinkingLevel; - } + // If session has data, restore thinking level from it + if (thinkingLevel === undefined && hasExistingSession) { + thinkingLevel = existingSession.thinkingLevel as ThinkingLevel; } // Fall back to settings default @@ -595,11 +581,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }), }); - if (shouldRestoreFromSession) { - const messages = sessionManager.loadMessages(); - if (messages.length > 0) { - agent.replaceMessages(messages); - } + // Restore messages if session has existing data + if (hasExistingSession) { + agent.replaceMessages(existingSession.messages); } const session = new AgentSession({ diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 81050b46..dbce7623 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -12,10 +12,6 @@ function uuidv4(): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } -// ============================================================================ -// Session entry types -// ============================================================================ - export interface SessionHeader { type: "session"; id: string; @@ -50,11 +46,10 @@ export interface CompactionEntry { type: "compaction"; timestamp: string; summary: string; - firstKeptEntryIndex: number; // Index into session entries where we start keeping + firstKeptEntryIndex: number; tokensBefore: number; } -/** Union of all session entry types */ export type SessionEntry = | SessionHeader | SessionMessageEntry @@ -62,16 +57,22 @@ export type SessionEntry = | ModelChangeEntry | CompactionEntry; -// ============================================================================ -// Session loading with compaction support -// ============================================================================ - export interface LoadedSession { messages: AppMessage[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; } +export interface SessionInfo { + path: string; + id: string; + created: Date; + modified: Date; + messageCount: number; + firstMessage: string; + allMessagesText: string; +} + export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: @@ -80,9 +81,6 @@ export const SUMMARY_PREFIX = `The conversation history before this point was co export const SUMMARY_SUFFIX = ` `; -/** - * Create a user message containing the summary with the standard prefix. - */ export function createSummaryMessage(summary: string): AppMessage { return { role: "user", @@ -91,9 +89,6 @@ export function createSummaryMessage(summary: string): AppMessage { }; } -/** - * Parse session file content into entries. - */ export function parseSessionEntries(content: string): SessionEntry[] { const entries: SessionEntry[] = []; const lines = content.trim().split("\n"); @@ -111,17 +106,6 @@ export function parseSessionEntries(content: string): SessionEntry[] { return entries; } -/** - * Load session from entries, handling compaction events. - * - * Algorithm: - * 1. Find latest compaction event (if any) - * 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages) - * 3. Prepend summary as user message - */ -/** - * Get the latest compaction entry from session entries, if any. - */ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null { for (let i = entries.length - 1; i >= 0; i--) { if (entries[i].type === "compaction") { @@ -132,7 +116,6 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt } export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { - // Find model and thinking level (always scan all entries) let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; @@ -147,7 +130,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { } } - // Find latest compaction event let latestCompactionIndex = -1; for (let i = entries.length - 1; i >= 0; i--) { if (entries[i].type === "compaction") { @@ -156,7 +138,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { } } - // No compaction: return all messages if (latestCompactionIndex === -1) { const messages: AppMessage[] = []; for (const entry of entries) { @@ -169,7 +150,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { const compactionEvent = entries[latestCompactionIndex] as CompactionEntry; - // Extract messages from firstKeptEntryIndex to end (skipping compaction entries) const keptMessages: AppMessage[] = []; for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) { const entry = entries[i]; @@ -178,7 +158,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { } } - // Build final messages: summary + kept messages const messages: AppMessage[] = []; messages.push(createSummaryMessage(compactionEvent.summary)); messages.push(...keptMessages); @@ -186,320 +165,137 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { return { messages, thinkingLevel, model }; } +function getSessionDirectory(cwd: string): string { + const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const configDir = getAgentDir(); + const sessionDir = join(configDir, "sessions", safePath); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + return sessionDir; +} + +function loadEntriesFromFile(filePath: string): SessionEntry[] { + if (!existsSync(filePath)) return []; + + const content = readFileSync(filePath, "utf8"); + const entries: SessionEntry[] = []; + const lines = content.trim().split("\n"); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as SessionEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + } + + return entries; +} + +function extractSessionIdFromFile(filePath: string): string | null { + if (!existsSync(filePath)) return null; + + const lines = readFileSync(filePath, "utf8").trim().split("\n"); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === "session") { + return entry.id; + } + } catch { + // Skip malformed lines + } + } + return null; +} + +function findMostRecentSession(sessionDir: string): string | null { + try { + const files = readdirSync(sessionDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => ({ + path: join(sessionDir, f), + mtime: statSync(join(sessionDir, f)).mtime, + })) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + return files[0]?.path || null; + } catch { + return null; + } +} + export class SessionManager { - private sessionId!: string; - private sessionFile!: string; + private sessionId: string; + private sessionFile: string; private sessionDir: string; - private enabled: boolean = true; - private sessionInitialized: boolean = false; + private cwd: string; + private enabled: boolean; + private sessionInitialized: boolean; private pendingEntries: SessionEntry[] = []; - // In-memory entries for --no-session mode (when enabled=false) private inMemoryEntries: SessionEntry[] = []; - constructor(continueSession: boolean = false, customSessionPath?: string) { - this.sessionDir = this.getSessionDirectory(); + private constructor(cwd: string, sessionFile: string | null, enabled: boolean) { + this.cwd = cwd; + this.sessionDir = getSessionDirectory(cwd); + this.enabled = enabled; - if (customSessionPath) { - // Use custom session file path - this.sessionFile = resolve(customSessionPath); - this.loadSessionId(); - // If file doesn't exist, loadSessionId() won't set sessionId, so generate one - if (!this.sessionId) { - this.sessionId = uuidv4(); - } - // Mark as initialized since we're loading an existing session + if (sessionFile) { + this.sessionFile = resolve(sessionFile); + this.sessionId = extractSessionIdFromFile(this.sessionFile) ?? uuidv4(); this.sessionInitialized = existsSync(this.sessionFile); - // Load entries into memory if (this.sessionInitialized) { - this.inMemoryEntries = this.loadEntriesFromFile(); - } - } else if (continueSession) { - const mostRecent = this.findMostRecentlyModifiedSession(); - if (mostRecent) { - this.sessionFile = mostRecent; - this.loadSessionId(); - // Mark as initialized since we're loading an existing session - this.sessionInitialized = true; - // Load entries into memory - this.inMemoryEntries = this.loadEntriesFromFile(); - } else { - this.initNewSession(); + this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); } } else { - this.initNewSession(); + this.sessionId = uuidv4(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + this.sessionInitialized = false; } } - /** Disable session saving (for --no-session mode) */ - disable() { - this.enabled = false; + /** Create a new session for the given directory */ + static create(cwd: string): SessionManager { + return new SessionManager(cwd, null, true); } - /** Check if session persistence is enabled */ - isEnabled(): boolean { - return this.enabled; + /** Open a specific session file */ + static open(path: string): SessionManager { + // Extract cwd from session header if possible, otherwise use process.cwd() + const entries = loadEntriesFromFile(path); + const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; + const cwd = header?.cwd ?? process.cwd(); + return new SessionManager(cwd, path, true); } - private getSessionDirectory(): string { - const cwd = process.cwd(); - // Replace all path separators and colons (for Windows drive letters) with dashes - const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - - const configDir = getAgentDir(); - const sessionDir = join(configDir, "sessions", safePath); - if (!existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); + /** Continue the most recent session for the given directory, or create new if none */ + static continueRecent(cwd: string): SessionManager { + const sessionDir = getSessionDirectory(cwd); + const mostRecent = findMostRecentSession(sessionDir); + if (mostRecent) { + return new SessionManager(cwd, mostRecent, true); } - return sessionDir; + return new SessionManager(cwd, null, true); } - private initNewSession(): void { - this.sessionId = uuidv4(); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + /** Create an in-memory session (no file persistence) */ + static inMemory(): SessionManager { + return new SessionManager(process.cwd(), null, false); } - /** Reset to a fresh session. Clears pending entries and starts a new session file. */ - reset(): void { - this.pendingEntries = []; - this.inMemoryEntries = []; - this.sessionInitialized = false; - this.initNewSession(); - } - - private findMostRecentlyModifiedSession(): string | null { - try { - const files = readdirSync(this.sessionDir) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => ({ - name: f, - path: join(this.sessionDir, f), - mtime: statSync(join(this.sessionDir, f)).mtime, - })) - .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); - - return files[0]?.path || null; - } catch { - return null; - } - } - - private loadSessionId(): void { - if (!existsSync(this.sessionFile)) return; - - const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n"); - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === "session") { - this.sessionId = entry.id; - return; - } - } catch { - // Skip malformed lines - } - } - this.sessionId = uuidv4(); - } - - startSession(state: AgentState): void { - if (this.sessionInitialized) return; - this.sessionInitialized = true; - - const entry: SessionHeader = { - type: "session", - id: this.sessionId, - timestamp: new Date().toISOString(), - cwd: process.cwd(), - provider: state.model.provider, - modelId: state.model.id, - thinkingLevel: state.thinkingLevel, - }; - - // Always track in memory - this.inMemoryEntries.push(entry); - for (const pending of this.pendingEntries) { - this.inMemoryEntries.push(pending); - } - this.pendingEntries = []; - - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - for (const memEntry of this.inMemoryEntries.slice(1)) { - appendFileSync(this.sessionFile, `${JSON.stringify(memEntry)}\n`); - } - } - } - - saveMessage(message: any): void { - const entry: SessionMessageEntry = { - type: "message", - timestamp: new Date().toISOString(), - message, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - // Always track in memory - this.inMemoryEntries.push(entry); - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveThinkingLevelChange(thinkingLevel: string): void { - const entry: ThinkingLevelChangeEntry = { - type: "thinking_level_change", - timestamp: new Date().toISOString(), - thinkingLevel, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - // Always track in memory - this.inMemoryEntries.push(entry); - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveModelChange(provider: string, modelId: string): void { - const entry: ModelChangeEntry = { - type: "model_change", - timestamp: new Date().toISOString(), - provider, - modelId, - }; - - if (!this.sessionInitialized) { - this.pendingEntries.push(entry); - } else { - // Always track in memory - this.inMemoryEntries.push(entry); - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - } - - saveCompaction(entry: CompactionEntry): void { - // Always track in memory - this.inMemoryEntries.push(entry); - // Write to file only if enabled - if (this.enabled) { - appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); - } - } - - /** - * Load session data (messages, model, thinking level) with compaction support. - */ - loadSession(): LoadedSession { - const entries = this.loadEntries(); - return loadSessionFromEntries(entries); - } - - /** - * @deprecated Use loadSession().messages instead - */ - loadMessages(): AppMessage[] { - return this.loadSession().messages; - } - - /** - * @deprecated Use loadSession().thinkingLevel instead - */ - loadThinkingLevel(): string { - return this.loadSession().thinkingLevel; - } - - /** - * @deprecated Use loadSession().model instead - */ - loadModel(): { provider: string; modelId: string } | null { - return this.loadSession().model; - } - - getSessionId(): string { - return this.sessionId; - } - - getSessionFile(): string { - return this.sessionFile; - } - - /** - * Load entries directly from the session file (internal helper). - */ - private loadEntriesFromFile(): SessionEntry[] { - if (!existsSync(this.sessionFile)) return []; - - const content = readFileSync(this.sessionFile, "utf8"); - const entries: SessionEntry[] = []; - const lines = content.trim().split("\n"); - - for (const line of lines) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line) as SessionEntry; - entries.push(entry); - } catch { - // Skip malformed lines - } - } - - return entries; - } - - /** - * Load all entries from the session file or in-memory store. - * When file persistence is enabled, reads from file (source of truth for resumed sessions). - * When disabled (--no-session), returns in-memory entries. - */ - loadEntries(): SessionEntry[] { - // If file persistence is enabled and file exists, read from file - if (this.enabled && existsSync(this.sessionFile)) { - return this.loadEntriesFromFile(); - } - - // Otherwise return in-memory entries (for --no-session mode) - return [...this.inMemoryEntries]; - } - - /** - * Load all sessions for the current directory with metadata - */ - loadAllSessions(): Array<{ - path: string; - id: string; - created: Date; - modified: Date; - messageCount: number; - firstMessage: string; - allMessagesText: string; - }> { - const sessions: Array<{ - path: string; - id: string; - created: Date; - modified: Date; - messageCount: number; - firstMessage: string; - allMessagesText: string; - }> = []; + /** List all sessions for a directory */ + static list(cwd: string): SessionInfo[] { + const sessionDir = getSessionDirectory(cwd); + const sessions: SessionInfo[] = []; try { - const files = readdirSync(this.sessionDir) + const files = readdirSync(sessionDir) .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(this.sessionDir, f)); + .map((f) => join(sessionDir, f)); for (const file of files) { try { @@ -517,17 +313,14 @@ export class SessionManager { try { const entry = JSON.parse(line); - // Extract session ID from first session entry if (entry.type === "session" && !sessionId) { sessionId = entry.id; created = new Date(entry.timestamp); } - // Count messages and collect all text if (entry.type === "message") { messageCount++; - // Extract text from user and assistant messages if (entry.message.role === "user" || entry.message.role === "assistant") { const textContent = entry.message.content .filter((c: any) => c.type === "text") @@ -537,7 +330,6 @@ export class SessionManager { if (textContent) { allMessages.push(textContent); - // Get first user message for display if (!firstMessage && entry.message.role === "user") { firstMessage = textContent; } @@ -558,42 +350,168 @@ export class SessionManager { firstMessage: firstMessage || "(no messages)", allMessagesText: allMessages.join(" "), }); - } catch (error) { + } catch { // Skip files that can't be read - console.error(`Failed to read session file ${file}:`, error); } } - // Sort by modified date (most recent first) sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); - } catch (error) { - console.error("Failed to load sessions:", error); + } catch { + // Return empty list on error } return sessions; } - /** - * Set the session file to an existing session - */ + isEnabled(): boolean { + return this.enabled; + } + + getCwd(): string { + return this.cwd; + } + + getSessionId(): string { + return this.sessionId; + } + + getSessionFile(): string { + return this.sessionFile; + } + + /** Switch to a different session file (used for resume and branching) */ setSessionFile(path: string): void { - this.sessionFile = path; - this.loadSessionId(); - // Mark as initialized since we're loading an existing session - this.sessionInitialized = existsSync(path); - // Load entries into memory for consistency + this.sessionFile = resolve(path); + this.sessionId = extractSessionIdFromFile(this.sessionFile) ?? uuidv4(); + this.sessionInitialized = existsSync(this.sessionFile); if (this.sessionInitialized) { - this.inMemoryEntries = this.loadEntriesFromFile(); + this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); } else { this.inMemoryEntries = []; } this.pendingEntries = []; } - /** - * Check if we should initialize the session based on message history. - * Session is initialized when we have at least 1 user message and 1 assistant message. - */ + reset(): void { + this.pendingEntries = []; + this.inMemoryEntries = []; + this.sessionInitialized = false; + this.sessionId = uuidv4(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + } + + startSession(state: AgentState): void { + if (this.sessionInitialized) return; + this.sessionInitialized = true; + + const entry: SessionHeader = { + type: "session", + id: this.sessionId, + timestamp: new Date().toISOString(), + cwd: this.cwd, + provider: state.model.provider, + modelId: state.model.id, + thinkingLevel: state.thinkingLevel, + }; + + this.inMemoryEntries.push(entry); + for (const pending of this.pendingEntries) { + this.inMemoryEntries.push(pending); + } + this.pendingEntries = []; + + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + for (const memEntry of this.inMemoryEntries.slice(1)) { + appendFileSync(this.sessionFile, `${JSON.stringify(memEntry)}\n`); + } + } + } + + saveMessage(message: any): void { + const entry: SessionMessageEntry = { + type: "message", + timestamp: new Date().toISOString(), + message, + }; + + if (!this.sessionInitialized) { + this.pendingEntries.push(entry); + } else { + this.inMemoryEntries.push(entry); + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + } + + saveThinkingLevelChange(thinkingLevel: string): void { + const entry: ThinkingLevelChangeEntry = { + type: "thinking_level_change", + timestamp: new Date().toISOString(), + thinkingLevel, + }; + + if (!this.sessionInitialized) { + this.pendingEntries.push(entry); + } else { + this.inMemoryEntries.push(entry); + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + } + + saveModelChange(provider: string, modelId: string): void { + const entry: ModelChangeEntry = { + type: "model_change", + timestamp: new Date().toISOString(), + provider, + modelId, + }; + + if (!this.sessionInitialized) { + this.pendingEntries.push(entry); + } else { + this.inMemoryEntries.push(entry); + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + } + + saveCompaction(entry: CompactionEntry): void { + this.inMemoryEntries.push(entry); + if (this.enabled) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + + loadSession(): LoadedSession { + const entries = this.loadEntries(); + return loadSessionFromEntries(entries); + } + + loadMessages(): AppMessage[] { + return this.loadSession().messages; + } + + loadThinkingLevel(): string { + return this.loadSession().thinkingLevel; + } + + loadModel(): { provider: string; modelId: string } | null { + return this.loadSession().model; + } + + loadEntries(): SessionEntry[] { + if (this.enabled && existsSync(this.sessionFile)) { + return loadEntriesFromFile(this.sessionFile); + } + return [...this.inMemoryEntries]; + } + shouldInitializeSession(messages: any[]): boolean { if (this.sessionInitialized) return false; @@ -603,23 +521,16 @@ export class SessionManager { return userMessages.length >= 1 && assistantMessages.length >= 1; } - /** - * Create a branched session from a specific message index. - * If branchFromIndex is -1, creates an empty session. - * Returns the new session file path. - */ createBranchedSession(state: any, branchFromIndex: number): string { - // Create a new session ID for the branch const newSessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); - // Write session header const entry: SessionHeader = { type: "session", id: newSessionId, timestamp: new Date().toISOString(), - cwd: process.cwd(), + cwd: this.cwd, provider: state.model.provider, modelId: state.model.id, thinkingLevel: state.thinkingLevel, @@ -627,7 +538,6 @@ export class SessionManager { }; appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); - // Write messages up to and including the branch point (if >= 0) if (branchFromIndex >= 0) { const messagesToWrite = state.messages.slice(0, branchFromIndex + 1); for (const message of messagesToWrite) { @@ -643,23 +553,16 @@ export class SessionManager { return newSessionFile; } - /** - * Create a branched session from session entries up to (but not including) a specific entry index. - * This preserves compaction events and all entry types. - * Returns the new session file path, or null if in --no-session mode (in-memory only). - */ createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null { const newSessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); - // Build new entries list (up to but not including branch point) const newEntries: SessionEntry[] = []; for (let i = 0; i < branchBeforeIndex; i++) { const entry = entries[i]; if (entry.type === "session") { - // Rewrite session header with new ID and branchedFrom newEntries.push({ ...entry, id: newSessionId, @@ -667,22 +570,18 @@ export class SessionManager { branchedFrom: this.enabled ? this.sessionFile : undefined, }); } else { - // Copy other entries as-is newEntries.push(entry); } } if (this.enabled) { - // Write to file for (const entry of newEntries) { appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); } return newSessionFile; - } else { - // In-memory mode: replace inMemoryEntries, no file created - this.inMemoryEntries = newEntries; - this.sessionId = newSessionId; - return null; } + this.inMemoryEntries = newEntries; + this.sessionId = newSessionId; + return null; } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 87e30209..14c207ef 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -117,6 +117,7 @@ export { parseSessionEntries, type SessionEntry, type SessionHeader, + type SessionInfo, SessionManager, type SessionMessageEntry, SUMMARY_PREFIX, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index b02ae8a2..217b563c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -29,7 +29,6 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js" import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js"; import { ensureTool } from "./utils/tools-manager.js"; -/** Configure OAuth storage to use the coding-agent's configurable path */ function configureOAuthStorage(): void { const oauthPath = getOAuthPath(); @@ -55,7 +54,6 @@ function configureOAuthStorage(): void { }); } -/** Check npm registry for new version (non-blocking) */ async function checkForNewVersion(currentVersion: string): Promise { try { const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest"); @@ -74,7 +72,6 @@ async function checkForNewVersion(currentVersion: string): Promise { +function createSessionManager(parsed: Args, cwd: string): SessionManager | null { + if (parsed.noSession) { + return SessionManager.inMemory(); + } + if (parsed.session) { + return SessionManager.open(parsed.session); + } + if (parsed.continue) { + return SessionManager.continueRecent(cwd); + } + // --resume is handled separately (needs picker UI) + // Default case (new session) returns null, SDK will create one + return null; +} + +function buildSessionOptions( + parsed: Args, + scopedModels: ScopedModel[], + sessionManager: SessionManager | null, +): CreateAgentSessionOptions { const options: CreateAgentSessionOptions = {}; + if (sessionManager) { + options.sessionManager = sessionManager; + } + // Model from CLI if (parsed.provider && parsed.model) { const { model, error } = findModel(parsed.provider, parsed.model); @@ -201,7 +218,6 @@ async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): P } options.model = model; } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { - // Use first scoped model options.model = scopedModels[0].model; } @@ -251,23 +267,10 @@ async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): P options.additionalCustomToolPaths = parsed.customTools; } - // Session handling - if (parsed.noSession) { - options.sessionFile = false; - } else if (parsed.session) { - options.sessionFile = parsed.session; - } - - // Continue session - if (parsed.continue && !parsed.resume) { - options.continueSession = true; - } - return options; } export async function main(args: string[]) { - // Configure OAuth storage first configureOAuthStorage(); const parsed = parseArgs(args); @@ -306,40 +309,38 @@ export async function main(args: string[]) { process.exit(1); } + const cwd = process.cwd(); const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed); const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; - // Initialize theme early const settingsManager = new SettingsManager(); initTheme(settingsManager.getTheme(), isInteractive); - // Resolve scoped models from --models flag let scopedModels: ScopedModel[] = []; if (parsed.models && parsed.models.length > 0) { scopedModels = await resolveModelScope(parsed.models); } + // Create session manager based on CLI flags + let sessionManager = createSessionManager(parsed, cwd); + // Handle --resume: show session picker - let sessionFileFromResume: string | undefined; if (parsed.resume) { - const tempSessionManager = new SessionManager(false, undefined); - const selectedSession = await selectSession(tempSessionManager); - if (!selectedSession) { + const sessions = SessionManager.list(cwd); + if (sessions.length === 0) { + console.log(chalk.dim("No sessions found")); + return; + } + const selectedPath = await selectSession(sessions); + if (!selectedPath) { console.log(chalk.dim("No session selected")); return; } - sessionFileFromResume = selectedSession; - } - - const sessionOptions = await buildSessionOptions(parsed, scopedModels); - - // Apply resume session file - if (sessionFileFromResume) { - sessionOptions.sessionFile = sessionFileFromResume; - sessionOptions.restoreFromSession = true; + sessionManager = SessionManager.open(selectedPath); } + const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager); const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions); if (!isInteractive && !session.model) { @@ -369,7 +370,6 @@ export async function main(args: string[]) { const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null); const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager); - // Show model scope if provided if (scopedModels.length > 0) { const modelList = scopedModels .map((sm) => { diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 678cbf20..8a4939be 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -11,27 +11,17 @@ import { Text, truncateToWidth, } from "@mariozechner/pi-tui"; -import type { SessionManager } from "../../../core/session-manager.js"; +import type { SessionInfo } from "../../../core/session-manager.js"; import { fuzzyFilter } from "../../../utils/fuzzy.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -interface SessionItem { - path: string; - id: string; - created: Date; - modified: Date; - messageCount: number; - firstMessage: string; - allMessagesText: string; -} - /** * Custom session list component with multi-line items and search */ class SessionList implements Component { - private allSessions: SessionItem[] = []; - private filteredSessions: SessionItem[] = []; + private allSessions: SessionInfo[] = []; + private filteredSessions: SessionInfo[] = []; private selectedIndex: number = 0; private searchInput: Input; public onSelect?: (sessionPath: string) => void; @@ -39,7 +29,7 @@ class SessionList implements Component { public onExit: () => void = () => {}; private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank) - constructor(sessions: SessionItem[]) { + constructor(sessions: SessionInfo[]) { this.allSessions = sessions; this.filteredSessions = sessions; this.searchInput = new Input(); @@ -176,16 +166,13 @@ export class SessionSelectorComponent extends Container { private sessionList: SessionList; constructor( - sessionManager: SessionManager, + sessions: SessionInfo[], onSelect: (sessionPath: string) => void, onCancel: () => void, onExit: () => void, ) { super(); - // Load all sessions - const sessions = sessionManager.loadAllSessions(); - // Add header this.addChild(new Spacer(1)); this.addChild(new Text(theme.bold("Resume Session"), 1, 0)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3ebf416e..2e5638a4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -32,7 +32,12 @@ import type { HookUIContext } from "../../core/hooks/index.js"; import { isBashExecutionMessage } from "../../core/messages.js"; import { invalidateOAuthCache } from "../../core/model-config.js"; import { listOAuthProviders, login, logout, type OAuthProvider } from "../../core/oauth/index.js"; -import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js"; +import { + getLatestCompactionEntry, + SessionManager, + SUMMARY_PREFIX, + SUMMARY_SUFFIX, +} from "../../core/session-manager.js"; import { loadSkills } from "../../core/skills.js"; import { loadProjectContextFiles } from "../../core/system-prompt.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; @@ -1513,8 +1518,9 @@ export class InteractiveMode { private showSessionSelector(): void { this.showSelector((done) => { + const sessions = SessionManager.list(this.sessionManager.getCwd()); const selector = new SessionSelectorComponent( - this.sessionManager, + sessions, async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 64d26861..b29299f4 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -56,10 +56,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { }, }); - sessionManager = new SessionManager(false); - if (noSession) { - sessionManager.disable(); - } + sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir); const settingsManager = new SettingsManager(tempDir); session = new AgentSession({ diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index c8d3b47e..a2574483 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -60,7 +60,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { }, }); - sessionManager = new SessionManager(false); + sessionManager = SessionManager.create(tempDir); const settingsManager = new SettingsManager(tempDir); session = new AgentSession({ @@ -174,9 +174,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { }, }); - // Create session manager and disable file persistence - const noSessionManager = new SessionManager(false); - noSessionManager.disable(); + // Create in-memory session manager + const noSessionManager = SessionManager.inMemory(); const settingsManager = new SettingsManager(tempDir);