Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/TOOL_PANEL_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions main/src/daemon/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<PaneDaemonHost> {
const mode = options.mode ?? 'desktop';
const startRemoteTransport = options.startRemoteTransport ?? true;
Expand All @@ -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)}`,
);
}

Expand Down
36 changes: 20 additions & 16 deletions main/src/database/database.panel-loading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 });
Expand All @@ -60,19 +59,24 @@ 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);
}
for (const panel of [db.getPanelsForStartup()[0], db.getActivePanels()[0]]) {
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 });
Expand Down
Loading
Loading