diff --git a/apps/frontend/src/main/terminal-session-store.ts b/apps/frontend/src/main/terminal-session-store.ts
index cd6d290a..26afbbcd 100644
--- a/apps/frontend/src/main/terminal-session-store.ts
+++ b/apps/frontend/src/main/terminal-session-store.ts
@@ -2,6 +2,7 @@ import { app } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, promises as fsPromises } from 'fs';
import type { TerminalWorktreeConfig } from '../shared/types';
+import { debugLog } from '../shared/utils/debug-logger';
/**
* Persisted terminal session data
@@ -376,12 +377,24 @@ export class TerminalSessionStore {
todaySessions[projectPath] = [];
}
+ // Debug: Log incoming outputBuffer info
+ const incomingBufferLen = session.outputBuffer?.length ?? 0;
+ debugLog('[TerminalSessionStore] Updating session in memory:', session.id,
+ 'incoming outputBuffer:', incomingBufferLen, 'bytes',
+ 'isClaudeMode:', session.isClaudeMode);
+
// Update existing or add new
const existingIndex = todaySessions[projectPath].findIndex(s => s.id === session.id);
if (existingIndex >= 0) {
// Preserve displayOrder from existing session if not provided in incoming session
// This prevents periodic saves (which don't include displayOrder) from losing tab order
const existingSession = todaySessions[projectPath][existingIndex];
+ const existingBufferLen = existingSession.outputBuffer?.length ?? 0;
+ const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length;
+ debugLog('[TerminalSessionStore] Updating existing session:', session.id,
+ 'existing outputBuffer:', existingBufferLen, 'bytes',
+ 'new outputBuffer (after truncation):', truncatedLen, 'bytes');
+
todaySessions[projectPath][existingIndex] = {
...session,
// Limit output buffer size
@@ -391,6 +404,10 @@ export class TerminalSessionStore {
displayOrder: session.displayOrder ?? existingSession.displayOrder,
};
} else {
+ const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length;
+ debugLog('[TerminalSessionStore] Creating new session:', session.id,
+ 'outputBuffer (after truncation):', truncatedLen, 'bytes');
+
todaySessions[projectPath].push({
...session,
outputBuffer: session.outputBuffer.slice(-MAX_OUTPUT_BUFFER),
@@ -437,9 +454,18 @@ export class TerminalSessionStore {
getSessions(projectPath: string): TerminalSession[] {
const today = getDateString();
+ debugLog('[TerminalSessionStore] Getting sessions for project:', projectPath, 'date:', today);
+
// First check today
const todaySessions = this.getTodaysSessions();
if (todaySessions[projectPath]?.length > 0) {
+ // Debug: Log outputBuffer info for each session
+ for (const session of todaySessions[projectPath]) {
+ const bufferLen = session.outputBuffer?.length ?? 0;
+ debugLog('[TerminalSessionStore] Session', session.id, 'outputBuffer:', bufferLen, 'bytes',
+ 'isClaudeMode:', session.isClaudeMode,
+ 'hasBuffer:', bufferLen > 0);
+ }
// Validate worktree configs before returning
return todaySessions[projectPath].map(session => ({
...session,
@@ -462,6 +488,15 @@ export class TerminalSessionStore {
console.warn(`[TerminalSessionStore] No sessions today, migrating sessions from ${mostRecentDate} to today`);
const sessions = this.data.sessionsByDate[mostRecentDate][projectPath] || [];
+ // Debug: Log outputBuffer info for sessions being migrated
+ for (const session of sessions) {
+ const bufferLen = session.outputBuffer?.length ?? 0;
+ debugLog('[TerminalSessionStore] Migrating session', session.id, 'from', mostRecentDate,
+ 'outputBuffer:', bufferLen, 'bytes',
+ 'isClaudeMode:', session.isClaudeMode,
+ 'hasBuffer:', bufferLen > 0);
+ }
+
// MIGRATE: Copy sessions to today's bucket with validated worktree configs
const migratedSessions = sessions.map(session => ({
...session,
@@ -629,8 +664,16 @@ export class TerminalSessionStore {
const session = sessions.find(s => s.id === sessionId);
if (session) {
+ const prevLen = session.outputBuffer?.length ?? 0;
session.outputBuffer = (session.outputBuffer + output).slice(-MAX_OUTPUT_BUFFER);
+ const newLen = session.outputBuffer.length;
session.lastActiveAt = new Date().toISOString();
+
+ // Debug: Log buffer update (throttled to avoid spam - only log when significant changes)
+ if (newLen - prevLen > 1000 || prevLen === 0) {
+ debugLog('[TerminalSessionStore] updateOutputBuffer:', sessionId,
+ 'prev:', prevLen, 'bytes, added:', output.length, 'bytes, new total:', newLen, 'bytes');
+ }
// Note: We don't save immediately here to avoid excessive disk writes
// Call saveAllPending() periodically or on app quit
}
diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts
index 423071a4..cdcf1c83 100644
--- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts
+++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts
@@ -149,6 +149,11 @@ export async function restoreTerminal(
'Stored Claude mode:', storedIsClaudeMode,
'Stored session ID:', storedClaudeSessionId);
+ // Debug: Log outputBuffer info from both passed and stored session
+ const passedBufferLen = session.outputBuffer?.length ?? 0;
+ const storedBufferLen = storedSession?.outputBuffer?.length ?? 0;
+ debugLog('[TerminalLifecycle] OutputBuffer info - passed session:', passedBufferLen, 'bytes, stored session:', storedBufferLen, 'bytes');
+
// Validate cwd exists - if the directory was deleted (e.g., worktree removed),
// fall back to project path to prevent shell exit with code 1
let effectiveCwd = session.cwd;
@@ -235,6 +240,12 @@ export async function restoreTerminal(
}
}
+ // Debug: Log the outputBuffer being returned for replay
+ const returnBufferLen = session.outputBuffer?.length ?? 0;
+ debugLog('[TerminalLifecycle] Returning outputBuffer for terminal:', session.id,
+ 'length:', returnBufferLen, 'bytes',
+ 'hasContent:', returnBufferLen > 0);
+
return {
success: true,
outputBuffer: session.outputBuffer
diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx
index ddbf2277..f7910749 100644
--- a/apps/frontend/src/renderer/components/Terminal.tsx
+++ b/apps/frontend/src/renderer/components/Terminal.tsx
@@ -439,6 +439,7 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
dragHandleListeners={dragHandleListeners}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
+ pendingClaudeResume={terminal?.pendingClaudeResume}
/>
void;
+ /** Whether this terminal has a pending Claude resume (deferred until tab activated) */
+ pendingClaudeResume?: boolean;
}
export function TerminalHeader({
@@ -64,6 +66,7 @@ export function TerminalHeader({
dragHandleListeners,
isExpanded,
onToggleExpand,
+ pendingClaudeResume,
}: TerminalHeaderProps) {
const { t } = useTranslation(['terminal', 'common']);
const backlogTasks = tasks.filter((t) => t.status === 'backlog');
@@ -106,6 +109,15 @@ export function TerminalHeader({
{terminalCount < 4 && Claude}
)}
+ {pendingClaudeResume && (
+
+
+ {terminalCount < 4 && {t('terminal:resume.pending')}}
+
+ )}
{isClaudeMode && (
{
if (result.success && result.data?.success) {
+ debugLog(`[usePtyProcess] Successfully restored PTY session for terminal: ${terminalId}`);
handleSuccess();
const store = getStore();
store.setTerminalStatus(terminalId, terminalState.isClaudeMode ? 'claude-active' : 'running');
store.updateTerminal(terminalId, { isRestored: false });
onCreated?.();
} else {
- handleError(`Error restoring session: ${result.data?.error || result.error}`);
+ const errorMsg = `Error restoring session: ${result.data?.error || result.error}`;
+ debugError(`[usePtyProcess] Failed to restore PTY session for terminal: ${terminalId}, error: ${errorMsg}`);
+ handleError(errorMsg);
}
}).catch((err) => {
+ debugError(`[usePtyProcess] Exception restoring PTY session for terminal: ${terminalId}, error:`, err);
handleError(err.message);
});
} else {
// New terminal
+ debugLog(`[usePtyProcess] Creating new PTY for terminal: ${terminalId}, cwd: ${cwd}, projectPath: ${projectPath}`);
window.electronAPI.createTerminal({
id: terminalId,
cwd,
@@ -199,15 +215,19 @@ export function usePtyProcess({
projectPath,
}).then((result) => {
if (result.success) {
+ debugLog(`[usePtyProcess] Successfully created PTY for terminal: ${terminalId}`);
handleSuccess();
if (!alreadyRunning) {
getStore().setTerminalStatus(terminalId, 'running');
}
onCreated?.();
} else {
- handleError(result.error || 'Unknown error');
+ const errorMsg = result.error || 'Unknown error';
+ debugError(`[usePtyProcess] Failed to create PTY for terminal: ${terminalId}, error: ${errorMsg}`);
+ handleError(errorMsg);
}
}).catch((err) => {
+ debugError(`[usePtyProcess] Exception creating PTY for terminal: ${terminalId}, error:`, err);
handleError(err.message);
});
}
diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts
index 856a32d0..391aeb32 100644
--- a/apps/frontend/src/renderer/components/terminal/useXterm.ts
+++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts
@@ -5,6 +5,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
import { SerializeAddon } from '@xterm/addon-serialize';
import { terminalBufferManager } from '../../lib/terminal-buffer-manager';
import { registerOutputCallback, unregisterOutputCallback } from '../../stores/terminal-store';
+import { debugLog, debugError } from '../../../shared/utils/debug-logger';
// Type augmentation for navigator.userAgentData (modern User-Agent Client Hints API)
interface NavigatorUAData {
@@ -44,7 +45,12 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
// Initialize xterm.js UI
useEffect(() => {
- if (!terminalRef.current || xtermRef.current) return;
+ if (!terminalRef.current || xtermRef.current) {
+ debugLog(`[useXterm] Skipping xterm initialization for terminal: ${terminalId} - already initialized or container not ready`);
+ return;
+ }
+
+ debugLog(`[useXterm] Initializing xterm for terminal: ${terminalId}`);
const xterm = new XTerm({
cursorBlink: true,
@@ -242,6 +248,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
// Call onDimensionsReady once when we have valid dimensions
if (!dimensionsReadyCalledRef.current && cols > 0 && rows > 0) {
dimensionsReadyCalledRef.current = true;
+ debugLog(`[useXterm] Dimensions ready for terminal: ${terminalId}, cols: ${cols}, rows: ${rows}`);
onDimensionsReady?.(cols, rows);
}
} else {
@@ -255,11 +262,14 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
// Replay buffered output if this is a remount or restored session
// This now includes ANSI codes for proper formatting/colors/prompt
- const bufferedOutput = terminalBufferManager.get(terminalId);
+ // Use atomic getAndClear to prevent race condition where new output could arrive between get() and clear()
+ const bufferedOutput = terminalBufferManager.getAndClear(terminalId);
if (bufferedOutput && bufferedOutput.length > 0) {
+ debugLog(`[useXterm] Replaying buffered output for terminal: ${terminalId}, buffer size: ${bufferedOutput.length} chars`);
xterm.write(bufferedOutput);
- // Clear buffer after replay to avoid duplicate output
- terminalBufferManager.clear(terminalId);
+ debugLog(`[useXterm] Buffer replay complete and cleared for terminal: ${terminalId}`);
+ } else {
+ debugLog(`[useXterm] No buffered output to replay for terminal: ${terminalId}`);
}
// Handle terminal input
@@ -298,7 +308,12 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
// This allows the global listener to write directly to xterm when terminal is visible
useEffect(() => {
// Only register if xterm is ready
- if (!xtermRef.current) return;
+ if (!xtermRef.current) {
+ debugLog(`[useXterm] Skipping output callback registration for terminal: ${terminalId} - xterm not ready`);
+ return;
+ }
+
+ debugLog(`[useXterm] Registering output callback for terminal: ${terminalId}`);
// Create a write function that writes directly to this xterm instance
const writeCallback = (data: string) => {
@@ -312,6 +327,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
// Cleanup: unregister callback when component unmounts
return () => {
+ debugLog(`[useXterm] Unregistering output callback for terminal: ${terminalId}`);
unregisterOutputCallback(terminalId);
};
}, [terminalId]);
@@ -394,19 +410,29 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
const serializeBuffer = useCallback(() => {
if (xtermRef.current && serializeAddonRef.current) {
try {
+ debugLog(`[useXterm] Serializing buffer for terminal: ${terminalId}`);
const serialized = serializeAddonRef.current.serialize();
if (serialized && serialized.length > 0) {
terminalBufferManager.set(terminalId, serialized);
+ debugLog(`[useXterm] Buffer serialized for terminal: ${terminalId}, size: ${serialized.length} chars`);
+ } else {
+ debugLog(`[useXterm] No content to serialize for terminal: ${terminalId}`);
}
} catch (error) {
- console.error('[useXterm] Failed to serialize terminal buffer:', error);
+ debugError('[useXterm] Failed to serialize terminal buffer:', error);
}
+ } else {
+ debugLog(`[useXterm] Cannot serialize buffer for terminal: ${terminalId} - xterm or serializeAddon not available`);
}
}, [terminalId]);
const dispose = useCallback(() => {
// Guard against double dispose (can happen in React StrictMode or rapid unmount)
- if (isDisposedRef.current) return;
+ if (isDisposedRef.current) {
+ debugLog(`[useXterm] Skipping dispose for terminal: ${terminalId} - already disposed`);
+ return;
+ }
+ debugLog(`[useXterm] Disposing xterm for terminal: ${terminalId}`);
isDisposedRef.current = true;
// Serialize buffer before disposing to preserve ANSI formatting
diff --git a/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts b/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts
index 13fc9953..29170614 100644
--- a/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts
+++ b/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts
@@ -65,6 +65,16 @@ class TerminalBufferManager {
this.buffers.delete(id);
}
+ /**
+ * Atomically get and clear a terminal's buffer
+ * This prevents race conditions where data could be appended between get() and clear()
+ */
+ getAndClear(id: string): string {
+ const buffer = this.buffers.get(id) || '';
+ this.buffers.delete(id);
+ return buffer;
+ }
+
/**
* Check if a terminal has a buffer
*/
diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts
index 8b156371..ba2314bc 100644
--- a/apps/frontend/src/renderer/stores/terminal-store.ts
+++ b/apps/frontend/src/renderer/stores/terminal-store.ts
@@ -192,10 +192,36 @@ export const useTerminalStore = create((set, get) => ({
addRestoredTerminal: (session: TerminalSession) => {
const state = get();
+ debugLog(`[TerminalStore] addRestoredTerminal called for session: ${session.id}, title: "${session.title}", projectPath: ${session.projectPath}`);
+
+ // CRITICAL: Always restore buffer to buffer manager FIRST, even if terminal already exists.
+ // This ensures useXterm can replay the buffer regardless of whether this is a fresh restore
+ // or a re-restore (e.g., after project switch). The buffer must be available before
+ // the Terminal component mounts and useXterm tries to read it.
+ if (session.outputBuffer) {
+ terminalBufferManager.set(session.id, session.outputBuffer);
+ debugLog(`[TerminalStore] Restored buffer for terminal ${session.id}, size: ${session.outputBuffer.length} chars`);
+ } else {
+ debugLog(`[TerminalStore] No output buffer to restore for terminal ${session.id}`);
+ }
// Check if terminal already exists
const existingTerminal = state.terminals.find(t => t.id === session.id);
if (existingTerminal) {
+ debugLog(`[TerminalStore] Terminal ${session.id} already exists in store, returning existing (buffer was still restored above)`);
+
+ // If session was in Claude mode before shutdown, update pendingClaudeResume for re-restore scenarios
+ // (e.g., after project switch). This ensures the deferred resume logic can trigger even when
+ // the terminal already exists in the store.
+ if (session.isClaudeMode === true && !existingTerminal.pendingClaudeResume) {
+ debugLog(`[TerminalStore] Updating pendingClaudeResume for existing terminal ${session.id}`);
+ set((state) => ({
+ terminals: state.terminals.map(t =>
+ t.id === session.id ? { ...t, pendingClaudeResume: true } : t
+ )
+ }));
+ }
+
return existingTerminal;
}
@@ -214,25 +240,26 @@ export const useTerminalStore = create((set, get) => ({
// Keep claudeSessionId so users can resume by clicking the invoke button
isClaudeMode: false,
claudeSessionId: session.claudeSessionId,
- // outputBuffer now stored in terminalBufferManager
+ // outputBuffer now stored in terminalBufferManager (done above before existence check)
isRestored: true,
projectPath: session.projectPath,
// Worktree config is validated in main process before restore
worktreeConfig: session.worktreeConfig,
// Restore displayOrder for tab position persistence (falls back to end if not set)
displayOrder: session.displayOrder ?? state.terminals.length,
+ // If session was in Claude mode before shutdown, mark for deferred resume.
+ // This ensures the renderer knows to trigger 'claude --continue' when the terminal
+ // becomes active, without relying on the TERMINAL_PENDING_RESUME IPC event timing
+ // (which may be sent before the Terminal component mounts its listener).
+ pendingClaudeResume: session.isClaudeMode === true,
};
- // Restore buffer to buffer manager
- if (session.outputBuffer) {
- terminalBufferManager.set(session.id, session.outputBuffer);
- }
-
set((state) => ({
terminals: [...state.terminals, restoredTerminal],
activeTerminalId: state.activeTerminalId || restoredTerminal.id,
}));
+ debugLog(`[TerminalStore] Successfully added restored terminal ${session.id} to store, isRestored: true, claudeSessionId: ${session.claudeSessionId || 'none'}, pendingClaudeResume: ${session.isClaudeMode === true}`);
return restoredTerminal;
},
@@ -489,10 +516,13 @@ export async function restoreTerminalSessions(projectPath: string): Promise