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
21 changes: 21 additions & 0 deletions frontend/src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) {
const [claudeExecutablePath, setClaudeExecutablePath] = useState('');
const [autoCheckUpdates, setAutoCheckUpdates] = useState(true);
const [devMode, setDevMode] = useState(false);
const [usePtyHost, setUsePtyHost] = useState(false);
const [initialUsePtyHost, setInitialUsePtyHost] = useState(false);
const [additionalPathsText, setAdditionalPathsText] = useState('');
const [platform, setPlatform] = useState<string>('darwin');
const [enableCommitFooter, setEnableCommitFooter] = useState(true);
Expand Down Expand Up @@ -142,6 +144,8 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) {
setVerbose(data.verbose || false);
setAutoCheckUpdates(data.autoCheckUpdates !== false); // Default to true
setDevMode(data.devMode || false);
setUsePtyHost(data.usePtyHost === true);
setInitialUsePtyHost(data.usePtyHost === true);
setClaudeExecutablePath(data.claudeExecutablePath || '');
setEnableCommitFooter(data.enableCommitFooter !== false); // Default to true
setUiScale(data.uiScale || 1.0);
Expand Down Expand Up @@ -213,6 +217,7 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) {
verbose,
autoCheckUpdates,
devMode,
usePtyHost,
claudeExecutablePath,
enableCommitFooter,
uiScale,
Expand Down Expand Up @@ -1084,6 +1089,22 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) {
Adds a "Messages" tab to each pane showing raw JSON responses from Claude Code. Useful for debugging and development.
</p>
</div>

<div className="mt-4">
<Checkbox
label="Use isolated PTY host (experimental)"
checked={usePtyHost}
onChange={(e) => setUsePtyHost(e.target.checked)}
/>
<p className="text-xs text-text-tertiary mt-1">
Run terminal processes in a separate utility process for better crash isolation and fixes for Claude Code v2.1.113+ on macOS. Requires app restart; existing terminals keep their current backend, and new terminals after restart use the selected PTY mode.
</p>
{usePtyHost !== initialUsePtyHost && (
<p className="text-xs text-status-warning mt-1">
Restart Pane for this change to take effect.
</p>
)}
</div>
</SettingsSection>

<SettingsSection
Expand Down
122 changes: 98 additions & 24 deletions frontend/src/components/panels/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,29 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
const isCliPanel = !!terminalState?.isCliPanel;
const [isCliReady, setIsCliReady] = useState(!!terminalState?.isCliReady);

// ptyId for the current PTY behind this panel, delivered via
// `terminal:ptyReady` when spawned through the ptyHost UtilityProcess.
// Null under the legacy `pty.spawn` path. Re-fires with a new value on
// auto-reattach after a supervisor restart, which re-subscribes the data
// listener below.
const [ptyId, setPtyId] = useState<string | null>(null);

// Ref holding the terminal output consumer installed by the main init effect.
// The data-subscription effect below reads from this ref so it can swap the
// subscription source (legacy `terminal:output` vs `electronAPI.ptyHost.onData`)
// without re-running the full terminal init.
const outputConsumerRef = useRef<{
write: (data: string) => void;
} | null>(null);

// Mirror of `ptyId` so the ack-flush closure (captured inside the init effect)
// can read the current value without re-creating. Updated by the effect below
// whenever `ptyId` changes (spawn, auto-reattach, or unmount).
const currentPtyIdRef = useRef<string | null>(null);
useEffect(() => {
currentPtyIdRef.current = ptyId;
}, [ptyId]);

// Sync isCliReady from panel prop when it changes (e.g. backend persisted isCliReady
// before this component subscribed to the IPC event, or panel state was updated externally)
useEffect(() => {
Expand All @@ -108,6 +131,32 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
return cleanup;
}, [panel.id, isCliPanel, isCliReady]);

// Listen for the ptyHost ptyId assignment. The main process fires this
// once per spawn when the `usePtyHost` setting is on; fires again on auto-reattach
// after a supervisor restart with a new ptyId. Updating state triggers the
// data-subscription effect below to tear down and re-subscribe.
useEffect(() => {
const cleanup = window.electronAPI.events.onTerminalPtyReady((data) => {
if (data.panelId === panel.id) {
setPtyId(data.ptyId);
}
});
return cleanup;
}, [panel.id]);

// Subscribe to the ptyHost MessagePort data stream for this panel when we
// have a `ptyId`. Flag-off panels keep the legacy `terminal:output` IPC
// subscription installed inside the main init effect and skip this effect
// entirely. Re-subscribes when `ptyId` changes (auto-reattach after a
// supervisor restart).
useEffect(() => {
if (!ptyId) return;
const unsubData = window.electronAPI.ptyHost.onData(ptyId, (data: string) => {
outputConsumerRef.current?.write(data);
});
return unsubData;
}, [ptyId]);

// Get session data from context using the safe hook
const sessionContext = useSession();
const sessionId = sessionContext?.sessionId;
Expand Down Expand Up @@ -641,7 +690,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
});

// Ack batching for flow control
const ACK_BATCH_SIZE = 10_000; // 10KB
const ACK_BATCH_SIZE = 5_000; // 5KB - aligned with main LOW_WATERMARK per VS Code FlowControlConstants
const ACK_BATCH_INTERVAL = 100; // ms
let pendingAckBytes = 0;
let ackFlushTimer: ReturnType<typeof setTimeout> | null = null;
Expand All @@ -654,7 +703,16 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
if (pendingAckBytes > 0) {
const bytes = pendingAckBytes;
pendingAckBytes = 0;
window.electronAPI.invoke('terminal:ack', panel.id, bytes);
// Under the ptyHost flag, ack over the per-window MessagePort so it
// bypasses the main IPC invoke queue. Flag-off keeps the legacy
// IPC path. `currentPtyIdRef` is a ref because the ptyId can change
// across auto-reattach after a supervisor restart.
const activePtyId = currentPtyIdRef.current;
if (activePtyId) {
window.electronAPI.ptyHost.ack(activePtyId, bytes);
} else {
window.electronAPI.invoke('terminal:ack', panel.id, bytes);
}
}
};

Expand Down Expand Up @@ -882,34 +940,49 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
setIsInitialized(true);
console.log('[TerminalPanel] Terminal initialization complete, isInitialized set to true');

// Set up IPC communication for terminal I/O
const outputHandler = (data: { panelId?: string; sessionId?: string; output?: string } | unknown) => {
// Check if this is panel terminal output (has panelId) vs session terminal output (has sessionId)
// Core write-and-ack: consume a raw output chunk (already filtered by
// source/panelId on the dispatcher side). Installed into a ref so the
// `ptyId` effect below can swap subscription sources (legacy
// `terminal:output` IPC vs `electronAPI.ptyHost.onData` port) without
// re-running the full terminal init.
const writeAndAck = (output: string) => {
if (!terminal || disposed) return;
const outputLength = output.length;
terminal.write(output, () => {
if (disposed) return;
// Ack AFTER xterm has rendered the data — proper backpressure
pendingAckBytes += outputLength;
if (pendingAckBytes >= ACK_BATCH_SIZE) {
flushAck();
} else if (!ackFlushTimer) {
ackFlushTimer = setTimeout(flushAck, ACK_BATCH_INTERVAL);
}
// Read scroll position LIVE after render, not before write —
// avoids stale shouldSnap=true yanking user back to bottom
if (isNearBottomRef.current && terminal) {
terminal.scrollToBottom();
}
});
};
outputConsumerRef.current = { write: writeAndAck };

// Legacy `terminal:output` IPC subscription. Stays the primary source
// for flag-off panels (which never receive a `ptyId`). Under flag-on
// main also tees bytes through the ptyHost MessagePort; to avoid
// double-delivery to xterm, this handler short-circuits once the
// panel's `ptyId` is populated and the dedicated effect below takes
// over as the single byte source.
const legacyOutputHandler = (data: unknown) => {
if (currentPtyIdRef.current) return;
if (data && typeof data === 'object' && 'panelId' in data && data.panelId && 'output' in data) {
const typedData = data as { panelId: string; output: string };
if (typedData.panelId === panel.id && terminal && !disposed && isActiveRef.current) {
const outputLength = typedData.output.length;
terminal.write(typedData.output, () => {
if (disposed) return;
// Ack AFTER xterm has rendered the data — proper backpressure
pendingAckBytes += outputLength;
if (pendingAckBytes >= ACK_BATCH_SIZE) {
flushAck();
} else if (!ackFlushTimer) {
ackFlushTimer = setTimeout(flushAck, ACK_BATCH_INTERVAL);
}
// Read scroll position LIVE after render, not before write —
// avoids stale shouldSnap=true yanking user back to bottom
if (isNearBottomRef.current && terminal) {
terminal.scrollToBottom();
}
});
if (typedData.panelId === panel.id) {
outputConsumerRef.current?.write(typedData.output);
}
}
// Ignore session terminal output (has sessionId instead of panelId)
};

const unsubscribeOutput = window.electronAPI.events.onTerminalOutput(outputHandler);
const unsubscribeOutput = window.electronAPI.events.onTerminalOutput(legacyOutputHandler);
console.log('[TerminalPanel] Subscribed to terminal output events for panel:', panel.id);

// Detect full-screen TUI apps (vim, htop, etc.) via alternate screen buffer.
Expand Down Expand Up @@ -1107,6 +1180,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
disposed = true;
interceptor.dispose();
interceptorRef.current = null;
outputConsumerRef.current = null;
flushAck();
if (ackFlushTimer) clearTimeout(ackFlushTimer);
resizeObserver.disconnect();
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export interface AppConfig {
enabled: boolean;
};
devMode?: boolean;
// Route PTY spawns through an isolated ptyHost UtilityProcess for crash
// isolation. Off by default. Requires app restart to take effect.
usePtyHost?: boolean;
sessionCreationPreferences?: {
sessionCount?: number;
toolType?: 'claude' | 'none';
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ interface ElectronAPI {
onTerminalCliReady: (callback: (data: { panelId: string }) => void) => () => void;
onTerminalExited: (callback: (data: { sessionId: string; panelId: string; exitCode: number; signal: number | null }) => void) => () => void;
onTerminalAlternateScreen: (callback: (data: { panelId: string; active: boolean }) => void) => () => void;
/**
* Fired when a terminal panel is spawned via the ptyHost UtilityProcess.
* Carries the host-allocated `ptyId` so TerminalPanel.tsx can subscribe to
* `electronAPI.ptyHost.onData(ptyId, cb)` when the `usePtyHost` setting is on.
* Re-fires on auto-reattach after a supervisor restart with a new ptyId.
*/
onTerminalPtyReady: (callback: (data: { sessionId: string; panelId: string; ptyId: string }) => void) => () => void;
onUncleanShutdownDetected: (callback: () => void) => () => void;
onMainLog: (callback: (level: string, message: string) => void) => () => void;
onVersionUpdateAvailable: (callback: (versionInfo: VersionUpdateInfo) => void) => () => void;
Expand Down Expand Up @@ -403,6 +410,25 @@ interface ElectronAPI {
window: {
isFocused: () => Promise<boolean>;
};

// ptyHost: typed wrapper over the per-window MessagePort installed by the
// preload script. The raw port never crosses contextBridge — these
// functions are the only surface. Chunk D will switch TerminalPanel.tsx
// over to these; Chunk C ships the plumbing so renderer code can start
// subscribing when the `usePtyHost` setting is on.
ptyHost: {
/** Subscribe to PTY byte output for a given ptyId. Returns unsubscribe. */
onData: (ptyId: string, cb: (data: string) => void) => () => void;
/** Subscribe to PTY exit for a given ptyId. Returns unsubscribe. */
onExit: (
ptyId: string,
cb: (exitCode: number | null, signal: number | null) => void,
) => () => void;
/** Ack `bytes` bytes back over the port for flow-control bookkeeping. */
ack: (ptyId: string, bytes: number) => void;
/** Write `data` over the port without round-tripping through IPC invoke. */
write: (ptyId: string, data: string) => void;
};
}

interface CloudVmState {
Expand Down
77 changes: 77 additions & 0 deletions main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { terminalPanelManager } from './services/terminalPanelManager';
import { panelManager } from './services/panelManager';
import { TerminalPanelState } from '../../shared/types/panels';
import { worktreePoolManager } from './services/worktreePoolManager';
import { PtyHostSupervisor } from './ptyHost/ptyHostSupervisor';

export let mainWindow: BrowserWindow | null = null;

Expand Down Expand Up @@ -115,6 +116,23 @@ let archiveProgressManager: ArchiveProgressManager;
let analyticsManager: AnalyticsManager;
let spotlightManager: SpotlightManager;

// ptyHost supervisor — forked as an Electron UtilityProcess on app ready,
// but only when the `usePtyHost` setting is enabled (default: off). When
// disabled, the supervisor is never forked and every manager transparently
// falls through to the legacy in-main `pty.spawn` path.
let ptyHostSupervisor: PtyHostSupervisor | null = null;

/**
* Getter for the ptyHost supervisor. Managers route spawn/write/resize/kill
* through this when `configManager.getUsePtyHost()` returns true and the
* supervisor fork succeeded. Returns null when the setting is off OR when
* fork failed — callers must handle the null case and fall back to the
* legacy `pty.spawn` path.
*/
export function getPtyHostSupervisor(): PtyHostSupervisor | null {
return ptyHostSupervisor;
}

// Store app start time for session duration tracking
let appStartTime: number;

Expand Down Expand Up @@ -861,6 +879,15 @@ async function createWindow() {
}
resourceMonitorService.handleVisibilityChange(false);
});

// Hand the renderer its per-window ptyHost data port once the preload
// listener is guaranteed to be installed. Chunk C: the port is a
// passthrough; Chunk D switches `TerminalPanel.tsx` to subscribe on it.
mainWindow.webContents.once('did-finish-load', () => {
if (ptyHostSupervisor && mainWindow) {
ptyHostSupervisor.attachWindow(mainWindow.webContents);
}
});
}

async function initializeServices() {
Expand Down Expand Up @@ -1070,6 +1097,56 @@ app.whenReady().then(async () => {
console.log('[Main] App is ready, initializing services...');
await initializeServices();
console.log('[Main] Services initialized, creating window...');

// Start the ptyHost supervisor before the window opens so the renderer's
// preload listener for 'ptyHost-port' has a port to receive when the window
// finishes loading. Gated on the `usePtyHost` setting: when off (default),
// the supervisor is never forked and every spawn site falls through to the
// legacy in-main `pty.spawn` path with zero ptyHost code executing.
if (configManager.getUsePtyHost()) {
try {
ptyHostSupervisor = new PtyHostSupervisor();
await ptyHostSupervisor.start();

ptyHostSupervisor.on('renderer-ack', (ptyId: string, bytes: number) => {
terminalPanelManager.acknowledgePtyHostBytes(ptyId, bytes);
});

// Auto-reattach: on ptyHost restart, every manager re-enters the spawn
// path for its live panels. Order (per plan Task 6b / gotcha line 825):
// rejectPendingRpcs → keep manager maps → await nextReady → respawnAll
// The supervisor keeps manager maps intact; the `ready-after-restart`
// event marks "await nextReady" complete so we can drive respawnAll across
// every manager in parallel.
ptyHostSupervisor.on('ready-after-restart', () => {
console.log('[ptyHost] ready-after-restart; fanning respawnAll across managers');
const respawnTasks: Array<Promise<void>> = [
terminalPanelManager.respawnAll(),
];
if (cliManagerFactory) {
const managers = cliManagerFactory.getAllManagers();
for (const manager of managers) {
respawnTasks.push(manager.respawnAll());
}
}
if (runCommandManager) {
respawnTasks.push(runCommandManager.respawnAll());
}
if (sessionManager) {
respawnTasks.push(sessionManager.respawnAll());
}
Promise.all(respawnTasks)
.then(() => console.log('[ptyHost] respawnAll fan-out complete'))
.catch((err) => console.error('[ptyHost] respawnAll fan-out error:', err));
});
} catch (error) {
console.error('[ptyHost] supervisor failed to start; legacy pty.spawn path will be used', error);
ptyHostSupervisor = null;
}
} else {
console.log('[ptyHost] usePtyHost setting is disabled; skipping supervisor fork');
}

await createWindow();
console.log('[Main] Window created successfully');

Expand Down
Loading
Loading