diff --git a/docs/TOOL_PANEL_SYSTEM.md b/docs/TOOL_PANEL_SYSTEM.md index 8f4a21ed4..41cb382bf 100644 --- a/docs/TOOL_PANEL_SYSTEM.md +++ b/docs/TOOL_PANEL_SYSTEM.md @@ -31,7 +31,8 @@ The tool panel system consists of several key components: ## Terminal Panel Specifics - Each terminal panel spawns an independent PTY process using node-pty -- Terminal state (scrollback, history, dimensions) persists in `tool_panels.state` as JSON +- Terminal metadata (cwd, dimensions, agent session) persists in `tool_panels.state` as JSON, ceilinged at 256 KB per panel and merged key by key inside SQLite +- Terminal bytes (scrollback, serialized snapshot, alternate-screen frame) persist in `panel_buffers`, capped at 4 MB per panel with the oldest scrollback trimmed first - XTerm.js instances mount/unmount based on panel visibility to save memory - Working directories are maintained independently per panel - Command history and environment variables can be preserved across restarts diff --git a/main/src/daemon/bootstrap.ts b/main/src/daemon/bootstrap.ts index 528dd2e32..0e6818156 100644 --- a/main/src/daemon/bootstrap.ts +++ b/main/src/daemon/bootstrap.ts @@ -1,6 +1,6 @@ import path from 'path'; import { powerMonitor, type App, type BrowserWindow } from 'electron'; -import { startupRetentionResult } from '../services/database'; +import { startupPanelBufferMigration, startupRetentionResult } from '../services/database'; import { ConfigManager } from '../services/configManager'; import { Logger } from '../utils/logger'; import { DatabaseService } from '../database/database'; @@ -96,6 +96,10 @@ function registerPowerMonitorDiagnostics(logger: Logger): void { powerMonitor.on('unlock-screen', () => logger.info('[Lifecycle] power:unlock-screen')); } +function megabytes(bytes: number | null): string { + return bytes === null ? 'unknown' : `${(bytes / 1_000_000).toFixed(1)} MB`; +} + export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Promise { const mode = options.mode ?? 'desktop'; const startRemoteTransport = options.startRemoteTransport ?? true; @@ -111,13 +115,25 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom console.log('[Main] Logger initialized with file logging to ~/.pane/logs'); registerPowerMonitorDiagnostics(logger); + if (startupPanelBufferMigration.error) { + logger.error('[PanelBuffers] Startup migration failed', startupPanelBufferMigration.error); + } else if (startupPanelBufferMigration.result?.migrated) { + const migration = startupPanelBufferMigration.result; + logger.info( + `[PanelBuffers] Moved terminal bytes out of ${migration.panelsRepaired} panel states ` + + `(${migration.panelsWithBuffers} with buffers) in ${migration.durationMs} ms; ` + + `sessions.db ${megabytes(migration.fileBytesBefore)} -> ${megabytes(migration.fileBytesAfter)}; ` + + `backup ${migration.backupPath ?? 'none'}`, + ); + } + if (startupRetentionResult.error) { logger.error('[ScrollbackRetention] Sweep failed', startupRetentionResult.error); } else if (startupRetentionResult.result && startupRetentionResult.result.panelsCleared > 0) { const result = startupRetentionResult.result; logger.info( `[ScrollbackRetention] Cleared ${result.panelsCleared} panels across ` + - `${result.sessionsTouched} sessions, freed ~${(result.bytesFreed / 1_000_000).toFixed(1)} MB`, + `${result.sessionsTouched} sessions, freed ~${megabytes(result.bytesFreed)}`, ); } diff --git a/main/src/database/database.panel-loading.test.ts b/main/src/database/database.panel-loading.test.ts index b9ede7dc8..a0b14da60 100644 --- a/main/src/database/database.panel-loading.test.ts +++ b/main/src/database/database.panel-loading.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { DatabaseService } from './database'; describe('panel history loading', () => { - it('keeps terminal history out of startup and workspace summaries while preserving restoration', () => { + it('keeps terminal bytes out of every panel read while preserving them for restoration', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-panel-loading-')); const db = new DatabaseService(path.join(tempDir, 'sessions.db')); try { @@ -32,20 +32,19 @@ describe('panel history loading', () => { expect(db.getPanelsForStartup().map(panel => panel.id).sort()).toEqual(['browser', 'logs']); for (let index = 0; index < 12; index++) { - const summary = db.getPanelsForSession(`session-${index}`, false)[0]; + const summary = db.getPanelsForSession(`session-${index}`)[0]; expect(summary.state.customState).toEqual({ cwd: tempDir, isCliPanel: true, agentType: 'claude' }); - expect(db.getPanel(`panel-${index}`)?.state.customState).toMatchObject({ scrollbackBuffer: history }); + expect(db.getPanel(`panel-${index}`)?.state.customState).toEqual({ cwd: tempDir, isCliPanel: true, agentType: 'claude' }); + expect(db.getPanelBuffers(`panel-${index}`)).toEqual({ scrollback: history, serialized: history, alternate: null }); } - expect(db.getPanelsForSession('session-0')[0].state.customState).toMatchObject({ serializedBuffer: history }); - db.createPanel({ - id: 'legacy', sessionId: 'session-0', type: 'terminal', title: 'Legacy', - state: JSON.stringify({ isActive: false, customState: { scrollbackBuffer: [history], cwd: tempDir } }), - }); - expect(db.getPanelsForSession('session-0', false).find(panel => panel.id === 'legacy')?.state.customState) - .toEqual({ cwd: tempDir }); - expect(db.getPanel('legacy')?.state.customState).toEqual({ scrollbackBuffer: [history], cwd: tempDir }); - expect(db.getPanelsForSession('session-0').find(panel => panel.id === 'legacy')?.state.customState) - .toEqual({ scrollbackBuffer: [history], cwd: tempDir }); + + // Rows written by older builds (string-wrapped JSON, array scrollback) still decode. + db.getDb() + .prepare('INSERT INTO tool_panels (id, session_id, type, title, state) VALUES (?, ?, ?, ?, ?)') + .run('legacy', 'session-0', 'terminal', 'Legacy', JSON.stringify(JSON.stringify({ + isActive: false, customState: { scrollbackBuffer: ['first', 'second'], cwd: tempDir }, + }))); + expect(db.getPanel('legacy')?.state.customState).toEqual({ scrollbackBuffer: ['first', 'second'], cwd: tempDir }); } finally { db.close(); fs.rmSync(tempDir, { recursive: true, force: true }); @@ -60,10 +59,11 @@ describe('panel history loading', () => { db.createSession({ id: 'session', name: 'Session', initial_prompt: '', worktree_name: 'session', worktree_path: tempDir, project_id: null, tool_type: 'none' }); const state = { isActive: false, hasBeenViewed: true, customState: { isRunning: false } }; const metadata = { createdAt: '2026-09-06T00:00:00.000Z', lastActiveAt: '2026-09-06T00:00:00.000Z', position: 2 }; - db.createPanel({ id: 'legacy', sessionId: 'session', type: 'logs', title: 'Legacy', state: JSON.stringify(state), metadata: JSON.stringify(metadata) }); + const insert = db.getDb().prepare('INSERT INTO tool_panels (id, session_id, type, title, state, metadata) VALUES (?, ?, ?, ?, ?, ?)'); + insert.run('legacy', 'session', 'logs', 'Legacy', JSON.stringify(JSON.stringify(state)), JSON.stringify(JSON.stringify(metadata))); db.setActivePanel('session', 'legacy'); - for (const panel of [db.getPanel('legacy'), db.getActivePanel('session'), db.getPanelsForSession('session')[0], db.getPanelsForSession('session', false)[0]]) { + for (const panel of [db.getPanel('legacy'), db.getActivePanel('session'), db.getPanelsForSession('session')[0]]) { expect(panel?.state).toEqual({ ...state, isActive: true }); expect(panel?.metadata).toEqual(metadata); } @@ -71,8 +71,12 @@ describe('panel history loading', () => { expect(panel.state).toEqual(state); expect(panel.metadata).toEqual(metadata); } - db.createPanel({ id: 'malformed', sessionId: 'session', type: 'terminal', title: 'Malformed', state, metadata: 'invalid legacy JSON' }); + insert.run('malformed', 'session', 'terminal', 'Malformed', JSON.stringify(state), JSON.stringify('invalid legacy JSON')); expect(db.getPanel('malformed')?.metadata).toMatchObject({ position: 0 }); + + // A partial update normalizes the string-wrapped row and merges into it. + expect(db.updatePanel('legacy', { state: { isActive: true, customState: { isRunning: true } } })).toBe(true); + expect(db.getPanel('legacy')?.state).toEqual({ isActive: true, hasBeenViewed: true, customState: { isRunning: true } }); } finally { db.close(); fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/main/src/database/database.ts b/main/src/database/database.ts index 44b96f963..6a1759ffa 100644 --- a/main/src/database/database.ts +++ b/main/src/database/database.ts @@ -27,8 +27,22 @@ import type { GitStatus } from "../types/session"; import { boundary, decodeBoundary, + decodeOptionalBoundary, type JsonObject, } from "../../../shared/validation/boundaryDecoder"; +import { PanelBufferStore, splitPanelBufferState, type PanelBuffers } from "./panelBuffers"; +import { + migratePanelBuffers, + unwrapStringWrappedPanelState, + type PanelBufferMigrationResult, +} from "./panelBufferMigration"; + +/** + * Hard ceiling on one serialized `tool_panels.state` row. Terminal bytes live + * in `panel_buffers`, so a state over this size is a new unbounded field, and + * the write is refused at the boundary rather than found at the next parse. + */ +export const PANEL_STATE_CEILING_BYTES = 256 * 1024; const loadDatabaseDependency = createRequire(__filename); @@ -188,15 +202,40 @@ const toolAnalyticsMessageSchema = boundary.object({ })), }); +/** A panel state write was refused because the serialized JSON would exceed the ceiling. */ +class PanelStateCeilingError extends Error { + constructor( + readonly panelId: string, + readonly bytes: number, + readonly largestKey: string, + ) { + super( + `Refused panel state write for ${panelId}: ${bytes} bytes exceeds the ` + + `${PANEL_STATE_CEILING_BYTES} byte ceiling; largest key ${largestKey}`, + ); + this.name = "PanelStateCeilingError"; + } +} + +export interface PanelBufferMigrationOutcome { + result: PanelBufferMigrationResult | null; + error: Error | null; +} + export class DatabaseService { private db: Database.Database; + private readonly dbPath: string; + private readonly panelBuffers: PanelBufferStore; + private panelBufferMigration: PanelBufferMigrationOutcome = { result: null, error: null }; constructor(dbPath: string) { // Ensure the directory exists before creating the database const dir = dirname(dbPath); mkdirSync(dir, { recursive: true }); + this.dbPath = dbPath; this.db = new Database(dbPath); + this.panelBuffers = new PanelBufferStore(this.db); } /** @@ -242,6 +281,25 @@ export class DatabaseService { initialize(): void { this.initializeSchema(); this.runMigrations(); + // Runs before any pane opens: services/database.ts initializes at module + // load. A failure here must not keep the app from starting, so it is + // captured and logged by the daemon bootstrap instead of thrown. + try { + this.panelBufferMigration = { + result: migratePanelBuffers(this.db, this.dbPath, this.panelBuffers), + error: null, + }; + } catch (error) { + this.panelBufferMigration = { + result: null, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + /** Outcome of the startup terminal-buffer migration, for the bootstrap logger. */ + getPanelBufferMigration(): PanelBufferMigrationOutcome { + return this.panelBufferMigration; } private initializeSchema(): void { @@ -4426,108 +4484,218 @@ export class DatabaseService { } // Panel operations + + /** + * Insert a panel row. Terminal byte buffers in `state.customState` go to + * `panel_buffers`; the remaining state must fit under the ceiling or the + * insert is rolled back and PanelStateCeilingError thrown. + */ + private insertPanelRow(data: { + id: string; + sessionId: string; + type: string; + title: string; + state?: ToolPanelState; + metadata?: ToolPanelMetadata; + }): void { + const split = data.state ? splitPanelBufferState(data.state) : null; + const stateJson = split ? JSON.stringify(split.state) : null; + const metadataJson = data.metadata ? JSON.stringify(data.metadata) : null; + + this.db + .prepare( + ` + INSERT INTO tool_panels (id, session_id, type, title, state, metadata) + VALUES (?, ?, ?, ?, ?, ?) + `, + ) + .run( + data.id, + data.sessionId, + data.type, + data.title, + stateJson, + metadataJson, + ); + if (split?.patch) { + this.panelBuffers.apply(data.id, split.patch); + } + this.assertPanelStateWithinCeiling(data.id); + } + + /** Insert a panel; throws PanelStateCeilingError (already logged) when the state is over the ceiling. */ createPanel(data: { id: string; sessionId: string; type: string; title: string; - state?: unknown; - metadata?: unknown; + state?: ToolPanelState; + metadata?: ToolPanelMetadata; }): void { - this.transaction(() => { - const stateJson = data.state ? JSON.stringify(data.state) : null; - const metadataJson = data.metadata ? JSON.stringify(data.metadata) : null; + this.transaction(() => this.insertPanelRow(data)); + } + + private jsonPath(segments: readonly string[]): string { + return `$${segments.map((segment) => `."${segment.replace(/"/g, '\\"')}"`).join("")}`; + } + /** + * Merge a partial state into the stored JSON inside SQLite, one key at a + * time, so the main process never parses or re-serializes the whole blob. + * A key set to `undefined` is removed, matching the object spread this + * replaces; `customState` keys merge one level deep, also as before. + */ + private mergePanelState(panelId: string, state: ToolPanelState): void { + // A row created without state is NULL; older builds wrapped the object in + // a JSON string. json_set needs an object to merge into. + unwrapStringWrappedPanelState(this.db, panelId); + this.db + .prepare( + `UPDATE tool_panels SET state = '{}' + WHERE id = ? AND (state IS NULL OR json_valid(state) = 0 OR json_type(state) <> 'object')`, + ) + .run(panelId); + + const removePaths: string[] = []; + const setPaths: string[] = []; + const setValues: string[] = []; + for (const [key, value] of Object.entries(state)) { + if (key === "customState") continue; + const path = this.jsonPath([key]); + if (value === undefined) { + removePaths.push(path); + } else { + setPaths.push(path); + setValues.push(JSON.stringify(value)); + } + } + if (state.customState) { this.db .prepare( - ` - INSERT INTO tool_panels (id, session_id, type, title, state, metadata) - VALUES (?, ?, ?, ?, ?, ?) - `, + `UPDATE tool_panels SET state = json_set(state, '$.customState', json('{}')) + WHERE id = ? AND json_type(state, '$.customState') IS NOT 'object'`, ) - .run( - data.id, - data.sessionId, - data.type, - data.title, - stateJson, - metadataJson, - ); - }); + .run(panelId); + for (const [key, value] of Object.entries(state.customState)) { + const path = this.jsonPath(["customState", key]); + if (value === undefined) { + removePaths.push(path); + } else { + setPaths.push(path); + setValues.push(JSON.stringify(value)); + } + } + } + + let expression = "state"; + const params: string[] = []; + if (removePaths.length > 0) { + expression = `json_remove(${expression}, ${removePaths.map(() => "?").join(", ")})`; + params.push(...removePaths); + } + if (setPaths.length > 0) { + expression = `json_set(${expression}, ${setPaths.map(() => "?, json(?)").join(", ")})`; + setPaths.forEach((path, index) => params.push(path, setValues[index])); + } + this.db + .prepare(`UPDATE tool_panels SET state = ${expression}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`) + .run(...params, panelId); + } + + /** Name the largest leaf value in the state (e.g. `$.customState.initialInput`) for a refusal log line. */ + private largestPanelStateKey(panelId: string): string { + const largest = decodeOptionalBoundary( + this.db + .prepare( + `SELECT fullkey AS key, octet_length(value) AS bytes + FROM json_tree((SELECT state FROM tool_panels WHERE id = ?)) + WHERE atom IS NOT NULL + ORDER BY bytes DESC LIMIT 1`, + ) + .get(panelId), + boundary.object({ key: boundary.string, bytes: boundary.number }), + ); + return largest ? `${largest.key} (${largest.bytes} bytes)` : "unknown"; } + /** + * Log and throw PanelStateCeilingError when the stored state is over the + * ceiling; the caller's transaction rolls the write back. + */ + private assertPanelStateWithinCeiling(panelId: string): void { + const row = decodeOptionalBoundary( + this.db + .prepare("SELECT COALESCE(octet_length(state), 0) AS bytes FROM tool_panels WHERE id = ?") + .get(panelId), + boundary.object({ bytes: boundary.number }), + ); + if (!row || row.bytes <= PANEL_STATE_CEILING_BYTES) return; + const error = new PanelStateCeilingError(panelId, row.bytes, this.largestPanelStateKey(panelId)); + console.error(`[Database] ${error.message}`); + throw error; + } + + /** + * Update a panel. Returns false when the resulting state would exceed the + * ceiling (the refusal is logged with the panel, the size and the largest + * key); nothing is written in that case. + */ updatePanel( panelId: string, updates: { title?: string; - state?: unknown; - metadata?: unknown; + state?: ToolPanelState; + metadata?: ToolPanelMetadata; }, - ): void { - // Get existing panel first to merge state - const existingPanel = this.getPanel(panelId); - - this.transaction(() => { - const setClauses: string[] = []; - const values: (string | number | boolean | null)[] = []; - - if (updates.title !== undefined) { - setClauses.push("title = ?"); - values.push(updates.title); - } + ): boolean { + try { + this.transaction(() => { + const setClauses: string[] = []; + const values: string[] = []; - if (updates.state !== undefined) { - // Merge with existing state instead of replacing - const existingState: ToolPanelState = existingPanel?.state || { isActive: false }; - const stateUpdates: Partial = updates.state || {}; - const mergedState: ToolPanelState = { - ...existingState, - ...stateUpdates, - }; - - // If there's a customState in either, merge that too. - const existingCustomState = existingState.customState; - const updatesCustomState = stateUpdates.customState; - if (existingCustomState !== undefined || updatesCustomState !== undefined) { - mergedState.customState = { - ...existingCustomState, - ...updatesCustomState, - }; + if (updates.title !== undefined) { + setClauses.push("title = ?"); + values.push(updates.title); } - if (DEBUG_DB_PANEL_STATE) { - console.log("[DB-DEBUG] updatePanel state merge:", { - panelId, - updates: sanitizePanelStateForLog(stateUpdates), - existing: sanitizePanelStateForLog(existingState), - merged: sanitizePanelStateForLog(mergedState), - }); + if (updates.metadata !== undefined) { + setClauses.push("metadata = ?"); + values.push(JSON.stringify(updates.metadata)); } - setClauses.push("state = ?"); - values.push(JSON.stringify(mergedState)); - } - - if (updates.metadata !== undefined) { - setClauses.push("metadata = ?"); - values.push(JSON.stringify(updates.metadata)); - } + if (setClauses.length > 0) { + setClauses.push("updated_at = CURRENT_TIMESTAMP"); + this.db + .prepare(`UPDATE tool_panels SET ${setClauses.join(", ")} WHERE id = ?`) + .run(...values, panelId); + } - if (setClauses.length > 0) { - setClauses.push("updated_at = CURRENT_TIMESTAMP"); - values.push(panelId); + if (updates.state !== undefined) { + const { state, patch } = splitPanelBufferState(updates.state); + if (DEBUG_DB_PANEL_STATE) { + console.log("[DB-DEBUG] updatePanel state merge:", { + panelId, + updates: sanitizePanelStateForLog(state), + buffers: patch ? Object.keys(patch) : [], + }); + } + if (patch) { + this.panelBuffers.apply(panelId, patch); + } + this.mergePanelState(panelId, state); + this.assertPanelStateWithinCeiling(panelId); + } + }); + return true; + } catch (error) { + if (error instanceof PanelStateCeilingError) return false; + throw error; + } + } - this.db - .prepare( - ` - UPDATE tool_panels - SET ${setClauses.join(", ")} - WHERE id = ? - `, - ) - .run(...values); - } - }); + /** Persisted terminal bytes for a panel, or null when none are stored. */ + getPanelBuffers(panelId: string): PanelBuffers | null { + return this.panelBuffers.get(panelId); } deletePanel(panelId: string): void { @@ -4544,29 +4712,11 @@ export class DatabaseService { sessionId: string; type: string; title: string; - state?: unknown; - metadata?: unknown; + state?: ToolPanelState; + metadata?: ToolPanelMetadata; }): void { this.transaction(() => { - // Create the panel - const stateJson = data.state ? JSON.stringify(data.state) : null; - const metadataJson = data.metadata ? JSON.stringify(data.metadata) : null; - - this.db - .prepare( - ` - INSERT INTO tool_panels (id, session_id, type, title, state, metadata) - VALUES (?, ?, ?, ?, ?, ?) - `, - ) - .run( - data.id, - data.sessionId, - data.type, - data.title, - stateJson, - metadataJson, - ); + this.insertPanelRow(data); // Set as active panel this.db @@ -4611,19 +4761,12 @@ export class DatabaseService { }; } - getPanelsForSession(sessionId: string, includeScrollback = true): ToolPanel[] { + // Panel rows never carry terminal bytes (see panel_buffers), so one query + // serves both startup summaries and full restoration reads. + getPanelsForSession(sessionId: string): ToolPanel[] { // SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary. const rows = this.db - .prepare( - includeScrollback - ? "SELECT * FROM tool_panels WHERE session_id = ? ORDER BY created_at" - : `SELECT id, session_id, type, title, metadata, created_at, - json_remove( - CASE WHEN json_type(state) = 'text' THEN json_extract(state, '$') ELSE state END, - '$.customState.scrollbackBuffer', '$.customState.serializedBuffer' - ) AS state - FROM tool_panels WHERE session_id = ? ORDER BY created_at`, - ) + .prepare("SELECT * FROM tool_panels WHERE session_id = ? ORDER BY created_at") .all(sessionId) as ToolPanelRow[]; // Get the active panel ID for this session diff --git a/main/src/database/panelBufferMigration.test.ts b/main/src/database/panelBufferMigration.test.ts new file mode 100644 index 000000000..363316df2 --- /dev/null +++ b/main/src/database/panelBufferMigration.test.ts @@ -0,0 +1,129 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DatabaseService } from './database'; +import { PANEL_BUFFER_SCHEMA_VERSION } from './panelBufferMigration'; +import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder'; + +const bytesSchema = boundary.object({ bytes: boundary.number }); + +function backupFiles(dir: string): string[] { + return fs.readdirSync(dir).filter((name) => name.includes('.pre-panel-buffers-')); +} + +describe('panel buffer migration', () => { + let tempDir: string; + let dbPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-panel-migration-')); + dbPath = path.join(tempDir, 'sessions.db'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('versions a fresh database without a backup or a rewrite', () => { + const db = new DatabaseService(dbPath); + db.initialize(); + try { + expect(db.getPanelBufferMigration()).toEqual({ result: expect.objectContaining({ migrated: false }), error: null }); + expect(db.getDb().pragma('user_version', { simple: true })).toBe(PANEL_BUFFER_SCHEMA_VERSION); + expect(backupFiles(tempDir)).toEqual([]); + } finally { + db.close(); + } + }); + + it('moves a 30 MB legacy row into panel_buffers, leaves state under 10 KB, backs up first, and runs once', () => { + const seed = new DatabaseService(dbPath); + seed.initialize(); + seed.createSession({ + id: 'session', name: 'session', initial_prompt: '', worktree_name: 'session', + worktree_path: tempDir, project_id: null, tool_type: 'none', + }); + const sqlite = seed.getDb(); + const insert = sqlite.prepare('INSERT INTO tool_panels (id, session_id, type, title, state) VALUES (?, ?, ?, ?, ?)'); + + // What the pty output heuristic accumulated: cursor moves, colors and + // braille spinner glyphs, never a newline. + const frame = '\x1b[1;1H\x1b[2K\x1b[38;5;208m⠋ working\x1b[0m'; + const lastActiveCommand = frame.repeat(Math.ceil((30 * 1024 * 1024) / Buffer.byteLength(frame, 'utf8'))); + expect(Buffer.byteLength(lastActiveCommand, 'utf8')).toBeGreaterThanOrEqual(30 * 1024 * 1024); + insert.run('legacy', 'session', 'terminal', 'Codex', JSON.stringify({ + isActive: false, + hasBeenViewed: true, + customState: { + cwd: '/repo', + isCliPanel: true, + isAlternateScreen: true, + scrollbackBuffer: 'shell history\r\n', + serializedBuffer: 'serialized snapshot', + alternateScreenBuffer: 'alternate frame', + lastActiveCommand, + commandHistory: ['ls', lastActiveCommand.slice(0, 100_000)], + }, + })); + // The string-wrapped legacy format with the array scrollback shape. + insert.run('wrapped', 'session', 'terminal', 'Legacy', JSON.stringify(JSON.stringify({ + isActive: false, + customState: { cwd: '/wrapped', scrollbackBuffer: ['first', 'second'] }, + }))); + insert.run('logs', 'session', 'logs', 'Logs', JSON.stringify({ isActive: false, customState: { isRunning: false } })); + insert.run('empty', 'session', 'terminal', 'Empty', null); + sqlite.pragma('user_version = 0'); + seed.close(); + const sizeBefore = fs.statSync(dbPath).size; + expect(sizeBefore).toBeGreaterThan(30 * 1024 * 1024); + + const db = new DatabaseService(dbPath); + db.initialize(); + try { + const { result, error } = db.getPanelBufferMigration(); + expect(error).toBeNull(); + expect(result).toMatchObject({ migrated: true, panelsRepaired: 2, panelsWithBuffers: 2 }); + expect(result?.fileBytesBefore ?? 0).toBeGreaterThanOrEqual(sizeBefore); + expect(result?.backupPath).toBe(path.join(tempDir, backupFiles(tempDir)[0])); + expect(backupFiles(tempDir)).toHaveLength(1); + expect(fs.statSync(result?.backupPath ?? '').size).toBe(result?.fileBytesBefore); + expect(result?.fileBytesAfter ?? Infinity).toBeLessThan(1024 * 1024); + + const stateBytes = decodeBoundary( + db.getDb().prepare('SELECT LENGTH(CAST(state AS BLOB)) AS bytes FROM tool_panels WHERE id = ?').get('legacy'), + bytesSchema, + ).bytes; + expect(stateBytes).toBeLessThan(10 * 1024); + expect(db.getPanel('legacy')?.state.customState).toEqual({ cwd: '/repo', isCliPanel: true, isAlternateScreen: true }); + expect(db.getPanelBuffers('legacy')).toEqual({ + scrollback: 'shell history\r\n', + serialized: 'serialized snapshot', + alternate: 'alternate frame', + }); + expect(db.getPanel('wrapped')?.state.customState).toEqual({ cwd: '/wrapped' }); + expect(db.getPanelBuffers('wrapped')).toEqual({ scrollback: 'first\nsecond', serialized: null, alternate: null }); + expect(db.getPanel('logs')?.state.customState).toEqual({ isRunning: false }); + expect(db.getPanel('empty')?.state.customState).toEqual({}); + expect(db.getDb().pragma('user_version', { simple: true })).toBe(PANEL_BUFFER_SCHEMA_VERSION); + + // The repaired row is writable again through the normal partial merge. + expect(db.updatePanel('legacy', { state: { isActive: false, customState: { isCliReady: true } } })).toBe(true); + expect(db.getPanel('legacy')?.state.customState).toEqual({ + cwd: '/repo', isCliPanel: true, isAlternateScreen: true, isCliReady: true, + }); + } finally { + db.close(); + } + + const reopened = new DatabaseService(dbPath); + reopened.initialize(); + try { + expect(reopened.getPanelBufferMigration().result).toMatchObject({ migrated: false }); + expect(backupFiles(tempDir)).toHaveLength(1); + expect(reopened.getPanelBuffers('legacy')?.serialized).toBe('serialized snapshot'); + } finally { + reopened.close(); + } + }); +}); diff --git a/main/src/database/panelBufferMigration.ts b/main/src/database/panelBufferMigration.ts new file mode 100644 index 000000000..31bd9b40f --- /dev/null +++ b/main/src/database/panelBufferMigration.ts @@ -0,0 +1,192 @@ +import type Database from 'better-sqlite3-multiple-ciphers'; +import { constants, copyFileSync, existsSync, statSync } from 'fs'; +import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder'; +import { PANEL_BUFFER_KEYS, splitPanelBufferState, type PanelBufferStore } from './panelBuffers'; + +/** + * One-time repair that moves terminal bytes out of `tool_panels.state`. + * + * Before this schema version, every PTY output chunk without a newline was + * appended to `customState.lastActiveCommand` and, on newline, pushed into + * `customState.commandHistory`. Full-screen agents never emit a newline in the + * alternate screen, so a single panel reached a 428 MB state blob and the + * whole-blob JSON.parse / JSON.stringify on every panel update exhausted the + * V8 heap. The repair drops those two dead keys, moves `scrollbackBuffer`, + * `serializedBuffer` and `alternateScreenBuffer` into `panel_buffers` under + * the byte cap, and vacuums. + * + * Idempotent via `PRAGMA user_version`; a backup copy of the database file is + * written before the first real run. + */ +export const PANEL_BUFFER_SCHEMA_VERSION = 1; + +const DEAD_KEYS = ['lastActiveCommand', 'commandHistory'] as const; +const MOVED_PATHS = [...PANEL_BUFFER_KEYS, ...DEAD_KEYS].map((key) => `$.customState.${key}`); + +/** + * Rows whose state is a JSON object carrying any of the five keys. The + * substr test runs first so rows that cannot match are never JSON-parsed. + */ +const CANDIDATE_PREDICATE = ` + substr(state, 1, 1) = '{' + AND json_valid(state) + AND (${MOVED_PATHS.map((path) => `json_type(state, '${path}') IS NOT NULL`).join(' OR ')}) +`; + +export interface PanelBufferMigrationResult { + /** False when the schema version was already current or nothing needed moving. */ + migrated: boolean; + panelsRepaired: number; + panelsWithBuffers: number; + backupPath: string | null; + fileBytesBefore: number | null; + fileBytesAfter: number | null; + durationMs: number; +} + +interface CandidateIdRow { + id: string; +} + +/** Single-path json_extract returns native TEXT; an array scrollback comes back as JSON text. */ +interface CandidateBufferRow { + scrollback: string | null; + scrollbackType: string | null; + serialized: string | null; + alternate: string | null; +} + +const legacyScrollbackSchema = boundary.array(boundary.string); + +/** + * Older builds wrote `state` as a JSON string wrapping the object. Unwrap so + * the per-key merge in `updatePanel` and the migration predicate see an + * object. The migration runs this over every row once; `updatePanel` keeps + * it as a per-row guard in case the migration never completed. + */ +export function unwrapStringWrappedPanelState(db: Database.Database, panelId?: string): void { + db.prepare( + `UPDATE tool_panels + SET state = json_extract(state, '$') + WHERE ${panelId === undefined ? '1 = 1' : 'id = ?'} + AND substr(state, 1, 1) = '"' AND json_valid(state) + AND json_valid(json_extract(state, '$')) AND json_type(json_extract(state, '$')) = 'object'`, + ).run(...(panelId === undefined ? [] : [panelId])); +} + +function fileSize(dbPath: string): number | null { + try { + return existsSync(dbPath) ? statSync(dbPath).size : null; + } catch { + return null; + } +} + +function readUserVersion(db: Database.Database): number { + return decodeBoundary(db.pragma('user_version', { simple: true }), boundary.number); +} + +function writeBackup(dbPath: string): string | null { + if (!existsSync(dbPath)) return null; + const backupPath = `${dbPath}.pre-panel-buffers-${Date.now()}.bak`; + // APFS clones the file instantly; other filesystems fall back to a plain copy. + copyFileSync(dbPath, backupPath, constants.COPYFILE_FICLONE); + return backupPath; +} + +export function migratePanelBuffers( + db: Database.Database, + dbPath: string, + store: PanelBufferStore, +): PanelBufferMigrationResult { + const startedAt = Date.now(); + const skipped: PanelBufferMigrationResult = { + migrated: false, + panelsRepaired: 0, + panelsWithBuffers: 0, + backupPath: null, + fileBytesBefore: null, + fileBytesAfter: null, + durationMs: 0, + }; + + if (readUserVersion(db) >= PANEL_BUFFER_SCHEMA_VERSION) return skipped; + + unwrapStringWrappedPanelState(db); + + // One scan over the big blobs; everything after this addresses rows by id. + // SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary. + const candidates = (db + .prepare(`SELECT id FROM tool_panels WHERE ${CANDIDATE_PREDICATE}`) + .all() as CandidateIdRow[]).map((row) => row.id); + + if (candidates.length === 0) { + db.pragma(`user_version = ${PANEL_BUFFER_SCHEMA_VERSION}`); + return { ...skipped, durationMs: Date.now() - startedAt }; + } + + const fileBytesBefore = fileSize(dbPath); + const backupPath = writeBackup(dbPath); + console.log( + `[PanelBuffers] Migrating ${candidates.length} panel states (database ${fileBytesBefore ?? 'unknown'} bytes, ` + + `backup ${backupPath ?? 'skipped'})`, + ); + + let panelsWithBuffers = 0; + let panelsRepaired = 0; + const candidateIdsJson = JSON.stringify(candidates); + const migrate = db.transaction(() => { + // Only the three buffer values ever reach the JS heap, never the state + // blob they are extracted from. + const readBuffers = db.prepare( + `SELECT json_extract(state, '$.customState.scrollbackBuffer') AS scrollback, + json_type(state, '$.customState.scrollbackBuffer') AS scrollbackType, + json_extract(state, '$.customState.serializedBuffer') AS serialized, + json_extract(state, '$.customState.alternateScreenBuffer') AS alternate + FROM tool_panels WHERE id = ?`, + ); + for (const id of candidates) { + // SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary. + const row = readBuffers.get(id) as CandidateBufferRow | undefined; + if (!row || (row.scrollback === null && row.serialized === null && row.alternate === null)) continue; + const scrollbackBuffer = row.scrollbackType === 'array' && row.scrollback !== null + ? decodeBoundary(JSON.parse(row.scrollback), legacyScrollbackSchema) + : row.scrollback ?? undefined; + // Same normalization as a live write: legacy array scrollback joins, strings pass through. + const { patch } = splitPanelBufferState({ + isActive: false, + customState: { + scrollbackBuffer, + serializedBuffer: row.serialized ?? undefined, + alternateScreenBuffer: row.alternate ?? undefined, + }, + }); + if (patch) store.apply(id, patch); + panelsWithBuffers += 1; + } + + panelsRepaired = db + .prepare( + `UPDATE tool_panels + SET state = json_remove(state, ${MOVED_PATHS.map((path) => `'${path}'`).join(', ')}), + updated_at = CURRENT_TIMESTAMP + WHERE id IN (SELECT value FROM json_each(?))`, + ) + .run(candidateIdsJson).changes; + + db.pragma(`user_version = ${PANEL_BUFFER_SCHEMA_VERSION}`); + }); + migrate(); + + db.exec('VACUUM'); + + return { + migrated: true, + panelsRepaired, + panelsWithBuffers, + backupPath, + fileBytesBefore, + fileBytesAfter: fileSize(dbPath), + durationMs: Date.now() - startedAt, + }; +} diff --git a/main/src/database/panelBuffers.test.ts b/main/src/database/panelBuffers.test.ts new file mode 100644 index 000000000..516c61a22 --- /dev/null +++ b/main/src/database/panelBuffers.test.ts @@ -0,0 +1,194 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DatabaseService, PANEL_STATE_CEILING_BYTES } from './database'; +import { PANEL_BUFFER_CAP_BYTES, splitPanelBufferState } from './panelBuffers'; +import { ScrollbackRetentionService } from '../services/scrollbackRetention'; +import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder'; + +const rawStateSchema = boundary.object({ state: boundary.string }); +const bytesSchema = boundary.object({ bytes: boundary.number }); + +describe('panel buffers and the panel state ceiling', () => { + let tempDir: string; + let db: DatabaseService; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-panel-buffers-')); + db = new DatabaseService(path.join(tempDir, 'sessions.db')); + db.initialize(); + db.createSession({ + id: 'session', name: 'session', initial_prompt: '', worktree_name: 'session', + worktree_path: tempDir, project_id: null, tool_type: 'none', + }); + vi.mocked(console.error).mockClear(); + vi.mocked(console.warn).mockClear(); + }); + + afterEach(() => { + db.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function rawState(panelId: string): string { + return decodeBoundary( + db.getDb().prepare('SELECT state FROM tool_panels WHERE id = ?').get(panelId), + rawStateSchema, + ).state; + } + + it('keeps terminal bytes out of the state row and merges the rest key by key', () => { + db.createPanel({ + id: 'panel', sessionId: 'session', type: 'terminal', title: 'Terminal', + state: { + isActive: false, + hasBeenViewed: false, + customState: { + cwd: tempDir, + isAlternateScreen: false, + scrollbackBuffer: 'one\r\ntwo', + serializedBuffer: 'snapshot', + alternateScreenBuffer: 'frame', + }, + }, + }); + + expect(db.getPanel('panel')?.state.customState).toEqual({ cwd: tempDir, isAlternateScreen: false }); + expect(rawState('panel')).not.toContain('scrollbackBuffer'); + expect(db.getPanelBuffers('panel')).toEqual({ scrollback: 'one\r\ntwo', serialized: 'snapshot', alternate: 'frame' }); + + // Only the keys present in the update change; undefined removes; legacy arrays join. + expect(db.updatePanel('panel', { + state: { + isActive: false, + customState: { scrollbackBuffer: ['a', 'b'], serializedBuffer: undefined, dimensions: { cols: 10, rows: 5 } }, + }, + })).toBe(true); + expect(db.getPanelBuffers('panel')).toEqual({ scrollback: 'a\nb', serialized: null, alternate: 'frame' }); + expect(db.getPanel('panel')?.state.customState).toEqual({ + cwd: tempDir, isAlternateScreen: false, dimensions: { cols: 10, rows: 5 }, + }); + + expect(db.updatePanel('panel', { state: { isActive: false, hasBeenViewed: true, customState: { cwd: undefined } } })).toBe(true); + expect(db.getPanel('panel')?.state).toEqual({ + isActive: false, hasBeenViewed: true, customState: { isAlternateScreen: false, dimensions: { cols: 10, rows: 5 } }, + }); + + expect(db.updatePanel('panel', { title: 'Renamed' })).toBe(true); + expect(db.getPanel('panel')?.title).toBe('Renamed'); + expect(db.getPanel('panel')?.state.customState).toEqual({ isAlternateScreen: false, dimensions: { cols: 10, rows: 5 } }); + + // Clearing every buffer drops the row. + expect(db.updatePanel('panel', { + state: { isActive: false, customState: { scrollbackBuffer: undefined, alternateScreenBuffer: undefined } }, + })).toBe(true); + expect(db.getPanelBuffers('panel')).toBeNull(); + }); + + it('splits a state into buffer-free state and a patch without touching absent keys', () => { + const split = splitPanelBufferState({ + isActive: true, + customState: { cwd: '/repo', scrollbackBuffer: ['x', 'y'], serializedBuffer: undefined }, + }); + expect(split.state).toEqual({ isActive: true, customState: { cwd: '/repo' } }); + expect(split.patch).toEqual({ scrollback: 'x\ny', serialized: null }); + expect(splitPanelBufferState({ isActive: false, customState: { cwd: '/repo' } }).patch).toBeNull(); + }); + + it('refuses a state write over 256 KB with a logged error and accepts one at 200 KB', () => { + db.createPanel({ + id: 'panel', sessionId: 'session', type: 'terminal', title: 'Terminal', + state: { isActive: false, customState: { cwd: tempDir } }, + }); + const accepted = 'x'.repeat(200 * 1024); + + expect(db.updatePanel('panel', { state: { isActive: false, customState: { initialInput: accepted } } })).toBe(true); + expect(db.getPanel('panel')?.state.customState).toEqual({ cwd: tempDir, initialInput: accepted }); + expect(console.error).not.toHaveBeenCalled(); + + const refused = db.updatePanel('panel', { + title: 'Should not change', + state: { isActive: false, customState: { initialInput: 'y'.repeat(300 * 1024), scrollbackBuffer: 'bytes' } }, + }); + expect(refused).toBe(false); + expect(console.error).toHaveBeenCalledTimes(1); + const message = String(vi.mocked(console.error).mock.calls[0]?.[0]); + expect(message).toContain('panel'); + expect(message).toContain(`${PANEL_STATE_CEILING_BYTES} byte ceiling`); + expect(message).toMatch(/\$\.customState\.initialInput \(\d+ bytes\)/); + + // Nothing from the refused write landed: state, title, or buffers. + expect(db.getPanel('panel')?.state.customState).toEqual({ cwd: tempDir, initialInput: accepted }); + expect(db.getPanel('panel')?.title).toBe('Terminal'); + expect(db.getPanelBuffers('panel')).toBeNull(); + + expect(() => db.createPanel({ + id: 'oversized', sessionId: 'session', type: 'terminal', title: 'Terminal', + state: { isActive: false, customState: { initialInput: 'z'.repeat(300 * 1024) } }, + })).toThrow(/ceiling/); + expect(db.getPanel('oversized')).toBeNull(); + }); + + it('caps a panel at 4 MB, trimming the oldest scrollback first, with one warning per panel', () => { + const line = `${'x'.repeat(1023)}\n`; + const scrollback = line.repeat(5 * 1024); // 5 MiB + db.createPanel({ + id: 'panel', sessionId: 'session', type: 'terminal', title: 'Terminal', + state: { isActive: false, customState: { scrollbackBuffer: scrollback, alternateScreenBuffer: 'alt' } }, + }); + + const stored = db.getPanelBuffers('panel'); + const storedScrollback = stored?.scrollback ?? ''; + const storedBytes = Buffer.byteLength(storedScrollback, 'utf8') + Buffer.byteLength(stored?.alternate ?? '', 'utf8'); + expect(storedBytes).toBeLessThanOrEqual(PANEL_BUFFER_CAP_BYTES); + expect(storedBytes).toBeGreaterThan(PANEL_BUFFER_CAP_BYTES - 2048); + expect(scrollback.endsWith(storedScrollback)).toBe(true); + expect(storedScrollback.startsWith('x')).toBe(true); + expect(stored?.alternate).toBe('alt'); + expect(decodeBoundary( + db.getDb().prepare('SELECT bytes FROM panel_buffers WHERE panel_id = ?').get('panel'), + bytesSchema, + ).bytes).toBe(storedBytes); + + expect(console.warn).toHaveBeenCalledTimes(1); + const warning = String(vi.mocked(console.warn).mock.calls[0]?.[0]); + expect(warning).toContain('panel'); + expect(warning).toContain(`${PANEL_BUFFER_CAP_BYTES} byte cap`); + + // A second oversize write on the same panel trims again but stays quiet. + expect(db.updatePanel('panel', { state: { isActive: false, customState: { scrollbackBuffer: scrollback } } })).toBe(true); + expect(Buffer.byteLength(db.getPanelBuffers('panel')?.scrollback ?? '', 'utf8')).toBeLessThanOrEqual(PANEL_BUFFER_CAP_BYTES); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it('drops buffers with their panels and sweeps archived sessions', () => { + for (const id of ['keep', 'drop', 'swept']) { + db.createPanel({ + id, sessionId: 'session', type: 'terminal', title: 'Terminal', + state: { isActive: false, customState: { scrollbackBuffer: 'hello' } }, + }); + } + db.deletePanel('drop'); + expect(db.getPanelBuffers('drop')).toBeNull(); + expect(db.getPanelBuffers('keep')?.scrollback).toBe('hello'); + + db.createSession({ + id: 'archived', name: 'archived', initial_prompt: '', worktree_name: 'archived', + worktree_path: tempDir, project_id: null, tool_type: 'none', + }); + db.createPanel({ + id: 'old', sessionId: 'archived', type: 'terminal', title: 'Terminal', + state: { isActive: false, customState: { scrollbackBuffer: 'archived bytes', serializedBuffer: 'snap' } }, + }); + db.archiveSession('archived'); + + const result = new ScrollbackRetentionService(db).runRetentionSweep(); + expect(result).toEqual({ panelsCleared: 1, sessionsTouched: 1, bytesFreed: 'archived bytes'.length + 'snap'.length }); + expect(db.getPanelBuffers('old')).toBeNull(); + expect(db.getPanelBuffers('keep')?.scrollback).toBe('hello'); + + db.deletePanelsForSession('session'); + expect(db.getPanelBuffers('keep')).toBeNull(); + }); +}); diff --git a/main/src/database/panelBuffers.ts b/main/src/database/panelBuffers.ts new file mode 100644 index 000000000..97aef6c5d --- /dev/null +++ b/main/src/database/panelBuffers.ts @@ -0,0 +1,233 @@ +import type Database from 'better-sqlite3-multiple-ciphers'; +import type { TerminalPanelState, ToolPanelState } from '../../../shared/types/panels'; +import { boundary, decodeOptionalBoundary } from '../../../shared/validation/boundaryDecoder'; +import { trimAnsiSafe } from '../utils/ansiTrim'; + +/** + * Terminal bytes never live inside `tool_panels.state`. They go to the + * `panel_buffers` table, one row per panel, bounded by PANEL_BUFFER_CAP_BYTES + * across the three columns. `state` stays small metadata JSON. + */ +export const PANEL_BUFFER_CAP_BYTES = 4 * 1024 * 1024; + +/** State keys that are split out of `customState`, in `panel_buffers` column order. */ +export const PANEL_BUFFER_KEYS = ['scrollbackBuffer', 'serializedBuffer', 'alternateScreenBuffer'] as const; +const BUFFER_COLUMNS = ['scrollback', 'serialized', 'alternate'] as const; + +export interface PanelBuffers { + scrollback: string | null; + serialized: string | null; + alternate: string | null; +} + +/** `undefined` leaves a column untouched; `null` clears it. */ +export interface PanelBufferPatch { + scrollback?: string | null; + serialized?: string | null; + alternate?: string | null; +} + +interface PanelStateSplit { + /** The state with the three buffer keys removed. */ + state: ToolPanelState; + /** Buffer columns the caller wrote, or null when the state carried none. */ + patch: PanelBufferPatch | null; +} + +interface PanelBufferCapResult { + buffers: PanelBuffers; + trimmedBytes: number; +} + +interface PanelBufferRow { + scrollback: Buffer | null; + serialized: Buffer | null; + alternate: Buffer | null; +} + +interface PanelBufferLengthRow { + scrollback: number; + serialized: number; + alternate: number; +} + +const EMPTY_BUFFERS: PanelBuffers = { scrollback: null, serialized: null, alternate: null }; +const ZERO_LENGTHS: PanelBufferLengthRow = { scrollback: 0, serialized: 0, alternate: 0 }; + +const scrollbackSchema = boundary.union(boundary.string, boundary.array(boundary.string)); + +function normalizeScrollback(value: TerminalPanelState['scrollbackBuffer']): string | null { + const decoded = decodeOptionalBoundary(value, scrollbackSchema); + if (decoded === undefined) return null; + return Array.isArray(decoded) ? decoded.join('\n') : decoded; +} + +function normalizeText(value: string | undefined): string | null { + return decodeOptionalBoundary(value, boundary.string) ?? null; +} + +function byteLength(text: string | null): number { + return text === null ? 0 : Buffer.byteLength(text, 'utf8'); +} + +function totalBytes(buffers: PanelBuffers): number { + return byteLength(buffers.scrollback) + byteLength(buffers.serialized) + byteLength(buffers.alternate); +} + +function toText(value: Buffer | null): string | null { + return value === null ? null : value.toString('utf8'); +} + +function toBlob(value: string | null): Buffer | null { + return value === null ? null : Buffer.from(value, 'utf8'); +} + +/** + * Pull the terminal byte buffers out of a panel state so `tool_panels.state` + * never carries them. A key that is present, even as `undefined`, becomes a + * patch entry (undefined clears, mirroring the JSON merge it replaces); an + * absent key leaves the stored buffer untouched. + */ +export function splitPanelBufferState(state: ToolPanelState): PanelStateSplit { + const customState = state.customState; + if (!customState) return { state, patch: null }; + + const hasScrollback = Object.hasOwn(customState, 'scrollbackBuffer'); + const hasSerialized = Object.hasOwn(customState, 'serializedBuffer'); + const hasAlternate = Object.hasOwn(customState, 'alternateScreenBuffer'); + if (!hasScrollback && !hasSerialized && !hasAlternate) return { state, patch: null }; + + // SAFETY: Only terminal panels persist these three keys; the destructuring + // reads nothing else and every value is decoded before it is stored. + const { scrollbackBuffer, serializedBuffer, alternateScreenBuffer, ...rest } = customState as TerminalPanelState; + const patch: PanelBufferPatch = {}; + if (hasScrollback) patch.scrollback = normalizeScrollback(scrollbackBuffer); + if (hasSerialized) patch.serialized = normalizeText(serializedBuffer); + if (hasAlternate) patch.alternate = normalizeText(alternateScreenBuffer); + return { state: { ...state, customState: rest }, patch }; +} + +/** Trim a UTF-8 string to at most `targetBytes`, dropping the oldest bytes. */ +function trimToBytes(text: string, targetBytes: number): string { + let result = text; + for (let attempt = 0; attempt < 8 && byteLength(result) > targetBytes; attempt += 1) { + const ratio = targetBytes / byteLength(result); + result = trimAnsiSafe(result, Math.floor(result.length * ratio)); + if (result.length === 0) break; + } + return byteLength(result) > targetBytes ? '' : result; +} + +/** + * Enforce the per-panel cap across the three buffers. The oldest scrollback + * goes first, then the serialized snapshot, then the alternate-screen frame, + * each trimmed from its start so the newest bytes survive. + */ +function applyPanelBufferCap(buffers: PanelBuffers, capBytes: number): PanelBufferCapResult { + const total = totalBytes(buffers); + let over = total - capBytes; + if (over <= 0) return { buffers, trimmedBytes: 0 }; + + const capped: PanelBuffers = { ...buffers }; + for (const column of BUFFER_COLUMNS) { + if (over <= 0) break; + const text = capped[column]; + if (text === null || text.length === 0) continue; + const bytes = byteLength(text); + if (bytes <= over) { + capped[column] = null; + over -= bytes; + continue; + } + capped[column] = trimToBytes(text, bytes - over); + over = 0; + } + return { buffers: capped, trimmedBytes: total - totalBytes(capped) }; +} + +export class PanelBufferStore { + /** Panels that already logged a trim warning this process. */ + private readonly warnedPanels = new Set(); + + constructor(private readonly db: Database.Database) {} + + get(panelId: string): PanelBuffers | null { + // SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary. + const row = this.db + .prepare('SELECT scrollback, serialized, alternate FROM panel_buffers WHERE panel_id = ?') + .get(panelId) as PanelBufferRow | undefined; + if (!row) return null; + return { + scrollback: toText(row.scrollback), + serialized: toText(row.serialized), + alternate: toText(row.alternate), + }; + } + + /** Byte size of each stored column, zero when the row or column is absent. */ + private getLengths(panelId: string): PanelBufferLengthRow { + // SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary. + const row = this.db + .prepare( + `SELECT COALESCE(LENGTH(scrollback), 0) AS scrollback, + COALESCE(LENGTH(serialized), 0) AS serialized, + COALESCE(LENGTH(alternate), 0) AS alternate + FROM panel_buffers WHERE panel_id = ?`, + ) + .get(panelId) as PanelBufferLengthRow | undefined; + return row ?? ZERO_LENGTHS; + } + + /** + * Merge a patch into the panel's row, enforcing the cap on the merged + * result. Trimming logs one warning per panel per process, never silently. + * A panel that no longer exists is left alone (its row cascades with it). + */ + apply(panelId: string, patch: PanelBufferPatch): void { + const columns = BUFFER_COLUMNS.filter((column) => patch[column] !== undefined); + if (columns.length === 0) return; + + // The stored row is only consulted for columns the patch leaves alone; + // the usual full save touches all three and never reads it. + const coversAll = columns.length === BUFFER_COLUMNS.length; + const stored = coversAll ? ZERO_LENGTHS : this.getLengths(panelId); + let bytes = BUFFER_COLUMNS.reduce((sum, column) => { + const value = patch[column]; + return sum + (value === undefined ? stored[column] : byteLength(value)); + }, 0); + + let writes: PanelBufferPatch = patch; + if (bytes > PANEL_BUFFER_CAP_BYTES) { + const existing = coversAll ? null : this.get(panelId); + const merged: PanelBuffers = { ...EMPTY_BUFFERS, ...existing, ...patch }; + const capped = applyPanelBufferCap(merged, PANEL_BUFFER_CAP_BYTES); + writes = capped.buffers; + bytes = totalBytes(capped.buffers); + if (capped.trimmedBytes > 0 && !this.warnedPanels.has(panelId)) { + this.warnedPanels.add(panelId); + console.warn( + `[PanelBuffers] Trimmed ${capped.trimmedBytes} bytes from panel ${panelId} ` + + `to stay under the ${PANEL_BUFFER_CAP_BYTES} byte cap (oldest scrollback first)`, + ); + } + } + + const written = BUFFER_COLUMNS.filter((column) => writes[column] !== undefined); + this.db + .prepare( + `INSERT INTO panel_buffers (panel_id, ${written.join(', ')}, bytes) + SELECT ?, ${written.map(() => '?').join(', ')}, ? + WHERE EXISTS (SELECT 1 FROM tool_panels WHERE id = ?) + ON CONFLICT(panel_id) DO UPDATE SET + ${written.map((column) => `${column} = excluded.${column}`).join(', ')}, + bytes = excluded.bytes, + updated_at = CURRENT_TIMESTAMP`, + ) + .run(panelId, ...written.map((column) => toBlob(writes[column] ?? null)), bytes, panelId); + if (bytes === 0) { + this.db + .prepare('DELETE FROM panel_buffers WHERE panel_id = ? AND scrollback IS NULL AND serialized IS NULL AND alternate IS NULL') + .run(panelId); + } + } +} diff --git a/main/src/database/schema.sql b/main/src/database/schema.sql index cfc801f2a..b52236e67 100644 --- a/main/src/database/schema.sql +++ b/main/src/database/schema.sql @@ -124,3 +124,16 @@ CREATE INDEX IF NOT EXISTS idx_conversation_messages_timestamp ON conversation_m CREATE INDEX IF NOT EXISTS idx_sessions_project_id ON sessions(project_id); CREATE INDEX IF NOT EXISTS idx_sessions_worktree_path ON sessions(worktree_path); CREATE INDEX IF NOT EXISTS idx_session_git_status_cache_updated_at ON session_git_status_cache(updated_at); + +-- Terminal bytes for one panel. tool_panels.state never carries them: the +-- scrollback log, the serialized emulator snapshot and the alternate-screen +-- frame live here, capped per panel at write time (see panelBuffers.ts). +-- Foreign keys are enforced by this SQLite build, so rows go with their panel. +CREATE TABLE IF NOT EXISTS panel_buffers ( + panel_id TEXT PRIMARY KEY REFERENCES tool_panels(id) ON DELETE CASCADE, + scrollback BLOB, + serialized BLOB, + alternate BLOB, + bytes INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/main/src/ipc/panels.ts b/main/src/ipc/panels.ts index ab5a80870..d27bf860a 100644 --- a/main/src/ipc/panels.ts +++ b/main/src/ipc/panels.ts @@ -10,7 +10,7 @@ import { getPaneWebviewContextMap } from '../core/runtime'; import { panelManager } from '../services/panelManager'; import { terminalPanelManager } from '../services/terminalPanelManager'; import { databaseService } from '../services/database'; -import { CreatePanelRequest, PanelEventType, SessionPanelLayout, ToolPanel, type PanelLayoutNode, type ToolPanelState } from '../../../shared/types/panels'; +import { CreatePanelRequest, PanelEventType, SessionPanelLayout, ToolPanel, type PanelLayoutNode } from '../../../shared/types/panels'; import type { AppServices } from './types'; import { getAppSubdirectory } from '../utils/appDirectory'; import { sanitizeTerminalOutput } from '../utils/terminalOutputSanitizer'; @@ -102,23 +102,9 @@ function resolveTerminalInitializationCwd( return requestedCwd; } -type PersistedCustomState = NonNullable; - -function readPersistedScrollback(customState: PersistedCustomState | undefined): string | null { - try { - const state = decodeBoundary(customState, boundary.object({ - scrollbackBuffer: boundary.optional(boundary.union( - boundary.string, - boundary.array(boundary.string), - )), - })); - if (state.scrollbackBuffer === undefined) return null; - return Array.isArray(state.scrollbackBuffer) - ? state.scrollbackBuffer.join('\n') - : state.scrollbackBuffer; - } catch { - return null; - } +/** Scrollback for a panel whose terminal is not live: read from panel_buffers, never from panel state. */ +function readPersistedScrollback(panelId: string): string | null { + return databaseService.getPanelBuffers(panelId)?.scrollback ?? null; } /** @@ -723,8 +709,7 @@ export function registerPanelHandlers( // Fall back to persisted scrollback for lazy/inactive terminals if (rawScrollback === null) { - const panel = panelManager.getPanel(panelId); - rawScrollback = readPersistedScrollback(panel?.state?.customState); + rawScrollback = readPersistedScrollback(panelId); } if (rawScrollback === null || rawScrollback === '') { @@ -824,8 +809,7 @@ export function registerPanelHandlers( let rawScrollback = terminalPanelManager.getTerminalScrollback(panelId); if (rawScrollback === null) { - const panel = panelManager.getPanel(panelId); - rawScrollback = readPersistedScrollback(panel?.state?.customState); + rawScrollback = readPersistedScrollback(panelId); } if (rawScrollback === null || rawScrollback === '') { diff --git a/main/src/ipc/runpane.test.ts b/main/src/ipc/runpane.test.ts index eb30de1e8..0f5ee2f10 100644 --- a/main/src/ipc/runpane.test.ts +++ b/main/src/ipc/runpane.test.ts @@ -13,6 +13,7 @@ import type { RunpaneToolSpec } from '../../../shared/types/runpaneOrchestration import { RUNPANE_CONTRACT } from '../../../shared/types/generatedRunpaneContract'; import { panelManager } from '../services/panelManager'; import { terminalPanelManager } from '../services/terminalPanelManager'; +import { databaseService as panelDatabase } from '../services/database'; import { ArchiveProgressManager } from '../services/archiveProgressManager'; import { WorkspaceJournal } from '../services/workspaceJournal'; import { WorkspaceCursorStore } from '../services/workspaceCursorStore'; @@ -37,6 +38,7 @@ vi.spyOn(terminalPanelManager, 'getLastOutputAt'); vi.spyOn(terminalPanelManager, 'getOutputGeneration'); vi.spyOn(terminalPanelManager, 'deliverPendingInitialInput'); vi.spyOn(terminalPanelManager, 'getAgentStatus'); +vi.spyOn(panelDatabase, 'getPanelBuffers'); vi.spyOn(usageManager, 'getPaneCosts'); const project: Project = { @@ -293,6 +295,7 @@ describe('runpane IPC handlers', () => { vi.mocked(terminalPanelManager.isTerminalInitialized).mockReset(); vi.mocked(terminalPanelManager.getTerminalSnapshot).mockReset(); vi.mocked(terminalPanelManager.getTerminalScrollback).mockReset(); + vi.mocked(panelDatabase.getPanelBuffers).mockReset().mockReturnValue(null); vi.mocked(terminalPanelManager.writeToTerminal).mockReset(); vi.mocked(terminalPanelManager.getLastOutputAt).mockReset(); vi.mocked(terminalPanelManager.getOutputGeneration).mockReset(); @@ -1315,19 +1318,14 @@ describe('runpane IPC handlers', () => { }); it('reads persisted terminal scrollback when the terminal is not live', async () => { - const panelWithPersistedScrollback: ToolPanel = { - ...terminalPanel, - state: { - ...terminalPanel.state, - customState: { - ...terminalPanel.state.customState, - scrollbackBuffer: 'persisted one\npersisted two\n', - serializedBuffer: undefined, - }, - }, - }; + // Persisted bytes live in panel_buffers, never in the panel state. vi.mocked(panelManager.getPanel).mockImplementation((panelId: string) => - panelId === terminalPanel.id ? panelWithPersistedScrollback : undefined + panelId === terminalPanel.id ? terminalPanel : undefined + ); + vi.mocked(panelDatabase.getPanelBuffers).mockImplementation((panelId: string) => + panelId === terminalPanel.id + ? { scrollback: 'persisted one\npersisted two\n', serialized: null, alternate: null } + : null ); const services = createServices(); const registry = createRegistry(services); @@ -1759,10 +1757,10 @@ describe('runpane IPC handlers', () => { ...terminalPanel.state.customState, isInitialized: true, isCliReady: true, - scrollbackBuffer: 'persisted ready\n', }, }, }); + vi.mocked(panelDatabase.getPanelBuffers).mockReturnValue({ scrollback: 'persisted ready\n', serialized: null, alternate: null }); const registry = createRegistry(); const ready = await registry.invoke('runpane:panels:wait', [{ diff --git a/main/src/ipc/runpane.ts b/main/src/ipc/runpane.ts index 102e2fb14..6fe408024 100644 --- a/main/src/ipc/runpane.ts +++ b/main/src/ipc/runpane.ts @@ -9,6 +9,8 @@ import { sanitizeTerminalOutput } from '../utils/terminalOutputSanitizer'; import { escapeShellArg } from '../utils/shellEscape'; import { panelManager } from '../services/panelManager'; import { terminalPanelManager, type TerminalPanelSnapshot } from '../services/terminalPanelManager'; +import { databaseService as panelDatabase } from '../services/database'; +import type { PanelBuffers } from '../database/panelBuffers'; import { ensureProjectAgentContext } from '../services/agentContextManager'; import { fastCheckWorkingDirectory, listCommitsAhead } from '../services/gitPlumbingCommands'; import { assessComposerEvidence, isSlashCommandInput } from './runpaneComposerEvidence'; @@ -1521,7 +1523,8 @@ async function buildPanelScreenResult(panel: ToolPanel, limit: number): Promise< const liveSnapshot = terminalPanelManager.getTerminalSnapshot(panel.id); const customState = getTerminalCustomState(panel); const state = panelStateSummary(panel, liveSnapshot, customState); - const { source, rawText } = selectPanelScreenText(liveSnapshot, customState); + const persisted = liveSnapshot ? null : panelDatabase.getPanelBuffers(panel.id); + const { source, rawText } = selectPanelScreenText(liveSnapshot, customState, persisted); const bounded = boundSanitizedLines(rawText, limit); const composer = detectPanelComposer(bounded.text, state.agentType); @@ -1576,6 +1579,7 @@ interface PanelScreenText { function selectPanelScreenText( snapshot: TerminalPanelSnapshot | null, customState: TerminalPanelState, + persisted: PanelBuffers | null, ): PanelScreenText { if (snapshot) { if (snapshot.screenText !== undefined) { @@ -1593,12 +1597,12 @@ function selectPanelScreenText( return { source: 'empty', rawText: '' }; } - const persistedAlternate = customState.alternateScreenBuffer; + const persistedAlternate = persisted?.alternate; if (customState.isAlternateScreen && persistedAlternate) { return { source: 'persistedOutput', rawText: persistedAlternate }; } - const persistedScrollback = normalizeScrollbackBuffer(customState.scrollbackBuffer); + const persistedScrollback = persisted?.scrollback; if (persistedScrollback) { return { source: 'persistedOutput', rawText: persistedScrollback }; } @@ -1628,9 +1632,7 @@ function panelStateSummary( function getTerminalCustomState(panel: ToolPanel): TerminalPanelState { try { return decodeBoundary(panel.state.customState, boundary.object({ - alternateScreenBuffer: boundary.optional(boundary.string), isAlternateScreen: boundary.optional(boundary.boolean), - scrollbackBuffer: boundary.optional(boundary.union(boundary.string, boundary.array(boundary.string))), agentType: boundary.optional(boundary.enumeration(...RUNPANE_CONTRACT.enums.agents)), isCliReady: boundary.optional(boundary.boolean), isCliPanel: boundary.optional(boundary.boolean), @@ -1641,15 +1643,6 @@ function getTerminalCustomState(panel: ToolPanel): TerminalPanelState { } } -function normalizeScrollbackBuffer(value: TerminalPanelState['scrollbackBuffer']): string { - const stringValue = optionalString(value); - if (stringValue !== undefined) return stringValue; - if (Array.isArray(value)) { - return value.join('\n'); - } - return ''; -} - interface BoundedSanitizedLines { text: string; hasMore: boolean; @@ -2117,7 +2110,7 @@ function getPanelScrollback(panel: ToolPanel): string | null { return liveScrollback; } - const persisted = normalizeScrollbackBuffer(getTerminalCustomState(panel).scrollbackBuffer); + const persisted = panelDatabase.getPanelBuffers(panel.id)?.scrollback; if (persisted) return persisted; return null; diff --git a/main/src/services/database.ts b/main/src/services/database.ts index 55ff8fb8c..7ca6ccf7c 100644 --- a/main/src/services/database.ts +++ b/main/src/services/database.ts @@ -7,8 +7,10 @@ import { ScrollbackRetentionService, RetentionSweepResult } from './scrollbackRe const dbPath = join(getAppDirectory(), 'sessions.db'); export const databaseService = new DatabaseService(dbPath); -// Initialize the database schema and run migrations +// Initialize the database schema and run migrations, including the one-time +// move of terminal bytes out of tool_panels.state (see panelBufferMigration). databaseService.initialize(); +export const startupPanelBufferMigration = databaseService.getPanelBufferMigration(); // Scrollback retention sweep: runs synchronously at module load, which happens // before panelManager restores panels on demand. Deferring this diff --git a/main/src/services/paneChatManager.ts b/main/src/services/paneChatManager.ts index ee17b2169..ca0cba4fd 100644 --- a/main/src/services/paneChatManager.ts +++ b/main/src/services/paneChatManager.ts @@ -163,7 +163,6 @@ export class PaneChatManager { nextCustomState.scrollbackBuffer = ''; nextCustomState.alternateScreenBuffer = ''; nextCustomState.serializedBuffer = undefined; - nextCustomState.lastActiveCommand = undefined; nextCustomState.isInitialized = false; } diff --git a/main/src/services/panelManager.ts b/main/src/services/panelManager.ts index fe0451130..22b42bef5 100644 --- a/main/src/services/panelManager.ts +++ b/main/src/services/panelManager.ts @@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import { ToolPanel, CreatePanelRequest, PanelEventType, ToolPanelState, ToolPanelMetadata, ToolPanelType, LogsPanelState } from '../../../shared/types/panels'; import { getPaneEventSink, getPaneWebviewContextMap } from '../core/runtime'; import { databaseService } from './database'; +import { splitPanelBufferState } from '../database/panelBuffers'; import { panelEventBus } from './panelEventBus'; import { withLock } from '../utils/mutex'; import type { AnalyticsManager } from './analyticsManager'; @@ -279,16 +280,20 @@ class PanelManager { return; } - // Update in database - databaseService.updatePanel(panelId, { + // Update in database. A refused write (state over the ceiling) is + // already logged there with the panel, size and largest key; the cache + // and renderer keep the last accepted state. + const written = databaseService.updatePanel(panelId, { title: updates.title, state: updates.state, metadata: updates.metadata }); + if (!written) return; - // Update in cache + // Update in cache. Terminal bytes are stored in panel_buffers, so the + // cached state (and the panel:updated payload) never carries them. if (updates.title !== undefined) panel.title = updates.title; - if (updates.state !== undefined) panel.state = updates.state; + if (updates.state !== undefined) panel.state = splitPanelBufferState(updates.state).state; if (updates.metadata !== undefined) panel.metadata = updates.metadata; // Emit IPC event to notify frontend @@ -368,15 +373,14 @@ class PanelManager { return undefined; } - getPanelsForSession(sessionId: string, includeScrollback = true): ToolPanel[] { + getPanelsForSession(sessionId: string): ToolPanel[] { // Always get fresh from database to ensure consistency - const panels = databaseService.getPanelsForSession(sessionId, includeScrollback); + const panels = databaseService.getPanelsForSession(sessionId); // If this session has been archived in this process, we still // return the panels (callers like the sessions:delete PTY-destroy // loop need them) but we do NOT re-populate this.panels — doing so // would undo the L3 cleanup that cleared them moments earlier. - // Summary reads must never replace complete cached state with missing buffers. - const shouldCache = includeScrollback && !this.archivedSessionIds.has(sessionId); + const shouldCache = !this.archivedSessionIds.has(sessionId); if (shouldCache) { for (const panel of panels) { diff --git a/main/src/services/scrollbackRetention.ts b/main/src/services/scrollbackRetention.ts index 199d79af1..9fc71f9c4 100644 --- a/main/src/services/scrollbackRetention.ts +++ b/main/src/services/scrollbackRetention.ts @@ -9,6 +9,11 @@ export interface RetentionSweepResult { bytesFreed: number; } +/** + * Drops persisted terminal bytes for panels of sessions archived more than + * RETENTION_DAYS ago. Bytes live in `panel_buffers`, never in + * `tool_panels.state`, so the sweep deletes rows there. + */ export class ScrollbackRetentionService { constructor(private db: DatabaseService) {} @@ -28,32 +33,16 @@ export class ScrollbackRetentionService { } const idsJson = JSON.stringify(targetSessions.map(s => s.id)); + const panelFilter = `panel_id IN ( + SELECT id FROM tool_panels WHERE session_id IN (SELECT value FROM json_each(?)) + )`; const sizeRow = decodeBoundary(sqlite - .prepare( - `SELECT COALESCE(SUM( - COALESCE(LENGTH(json_extract(state, '$.customState.scrollbackBuffer')), 0) + - COALESCE(LENGTH(json_extract(state, '$.customState.serializedBuffer')), 0) - ), 0) AS bytes - FROM tool_panels - WHERE session_id IN (SELECT value FROM json_each(?)) - AND ( - json_extract(state, '$.customState.scrollbackBuffer') IS NOT NULL - OR json_extract(state, '$.customState.serializedBuffer') IS NOT NULL - )` - ) + .prepare(`SELECT COALESCE(SUM(bytes), 0) AS bytes FROM panel_buffers WHERE ${panelFilter}`) .get(idsJson), boundary.object({ bytes: boundary.number })); const result = sqlite - .prepare( - `UPDATE tool_panels - SET state = json_remove(state, '$.customState.scrollbackBuffer', '$.customState.serializedBuffer') - WHERE session_id IN (SELECT value FROM json_each(?)) - AND ( - json_extract(state, '$.customState.scrollbackBuffer') IS NOT NULL - OR json_extract(state, '$.customState.serializedBuffer') IS NOT NULL - )` - ) + .prepare(`DELETE FROM panel_buffers WHERE ${panelFilter}`) .run(idsJson); return { diff --git a/main/src/services/terminalPanelManager.persistence.test.ts b/main/src/services/terminalPanelManager.persistence.test.ts new file mode 100644 index 000000000..a84abc5e9 --- /dev/null +++ b/main/src/services/terminalPanelManager.persistence.test.ts @@ -0,0 +1,260 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetPaneRuntimeForTests, setPaneRuntime, type PtyHandleLike, type PtyHostRuntime } from '../core/runtime'; +import type { PaneEventArgument } from '../core/eventSink'; +import type { PtyHostSpawnOpts } from '../ptyHost/types'; +import type { ToolPanel } from '../../../shared/types/panels'; +import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder'; +import { PANEL_STATE_CEILING_BYTES } from '../database/database'; +import { splitPanelBufferState } from '../database/panelBuffers'; +import { trimAnsiSafe } from '../utils/ansiTrim'; +import { ConfigManager } from './configManager'; +import { databaseService } from './database'; +import { panelManager as panelManagerMock } from '../test/setup'; +import { MAX_RESTORE_PAYLOAD_SIZE, TerminalPanelManager } from './terminalPanelManager'; + +/** In-process stand-in for a ptyHost PTY: output is whatever the test emits. */ +class FakePtyHandle implements PtyHandleLike { + readonly pid = 4242; + readonly written: string[] = []; + private readonly listeners = new Set<(data: string) => void>(); + + constructor(readonly id: string) {} + + onData(listener: (data: string) => void) { + this.listeners.add(listener); + return { dispose: () => { this.listeners.delete(listener); } }; + } + + onExit() { + return { dispose: () => undefined }; + } + + async write(data: string): Promise { + this.written.push(data); + } + + async resize(): Promise {} + async kill(): Promise {} + async pause(): Promise {} + async resume(): Promise {} + + emit(data: string): void { + for (const listener of this.listeners) listener(data); + } +} + +class FakePtyHost implements PtyHostRuntime { + readonly handles = new Map(); + readonly posted: Array<{ ptyId: string; data: string }> = []; + + async spawn(_opts: PtyHostSpawnOpts): Promise<{ ptyId: string; pid: number }> { + const ptyId = `pty-${this.handles.size + 1}`; + const handle = new FakePtyHandle(ptyId); + this.handles.set(ptyId, handle); + return { ptyId, pid: handle.pid }; + } + + async write(): Promise {} + async resize(): Promise {} + async kill(): Promise {} + async ack(): Promise {} + async pause(): Promise {} + async resume(): Promise {} + + getHandle(ptyId: string): PtyHandleLike | undefined { + return this.handles.get(ptyId); + } + + postDataToRenderers(ptyId: string, data: string): void { + this.posted.push({ ptyId, data }); + } + + latest(): FakePtyHandle { + const handle = Array.from(this.handles.values()).at(-1); + if (!handle) throw new Error('no pty spawned'); + return handle; + } +} + +interface RendererEvent { + channel: string; + args: PaneEventArgument[]; +} + +const persistedStateSchema = boundary.object({ + customState: boundary.object({ + scrollbackBuffer: boundary.optional(boundary.union(boundary.string, boundary.array(boundary.string))), + alternateScreenBuffer: boundary.optional(boundary.string), + serializedBuffer: boundary.optional(boundary.string), + isAlternateScreen: boundary.optional(boundary.boolean), + lastActivityTime: boundary.optional(boundary.string), + }), +}); + +const restoreCustomStateSchema = boundary.object({ + cwd: boundary.optional(boundary.string), + lastActivityTime: boundary.optional(boundary.string), +}); + +const outputEventSchema = boundary.object({ panelId: boundary.string, output: boundary.string }); + +function makePanel(id: string): ToolPanel { + return { + id, + sessionId: 'session', + type: 'terminal', + title: 'Terminal', + state: { isActive: false, hasBeenViewed: true, customState: {} }, + metadata: { createdAt: '2026-09-11T00:00:00.000Z', lastActiveAt: '2026-09-11T00:00:00.000Z', position: 0 }, + }; +} + +describe('terminal panel persistence', () => { + let tempDir: string; + let ptyHost: FakePtyHost; + let events: RendererEvent[]; + let managers: TerminalPanelManager[]; + let lastPersisted: ToolPanel['state'] | null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-terminal-persistence-')); + ptyHost = new FakePtyHost(); + events = []; + managers = []; + lastPersisted = null; + const configManager = new ConfigManager(); + vi.spyOn(configManager, 'getUsePtyHost').mockReturnValue(true); + setPaneRuntime({ + eventSink: { + send: (channel, ...args) => { + events.push({ channel, args }); + }, + }, + getConfigManager: () => configManager, + getPtyHostRuntime: () => ptyHost, + getWebviewContextMap: () => new Map(), + }); + panelManagerMock.updatePanel.mockImplementation(async (_panelId: string, updates: Partial) => { + if (updates.state) lastPersisted = updates.state; + }); + if (!databaseService.getSession('session')) { + databaseService.createSession({ + id: 'session', name: 'session', initial_prompt: '', worktree_name: 'session', + worktree_path: tempDir, project_id: null, tool_type: 'none', + }); + } + }); + + afterEach(() => { + for (const manager of managers) { + for (const panelId of manager.getActiveTerminals()) manager.destroyTerminal(panelId); + } + panelManagerMock.updatePanel.mockReset(); + panelManagerMock.getPanel.mockReset(); + resetPaneRuntimeForTests(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + async function startTerminal(panel: ToolPanel): Promise<{ manager: TerminalPanelManager; handle: FakePtyHandle }> { + const manager = new TerminalPanelManager(); + managers.push(manager); + panelManagerMock.getPanel.mockReturnValue(panel); + if (!databaseService.getPanel(panel.id)) { + databaseService.createPanel({ id: panel.id, sessionId: panel.sessionId, type: 'terminal', title: panel.title, state: panel.state }); + } + await manager.initializeTerminal(panel, tempDir); + return { manager, handle: ptyHost.latest() }; + } + + it('streams 50 MB of newline-free alternate-screen frames without growing the persisted state', async () => { + const panel = makePanel('panel-frames'); + const { manager, handle } = await startTerminal(panel); + manager.setVisibility(panel.id, false); + + handle.emit('\x1b[?1049h'); + const frame = `\x1b[1;1H\x1b[2K${'⠋ '.repeat(40)}\x1b[2;1H\x1b[38;5;208m${'x'.repeat(200)}\x1b[0m`.padEnd(4096, ' '); + expect(frame).not.toMatch(/[\r\n]/); + const target = 50 * 1024 * 1024; + let sent = 0; + for (let index = 0; sent < target; index += 1) { + handle.emit(frame); + sent += frame.length; + // A real PTY delivers between event-loop turns; let the emulator drain + // so xterm's write buffer never trips its discard watermark. + if (index % 256 === 0) await manager.waitForTerminalState(panel.id); + } + + expect(manager.getTerminalSnapshot(panel.id)?.currentCommand.length ?? 0).toBeLessThanOrEqual(4096); + + await manager.saveTerminalState(panel.id); + expect(lastPersisted).not.toBeNull(); + const persisted = lastPersisted ?? { isActive: false }; + expect(persisted.customState).not.toHaveProperty('lastActiveCommand'); + expect(persisted.customState).not.toHaveProperty('commandHistory'); + expect(JSON.stringify(splitPanelBufferState(persisted).state).length).toBeLessThan(PANEL_STATE_CEILING_BYTES); + + expect(databaseService.updatePanel(panel.id, { state: persisted })).toBe(true); + const storedBytes = decodeBoundary( + databaseService.getDb().prepare('SELECT LENGTH(CAST(state AS BLOB)) AS bytes FROM tool_panels WHERE id = ?').get(panel.id), + boundary.object({ bytes: boundary.number }), + ).bytes; + expect(storedBytes).toBeLessThan(PANEL_STATE_CEILING_BYTES); + expect(databaseService.getPanelBuffers(panel.id)?.alternate?.length ?? 0).toBeGreaterThan(0); + }, 120_000); + + it('caps the in-memory command accumulator at 4 KB on the normal screen', async () => { + const panel = makePanel('panel-accumulator'); + const { manager, handle } = await startTerminal(panel); + manager.setVisibility(panel.id, false); + + for (let index = 0; index < 512; index += 1) handle.emit('\x1b[2K\x1b[Gprogress '.padEnd(1024, '.')); + expect(manager.getTerminalSnapshot(panel.id)?.currentCommand.length ?? Infinity).toBeLessThanOrEqual(4096); + + handle.emit('git status\r\n'); + expect(manager.getTerminalSnapshot(panel.id)?.currentCommand).toBe(''); + }); + + it.each(['normal', 'alternate'] as const)('replays the same bytes after a manager restart (%s screen)', async (mode) => { + const panel = makePanel(`panel-restore-${mode}`); + const { manager: first, handle } = await startTerminal(panel); + handle.emit('$ echo hello\r\nhello\r\n$ '); + if (mode === 'alternate') handle.emit('\x1b[?1049h\x1b[1;1H\x1b[2Kfull screen app frame'); + + await first.saveTerminalState(panel.id); + const saved = decodeBoundary(lastPersisted, persistedStateSchema).customState; + const oldScrollback = Array.isArray(saved.scrollbackBuffer) ? saved.scrollbackBuffer.join('\n') : saved.scrollbackBuffer ?? ''; + expect(oldScrollback.length).toBeGreaterThan(0); + expect(saved.isAlternateScreen).toBe(mode === 'alternate'); + if (mode === 'alternate') expect(saved.alternateScreenBuffer).toContain('full screen app frame'); + + // The old path persisted the buffers inside the state JSON; the new path + // routes the same write into panel_buffers. + expect(lastPersisted).not.toBeNull(); + expect(databaseService.updatePanel(panel.id, { state: lastPersisted ?? { isActive: false } })).toBe(true); + first.destroyTerminal(panel.id); + + const second = new TerminalPanelManager(); + managers.push(second); + const reloaded = databaseService.getPanel(panel.id); + expect(reloaded?.state.customState).not.toHaveProperty('scrollbackBuffer'); + expect(reloaded?.state.customState).not.toHaveProperty('serializedBuffer'); + expect(reloaded?.state.customState).not.toHaveProperty('alternateScreenBuffer'); + const restoreState = decodeBoundary(reloaded?.state.customState, restoreCustomStateSchema); + + events.length = 0; + await second.restoreTerminalState(makePanel(panel.id), restoreState); + + const replay = events.find((event) => event.channel === 'terminal:output'); + const output = decodeBoundary(replay?.args[0], outputEventSchema); + const restorationMsg = `\r\n[Session Restored from ${saved.lastActivityTime}]\r\n`; + expect(output.output).toBe(trimAnsiSafe(oldScrollback, MAX_RESTORE_PAYLOAD_SIZE) + restorationMsg); + expect(ptyHost.latest().written).toContain(restorationMsg); + + const snapshot = second.getTerminalSnapshot(panel.id); + expect(snapshot?.scrollbackBuffer).toBe(oldScrollback); + expect(snapshot?.alternateScreenBuffer).toBe(saved.alternateScreenBuffer ?? ''); + }); +}); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 5c53733a8..195730891 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -7,6 +7,8 @@ import * as path from 'path'; import { promises as fs } from 'fs'; import { randomUUID } from 'crypto'; import { getShellPath } from '../utils/shellPath'; +import { trimAnsiSafe } from '../utils/ansiTrim'; +import { databaseService } from './database'; import { ShellDetector } from '../utils/shellDetector'; import type { AnalyticsManager } from './analyticsManager'; import { getWSLShellSpawn, buildWSLENV, WSLContext } from '../utils/wslUtils'; @@ -33,6 +35,12 @@ const MAX_CONCURRENT_SPAWNS = 3; const AGENT_STATUS_POLL_MS = 500; // cadence for re-deriving blocked/working/done from the live screen const MAX_SCROLLBACK_BUFFER_SIZE = 500_000; // 500KB of normal shell history const MAX_ALTERNATE_SCREEN_BUFFER_SIZE = 100_000; // 100KB of recent TUI redraw state +// Command-detection heuristic bounds. These buffers live in memory only and +// are never persisted; full-screen apps redraw without newlines, so the +// accumulator is frozen while the alternate screen is active. +const MAX_CURRENT_COMMAND_SIZE = 4096; +const MAX_COMMAND_HISTORY_ENTRY_SIZE = 1024; +const MAX_COMMAND_HISTORY_ENTRIES = 100; const MIN_PTY_COLS = 20; const MIN_PTY_ROWS = 5; const FORCED_REDRAW_TRANSITION_MS = 50; @@ -44,7 +52,7 @@ const SHELL_PROMPT_FALLBACK_MS = 5000; // Orca (TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) and Superset (MAX_HISTORY_SCROLLBACK_BYTES) both use // 512 * 1024. The 2500-line emulator serialization sits well under this in // practice; the cap is a backstop against pathological payloads. -const MAX_RESTORE_PAYLOAD_SIZE = 512 * 1024; +export const MAX_RESTORE_PAYLOAD_SIZE = 512 * 1024; import { CliAgentType, resolveAgentTypeFromCommand } from './agents/agentIdentity'; import { buildCursorLaunchCommand, createCursorReadyDetector, extractCursorChatId } from './agents/cursorLaunch'; @@ -530,7 +538,7 @@ export class TerminalPanelManager { private captureAgentSessionId(terminal: TerminalProcess, output: string): void { if (terminal.agentType !== 'codex' && terminal.agentType !== 'cursor') return; - terminal.agentSessionScrapeBuffer = this.trimAnsiSafe( + terminal.agentSessionScrapeBuffer = trimAnsiSafe( terminal.agentSessionScrapeBuffer + output, 2000 ); @@ -605,50 +613,6 @@ export class TerminalPanelManager { } } - private trimAnsiSafe(buffer: string, maxSize: number): string { - if (buffer.length <= maxSize) return buffer; - - let start = buffer.length - maxSize; - - // Prefer a line boundary so replay starts from a sane row. - const nextNewline = buffer.indexOf('\n', start); - if (nextNewline !== -1 && nextNewline < buffer.length - 1) { - start = nextNewline + 1; - } - - // If the cut lands inside a common ANSI escape sequence, advance past it. - const lastEsc = buffer.lastIndexOf('\x1b', start); - if (lastEsc !== -1) { - let sequenceEnd = -1; - const introducer = buffer[lastEsc + 1]; - - if (introducer === '[') { - const finalByte = buffer.slice(lastEsc + 2).search(/[@-~]/); - sequenceEnd = finalByte === -1 ? -1 : lastEsc + 2 + finalByte; - } else if (introducer === ']') { - const belEnd = buffer.indexOf('\x07', lastEsc + 2); - const stEnd = buffer.indexOf('\x1b\\', lastEsc + 2); - if (belEnd !== -1 && stEnd !== -1) { - sequenceEnd = Math.min(belEnd, stEnd + 1); - } else if (belEnd !== -1) { - sequenceEnd = belEnd; - } else if (stEnd !== -1) { - sequenceEnd = stEnd + 1; - } - } else if (introducer) { - sequenceEnd = lastEsc + 1; - } - - if (sequenceEnd === -1) { - start = buffer.length; - } else if (sequenceEnd >= start) { - start = sequenceEnd + 1; - } - } - - return buffer.slice(start); - } - private flushOutputBuffer(terminal: TerminalProcess): void { if (terminal.outputFlushTimer) { clearTimeout(terminal.outputFlushTimer); @@ -1238,7 +1202,10 @@ export class TerminalPanelManager { // Detect commands (simple heuristic - look for carriage returns) if (data.includes('\r') || data.includes('\n')) { if (terminal.currentCommand.trim()) { - terminal.commandHistory.push(terminal.currentCommand); + terminal.commandHistory.push(terminal.currentCommand.slice(0, MAX_COMMAND_HISTORY_ENTRY_SIZE)); + if (terminal.commandHistory.length > MAX_COMMAND_HISTORY_ENTRIES) { + terminal.commandHistory.splice(0, terminal.commandHistory.length - MAX_COMMAND_HISTORY_ENTRIES); + } // Emit command executed event panelManager.emitPanelEvent( @@ -1264,9 +1231,13 @@ export class TerminalPanelManager { terminal.currentCommand = ''; } - } else { - // Accumulate command input + } else if (!terminal.isAlternateScreen) { + // Accumulate command input. Anything past the cap is not a command + // (a TUI frame, a paste, a progress bar), so drop it rather than grow. terminal.currentCommand += data; + if (terminal.currentCommand.length > MAX_CURRENT_COMMAND_SIZE) { + terminal.currentCommand = ''; + } } // Buffer output for batching instead of sending immediately @@ -1327,14 +1298,14 @@ export class TerminalPanelManager { private addToScrollback(terminal: TerminalProcess, data: string): void { if (terminal.isAlternateScreen) { - terminal.alternateScreenBuffer = this.trimAnsiSafe( + terminal.alternateScreenBuffer = trimAnsiSafe( terminal.alternateScreenBuffer + data, MAX_ALTERNATE_SCREEN_BUFFER_SIZE ); return; } - terminal.scrollbackBuffer = this.trimAnsiSafe( + terminal.scrollbackBuffer = trimAnsiSafe( terminal.scrollbackBuffer + data, MAX_SCROLLBACK_BUFFER_SIZE ); @@ -1488,7 +1459,7 @@ export class TerminalPanelManager { // append log with its accumulated repaint traffic. const savedScrollback = !savedIsAlternateScreen && terminal.screenEmulator - ? this.trimAnsiSafe(terminal.screenEmulator.serializeForRestore(true), MAX_RESTORE_PAYLOAD_SIZE) + ? trimAnsiSafe(terminal.screenEmulator.serializeForRestore(true), MAX_RESTORE_PAYLOAD_SIZE) : terminal.scrollbackBuffer; const customState: TerminalPanelState = { ...terminalCustomState(state), @@ -1497,9 +1468,7 @@ export class TerminalPanelManager { scrollbackBuffer: savedScrollback, alternateScreenBuffer: terminal.alternateScreenBuffer, isAlternateScreen: savedIsAlternateScreen, - commandHistory: terminal.commandHistory.slice(-100), // Keep last 100 commands lastActivityTime: terminal.lastActivity.toISOString(), - lastActiveCommand: terminal.currentCommand, serializedBuffer: terminal.screenEmulator?.isAlternateScreen ? terminal.screenEmulator.serializeForRestore() : this.serializedBuffers.get(panelId), @@ -1529,7 +1498,10 @@ export class TerminalPanelManager { } async restoreTerminalState(panel: ToolPanel, state: TerminalPanelState, wslContext?: WSLContext | null): Promise { - if (!state.scrollbackBuffer || state.scrollbackBuffer.length === 0) { + // Terminal bytes live in panel_buffers, never in the panel state JSON. + const buffers = databaseService.getPanelBuffers(panel.id); + const scrollback = buffers?.scrollback ?? ''; + if (scrollback.length === 0) { return; } @@ -1539,15 +1511,8 @@ export class TerminalPanelManager { const terminal = this.terminals.get(panel.id); if (!terminal) return; - // Restore scrollback buffer (handle both string and array formats) - if (Array.isArray(state.scrollbackBuffer)) { - // Convert legacy array format to string - terminal.scrollbackBuffer = state.scrollbackBuffer.join('\n'); - } else { - terminal.scrollbackBuffer = state.scrollbackBuffer; - } - terminal.alternateScreenBuffer = state.alternateScreenBuffer || ''; - terminal.commandHistory = state.commandHistory || []; + terminal.scrollbackBuffer = scrollback; + terminal.alternateScreenBuffer = buffers?.alternate ?? ''; // Send restoration indicator to terminal const restorationMsg = `\r\n[Session Restored from ${state.lastActivityTime || 'previous session'}]\r\n`; @@ -1555,21 +1520,16 @@ export class TerminalPanelManager { // Send scrollback to frontend. Dual-path mirrors `flushOutputBuffer`: // `terminal:output` IPC for legacy subscribers, ptyHost port for flag-on. - if (state.scrollbackBuffer) { - // Cap the renderer replay at the formal ceiling; main's own buffer (set above) keeps full content. - const rawScrollback = Array.isArray(state.scrollbackBuffer) - ? state.scrollbackBuffer.join('\n') - : state.scrollbackBuffer; - const output = this.trimAnsiSafe(rawScrollback, MAX_RESTORE_PAYLOAD_SIZE) + restorationMsg; - this.sendRendererEvent('terminal:output', { - sessionId: panel.sessionId, - panelId: panel.id, - output, - }); - if (terminal.isPtyHost && terminal.ptyId) { - const supervisor = getPtyHostRuntime(); - supervisor?.postDataToRenderers(terminal.ptyId, output); - } + // Cap the renderer replay at the formal ceiling; main's own buffer (set above) keeps full content. + const output = trimAnsiSafe(scrollback, MAX_RESTORE_PAYLOAD_SIZE) + restorationMsg; + this.sendRendererEvent('terminal:output', { + sessionId: panel.sessionId, + panelId: panel.id, + output, + }); + if (terminal.isPtyHost && terminal.ptyId) { + const supervisor = getPtyHostRuntime(); + supervisor?.postDataToRenderers(terminal.ptyId, output); } } @@ -1587,8 +1547,8 @@ export class TerminalPanelManager { // repaints overwrite in place — so its serialization is duplicate-free. const cappedScrollback = !isAlternateScreen && terminal.screenEmulator - ? this.trimAnsiSafe(terminal.screenEmulator.serializeForRestore(true), MAX_RESTORE_PAYLOAD_SIZE) - : this.trimAnsiSafe(terminal.scrollbackBuffer, MAX_RESTORE_PAYLOAD_SIZE); + ? trimAnsiSafe(terminal.screenEmulator.serializeForRestore(true), MAX_RESTORE_PAYLOAD_SIZE) + : trimAnsiSafe(terminal.scrollbackBuffer, MAX_RESTORE_PAYLOAD_SIZE); return { isInitialized: true, cwd: process.cwd(), // Simplified - would need platform-specific implementation @@ -1596,9 +1556,7 @@ export class TerminalPanelManager { scrollbackBuffer: cappedScrollback, alternateScreenBuffer: terminal.alternateScreenBuffer, isAlternateScreen, - commandHistory: terminal.commandHistory, lastActivityTime: terminal.lastActivity.toISOString(), - lastActiveCommand: terminal.currentCommand, // An active alternate screen cannot be reconstructed from normal shell // scrollback. Serialize the authoritative live model for renderer remounts. serializedBuffer: isAlternateScreen diff --git a/main/src/services/workspaceStateReader.ts b/main/src/services/workspaceStateReader.ts index 86dee9ad6..3abe4efe8 100644 --- a/main/src/services/workspaceStateReader.ts +++ b/main/src/services/workspaceStateReader.ts @@ -63,7 +63,7 @@ export class WorkspaceStateReader { const panelSummaries: RunpaneWorkspacePanelSummary[] = []; - for (const panel of panelManager.getPanelsForSession(session.id, false)) { + for (const panel of panelManager.getPanelsForSession(session.id)) { if (panel.type !== 'terminal') continue; const customState = decodeBoundary(panel.state.customState ?? {}, boundary.object({ isCliPanel: boundary.optional(boundary.boolean), @@ -105,7 +105,7 @@ export class WorkspaceStateReader { for (const session of sessions) { if (session.archived || session.isHidden) continue; const project = this.sessionManager.getProjectForSession(session.id); - for (const panel of panelManager.getPanelsForSession(session.id, false)) { + for (const panel of panelManager.getPanelsForSession(session.id)) { if (panel.type !== 'terminal') continue; const customState = decodeBoundary(panel.state.customState ?? {}, boundary.object({ isCliPanel: boundary.optional(boundary.boolean), diff --git a/main/src/test/setup.ts b/main/src/test/setup.ts index 4ea45210e..6fb83a795 100644 --- a/main/src/test/setup.ts +++ b/main/src/test/setup.ts @@ -1,6 +1,14 @@ // Test setup file for Vitest +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { vi } from 'vitest'; +// Every module that imports services/database opens `${PANE_DIR}/sessions.db` +// and runs the startup migrations at import time. Point that at a scratch +// directory so a test run can never touch the developer's live ~/.pane. +process.env.PANE_DIR = mkdtempSync(join(tmpdir(), 'pane-vitest-')); + export const app = { getPath: vi.fn(() => '/mock/path'), getName: vi.fn(() => 'Pane'), diff --git a/main/src/utils/ansiTrim.ts b/main/src/utils/ansiTrim.ts new file mode 100644 index 000000000..f2640806b --- /dev/null +++ b/main/src/utils/ansiTrim.ts @@ -0,0 +1,48 @@ +/** + * Drop the oldest bytes of a terminal byte log without leaving a torn ANSI + * escape sequence at the new start. Shared by the in-memory PTY buffers in + * TerminalPanelManager and the persisted `panel_buffers` cap. + */ +export function trimAnsiSafe(buffer: string, maxSize: number): string { + if (buffer.length <= maxSize) return buffer; + + let start = buffer.length - maxSize; + + // Prefer a line boundary so replay starts from a sane row. + const nextNewline = buffer.indexOf('\n', start); + if (nextNewline !== -1 && nextNewline < buffer.length - 1) { + start = nextNewline + 1; + } + + // If the cut lands inside a common ANSI escape sequence, advance past it. + const lastEsc = buffer.lastIndexOf('\x1b', start); + if (lastEsc !== -1) { + let sequenceEnd = -1; + const introducer = buffer[lastEsc + 1]; + + if (introducer === '[') { + const finalByte = buffer.slice(lastEsc + 2).search(/[@-~]/); + sequenceEnd = finalByte === -1 ? -1 : lastEsc + 2 + finalByte; + } else if (introducer === ']') { + const belEnd = buffer.indexOf('\x07', lastEsc + 2); + const stEnd = buffer.indexOf('\x1b\\', lastEsc + 2); + if (belEnd !== -1 && stEnd !== -1) { + sequenceEnd = Math.min(belEnd, stEnd + 1); + } else if (belEnd !== -1) { + sequenceEnd = belEnd; + } else if (stEnd !== -1) { + sequenceEnd = stEnd + 1; + } + } else if (introducer) { + sequenceEnd = lastEsc + 1; + } + + if (sequenceEnd === -1) { + start = buffer.length; + } else if (sequenceEnd >= start) { + start = sequenceEnd + 1; + } + } + + return buffer.slice(start); +} diff --git a/shared/types/panels.ts b/shared/types/panels.ts index 7a3478bbf..3d0fedb0b 100644 --- a/shared/types/panels.ts +++ b/shared/types/panels.ts @@ -38,15 +38,15 @@ export interface TerminalPanelState { initialInputSentAt?: string; // Set after initialInput has been written once initialInputError?: string; // Best-effort error if initialInput could not be written - // Enhanced persistence (can be added incrementally) + // Terminal bytes. On the way to the database these three keys are split out + // of the state JSON into the bounded panel_buffers table; they only appear + // here on the live terminal:getState path and in write patches. scrollbackBuffer?: string | string[]; // Full terminal output history (string for new format, array for legacy) alternateScreenBuffer?: string; // Recent TUI/alternate-screen output, kept separate from shell scrollback isAlternateScreen?: boolean; // Whether the live terminal is currently in alternate-screen/TUI mode serializedBuffer?: string; // xterm.js serialized terminal state (includes full visual buffer) - commandHistory?: string[]; // Commands entered by user environmentVars?: Record; // Modified env vars dimensions?: { cols: number; rows: number }; // Terminal size - lastActiveCommand?: string; // Command running when closed cursorPosition?: { x: number; y: number }; // Cursor location selectionText?: string; // Any selected text lastActivityTime?: string; // For "idle since" indicators