From 5a8e140c394486db62b587623a15ec1dcce2b3be Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 13 Feb 2026 23:54:28 +0100 Subject: [PATCH 01/20] auto-claude: subtask-1-1 - Create terminal-machine.ts XState v5 state machine Add XState v5 state machine for terminal lifecycle with states: idle, shell_ready, claude_starting, claude_active, swapping, pending_resume, exited. Context tracks claudeSessionId, profileId, swap state, isBusy, and error. Includes guards (hasActiveSession, isSwapping) and assign actions for all context updates. Co-Authored-By: Claude Opus 4.6 --- .../shared/state-machines/terminal-machine.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 apps/frontend/src/shared/state-machines/terminal-machine.ts diff --git a/apps/frontend/src/shared/state-machines/terminal-machine.ts b/apps/frontend/src/shared/state-machines/terminal-machine.ts new file mode 100644 index 00000000..7484ad94 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -0,0 +1,203 @@ +import { assign, createMachine } from 'xstate'; + +/** + * Terminal lifecycle state machine context. + * + * Tracks Claude Code session state, profile swap progress, + * and error information for a single terminal instance. + */ +export interface TerminalContext { + claudeSessionId?: string; + profileId?: string; + swapTargetProfileId?: string; + swapPhase?: 'capturing' | 'migrating' | 'recreating' | 'resuming'; + isBusy: boolean; + error?: string; +} + +/** + * Discriminated union of all terminal lifecycle events. + */ +export type TerminalEvent = + | { type: 'SHELL_READY' } + | { type: 'CLAUDE_START'; profileId: string } + | { type: 'CLAUDE_ACTIVE'; claudeSessionId?: string } + | { type: 'CLAUDE_BUSY'; isBusy: boolean } + | { type: 'CLAUDE_EXITED'; exitCode?: number; error?: string } + | { type: 'SWAP_INITIATED'; targetProfileId: string } + | { type: 'SWAP_SESSION_CAPTURED'; claudeSessionId: string } + | { type: 'SWAP_MIGRATED' } + | { type: 'SWAP_TERMINAL_RECREATED' } + | { type: 'SWAP_RESUME_COMPLETE'; claudeSessionId?: string; profileId: string } + | { type: 'SWAP_FAILED'; error: string } + | { type: 'RESUME_REQUESTED'; claudeSessionId: string } + | { type: 'RESUME_COMPLETE'; claudeSessionId?: string } + | { type: 'RESUME_FAILED'; error: string } + | { type: 'SHELL_EXITED'; exitCode?: number; signal?: string } + | { type: 'RESET' }; + +export const terminalMachine = createMachine( + { + id: 'terminal', + initial: 'idle', + types: {} as { + context: TerminalContext; + events: TerminalEvent; + }, + context: { + claudeSessionId: undefined, + profileId: undefined, + swapTargetProfileId: undefined, + swapPhase: undefined, + isBusy: false, + error: undefined, + }, + states: { + idle: { + on: { + SHELL_READY: 'shell_ready', + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + shell_ready: { + on: { + CLAUDE_START: { target: 'claude_starting', actions: 'setProfileId' }, + SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + claude_starting: { + on: { + CLAUDE_ACTIVE: { target: 'claude_active', actions: 'setClaudeSessionId' }, + CLAUDE_EXITED: { target: 'shell_ready', actions: ['setError', 'clearSession'] }, + SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + claude_active: { + on: { + CLAUDE_BUSY: { actions: 'setBusy' }, + CLAUDE_EXITED: { target: 'shell_ready', actions: 'clearSession' }, + SWAP_INITIATED: { + target: 'swapping', + guard: 'hasActiveSession', + actions: 'setSwapTarget', + }, + SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + swapping: { + on: { + SWAP_SESSION_CAPTURED: { + actions: ['setCapturedSession', 'setSwapPhaseMigrating'], + }, + SWAP_MIGRATED: { + actions: 'setSwapPhaseRecreating', + }, + SWAP_TERMINAL_RECREATED: { + actions: 'setSwapPhaseResuming', + }, + SWAP_RESUME_COMPLETE: { + target: 'claude_active', + actions: 'applySwapComplete', + }, + SWAP_FAILED: { + target: 'shell_ready', + actions: ['setError', 'clearSwapState'], + }, + SHELL_EXITED: { target: 'exited', actions: ['clearSession', 'clearSwapState'] }, + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + pending_resume: { + on: { + RESUME_COMPLETE: { target: 'claude_active', actions: 'setClaudeSessionId' }, + RESUME_FAILED: { target: 'shell_ready', actions: ['setError', 'clearSession'] }, + SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + exited: { + on: { + SHELL_READY: { target: 'shell_ready', actions: 'clearError' }, + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + }, + }, + { + guards: { + hasActiveSession: ({ context }) => context.claudeSessionId !== undefined, + isSwapping: ({ context }) => context.swapTargetProfileId !== undefined, + }, + actions: { + setProfileId: assign({ + profileId: ({ event }) => + event.type === 'CLAUDE_START' ? event.profileId : undefined, + error: () => undefined, + }), + setClaudeSessionId: assign({ + claudeSessionId: ({ event }) => { + if (event.type === 'CLAUDE_ACTIVE') return event.claudeSessionId; + if (event.type === 'RESUME_COMPLETE') return event.claudeSessionId; + return undefined; + }, + isBusy: () => false, + error: () => undefined, + }), + setBusy: assign({ + isBusy: ({ event }) => + event.type === 'CLAUDE_BUSY' ? event.isBusy : false, + }), + setError: assign({ + error: ({ event }) => { + if (event.type === 'CLAUDE_EXITED') return event.error; + if (event.type === 'SWAP_FAILED') return event.error; + if (event.type === 'RESUME_FAILED') return event.error; + return undefined; + }, + }), + clearError: assign({ error: () => undefined }), + clearSession: assign({ + claudeSessionId: () => undefined, + isBusy: () => false, + }), + setSwapTarget: assign({ + swapTargetProfileId: ({ event }) => + event.type === 'SWAP_INITIATED' ? event.targetProfileId : undefined, + swapPhase: () => 'capturing' as const, + error: () => undefined, + }), + setCapturedSession: assign({ + claudeSessionId: ({ event }) => + event.type === 'SWAP_SESSION_CAPTURED' ? event.claudeSessionId : undefined, + }), + setSwapPhaseMigrating: assign({ swapPhase: () => 'migrating' as const }), + setSwapPhaseRecreating: assign({ swapPhase: () => 'recreating' as const }), + setSwapPhaseResuming: assign({ swapPhase: () => 'resuming' as const }), + applySwapComplete: assign({ + claudeSessionId: ({ event }) => + event.type === 'SWAP_RESUME_COMPLETE' ? event.claudeSessionId : undefined, + profileId: ({ event }) => + event.type === 'SWAP_RESUME_COMPLETE' ? event.profileId : undefined, + swapTargetProfileId: () => undefined, + swapPhase: () => undefined, + isBusy: () => false, + error: () => undefined, + }), + clearSwapState: assign({ + swapTargetProfileId: () => undefined, + swapPhase: () => undefined, + }), + resetContext: assign({ + claudeSessionId: () => undefined, + profileId: () => undefined, + swapTargetProfileId: () => undefined, + swapPhase: () => undefined, + isBusy: () => false, + error: () => undefined, + }), + }, + } +); From ca370eaa1fd831d345c635df5e7cb21330fcae20 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 13 Feb 2026 23:55:38 +0100 Subject: [PATCH 02/20] auto-claude: subtask-1-2 - Add TerminalSwapState interface and swapState to TerminalProcess Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/main/terminal/types.ts | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index d68ca2c1..79fddd85 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -28,6 +28,37 @@ export interface TerminalProcess { shellType?: WindowsShellType; /** Whether this terminal is waiting for Claude onboarding to complete (login flow) */ awaitingOnboardingComplete?: boolean; + /** Current account swap state (tracks progress of profile switching) */ + swapState?: TerminalSwapState; +} + +/** + * Phase of a terminal account swap operation + */ +export type TerminalSwapPhase = + | 'capturing_session' + | 'migrating' + | 'recreating' + | 'resuming'; + +/** + * Tracks the progress of an account swap for a terminal. + * Enables the main process to track swap progress independently + * of the renderer's XState machine. + */ +export interface TerminalSwapState { + /** Whether a swap is currently in progress */ + isSwapping: boolean; + /** Current phase of the swap operation */ + phase: TerminalSwapPhase; + /** Profile ID being swapped to */ + targetProfileId: string; + /** Profile ID being swapped from */ + sourceProfileId: string; + /** Whether the Claude session was successfully migrated */ + sessionMigrated: boolean; + /** Error message if the swap failed */ + error?: string; } /** From 84775ed46cc3122011ddfc0645f1d3becdbd38ca Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 13 Feb 2026 23:57:05 +0100 Subject: [PATCH 03/20] auto-claude: subtask-1-3 - Update shared/state-machines/index.ts barrel export Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/shared/state-machines/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/frontend/src/shared/state-machines/index.ts b/apps/frontend/src/shared/state-machines/index.ts index 68cd1f3a..a2f8afed 100644 --- a/apps/frontend/src/shared/state-machines/index.ts +++ b/apps/frontend/src/shared/state-machines/index.ts @@ -7,3 +7,6 @@ export { mapStateToLegacy, } from './task-state-utils'; export type { TaskStateName } from './task-state-utils'; + +export { terminalMachine } from './terminal-machine'; +export type { TerminalContext, TerminalEvent } from './terminal-machine'; From ab42f88635be112e87ef49be4860b23267f4730f Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 13 Feb 2026 23:58:13 +0100 Subject: [PATCH 04/20] auto-claude: subtask-2-1 - Add migratedSession option to resumeClaudeAsync and preserve YOLO mode - Add optional `options` parameter with `migratedSession` flag to resumeClaudeAsync() - When migratedSession is true, log post-swap resume context and skip sessionId deprecation warning - Preserve dangerouslySkipPermissions flag from terminal's stored state during resume Co-Authored-By: Claude Opus 4.6 --- .../main/terminal/claude-integration-handler.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index ef4c92b9..deb6e9df 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -1386,7 +1386,8 @@ export async function invokeClaudeAsync( export async function resumeClaudeAsync( terminal: TerminalProcess, sessionId: string | undefined, - getWindow: WindowGetter + getWindow: WindowGetter, + options?: { migratedSession?: boolean } ): Promise { // Track terminal state for cleanup on error const wasClaudeMode = terminal.isClaudeMode; @@ -1419,12 +1420,19 @@ export async function resumeClaudeAsync( // and we don't want stale IDs persisting through SessionHandler.persistSessionAsync(). terminal.claudeSessionId = undefined; - // Deprecation warning for callers still passing sessionId - if (sessionId) { + // Deprecation warning for callers still passing sessionId (skip for migrated sessions) + if (sessionId && !options?.migratedSession) { console.warn('[ClaudeIntegration:resumeClaudeAsync] sessionId parameter is deprecated and ignored; using claude --continue instead'); } - const command = `${pathPrefix}${escapedClaudeCmd} --continue`; + if (options?.migratedSession) { + debugLog('[ClaudeIntegration:resumeClaudeAsync] Post-swap resume for terminal:', terminal.id); + } + + // Preserve YOLO mode flag from terminal's stored state + const extraFlags = terminal.dangerouslySkipPermissions ? YOLO_MODE_FLAG : ''; + + const command = `${pathPrefix}${escapedClaudeCmd} --continue${extraFlags}`; // Use PtyManager.writeToPty for safer write with error handling PtyManager.writeToPty(terminal, `${command}\r`); From 7578a3263b02cdab12be97b4cce5d33464a4baf8 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 13 Feb 2026 23:59:51 +0100 Subject: [PATCH 05/20] auto-claude: subtask-2-2 - Update terminal-manager with swap orchestration and options passthrough Co-Authored-By: Claude Opus 4.6 --- .../src/main/terminal/terminal-manager.ts | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index a81f6e1e..e8cf4ccf 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -11,13 +11,15 @@ import type { TerminalProcess, WindowGetter, TerminalOperationResult, - TerminalProfileChangeInfo + TerminalProfileChangeInfo, + TerminalSwapState } from './types'; import * as PtyManager from './pty-manager'; import * as SessionHandler from './session-handler'; import * as TerminalLifecycle from './terminal-lifecycle'; import * as TerminalEventHandler from './terminal-event-handler'; import * as ClaudeIntegration from './claude-integration-handler'; +import { migrateSession } from '../claude-profile/session-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; export class TerminalManager { @@ -223,13 +225,13 @@ export class TerminalManager { /** * Resume Claude in a terminal asynchronously (non-blocking) */ - async resumeClaudeAsync(id: string, sessionId?: string): Promise { + async resumeClaudeAsync(id: string, sessionId?: string, options?: { migratedSession?: boolean }): Promise { const terminal = this.terminals.get(id); if (!terminal) { return; } - await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow); + await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow, options); } /** @@ -247,11 +249,92 @@ export class TerminalManager { return; } + // Determine if this is a post-swap deferred resume + const isPostSwap = terminal.swapState?.isSwapping === true && terminal.swapState?.sessionMigrated === true; + // Clear the pending flag terminal.pendingClaudeResume = false; + // Clear swap state if this was a post-swap resume + if (isPostSwap) { + terminal.swapState = undefined; + } + // Now actually resume Claude - await ClaudeIntegration.resumeClaudeAsync(terminal, undefined, this.getWindow); + await ClaudeIntegration.resumeClaudeAsync(terminal, undefined, this.getWindow, isPostSwap ? { migratedSession: true } : undefined); + } + + /** + * Orchestrate a profile swap for a single terminal. + * Captures the current session, migrates it to the target profile, and marks swap state. + * Called from the CLAUDE_PROFILE_SET_ACTIVE IPC handler for each terminal. + * + * @returns Info about the swap result for the event payload + */ + async swapProfileAndResume( + terminalId: string, + sourceConfigDir: string, + targetConfigDir: string, + targetProfileId: string + ): Promise<{ sessionMigrated: boolean; sessionId?: string; isClaudeMode: boolean }> { + const terminal = this.terminals.get(terminalId); + if (!terminal) { + return { sessionMigrated: false, isClaudeMode: false }; + } + + const isClaudeMode = terminal.isClaudeMode; + const sessionId = terminal.claudeSessionId; + const sourceProfileId = terminal.claudeProfileId ?? 'unknown'; + + // If no active Claude session or no session ID, skip migration + if (!isClaudeMode || !sessionId) { + debugLog('[terminal-manager] No active Claude session for terminal:', terminalId, '- skipping migration'); + return { sessionMigrated: false, isClaudeMode }; + } + + // Mark swap state: capturing session + terminal.swapState = { + isSwapping: true, + phase: 'capturing_session', + targetProfileId, + sourceProfileId, + sessionMigrated: false + }; + + // Migrate the session from source to target profile + terminal.swapState.phase = 'migrating'; + debugLog('[terminal-manager] Migrating session for terminal:', terminalId, 'session:', sessionId); + + const migrationResult = migrateSession( + sourceConfigDir, + targetConfigDir, + terminal.cwd, + sessionId + ); + + if (!migrationResult.success) { + debugError('[terminal-manager] Session migration failed for terminal:', terminalId, 'error:', migrationResult.error); + terminal.swapState.error = migrationResult.error; + terminal.swapState.sessionMigrated = false; + return { sessionMigrated: false, sessionId, isClaudeMode }; + } + + // Migration succeeded + terminal.swapState.sessionMigrated = true; + terminal.swapState.phase = 'recreating'; + debugLog('[terminal-manager] Session migrated successfully for terminal:', terminalId); + + // Notify renderer about swap progress + const win = this.getWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send('TERMINAL_SWAP_PROGRESS', { + terminalId, + phase: 'migrating', + sessionMigrated: true + }); + } + + return { sessionMigrated: true, sessionId, isClaudeMode }; } /** From fc3e8a8ad5207ec983c2df775134b6907d02f9c4 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:02:26 +0100 Subject: [PATCH 06/20] auto-claude: subtask-2-3 - Add isClaudeMode to TERMINAL_PROFILE_CHANGED event and migratedSession to TERMINAL_RESUME_CLAUDE Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/main/ipc-handlers/terminal-handlers.ts | 8 +++++--- apps/frontend/src/shared/types/agent.ts | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 0bfef379..561cf76d 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -252,6 +252,7 @@ export function registerTerminalHandlers( id: string; sessionId?: string; sessionMigrated?: boolean; + isClaudeMode?: boolean; }> = []; // Process each terminal @@ -288,7 +289,8 @@ export function registerTerminalHandlers( terminalsNeedingRefresh.push({ id: terminal.id, sessionId: terminal.claudeSessionId, - sessionMigrated + sessionMigrated, + isClaudeMode: terminal.isClaudeMode }); } @@ -613,9 +615,9 @@ export function registerTerminalHandlers( ipcMain.on( IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, - (_, id: string, sessionId?: string) => { + (_, id: string, sessionId?: string, options?: { migratedSession?: boolean }) => { // Use async version to avoid blocking main process during CLI detection - terminalManager.resumeClaudeAsync(id, sessionId).catch((error) => { + terminalManager.resumeClaudeAsync(id, sessionId, options).catch((error) => { console.warn('[terminal-handlers] Failed to resume Claude:', error); }); } diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index 2fb39902..b3b2f8a1 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -255,6 +255,8 @@ export interface TerminalProfileChangedEvent { sessionId?: string; /** Whether the session was successfully migrated to new profile */ sessionMigrated?: boolean; + /** Whether the terminal was in Claude mode (had an active Claude session) */ + isClaudeMode?: boolean; }>; } From 6c991774ad0a760bbd099c86d0c0a1b23999d8ec Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:05:04 +0100 Subject: [PATCH 07/20] auto-claude: subtask-3-1 - Auto-resume Claude session after terminal profile change Replace manual "Run: claude --resume" message with automatic resume call via resumeClaudeInTerminal IPC. Updated preload API and IPC types to pass migratedSession option. YOLO mode is preserved automatically via the main process terminal object. Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/preload/api/terminal-api.ts | 6 ++-- .../hooks/useTerminalProfileChange.ts | 32 ++++++++++++------- apps/frontend/src/shared/types/ipc.ts | 2 +- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index 67171091..fe09cb0f 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -47,7 +47,7 @@ export interface TerminalAPI { rows?: number ) => Promise>; clearTerminalSessions: (projectPath: string) => Promise; - resumeClaudeInTerminal: (id: string, sessionId?: string) => void; + resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }) => void; activateDeferredClaudeResume: (id: string) => void; getTerminalSessionDates: (projectPath?: string) => Promise>; getTerminalSessionsForDate: ( @@ -166,8 +166,8 @@ export const createTerminalAPI = (): TerminalAPI => ({ clearTerminalSessions: (projectPath: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, projectPath), - resumeClaudeInTerminal: (id: string, sessionId?: string): void => - ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId), + resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }): void => + ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId, options), activateDeferredClaudeResume: (id: string): void => ipcRenderer.send(IPC_CHANNELS.TERMINAL_ACTIVATE_DEFERRED_RESUME, id), diff --git a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts index 4b7683b7..ffc27a9e 100644 --- a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts +++ b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts @@ -8,7 +8,7 @@ import { debugLog, debugError } from '../../shared/utils/debug-logger'; * Hook to handle terminal profile change events. * When a Claude profile switches, all terminals need to be recreated with the new profile's * environment variables. Terminals with active Claude sessions will have their sessions - * migrated and can be resumed with --resume {sessionId}. + * migrated and automatically resumed with --continue. */ export function useTerminalProfileChange(): void { // Track terminals being recreated to prevent duplicate processing @@ -101,19 +101,27 @@ export function useTerminalProfileChange(): void { newId: newTerminal.id }); - // If there was an active Claude session that was migrated, show a message - // and set up for potential resume + // If there was an active Claude session that was migrated, auto-resume it if (sessionId && sessionMigrated) { - debugLog('[useTerminalProfileChange] Session migrated, ready for resume:', sessionId); - // Store the session ID so the user can resume if desired + debugLog('[useTerminalProfileChange] Session migrated, auto-resuming:', sessionId); + // Store the session ID for tracking store.setClaudeSessionId(newTerminal.id, sessionId); - // Set pending resume flag - user can trigger resume from terminal tab - store.setPendingClaudeResume(newTerminal.id, true); - // Send a message to the terminal about the session - window.electronAPI.sendTerminalInput( - newTerminal.id, - `# Profile switched. Previous Claude session available.\n# Run: claude --resume ${sessionId}\n` - ); + + try { + // Auto-resume the Claude session with --continue + // YOLO mode (dangerouslySkipPermissions) is preserved on the main process + // terminal object, so resumeClaudeAsync will pick it up automatically + window.electronAPI.resumeClaudeInTerminal( + newTerminal.id, + sessionId, + { migratedSession: true } + ); + debugLog('[useTerminalProfileChange] Resume initiated for terminal:', newTerminal.id); + } catch (error) { + debugError('[useTerminalProfileChange] Failed to auto-resume Claude session:', error); + // Leave terminal at shell prompt - user can manually resume if needed + store.setPendingClaudeResume(newTerminal.id, true); + } } } finally { diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index b1fc2c4b..82168ffa 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -252,7 +252,7 @@ export interface ElectronAPI { getTerminalSessions: (projectPath: string) => Promise>; restoreTerminalSession: (session: TerminalSession, cols?: number, rows?: number) => Promise>; clearTerminalSessions: (projectPath: string) => Promise; - resumeClaudeInTerminal: (id: string, sessionId?: string) => void; + resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }) => void; activateDeferredClaudeResume: (id: string) => void; getTerminalSessionDates: (projectPath?: string) => Promise>; getTerminalSessionsForDate: (date: string, projectPath: string) => Promise>; From 1302c61b71c5b5f17cbc4792d2bf5455543825c5 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:07:18 +0100 Subject: [PATCH 08/20] auto-claude: subtask-3-2 - Integrate XState terminal machine into terminal-store Add module-level terminalActors Map and helper functions (getOrCreateTerminalActor, sendTerminalMachineEvent, deriveTerminalStateFromMachine) for XState actor management. Store actions (setClaudeMode, setClaudeSessionId, setClaudeBusy, setPendingClaudeResume) now forward corresponding events to the XState machine. Actors are cleaned up on terminal removal and clearAllTerminals. Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/terminal-store.ts | 96 ++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index ffdc2469..1fca6890 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -1,10 +1,66 @@ import { create } from 'zustand'; +import { createActor } from 'xstate'; +import type { ActorRefFrom, SnapshotFrom } from 'xstate'; import { v4 as uuid } from 'uuid'; import { arrayMove } from '@dnd-kit/sortable'; import type { TerminalSession, TerminalWorktreeConfig } from '../../shared/types'; +import { terminalMachine, type TerminalEvent } from '../../shared/state-machines'; import { terminalBufferManager } from '../lib/terminal-buffer-manager'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; +type TerminalActor = ActorRefFrom; + +/** + * Module-level Map to store terminal ID -> XState actor mappings. + * + * DESIGN NOTE: Stored outside Zustand because actors are mutable references + * that shouldn't be serialized in state. Similar pattern to xtermCallbacks. + */ +const terminalActors = new Map(); + +/** + * Get or create an XState terminal actor for a given terminal ID. + * Actors are lazily created on first access and cached for the terminal's lifetime. + */ +export function getOrCreateTerminalActor(terminalId: string): TerminalActor { + let actor = terminalActors.get(terminalId); + if (!actor) { + actor = createActor(terminalMachine); + actor.start(); + terminalActors.set(terminalId, actor); + debugLog(`[TerminalStore] Created XState actor for terminal: ${terminalId}`); + } + return actor; +} + +/** + * Send an event to a terminal's XState machine. + * Creates the actor if it doesn't exist yet. + */ +export function sendTerminalMachineEvent(terminalId: string, event: TerminalEvent): void { + const actor = getOrCreateTerminalActor(terminalId); + const stateBefore = String(actor.getSnapshot().value); + actor.send(event); + const stateAfter = String(actor.getSnapshot().value); + debugLog(`[TerminalStore] Machine ${terminalId}: ${event.type} (${stateBefore} -> ${stateAfter})`); +} + +/** + * Derive terminal boolean fields from an XState machine snapshot. + * Used to keep legacy boolean fields in sync with the machine state. + */ +export function deriveTerminalStateFromMachine(snapshot: SnapshotFrom): { + isClaudeMode: boolean; + isClaudeBusy: boolean; + pendingClaudeResume: boolean; +} { + const state = String(snapshot.value); + const isClaudeMode = state === 'claude_starting' || state === 'claude_active' || state === 'swapping'; + const isClaudeBusy = snapshot.context.isBusy; + const pendingClaudeResume = state === 'pending_resume'; + return { isClaudeMode, isClaudeBusy, pendingClaudeResume }; +} + /** * Module-level Map to store terminal ID -> xterm write callback mappings. * @@ -301,9 +357,15 @@ export const useTerminalStore = create((set, get) => ({ }, removeTerminal: (id: string) => { - // Clean up buffer manager and output callback + // Clean up buffer manager, output callback, and XState actor terminalBufferManager.dispose(id); xtermCallbacks.delete(id); + const actor = terminalActors.get(id); + if (actor) { + actor.stop(); + terminalActors.delete(id); + debugLog(`[TerminalStore] Cleaned up XState actor for terminal: ${id}`); + } set((state) => { const newTerminals = state.terminals.filter((t) => t.id !== id); @@ -339,6 +401,13 @@ export const useTerminalStore = create((set, get) => ({ }, setClaudeMode: (id: string, isClaudeMode: boolean) => { + // Send corresponding event to XState machine + if (isClaudeMode) { + sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE' }); + } else { + sendTerminalMachineEvent(id, { type: 'CLAUDE_EXITED' }); + } + set((state) => ({ terminals: state.terminals.map((t) => t.id === id @@ -356,6 +425,9 @@ export const useTerminalStore = create((set, get) => ({ }, setClaudeSessionId: (id: string, sessionId: string) => { + // Send CLAUDE_ACTIVE with session ID to XState machine + sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE', claudeSessionId: sessionId }); + set((state) => ({ terminals: state.terminals.map((t) => t.id === id ? { ...t, claudeSessionId: sessionId } : t @@ -380,6 +452,9 @@ export const useTerminalStore = create((set, get) => ({ }, setClaudeBusy: (id: string, isBusy: boolean) => { + // Send CLAUDE_BUSY event to XState machine + sendTerminalMachineEvent(id, { type: 'CLAUDE_BUSY', isBusy }); + set((state) => ({ terminals: state.terminals.map((t) => t.id === id ? { ...t, isClaudeBusy: isBusy } : t @@ -388,6 +463,20 @@ export const useTerminalStore = create((set, get) => ({ }, setPendingClaudeResume: (id: string, pending: boolean) => { + // Send RESUME_REQUESTED or RESUME_COMPLETE to XState machine + if (pending) { + const terminal = get().terminals.find(t => t.id === id); + if (terminal?.claudeSessionId) { + sendTerminalMachineEvent(id, { type: 'RESUME_REQUESTED', claudeSessionId: terminal.claudeSessionId }); + } + } else { + // Resume cleared - either completed or cancelled + const actor = terminalActors.get(id); + if (actor && String(actor.getSnapshot().value) === 'pending_resume') { + sendTerminalMachineEvent(id, { type: 'RESUME_COMPLETE' }); + } + } + set((state) => ({ terminals: state.terminals.map((t) => t.id === id ? { ...t, pendingClaudeResume: pending } : t @@ -404,6 +493,11 @@ export const useTerminalStore = create((set, get) => ({ }, clearAllTerminals: () => { + // Clean up all XState actors + for (const [_id, actor] of terminalActors) { + actor.stop(); + } + terminalActors.clear(); set({ terminals: [], activeTerminalId: null, hasRestoredSessions: false }); }, From e5f1c6b2dbc29168afe5894d384c986073f22670 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:08:18 +0100 Subject: [PATCH 09/20] auto-claude: subtask-4-1 - Add i18n translation keys for swap/resume UI messages Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/shared/i18n/locales/en/terminal.json | 7 +++++++ apps/frontend/src/shared/i18n/locales/fr/terminal.json | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/apps/frontend/src/shared/i18n/locales/en/terminal.json b/apps/frontend/src/shared/i18n/locales/en/terminal.json index 7d87ea7d..f4ef80da 100644 --- a/apps/frontend/src/shared/i18n/locales/en/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/en/terminal.json @@ -12,6 +12,13 @@ "terminalTitle": "Auth: {{profileName}}", "maxTerminalsReached": "Cannot open auth terminal: maximum terminals reached. Close a terminal first." }, + "swap": { + "inProgress": "Switching profile...", + "resumingSession": "Resuming Claude session...", + "sessionResumed": "Session resumed under new profile", + "resumeFailed": "Could not resume session. You can start a new session.", + "noSession": "Profile switched. No active session to resume." + }, "worktree": { "create": "Worktree", "createNew": "New Worktree", diff --git a/apps/frontend/src/shared/i18n/locales/fr/terminal.json b/apps/frontend/src/shared/i18n/locales/fr/terminal.json index a13c0ab0..023397ec 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/fr/terminal.json @@ -12,6 +12,13 @@ "terminalTitle": "Auth: {{profileName}}", "maxTerminalsReached": "Impossible d'ouvrir le terminal d'auth: nombre maximum de terminaux atteint. Fermez un terminal d'abord." }, + "swap": { + "inProgress": "Changement de profil...", + "resumingSession": "Reprise de la session Claude...", + "sessionResumed": "Session reprise sous le nouveau profil", + "resumeFailed": "Impossible de reprendre la session. Vous pouvez demarrer une nouvelle session.", + "noSession": "Profil change. Aucune session active a reprendre." + }, "worktree": { "create": "Worktree", "createNew": "Nouveau Worktree", From c8fe01ef0cba469586c34498fe672717aa1e572d Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 00:10:16 +0100 Subject: [PATCH 10/20] auto-claude: subtask-4-2 - Write comprehensive unit tests for terminal-machine Co-Authored-By: Claude Opus 4.6 --- .../__tests__/terminal-machine.test.ts | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts diff --git a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts new file mode 100644 index 00000000..5518010b --- /dev/null +++ b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts @@ -0,0 +1,510 @@ +import { describe, it, expect } from 'vitest'; +import { createActor } from 'xstate'; +import { terminalMachine, type TerminalEvent } from '../terminal-machine'; + +/** + * Helper to run a sequence of events and get the final snapshot. + * Optionally starts from a restored state with given context. + */ +function runEvents( + events: TerminalEvent[], + initialState?: string, + initialContext?: Record +) { + const actor = initialState + ? createActor(terminalMachine, { + snapshot: terminalMachine.resolveState({ + value: initialState, + context: { + claudeSessionId: undefined, + profileId: undefined, + swapTargetProfileId: undefined, + swapPhase: undefined, + isBusy: false, + error: undefined, + ...initialContext, + }, + }), + }) + : createActor(terminalMachine); + actor.start(); + + for (const event of events) { + actor.send(event); + } + + const snapshot = actor.getSnapshot(); + actor.stop(); + return snapshot; +} + +describe('terminalMachine', () => { + describe('initial state', () => { + it('should start in idle state', () => { + const actor = createActor(terminalMachine); + actor.start(); + expect(actor.getSnapshot().value).toBe('idle'); + actor.stop(); + }); + + it('should have default context initially', () => { + const actor = createActor(terminalMachine); + actor.start(); + const { context } = actor.getSnapshot(); + expect(context.claudeSessionId).toBeUndefined(); + expect(context.profileId).toBeUndefined(); + expect(context.swapTargetProfileId).toBeUndefined(); + expect(context.swapPhase).toBeUndefined(); + expect(context.isBusy).toBe(false); + expect(context.error).toBeUndefined(); + actor.stop(); + }); + }); + + describe('happy path: idle → shell_ready → claude_active → exited', () => { + it('should transition from idle to shell_ready', () => { + const snapshot = runEvents([{ type: 'SHELL_READY' }]); + expect(snapshot.value).toBe('shell_ready'); + }); + + it('should transition from shell_ready to claude_starting', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + ]); + expect(snapshot.value).toBe('claude_starting'); + expect(snapshot.context.profileId).toBe('profile-1'); + }); + + it('should transition from claude_starting to claude_active', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + ]); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('session-1'); + }); + + it('should transition from claude_active to shell_ready on CLAUDE_EXITED', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'CLAUDE_EXITED' }, + ]); + expect(snapshot.value).toBe('shell_ready'); + expect(snapshot.context.claudeSessionId).toBeUndefined(); + expect(snapshot.context.isBusy).toBe(false); + }); + + it('should transition to exited on SHELL_EXITED from claude_active', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'SHELL_EXITED', exitCode: 0 }, + ]); + expect(snapshot.value).toBe('exited'); + expect(snapshot.context.claudeSessionId).toBeUndefined(); + }); + + it('should complete full lifecycle: idle → shell_ready → claude_active → exited', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'SHELL_EXITED' }, + ]); + expect(snapshot.value).toBe('exited'); + }); + }); + + describe('swap flow: claude_active → swapping → claude_active', () => { + const toClaudeActive: TerminalEvent[] = [ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + ]; + + it('should transition to swapping on SWAP_INITIATED with active session', () => { + const snapshot = runEvents([ + ...toClaudeActive, + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + ]); + expect(snapshot.value).toBe('swapping'); + expect(snapshot.context.swapTargetProfileId).toBe('profile-2'); + expect(snapshot.context.swapPhase).toBe('capturing'); + }); + + it('should progress through swap phases', () => { + const snapshot = runEvents([ + ...toClaudeActive, + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + { type: 'SWAP_SESSION_CAPTURED', claudeSessionId: 'captured-session' }, + ]); + expect(snapshot.value).toBe('swapping'); + expect(snapshot.context.swapPhase).toBe('migrating'); + expect(snapshot.context.claudeSessionId).toBe('captured-session'); + }); + + it('should progress to recreating phase', () => { + const snapshot = runEvents([ + ...toClaudeActive, + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + { type: 'SWAP_SESSION_CAPTURED', claudeSessionId: 'captured-session' }, + { type: 'SWAP_MIGRATED' }, + ]); + expect(snapshot.context.swapPhase).toBe('recreating'); + }); + + it('should progress to resuming phase', () => { + const snapshot = runEvents([ + ...toClaudeActive, + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + { type: 'SWAP_SESSION_CAPTURED', claudeSessionId: 'captured-session' }, + { type: 'SWAP_MIGRATED' }, + { type: 'SWAP_TERMINAL_RECREATED' }, + ]); + expect(snapshot.context.swapPhase).toBe('resuming'); + }); + + it('should return to claude_active after successful swap', () => { + const snapshot = runEvents([ + ...toClaudeActive, + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + { type: 'SWAP_SESSION_CAPTURED', claudeSessionId: 'captured-session' }, + { type: 'SWAP_MIGRATED' }, + { type: 'SWAP_TERMINAL_RECREATED' }, + { + type: 'SWAP_RESUME_COMPLETE', + claudeSessionId: 'new-session', + profileId: 'profile-2', + }, + ]); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('new-session'); + expect(snapshot.context.profileId).toBe('profile-2'); + expect(snapshot.context.swapTargetProfileId).toBeUndefined(); + expect(snapshot.context.swapPhase).toBeUndefined(); + expect(snapshot.context.isBusy).toBe(false); + expect(snapshot.context.error).toBeUndefined(); + }); + }); + + describe('failed swap: swapping → shell_ready with error', () => { + const toSwapping: TerminalEvent[] = [ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + ]; + + it('should transition to shell_ready on SWAP_FAILED', () => { + const snapshot = runEvents([ + ...toSwapping, + { type: 'SWAP_FAILED', error: 'Swap error' }, + ]); + expect(snapshot.value).toBe('shell_ready'); + expect(snapshot.context.error).toBe('Swap error'); + expect(snapshot.context.swapTargetProfileId).toBeUndefined(); + expect(snapshot.context.swapPhase).toBeUndefined(); + }); + + it('should transition to exited on SHELL_EXITED during swap', () => { + const snapshot = runEvents([ + ...toSwapping, + { type: 'SHELL_EXITED', exitCode: 1 }, + ]); + expect(snapshot.value).toBe('exited'); + expect(snapshot.context.swapTargetProfileId).toBeUndefined(); + expect(snapshot.context.swapPhase).toBeUndefined(); + expect(snapshot.context.claudeSessionId).toBeUndefined(); + }); + }); + + describe('deferred resume: pending_resume → claude_active', () => { + it('should transition to claude_active on RESUME_COMPLETE', () => { + const snapshot = runEvents( + [{ type: 'RESUME_COMPLETE', claudeSessionId: 'resumed-session' }], + 'pending_resume', + { claudeSessionId: 'old-session', profileId: 'profile-1' } + ); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('resumed-session'); + }); + + it('should transition to shell_ready on RESUME_FAILED', () => { + const snapshot = runEvents( + [{ type: 'RESUME_FAILED', error: 'Resume failed' }], + 'pending_resume', + { claudeSessionId: 'old-session', profileId: 'profile-1' } + ); + expect(snapshot.value).toBe('shell_ready'); + expect(snapshot.context.error).toBe('Resume failed'); + expect(snapshot.context.claudeSessionId).toBeUndefined(); + }); + + it('should transition to exited on SHELL_EXITED from pending_resume', () => { + const snapshot = runEvents( + [{ type: 'SHELL_EXITED' }], + 'pending_resume' + ); + expect(snapshot.value).toBe('exited'); + }); + + it('should reset from pending_resume', () => { + const snapshot = runEvents( + [{ type: 'RESET' }], + 'pending_resume', + { claudeSessionId: 'session-1', profileId: 'profile-1' } + ); + expect(snapshot.value).toBe('idle'); + expect(snapshot.context.claudeSessionId).toBeUndefined(); + expect(snapshot.context.profileId).toBeUndefined(); + }); + }); + + describe('invalid transitions rejected', () => { + it('should not allow SWAP_INITIATED from idle', () => { + const snapshot = runEvents([ + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + ]); + expect(snapshot.value).toBe('idle'); + }); + + it('should not allow SWAP_INITIATED from shell_ready', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + ]); + expect(snapshot.value).toBe('shell_ready'); + }); + + it('should not allow CLAUDE_START from idle', () => { + const snapshot = runEvents([ + { type: 'CLAUDE_START', profileId: 'profile-1' }, + ]); + expect(snapshot.value).toBe('idle'); + }); + + it('should not allow CLAUDE_ACTIVE from idle', () => { + const snapshot = runEvents([ + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + ]); + expect(snapshot.value).toBe('idle'); + }); + + it('should not allow SWAP_INITIATED from claude_starting', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }, + ]); + expect(snapshot.value).toBe('claude_starting'); + }); + + it('should not allow RESUME_COMPLETE from claude_active', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'RESUME_COMPLETE', claudeSessionId: 'session-2' }, + ]); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('session-1'); + }); + }); + + describe('context mutations', () => { + it('should set profileId on CLAUDE_START', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'my-profile' }, + ]); + expect(snapshot.context.profileId).toBe('my-profile'); + }); + + it('should set claudeSessionId on CLAUDE_ACTIVE', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-abc' }, + ]); + expect(snapshot.context.claudeSessionId).toBe('session-abc'); + }); + + it('should set isBusy on CLAUDE_BUSY', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'CLAUDE_BUSY', isBusy: true }, + ]); + expect(snapshot.context.isBusy).toBe(true); + }); + + it('should unset isBusy on CLAUDE_BUSY false', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'CLAUDE_BUSY', isBusy: true }, + { type: 'CLAUDE_BUSY', isBusy: false }, + ]); + expect(snapshot.context.isBusy).toBe(false); + }); + + it('should clear error on CLAUDE_START', () => { + const snapshot = runEvents( + [{ type: 'CLAUDE_START', profileId: 'profile-1' }], + 'shell_ready', + { error: 'previous error' } + ); + expect(snapshot.context.error).toBeUndefined(); + }); + + it('should set error on CLAUDE_EXITED with error', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_EXITED', error: 'crash' }, + ]); + expect(snapshot.value).toBe('shell_ready'); + expect(snapshot.context.error).toBe('crash'); + }); + + it('should clear session on CLAUDE_EXITED', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'CLAUDE_EXITED' }, + ]); + expect(snapshot.context.claudeSessionId).toBeUndefined(); + expect(snapshot.context.isBusy).toBe(false); + }); + + it('should clear error on SHELL_READY from exited', () => { + const snapshot = runEvents( + [{ type: 'SHELL_READY' }], + 'exited', + { error: 'old error' } + ); + expect(snapshot.value).toBe('shell_ready'); + expect(snapshot.context.error).toBeUndefined(); + }); + }); + + describe('guard conditions', () => { + it('should not allow SWAP_INITIATED without active session', () => { + const snapshot = runEvents( + [{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }], + 'claude_active', + { claudeSessionId: undefined } + ); + expect(snapshot.value).toBe('claude_active'); + }); + + it('should allow SWAP_INITIATED with active session', () => { + const snapshot = runEvents( + [{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }], + 'claude_active', + { claudeSessionId: 'session-1' } + ); + expect(snapshot.value).toBe('swapping'); + }); + }); + + describe('RESET from all states', () => { + const states = [ + 'idle', + 'shell_ready', + 'claude_starting', + 'claude_active', + 'swapping', + 'pending_resume', + 'exited', + ]; + + for (const state of states) { + it(`should reset to idle from ${state}`, () => { + const snapshot = runEvents( + [{ type: 'RESET' }], + state, + { claudeSessionId: 'session-1', profileId: 'profile-1', isBusy: true, error: 'err' } + ); + expect(snapshot.value).toBe('idle'); + expect(snapshot.context.claudeSessionId).toBeUndefined(); + expect(snapshot.context.profileId).toBeUndefined(); + expect(snapshot.context.isBusy).toBe(false); + expect(snapshot.context.error).toBeUndefined(); + }); + } + }); + + describe('state restoration from snapshot', () => { + it('should restore claude_active state with context', () => { + const actor = createActor(terminalMachine, { + snapshot: terminalMachine.resolveState({ + value: 'claude_active', + context: { + claudeSessionId: 'restored-session', + profileId: 'restored-profile', + swapTargetProfileId: undefined, + swapPhase: undefined, + isBusy: true, + error: undefined, + }, + }), + }); + actor.start(); + const snapshot = actor.getSnapshot(); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('restored-session'); + expect(snapshot.context.profileId).toBe('restored-profile'); + expect(snapshot.context.isBusy).toBe(true); + actor.stop(); + }); + + it('should restore swapping state and complete swap', () => { + const snapshot = runEvents( + [ + { + type: 'SWAP_RESUME_COMPLETE', + claudeSessionId: 'new-session', + profileId: 'profile-2', + }, + ], + 'swapping', + { + claudeSessionId: 'old-session', + profileId: 'profile-1', + swapTargetProfileId: 'profile-2', + swapPhase: 'resuming', + } + ); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.profileId).toBe('profile-2'); + expect(snapshot.context.claudeSessionId).toBe('new-session'); + }); + + it('should restore pending_resume and complete resume', () => { + const snapshot = runEvents( + [{ type: 'RESUME_COMPLETE', claudeSessionId: 'resumed-session' }], + 'pending_resume', + { claudeSessionId: 'stale-session', profileId: 'profile-1' } + ); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('resumed-session'); + }); + + it('should restore exited state and restart', () => { + const snapshot = runEvents( + [{ type: 'SHELL_READY' }], + 'exited' + ); + expect(snapshot.value).toBe('shell_ready'); + }); + }); +}); From 572d62da8b4849a74a3f852c30eca83ab71d362b Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 10:30:16 +0100 Subject: [PATCH 11/20] Fix PR review findings: XState machine wiring, dead code, YOLO mode preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make pending_resume state reachable by adding RESUME_REQUESTED transitions from shell_ready and claude_active states - Add CLAUDE_ACTIVE transition from shell_ready for direct activation path - Wire SHELL_READY dispatch in setTerminalStatus and setClaudeMode to prevent XState machine from being stuck in idle - Remove dead TERMINAL_SWAP_PROGRESS IPC send (no listener existed) - Remove unused isSwapping guard from terminal machine - Remove ineffective try/catch around fire-and-forget ipcRenderer.send() - Preserve dangerouslySkipPermissions (YOLO mode) through profile swap terminal recreation flow via resume options - Align swap phase naming: capturing_session → capturing (matches XState) - Fix French translation accents: démarrer, changé, à Co-Authored-By: Claude Opus 4.6 --- .../main/ipc-handlers/terminal-handlers.ts | 6 ++-- .../src/main/terminal/terminal-manager.ts | 22 ++++++-------- apps/frontend/src/main/terminal/types.ts | 3 +- apps/frontend/src/preload/api/terminal-api.ts | 2 +- .../hooks/useTerminalProfileChange.ts | 29 ++++++++----------- .../src/renderer/stores/terminal-store.ts | 10 +++++++ .../src/shared/i18n/locales/fr/terminal.json | 4 +-- .../__tests__/terminal-machine.test.ts | 29 +++++++++++++++++++ .../shared/state-machines/terminal-machine.ts | 5 +++- apps/frontend/src/shared/types/agent.ts | 2 ++ apps/frontend/src/shared/types/ipc.ts | 2 +- 11 files changed, 76 insertions(+), 38 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 561cf76d..f82355d1 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -253,6 +253,7 @@ export function registerTerminalHandlers( sessionId?: string; sessionMigrated?: boolean; isClaudeMode?: boolean; + dangerouslySkipPermissions?: boolean; }> = []; // Process each terminal @@ -290,7 +291,8 @@ export function registerTerminalHandlers( id: terminal.id, sessionId: terminal.claudeSessionId, sessionMigrated, - isClaudeMode: terminal.isClaudeMode + isClaudeMode: terminal.isClaudeMode, + dangerouslySkipPermissions: terminal.dangerouslySkipPermissions }); } @@ -615,7 +617,7 @@ export function registerTerminalHandlers( ipcMain.on( IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, - (_, id: string, sessionId?: string, options?: { migratedSession?: boolean }) => { + (_, id: string, sessionId?: string, options?: { migratedSession?: boolean; dangerouslySkipPermissions?: boolean }) => { // Use async version to avoid blocking main process during CLI detection terminalManager.resumeClaudeAsync(id, sessionId, options).catch((error) => { console.warn('[terminal-handlers] Failed to resume Claude:', error); diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index e8cf4ccf..e68daf87 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -225,12 +225,17 @@ export class TerminalManager { /** * Resume Claude in a terminal asynchronously (non-blocking) */ - async resumeClaudeAsync(id: string, sessionId?: string, options?: { migratedSession?: boolean }): Promise { + async resumeClaudeAsync(id: string, sessionId?: string, options?: { migratedSession?: boolean; dangerouslySkipPermissions?: boolean }): Promise { const terminal = this.terminals.get(id); if (!terminal) { return; } + // Preserve YOLO mode flag if passed from profile swap flow + if (options?.dangerouslySkipPermissions !== undefined) { + terminal.dangerouslySkipPermissions = options.dangerouslySkipPermissions; + } + await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow, options); } @@ -295,7 +300,7 @@ export class TerminalManager { // Mark swap state: capturing session terminal.swapState = { isSwapping: true, - phase: 'capturing_session', + phase: 'capturing', targetProfileId, sourceProfileId, sessionMigrated: false @@ -324,16 +329,6 @@ export class TerminalManager { terminal.swapState.phase = 'recreating'; debugLog('[terminal-manager] Session migrated successfully for terminal:', terminalId); - // Notify renderer about swap progress - const win = this.getWindow(); - if (win && !win.isDestroyed()) { - win.webContents.send('TERMINAL_SWAP_PROGRESS', { - terminalId, - phase: 'migrating', - sessionMigrated: true - }); - } - return { sessionMigrated: true, sessionId, isClaudeMode }; } @@ -470,7 +465,8 @@ export class TerminalManager { projectPath: terminal.projectPath, claudeSessionId: terminal.claudeSessionId, claudeProfileId: terminal.claudeProfileId, - isClaudeMode: terminal.isClaudeMode + isClaudeMode: terminal.isClaudeMode, + dangerouslySkipPermissions: terminal.dangerouslySkipPermissions }); } diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index 79fddd85..ce28500f 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -36,7 +36,7 @@ export interface TerminalProcess { * Phase of a terminal account swap operation */ export type TerminalSwapPhase = - | 'capturing_session' + | 'capturing' | 'migrating' | 'recreating' | 'resuming'; @@ -130,4 +130,5 @@ export interface TerminalProfileChangeInfo { claudeSessionId?: string; claudeProfileId?: string; isClaudeMode: boolean; + dangerouslySkipPermissions?: boolean; } diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index fe09cb0f..e260a092 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -166,7 +166,7 @@ export const createTerminalAPI = (): TerminalAPI => ({ clearTerminalSessions: (projectPath: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, projectPath), - resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }): void => + resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean; dangerouslySkipPermissions?: boolean }): void => ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId, options), activateDeferredClaudeResume: (id: string): void => diff --git a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts index ffc27a9e..bff9fe80 100644 --- a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts +++ b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts @@ -17,7 +17,8 @@ export function useTerminalProfileChange(): void { const recreateTerminal = useCallback(async ( terminalId: string, sessionId?: string, - sessionMigrated?: boolean + sessionMigrated?: boolean, + dangerouslySkipPermissions?: boolean ) => { // Prevent duplicate recreation if (recreatingTerminals.current.has(terminalId)) { @@ -107,21 +108,14 @@ export function useTerminalProfileChange(): void { // Store the session ID for tracking store.setClaudeSessionId(newTerminal.id, sessionId); - try { - // Auto-resume the Claude session with --continue - // YOLO mode (dangerouslySkipPermissions) is preserved on the main process - // terminal object, so resumeClaudeAsync will pick it up automatically - window.electronAPI.resumeClaudeInTerminal( - newTerminal.id, - sessionId, - { migratedSession: true } - ); - debugLog('[useTerminalProfileChange] Resume initiated for terminal:', newTerminal.id); - } catch (error) { - debugError('[useTerminalProfileChange] Failed to auto-resume Claude session:', error); - // Leave terminal at shell prompt - user can manually resume if needed - store.setPendingClaudeResume(newTerminal.id, true); - } + // Auto-resume the Claude session with --continue + // Pass dangerouslySkipPermissions so the new terminal preserves YOLO mode + window.electronAPI.resumeClaudeInTerminal( + newTerminal.id, + sessionId, + { migratedSession: true, dangerouslySkipPermissions } + ); + debugLog('[useTerminalProfileChange] Resume initiated for terminal:', newTerminal.id); } } finally { @@ -142,7 +136,8 @@ export function useTerminalProfileChange(): void { await recreateTerminal( terminalInfo.id, terminalInfo.sessionId, - terminalInfo.sessionMigrated + terminalInfo.sessionMigrated, + terminalInfo.dangerouslySkipPermissions ); } diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 1fca6890..0819a7e8 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -393,6 +393,11 @@ export const useTerminalStore = create((set, get) => ({ }, setTerminalStatus: (id: string, status: TerminalStatus) => { + // Notify XState machine when terminal becomes running (shell is ready) + if (status === 'running') { + sendTerminalMachineEvent(id, { type: 'SHELL_READY' }); + } + set((state) => ({ terminals: state.terminals.map((t) => t.id === id ? { ...t, status } : t @@ -403,6 +408,11 @@ export const useTerminalStore = create((set, get) => ({ setClaudeMode: (id: string, isClaudeMode: boolean) => { // Send corresponding event to XState machine if (isClaudeMode) { + // Ensure machine has transitioned past idle before sending CLAUDE_ACTIVE + const actor = getOrCreateTerminalActor(id); + if (String(actor.getSnapshot().value) === 'idle') { + sendTerminalMachineEvent(id, { type: 'SHELL_READY' }); + } sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE' }); } else { sendTerminalMachineEvent(id, { type: 'CLAUDE_EXITED' }); diff --git a/apps/frontend/src/shared/i18n/locales/fr/terminal.json b/apps/frontend/src/shared/i18n/locales/fr/terminal.json index 023397ec..00a957cf 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/fr/terminal.json @@ -16,8 +16,8 @@ "inProgress": "Changement de profil...", "resumingSession": "Reprise de la session Claude...", "sessionResumed": "Session reprise sous le nouveau profil", - "resumeFailed": "Impossible de reprendre la session. Vous pouvez demarrer une nouvelle session.", - "noSession": "Profil change. Aucune session active a reprendre." + "resumeFailed": "Impossible de reprendre la session. Vous pouvez démarrer une nouvelle session.", + "noSession": "Profil changé. Aucune session active à reprendre." }, "worktree": { "create": "Worktree", diff --git a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts index 5518010b..d1a0987b 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts @@ -76,6 +76,15 @@ describe('terminalMachine', () => { expect(snapshot.context.profileId).toBe('profile-1'); }); + it('should transition from shell_ready directly to claude_active on CLAUDE_ACTIVE', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-direct' }, + ]); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('session-direct'); + }); + it('should transition from claude_starting to claude_active', () => { const snapshot = runEvents([ { type: 'SHELL_READY' }, @@ -224,6 +233,26 @@ describe('terminalMachine', () => { }); describe('deferred resume: pending_resume → claude_active', () => { + it('should transition from shell_ready to pending_resume on RESUME_REQUESTED', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'RESUME_REQUESTED', claudeSessionId: 'session-1' }, + ]); + expect(snapshot.value).toBe('pending_resume'); + expect(snapshot.context.claudeSessionId).toBe('session-1'); + }); + + it('should transition from claude_active to pending_resume on RESUME_REQUESTED', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'RESUME_REQUESTED', claudeSessionId: 'session-2' }, + ]); + expect(snapshot.value).toBe('pending_resume'); + expect(snapshot.context.claudeSessionId).toBe('session-2'); + }); + it('should transition to claude_active on RESUME_COMPLETE', () => { const snapshot = runEvents( [{ type: 'RESUME_COMPLETE', claudeSessionId: 'resumed-session' }], diff --git a/apps/frontend/src/shared/state-machines/terminal-machine.ts b/apps/frontend/src/shared/state-machines/terminal-machine.ts index 7484ad94..5a172723 100644 --- a/apps/frontend/src/shared/state-machines/terminal-machine.ts +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -62,6 +62,8 @@ export const terminalMachine = createMachine( shell_ready: { on: { CLAUDE_START: { target: 'claude_starting', actions: 'setProfileId' }, + CLAUDE_ACTIVE: { target: 'claude_active', actions: 'setClaudeSessionId' }, + RESUME_REQUESTED: { target: 'pending_resume', actions: 'setClaudeSessionId' }, SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, RESET: { target: 'idle', actions: 'resetContext' }, }, @@ -83,6 +85,7 @@ export const terminalMachine = createMachine( guard: 'hasActiveSession', actions: 'setSwapTarget', }, + RESUME_REQUESTED: { target: 'pending_resume', actions: 'setClaudeSessionId' }, SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, RESET: { target: 'idle', actions: 'resetContext' }, }, @@ -129,7 +132,6 @@ export const terminalMachine = createMachine( { guards: { hasActiveSession: ({ context }) => context.claudeSessionId !== undefined, - isSwapping: ({ context }) => context.swapTargetProfileId !== undefined, }, actions: { setProfileId: assign({ @@ -141,6 +143,7 @@ export const terminalMachine = createMachine( claudeSessionId: ({ event }) => { if (event.type === 'CLAUDE_ACTIVE') return event.claudeSessionId; if (event.type === 'RESUME_COMPLETE') return event.claudeSessionId; + if (event.type === 'RESUME_REQUESTED') return event.claudeSessionId; return undefined; }, isBusy: () => false, diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index b3b2f8a1..e4448450 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -257,6 +257,8 @@ export interface TerminalProfileChangedEvent { sessionMigrated?: boolean; /** Whether the terminal was in Claude mode (had an active Claude session) */ isClaudeMode?: boolean; + /** Whether Claude was invoked with --dangerously-skip-permissions (YOLO mode) */ + dangerouslySkipPermissions?: boolean; }>; } diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 82168ffa..d02e2a05 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -252,7 +252,7 @@ export interface ElectronAPI { getTerminalSessions: (projectPath: string) => Promise>; restoreTerminalSession: (session: TerminalSession, cols?: number, rows?: number) => Promise>; clearTerminalSessions: (projectPath: string) => Promise; - resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }) => void; + resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean; dangerouslySkipPermissions?: boolean }) => void; activateDeferredClaudeResume: (id: string) => void; getTerminalSessionDates: (projectPath?: string) => Promise>; getTerminalSessionsForDate: (date: string, projectPath: string) => Promise>; From 9bcd1a30337b94a972c864d8016a4b747728c90f Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 11:30:12 +0100 Subject: [PATCH 12/20] Fix follow-up review: security, dead code, XState wiring completeness Security: - Move YOLO mode (dangerouslySkipPermissions) to server-side storage instead of accepting from renderer via IPC. Main process stores flag during profile migration and restores it when resume is called. XState machine fixes: - Add CLAUDE_ACTIVE self-transition in claude_active state so setClaudeSessionId can update context.claudeSessionId (was silently dropped, blocking hasActiveSession guard) - Add SHELL_EXITED dispatch in setTerminalStatus for 'exited' status so XState machine reaches its exited state Dead code removal: - Remove unused swapProfileAndResume method (migration handled directly in IPC handler) and its now-unused migrateSession import - Remove unused deriveTerminalStateFromMachine function and SnapshotFrom import Resource cleanup: - clearAllTerminals now disposes terminalBufferManager entries and xtermCallbacks alongside XState actors Co-Authored-By: Claude Opus 4.6 --- .../main/ipc-handlers/terminal-handlers.ts | 8 +- .../src/main/terminal/terminal-manager.ts | 88 +++++-------------- apps/frontend/src/preload/api/terminal-api.ts | 2 +- .../hooks/useTerminalProfileChange.ts | 12 +-- .../src/renderer/stores/terminal-store.ts | 29 +++--- .../__tests__/terminal-machine.test.ts | 12 +++ .../shared/state-machines/terminal-machine.ts | 1 + apps/frontend/src/shared/types/ipc.ts | 2 +- 8 files changed, 59 insertions(+), 95 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index f82355d1..fe51f120 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -286,6 +286,12 @@ export function registerTerminalHandlers( debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Session migration result:', migrationResult); } + // Store YOLO mode flag server-side for migrated sessions + // (consumed by resumeClaudeAsync when the new terminal resumes) + if (sessionMigrated && terminal.claudeSessionId && terminal.dangerouslySkipPermissions) { + terminalManager.storeMigratedSessionFlag(terminal.claudeSessionId, terminal.dangerouslySkipPermissions); + } + // All terminals need refresh (PTY env vars can't be updated) terminalsNeedingRefresh.push({ id: terminal.id, @@ -617,7 +623,7 @@ export function registerTerminalHandlers( ipcMain.on( IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, - (_, id: string, sessionId?: string, options?: { migratedSession?: boolean; dangerouslySkipPermissions?: boolean }) => { + (_, id: string, sessionId?: string, options?: { migratedSession?: boolean }) => { // Use async version to avoid blocking main process during CLI detection terminalManager.resumeClaudeAsync(id, sessionId, options).catch((error) => { console.warn('[terminal-handlers] Failed to resume Claude:', error); diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index e68daf87..4cbc6816 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -19,7 +19,6 @@ import * as SessionHandler from './session-handler'; import * as TerminalLifecycle from './terminal-lifecycle'; import * as TerminalEventHandler from './terminal-event-handler'; import * as ClaudeIntegration from './claude-integration-handler'; -import { migrateSession } from '../claude-profile/session-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; export class TerminalManager { @@ -28,6 +27,8 @@ export class TerminalManager { private saveTimer: NodeJS.Timeout | null = null; private lastNotifiedRateLimitReset: Map = new Map(); private eventCallbacks: TerminalEventHandler.EventHandlerCallbacks; + /** Server-side storage for YOLO mode flags during profile migration (sessionId → flag) */ + private migratedSessionFlags: Map = new Map(); constructor(getWindow: WindowGetter) { this.getWindow = getWindow; @@ -225,20 +226,34 @@ export class TerminalManager { /** * Resume Claude in a terminal asynchronously (non-blocking) */ - async resumeClaudeAsync(id: string, sessionId?: string, options?: { migratedSession?: boolean; dangerouslySkipPermissions?: boolean }): Promise { + async resumeClaudeAsync(id: string, sessionId?: string, options?: { migratedSession?: boolean }): Promise { const terminal = this.terminals.get(id); if (!terminal) { return; } - // Preserve YOLO mode flag if passed from profile swap flow - if (options?.dangerouslySkipPermissions !== undefined) { - terminal.dangerouslySkipPermissions = options.dangerouslySkipPermissions; + // For migrated sessions, restore YOLO mode from server-side storage + // (set during profile change in storeMigratedSessionFlag) + if (options?.migratedSession && sessionId) { + const storedFlag = this.migratedSessionFlags.get(sessionId); + if (storedFlag !== undefined) { + terminal.dangerouslySkipPermissions = storedFlag; + this.migratedSessionFlags.delete(sessionId); + } } await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow, options); } + /** + * Store YOLO mode flag for a session being migrated during profile swap. + * Called from the profile change handler before the renderer recreates terminals. + * The flag is consumed by resumeClaudeAsync when the new terminal resumes. + */ + storeMigratedSessionFlag(sessionId: string, dangerouslySkipPermissions: boolean): void { + this.migratedSessionFlags.set(sessionId, dangerouslySkipPermissions); + } + /** * Activate deferred Claude resume for a terminal * Called when a terminal with pendingClaudeResume becomes active (user views it) @@ -269,69 +284,6 @@ export class TerminalManager { await ClaudeIntegration.resumeClaudeAsync(terminal, undefined, this.getWindow, isPostSwap ? { migratedSession: true } : undefined); } - /** - * Orchestrate a profile swap for a single terminal. - * Captures the current session, migrates it to the target profile, and marks swap state. - * Called from the CLAUDE_PROFILE_SET_ACTIVE IPC handler for each terminal. - * - * @returns Info about the swap result for the event payload - */ - async swapProfileAndResume( - terminalId: string, - sourceConfigDir: string, - targetConfigDir: string, - targetProfileId: string - ): Promise<{ sessionMigrated: boolean; sessionId?: string; isClaudeMode: boolean }> { - const terminal = this.terminals.get(terminalId); - if (!terminal) { - return { sessionMigrated: false, isClaudeMode: false }; - } - - const isClaudeMode = terminal.isClaudeMode; - const sessionId = terminal.claudeSessionId; - const sourceProfileId = terminal.claudeProfileId ?? 'unknown'; - - // If no active Claude session or no session ID, skip migration - if (!isClaudeMode || !sessionId) { - debugLog('[terminal-manager] No active Claude session for terminal:', terminalId, '- skipping migration'); - return { sessionMigrated: false, isClaudeMode }; - } - - // Mark swap state: capturing session - terminal.swapState = { - isSwapping: true, - phase: 'capturing', - targetProfileId, - sourceProfileId, - sessionMigrated: false - }; - - // Migrate the session from source to target profile - terminal.swapState.phase = 'migrating'; - debugLog('[terminal-manager] Migrating session for terminal:', terminalId, 'session:', sessionId); - - const migrationResult = migrateSession( - sourceConfigDir, - targetConfigDir, - terminal.cwd, - sessionId - ); - - if (!migrationResult.success) { - debugError('[terminal-manager] Session migration failed for terminal:', terminalId, 'error:', migrationResult.error); - terminal.swapState.error = migrationResult.error; - terminal.swapState.sessionMigrated = false; - return { sessionMigrated: false, sessionId, isClaudeMode }; - } - - // Migration succeeded - terminal.swapState.sessionMigrated = true; - terminal.swapState.phase = 'recreating'; - debugLog('[terminal-manager] Session migrated successfully for terminal:', terminalId); - - return { sessionMigrated: true, sessionId, isClaudeMode }; - } - /** * Resume Claude in a terminal with a specific session ID * @deprecated Use resumeClaudeAsync for non-blocking behavior diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index e260a092..fe09cb0f 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -166,7 +166,7 @@ export const createTerminalAPI = (): TerminalAPI => ({ clearTerminalSessions: (projectPath: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, projectPath), - resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean; dangerouslySkipPermissions?: boolean }): void => + resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }): void => ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId, options), activateDeferredClaudeResume: (id: string): void => diff --git a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts index bff9fe80..697ba7b6 100644 --- a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts +++ b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts @@ -17,8 +17,7 @@ export function useTerminalProfileChange(): void { const recreateTerminal = useCallback(async ( terminalId: string, sessionId?: string, - sessionMigrated?: boolean, - dangerouslySkipPermissions?: boolean + sessionMigrated?: boolean ) => { // Prevent duplicate recreation if (recreatingTerminals.current.has(terminalId)) { @@ -109,11 +108,13 @@ export function useTerminalProfileChange(): void { store.setClaudeSessionId(newTerminal.id, sessionId); // Auto-resume the Claude session with --continue - // Pass dangerouslySkipPermissions so the new terminal preserves YOLO mode + // YOLO mode (dangerouslySkipPermissions) is preserved server-side by the + // main process during migration (storeMigratedSessionFlag), so resumeClaudeAsync + // will restore it automatically when migratedSession is true window.electronAPI.resumeClaudeInTerminal( newTerminal.id, sessionId, - { migratedSession: true, dangerouslySkipPermissions } + { migratedSession: true } ); debugLog('[useTerminalProfileChange] Resume initiated for terminal:', newTerminal.id); } @@ -136,8 +137,7 @@ export function useTerminalProfileChange(): void { await recreateTerminal( terminalInfo.id, terminalInfo.sessionId, - terminalInfo.sessionMigrated, - terminalInfo.dangerouslySkipPermissions + terminalInfo.sessionMigrated ); } diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 0819a7e8..81fad19e 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { createActor } from 'xstate'; -import type { ActorRefFrom, SnapshotFrom } from 'xstate'; +import type { ActorRefFrom } from 'xstate'; import { v4 as uuid } from 'uuid'; import { arrayMove } from '@dnd-kit/sortable'; import type { TerminalSession, TerminalWorktreeConfig } from '../../shared/types'; @@ -45,22 +45,6 @@ export function sendTerminalMachineEvent(terminalId: string, event: TerminalEven debugLog(`[TerminalStore] Machine ${terminalId}: ${event.type} (${stateBefore} -> ${stateAfter})`); } -/** - * Derive terminal boolean fields from an XState machine snapshot. - * Used to keep legacy boolean fields in sync with the machine state. - */ -export function deriveTerminalStateFromMachine(snapshot: SnapshotFrom): { - isClaudeMode: boolean; - isClaudeBusy: boolean; - pendingClaudeResume: boolean; -} { - const state = String(snapshot.value); - const isClaudeMode = state === 'claude_starting' || state === 'claude_active' || state === 'swapping'; - const isClaudeBusy = snapshot.context.isBusy; - const pendingClaudeResume = state === 'pending_resume'; - return { isClaudeMode, isClaudeBusy, pendingClaudeResume }; -} - /** * Module-level Map to store terminal ID -> xterm write callback mappings. * @@ -393,9 +377,11 @@ export const useTerminalStore = create((set, get) => ({ }, setTerminalStatus: (id: string, status: TerminalStatus) => { - // Notify XState machine when terminal becomes running (shell is ready) + // Notify XState machine of lifecycle transitions if (status === 'running') { sendTerminalMachineEvent(id, { type: 'SHELL_READY' }); + } else if (status === 'exited') { + sendTerminalMachineEvent(id, { type: 'SHELL_EXITED' }); } set((state) => ({ @@ -503,6 +489,13 @@ export const useTerminalStore = create((set, get) => ({ }, clearAllTerminals: () => { + // Clean up all resources for every terminal + const terminals = get().terminals; + for (const terminal of terminals) { + terminalBufferManager.dispose(terminal.id); + xtermCallbacks.delete(terminal.id); + } + // Clean up all XState actors for (const [_id, actor] of terminalActors) { actor.stop(); diff --git a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts index d1a0987b..94f1aa65 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts @@ -345,6 +345,18 @@ describe('terminalMachine', () => { }); }); + describe('CLAUDE_ACTIVE self-transition in claude_active', () => { + it('should update claudeSessionId via self-transition', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_ACTIVE' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'late-session' }, + ]); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('late-session'); + }); + }); + describe('context mutations', () => { it('should set profileId on CLAUDE_START', () => { const snapshot = runEvents([ diff --git a/apps/frontend/src/shared/state-machines/terminal-machine.ts b/apps/frontend/src/shared/state-machines/terminal-machine.ts index 5a172723..8829a923 100644 --- a/apps/frontend/src/shared/state-machines/terminal-machine.ts +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -78,6 +78,7 @@ export const terminalMachine = createMachine( }, claude_active: { on: { + CLAUDE_ACTIVE: { actions: 'setClaudeSessionId' }, CLAUDE_BUSY: { actions: 'setBusy' }, CLAUDE_EXITED: { target: 'shell_ready', actions: 'clearSession' }, SWAP_INITIATED: { diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index d02e2a05..82168ffa 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -252,7 +252,7 @@ export interface ElectronAPI { getTerminalSessions: (projectPath: string) => Promise>; restoreTerminalSession: (session: TerminalSession, cols?: number, rows?: number) => Promise>; clearTerminalSessions: (projectPath: string) => Promise; - resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean; dangerouslySkipPermissions?: boolean }) => void; + resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }) => void; activateDeferredClaudeResume: (id: string) => void; getTerminalSessionDates: (projectPath?: string) => Promise>; getTerminalSessionsForDate: (date: string, projectPath: string) => Promise>; From d1c63d23aaf73e11ea9f53c0f248614509c8c3b7 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 15:25:16 +0100 Subject: [PATCH 13/20] Fix follow-up review: security, dead code, XState wiring completeness - Convert migrateSession() from sync to async fs operations (mkdir, copyFile, cp, unlink from fs/promises) to avoid blocking the Electron main process during session migration - Remove dead TerminalSwapState/TerminalSwapPhase types and swapState field from TerminalProcess (no longer referenced after prior cleanup) - Remove dead swapState logic from activateDeferredResume - Add migratedSessionFlags.clear() to killAll() to prevent memory leaks - Add CLAUDE_ACTIVE handler to pending_resume XState state (fixes race where Claude becomes active before RESUME_COMPLETE fires) - Add test coverage for CLAUDE_ACTIVE in pending_resume state Co-Authored-By: Claude Opus 4.6 --- .../src/main/claude-profile/session-utils.ts | 15 ++++----- .../main/ipc-handlers/terminal-handlers.ts | 2 +- .../src/main/terminal/terminal-manager.ts | 12 ++----- apps/frontend/src/main/terminal/types.ts | 31 ------------------- .../__tests__/terminal-machine.test.ts | 10 ++++++ .../shared/state-machines/terminal-machine.ts | 1 + 6 files changed, 22 insertions(+), 49 deletions(-) diff --git a/apps/frontend/src/main/claude-profile/session-utils.ts b/apps/frontend/src/main/claude-profile/session-utils.ts index fae29e2d..0dde5903 100644 --- a/apps/frontend/src/main/claude-profile/session-utils.ts +++ b/apps/frontend/src/main/claude-profile/session-utils.ts @@ -6,7 +6,8 @@ * and can be copied between profiles to enable session continuity after profile switches. */ -import { existsSync, mkdirSync, copyFileSync, cpSync, unlinkSync } from 'fs'; +import { existsSync } from 'fs'; +import { mkdir, copyFile, cp, unlink } from 'fs/promises'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { isNodeError } from '../utils/type-guards'; @@ -95,12 +96,12 @@ export interface SessionMigrationResult { * @param sessionId - The session UUID to migrate * @returns Migration result with success status and details */ -export function migrateSession( +export async function migrateSession( sourceConfigDir: string, targetConfigDir: string, cwd: string, sessionId: string -): SessionMigrationResult { +): Promise { const result: SessionMigrationResult = { success: false, sessionId, @@ -118,13 +119,13 @@ export function migrateSession( try { // Ensure target directory exists (do this first, before any file operations) const targetParentDir = dirname(targetFile); - mkdirSync(targetParentDir, { recursive: true }); + await mkdir(targetParentDir, { recursive: true }); console.warn('[SessionUtils] Ensured target directory exists:', targetParentDir); // Attempt to copy the session .jsonl file // This will throw if source doesn't exist or target cannot be written try { - copyFileSync(sourceFile, targetFile); + await copyFile(sourceFile, targetFile); result.filesCopied++; console.warn('[SessionUtils] Copied session file:', sourceFile, '->', targetFile); } catch (copyError) { @@ -153,7 +154,7 @@ export function migrateSession( // Attempt to copy the session directory (tool-results) if it exists // Use try-catch instead of existsSync to avoid TOCTOU race try { - cpSync(sourceDir, targetDir, { recursive: true }); + await cp(sourceDir, targetDir, { recursive: true }); result.filesCopied++; console.warn('[SessionUtils] Copied session directory:', sourceDir, '->', targetDir); } catch (dirCopyError) { @@ -182,7 +183,7 @@ export function migrateSession( // Clean up partially migrated session file to enable retry // Use try-catch instead of existsSync to avoid TOCTOU race try { - unlinkSync(targetFile); + await unlink(targetFile); console.warn('[SessionUtils] Cleaned up partial migration file:', targetFile); } catch (cleanupError) { // If file doesn't exist during cleanup, that's fine diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index fe51f120..5aca8225 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -275,7 +275,7 @@ export function registerTerminalHandlers( to: targetConfigDir }); - const migrationResult = migrateSession( + const migrationResult = await migrateSession( sourceConfigDir, targetConfigDir, terminal.cwd, diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index 4cbc6816..7f12a7f2 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -12,7 +12,6 @@ import type { WindowGetter, TerminalOperationResult, TerminalProfileChangeInfo, - TerminalSwapState } from './types'; import * as PtyManager from './pty-manager'; import * as SessionHandler from './session-handler'; @@ -117,6 +116,7 @@ export class TerminalManager { * Kill all terminal processes */ async killAll(): Promise { + this.migratedSessionFlags.clear(); this.saveTimer = await TerminalLifecycle.destroyAllTerminals( this.terminals, this.saveTimer @@ -269,19 +269,11 @@ export class TerminalManager { return; } - // Determine if this is a post-swap deferred resume - const isPostSwap = terminal.swapState?.isSwapping === true && terminal.swapState?.sessionMigrated === true; - // Clear the pending flag terminal.pendingClaudeResume = false; - // Clear swap state if this was a post-swap resume - if (isPostSwap) { - terminal.swapState = undefined; - } - // Now actually resume Claude - await ClaudeIntegration.resumeClaudeAsync(terminal, undefined, this.getWindow, isPostSwap ? { migratedSession: true } : undefined); + await ClaudeIntegration.resumeClaudeAsync(terminal, undefined, this.getWindow); } /** diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index ce28500f..0f2956e9 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -28,37 +28,6 @@ export interface TerminalProcess { shellType?: WindowsShellType; /** Whether this terminal is waiting for Claude onboarding to complete (login flow) */ awaitingOnboardingComplete?: boolean; - /** Current account swap state (tracks progress of profile switching) */ - swapState?: TerminalSwapState; -} - -/** - * Phase of a terminal account swap operation - */ -export type TerminalSwapPhase = - | 'capturing' - | 'migrating' - | 'recreating' - | 'resuming'; - -/** - * Tracks the progress of an account swap for a terminal. - * Enables the main process to track swap progress independently - * of the renderer's XState machine. - */ -export interface TerminalSwapState { - /** Whether a swap is currently in progress */ - isSwapping: boolean; - /** Current phase of the swap operation */ - phase: TerminalSwapPhase; - /** Profile ID being swapped to */ - targetProfileId: string; - /** Profile ID being swapped from */ - sourceProfileId: string; - /** Whether the Claude session was successfully migrated */ - sessionMigrated: boolean; - /** Error message if the swap failed */ - error?: string; } /** diff --git a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts index 94f1aa65..697fd413 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts @@ -274,6 +274,16 @@ describe('terminalMachine', () => { expect(snapshot.context.claudeSessionId).toBeUndefined(); }); + it('should transition to claude_active on CLAUDE_ACTIVE (race condition)', () => { + const snapshot = runEvents( + [{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'race-session' }], + 'pending_resume', + { claudeSessionId: 'old-session', profileId: 'profile-1' } + ); + expect(snapshot.value).toBe('claude_active'); + expect(snapshot.context.claudeSessionId).toBe('race-session'); + }); + it('should transition to exited on SHELL_EXITED from pending_resume', () => { const snapshot = runEvents( [{ type: 'SHELL_EXITED' }], diff --git a/apps/frontend/src/shared/state-machines/terminal-machine.ts b/apps/frontend/src/shared/state-machines/terminal-machine.ts index 8829a923..9f82be9d 100644 --- a/apps/frontend/src/shared/state-machines/terminal-machine.ts +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -116,6 +116,7 @@ export const terminalMachine = createMachine( }, pending_resume: { on: { + CLAUDE_ACTIVE: { target: 'claude_active', actions: 'setClaudeSessionId' }, RESUME_COMPLETE: { target: 'claude_active', actions: 'setClaudeSessionId' }, RESUME_FAILED: { target: 'shell_ready', actions: ['setError', 'clearSession'] }, SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, From 966e5cadfa176f3d3cea48d0d3d01641662db12d Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 17:40:43 +0100 Subject: [PATCH 14/20] fix: address review findings for PR #1819 - Add setError action to claude_active CLAUDE_EXITED transition to preserve error messages - Fix missing await on resumeClaudeInTerminal call with proper error handling fallback - Improve TypeScript type safety in test helper using Partial - Add test coverage for CLAUDE_EXITED error preservation from claude_active state All tests pass (52/52). Co-Authored-By: Claude Opus 4.6 --- .../hooks/useTerminalProfileChange.ts | 27 ++++++++++++------- .../__tests__/terminal-machine.test.ts | 16 +++++++++-- .../shared/state-machines/terminal-machine.ts | 2 +- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts index 697ba7b6..8ff5c77d 100644 --- a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts +++ b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts @@ -107,16 +107,23 @@ export function useTerminalProfileChange(): void { // Store the session ID for tracking store.setClaudeSessionId(newTerminal.id, sessionId); - // Auto-resume the Claude session with --continue - // YOLO mode (dangerouslySkipPermissions) is preserved server-side by the - // main process during migration (storeMigratedSessionFlag), so resumeClaudeAsync - // will restore it automatically when migratedSession is true - window.electronAPI.resumeClaudeInTerminal( - newTerminal.id, - sessionId, - { migratedSession: true } - ); - debugLog('[useTerminalProfileChange] Resume initiated for terminal:', newTerminal.id); + try { + // Auto-resume the Claude session with --continue + // YOLO mode (dangerouslySkipPermissions) is preserved server-side by the + // main process during migration (storeMigratedSessionFlag), so resumeClaudeAsync + // will restore it automatically when migratedSession is true + await window.electronAPI.resumeClaudeInTerminal( + newTerminal.id, + sessionId, + { migratedSession: true } + ); + debugLog('[useTerminalProfileChange] Resume completed for terminal:', newTerminal.id); + } catch (error) { + // If auto-resume fails, set pendingClaudeResume flag + // so the user can manually resume when they view the terminal + debugError('[useTerminalProfileChange] Resume failed, marking as pending:', error); + store.setPendingClaudeResume(newTerminal.id, true); + } } } finally { diff --git a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts index 697fd413..7a988823 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { createActor } from 'xstate'; -import { terminalMachine, type TerminalEvent } from '../terminal-machine'; +import { terminalMachine, type TerminalEvent, type TerminalContext } from '../terminal-machine'; /** * Helper to run a sequence of events and get the final snapshot. @@ -9,7 +9,7 @@ import { terminalMachine, type TerminalEvent } from '../terminal-machine'; function runEvents( events: TerminalEvent[], initialState?: string, - initialContext?: Record + initialContext?: Partial ) { const actor = initialState ? createActor(terminalMachine, { @@ -436,6 +436,18 @@ describe('terminalMachine', () => { expect(snapshot.context.isBusy).toBe(false); }); + it('should set error on CLAUDE_EXITED with error from claude_active', () => { + const snapshot = runEvents([ + { type: 'SHELL_READY' }, + { type: 'CLAUDE_START', profileId: 'profile-1' }, + { type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' }, + { type: 'CLAUDE_EXITED', error: 'crash while active' }, + ]); + expect(snapshot.value).toBe('shell_ready'); + expect(snapshot.context.error).toBe('crash while active'); + expect(snapshot.context.claudeSessionId).toBeUndefined(); + }); + it('should clear error on SHELL_READY from exited', () => { const snapshot = runEvents( [{ type: 'SHELL_READY' }], diff --git a/apps/frontend/src/shared/state-machines/terminal-machine.ts b/apps/frontend/src/shared/state-machines/terminal-machine.ts index 9f82be9d..dfb5e6ac 100644 --- a/apps/frontend/src/shared/state-machines/terminal-machine.ts +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -80,7 +80,7 @@ export const terminalMachine = createMachine( on: { CLAUDE_ACTIVE: { actions: 'setClaudeSessionId' }, CLAUDE_BUSY: { actions: 'setBusy' }, - CLAUDE_EXITED: { target: 'shell_ready', actions: 'clearSession' }, + CLAUDE_EXITED: { target: 'shell_ready', actions: ['setError', 'clearSession'] }, SWAP_INITIATED: { target: 'swapping', guard: 'hasActiveSession', From ea45b353ecc259891b40ad86a552090644c60342 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 20:30:53 +0100 Subject: [PATCH 15/20] fix: resolve PR #1819 review findings - session ID preservation and IPC clarity Address three blocking review findings: 1. NEW-008: setClaudeMode now preserves claudeSessionId when dispatching CLAUDE_ACTIVE - Include terminal's current claudeSessionId in the event to prevent XState setClaudeSessionId action from overwriting it to undefined - Fixes hasActiveSession guard for profile swaps 2. NCR-001: setPendingClaudeResume preserves claudeSessionId in RESUME_COMPLETE event - Include terminal's claudeSessionId when sending RESUME_COMPLETE to XState - Prevents machine from losing session ID on resume completion 3. NEW-002: Remove ineffective try/catch around fire-and-forget IPC call - resumeClaudeInTerminal uses ipcRenderer.send() which returns void immediately - Removed try/catch and await that could never catch main process errors - Added comment documenting fire-and-forget nature and error handling via events All tests pass (52/52). Co-Authored-By: Claude Opus 4.6 --- .../hooks/useTerminalProfileChange.ts | 30 ++++++++----------- .../src/renderer/stores/terminal-store.ts | 8 +++-- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts index 8ff5c77d..4ef4b73a 100644 --- a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts +++ b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts @@ -107,23 +107,19 @@ export function useTerminalProfileChange(): void { // Store the session ID for tracking store.setClaudeSessionId(newTerminal.id, sessionId); - try { - // Auto-resume the Claude session with --continue - // YOLO mode (dangerouslySkipPermissions) is preserved server-side by the - // main process during migration (storeMigratedSessionFlag), so resumeClaudeAsync - // will restore it automatically when migratedSession is true - await window.electronAPI.resumeClaudeInTerminal( - newTerminal.id, - sessionId, - { migratedSession: true } - ); - debugLog('[useTerminalProfileChange] Resume completed for terminal:', newTerminal.id); - } catch (error) { - // If auto-resume fails, set pendingClaudeResume flag - // so the user can manually resume when they view the terminal - debugError('[useTerminalProfileChange] Resume failed, marking as pending:', error); - store.setPendingClaudeResume(newTerminal.id, true); - } + // Auto-resume the Claude session with --continue + // YOLO mode (dangerouslySkipPermissions) is preserved server-side by the + // main process during migration (storeMigratedSessionFlag), so resumeClaudeAsync + // will restore it automatically when migratedSession is true + // Note: resumeClaudeInTerminal uses fire-and-forget IPC (ipcRenderer.send), + // so errors in the main process cannot be caught here. The main process will + // emit onTerminalPendingResume if the resume fails, triggering deferred resume. + window.electronAPI.resumeClaudeInTerminal( + newTerminal.id, + sessionId, + { migratedSession: true } + ); + debugLog('[useTerminalProfileChange] Resume initiated for terminal:', newTerminal.id); } } finally { diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 81fad19e..bea3d7a5 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -399,7 +399,9 @@ export const useTerminalStore = create((set, get) => ({ if (String(actor.getSnapshot().value) === 'idle') { sendTerminalMachineEvent(id, { type: 'SHELL_READY' }); } - sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE' }); + // Include current claudeSessionId to prevent XState action from overwriting it + const terminal = get().terminals.find(t => t.id === id); + sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE', claudeSessionId: terminal?.claudeSessionId }); } else { sendTerminalMachineEvent(id, { type: 'CLAUDE_EXITED' }); } @@ -469,7 +471,9 @@ export const useTerminalStore = create((set, get) => ({ // Resume cleared - either completed or cancelled const actor = terminalActors.get(id); if (actor && String(actor.getSnapshot().value) === 'pending_resume') { - sendTerminalMachineEvent(id, { type: 'RESUME_COMPLETE' }); + // Include claudeSessionId to prevent XState action from overwriting it to undefined + const terminal = get().terminals.find(t => t.id === id); + sendTerminalMachineEvent(id, { type: 'RESUME_COMPLETE', claudeSessionId: terminal?.claudeSessionId }); } } From 33462978df70ee0f71ba41a0f9b8901334121a32 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 22:20:44 +0100 Subject: [PATCH 16/20] fix: address remaining PR #1819 code quality findings Three improvements for production code quality: 1. Use path alias for consistency - Change terminal-store.ts line 7 to use @shared/* instead of relative import - Matches project conventions (CLAUDE.md path alias guidelines) 2. Prevent XState/Zustand state divergence in setPendingClaudeResume - Only set pendingClaudeResume flag in Zustand if RESUME_REQUESTED was sent to XState - When claudeSessionId is missing, skip both XState event and Zustand update - Prevents UI showing "resume pending" when state machine doesn't know about it 3. Clean up migratedSessionFlags on individual terminal destroy - Previously only cleared on killAll(), entries could linger if terminal closed before resume - Now removes entry in destroy() if terminal has claudeSessionId - Prevents Map from accumulating stale session flags All tests pass (128/128 files, 3271/3277 tests). Co-Authored-By: Claude Opus 4.6 --- .../src/main/terminal/terminal-manager.ts | 6 ++++++ .../src/renderer/stores/terminal-store.ts | 21 +++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index 7f12a7f2..cb6a763b 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -103,6 +103,12 @@ export class TerminalManager { * Destroy a terminal process */ async destroy(id: string): Promise { + // Clean up migrated session flags if this terminal has a pending migrated session + const terminal = this.terminals.get(id); + if (terminal?.claudeSessionId) { + this.migratedSessionFlags.delete(terminal.claudeSessionId); + } + return TerminalLifecycle.destroyTerminal( id, this.terminals, diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index bea3d7a5..3215822c 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -4,7 +4,7 @@ import type { ActorRefFrom } from 'xstate'; import { v4 as uuid } from 'uuid'; import { arrayMove } from '@dnd-kit/sortable'; import type { TerminalSession, TerminalWorktreeConfig } from '../../shared/types'; -import { terminalMachine, type TerminalEvent } from '../../shared/state-machines'; +import { terminalMachine, type TerminalEvent } from '@shared/state-machines'; import { terminalBufferManager } from '../lib/terminal-buffer-manager'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; @@ -462,10 +462,16 @@ export const useTerminalStore = create((set, get) => ({ setPendingClaudeResume: (id: string, pending: boolean) => { // Send RESUME_REQUESTED or RESUME_COMPLETE to XState machine + let shouldUpdateZustand = true; + if (pending) { const terminal = get().terminals.find(t => t.id === id); if (terminal?.claudeSessionId) { sendTerminalMachineEvent(id, { type: 'RESUME_REQUESTED', claudeSessionId: terminal.claudeSessionId }); + } else { + // No claudeSessionId - can't send RESUME_REQUESTED, so don't set pendingClaudeResume + // to avoid XState/Zustand divergence (UI would show pending but machine wouldn't know) + shouldUpdateZustand = false; } } else { // Resume cleared - either completed or cancelled @@ -477,11 +483,14 @@ export const useTerminalStore = create((set, get) => ({ } } - set((state) => ({ - terminals: state.terminals.map((t) => - t.id === id ? { ...t, pendingClaudeResume: pending } : t - ), - })); + // Only update Zustand state if XState was notified (prevents state divergence) + if (shouldUpdateZustand) { + set((state) => ({ + terminals: state.terminals.map((t) => + t.id === id ? { ...t, pendingClaudeResume: pending } : t + ), + })); + } }, setClaudeNamedOnce: (id: string, named: boolean) => { From e1e3cbf86bddf86316e4b43a3b893dfeacad4a12 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Mon, 16 Feb 2026 08:51:18 +0100 Subject: [PATCH 17/20] fix: address all PR #1819 review findings (round 7) This commit addresses all 10 findings from the latest Auto Claude PR review: **BLOCKING FIXES (3 MEDIUM severity):** 1. NCR-NEW-004: Add user notification when session migration fails - Added i18n key 'terminal:swap.migrationFailed' (EN + FR) - Hook now shows toast notification when isClaudeMode && sessionId && !sessionMigrated - Users are now informed when their Claude session is lost during profile switch 2. NEW-001: Add idle-state guard to setClaudeSessionId - setClaudeSessionId now checks if XState machine is in 'idle' state - Sends SHELL_READY first before CLAUDE_ACTIVE, matching setClaudeMode pattern - Prevents XState/Zustand desync during profile change flow 3. NEW-003: Correct fire-and-forget IPC comment - Removed incorrect claim about onTerminalPendingResume fallback - Updated comment to accurately describe actual failure behavior - Documents that errors are logged but no event is emitted back to renderer **LOW SEVERITY FIXES (5):** 4. CMT-002: Remove EEXIST dead code - copyFile() without COPYFILE_EXCL never throws EEXIST - Removed unreachable catch branch at lines 136-141 - Added comment documenting silent overwrite behavior 5. NEW-004-STORE: Add state check for CLAUDE_EXITED event - setClaudeMode now checks machine state before sending CLAUDE_EXITED - Only sends event if machine is in 'claude_starting' or 'claude_active' - Prevents XState/Zustand desync when event is dropped 6. NEW-006: Preserve isBusy during CLAUDE_ACTIVE self-transitions - setClaudeSessionId action now checks if it's a self-transition - Preserves existing isBusy state during self-transitions - Prevents late session ID updates from incorrectly clearing busy indicator 7. CMT-NEW-001: Add swap state assertions to RESET tests - Updated RESET test to include swapTargetProfileId and swapPhase - Tests now verify all 6 context fields are cleared (not just 4) - Comprehensive test coverage for resetContext action **REMAINING LOW SEVERITY (acknowledged but not blocking):** 8. CMT-NEW-002: Swap phase events lack ordering guards - Acknowledged: ordering currently guaranteed by single caller - Guards would be defensive but not essential for current implementation - Can be addressed in future refactoring if needed 9. CMT-NEW-003: Dual Zustand/XState state updates lack architectural docs - Acknowledged: inline comments exist per method - Architectural-level documentation could be added separately - Does not block merge as per-method comments are clear All tests passing (3271 passed, 6 skipped). TypeScript compiles cleanly. Biome linting clean for all changed files. Co-Authored-By: Claude Opus 4.6 --- .../src/main/claude-profile/session-utils.ts | 7 +----- .../hooks/useTerminalProfileChange.ts | 25 ++++++++++++++----- .../src/renderer/stores/terminal-store.ts | 12 ++++++++- .../src/shared/i18n/locales/en/terminal.json | 3 ++- .../src/shared/i18n/locales/fr/terminal.json | 3 ++- .../__tests__/terminal-machine.test.ts | 11 +++++++- .../shared/state-machines/terminal-machine.ts | 11 +++++++- 7 files changed, 55 insertions(+), 17 deletions(-) diff --git a/apps/frontend/src/main/claude-profile/session-utils.ts b/apps/frontend/src/main/claude-profile/session-utils.ts index 0dde5903..1f4aedd0 100644 --- a/apps/frontend/src/main/claude-profile/session-utils.ts +++ b/apps/frontend/src/main/claude-profile/session-utils.ts @@ -124,6 +124,7 @@ export async function migrateSession( // Attempt to copy the session .jsonl file // This will throw if source doesn't exist or target cannot be written + // Note: copyFile silently overwrites by default (no COPYFILE_EXCL flag) try { await copyFile(sourceFile, targetFile); result.filesCopied++; @@ -133,12 +134,6 @@ export async function migrateSession( if (isNodeError(copyError)) { if (copyError.code === 'ENOENT') { result.error = `Source session file not found: ${sourceFile}`; - } else if (copyError.code === 'EEXIST') { - // Target already exists - this is OK, treat as successful skip - console.warn('[SessionUtils] Session already exists in target profile, skipping copy'); - result.success = true; - result.filesCopied = 0; - return result; } else { result.error = `Failed to copy session file: ${copyError.message}`; } diff --git a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts index 4ef4b73a..ad709dba 100644 --- a/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts +++ b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts @@ -1,4 +1,6 @@ import { useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from './use-toast'; import { useTerminalStore } from '../stores/terminal-store'; import { terminalBufferManager } from '../lib/terminal-buffer-manager'; import type { TerminalProfileChangedEvent } from '../../shared/types'; @@ -11,13 +13,15 @@ import { debugLog, debugError } from '../../shared/utils/debug-logger'; * migrated and automatically resumed with --continue. */ export function useTerminalProfileChange(): void { + const { t } = useTranslation(['terminal']); // Track terminals being recreated to prevent duplicate processing const recreatingTerminals = useRef>(new Set()); const recreateTerminal = useCallback(async ( terminalId: string, sessionId?: string, - sessionMigrated?: boolean + sessionMigrated?: boolean, + isClaudeMode?: boolean ) => { // Prevent duplicate recreation if (recreatingTerminals.current.has(terminalId)) { @@ -111,21 +115,29 @@ export function useTerminalProfileChange(): void { // YOLO mode (dangerouslySkipPermissions) is preserved server-side by the // main process during migration (storeMigratedSessionFlag), so resumeClaudeAsync // will restore it automatically when migratedSession is true - // Note: resumeClaudeInTerminal uses fire-and-forget IPC (ipcRenderer.send), - // so errors in the main process cannot be caught here. The main process will - // emit onTerminalPendingResume if the resume fails, triggering deferred resume. + // Note: resumeClaudeInTerminal uses fire-and-forget IPC (ipcRenderer.send). + // If resume fails in the main process, the error is logged but no failure event + // is emitted back to the renderer. The terminal will show an empty shell prompt. window.electronAPI.resumeClaudeInTerminal( newTerminal.id, sessionId, { migratedSession: true } ); debugLog('[useTerminalProfileChange] Resume initiated for terminal:', newTerminal.id); + } else if (isClaudeMode && sessionId && !sessionMigrated) { + // Session had an active Claude session but migration failed + // Notify user that their Claude session was lost + debugError('[useTerminalProfileChange] Session migration failed for terminal:', terminalId); + toast({ + title: t('terminal:swap.migrationFailed'), + variant: 'destructive', + }); } } finally { recreatingTerminals.current.delete(terminalId); } - }, []); + }, [t]); useEffect(() => { const cleanup = window.electronAPI.onTerminalProfileChanged(async (event: TerminalProfileChangedEvent) => { @@ -140,7 +152,8 @@ export function useTerminalProfileChange(): void { await recreateTerminal( terminalInfo.id, terminalInfo.sessionId, - terminalInfo.sessionMigrated + terminalInfo.sessionMigrated, + terminalInfo.isClaudeMode ); } diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 3215822c..8248c4ed 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -403,7 +403,12 @@ export const useTerminalStore = create((set, get) => ({ const terminal = get().terminals.find(t => t.id === id); sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE', claudeSessionId: terminal?.claudeSessionId }); } else { - sendTerminalMachineEvent(id, { type: 'CLAUDE_EXITED' }); + // Only send CLAUDE_EXITED if machine is in a state that accepts it + const actor = getOrCreateTerminalActor(id); + const currentState = String(actor.getSnapshot().value); + if (currentState === 'claude_starting' || currentState === 'claude_active') { + sendTerminalMachineEvent(id, { type: 'CLAUDE_EXITED' }); + } } set((state) => ({ @@ -423,6 +428,11 @@ export const useTerminalStore = create((set, get) => ({ }, setClaudeSessionId: (id: string, sessionId: string) => { + // Ensure machine has transitioned past idle before sending CLAUDE_ACTIVE + const actor = getOrCreateTerminalActor(id); + if (String(actor.getSnapshot().value) === 'idle') { + sendTerminalMachineEvent(id, { type: 'SHELL_READY' }); + } // Send CLAUDE_ACTIVE with session ID to XState machine sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE', claudeSessionId: sessionId }); diff --git a/apps/frontend/src/shared/i18n/locales/en/terminal.json b/apps/frontend/src/shared/i18n/locales/en/terminal.json index f4ef80da..d6e4c38e 100644 --- a/apps/frontend/src/shared/i18n/locales/en/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/en/terminal.json @@ -17,7 +17,8 @@ "resumingSession": "Resuming Claude session...", "sessionResumed": "Session resumed under new profile", "resumeFailed": "Could not resume session. You can start a new session.", - "noSession": "Profile switched. No active session to resume." + "noSession": "Profile switched. No active session to resume.", + "migrationFailed": "Profile switched, but session migration failed. Starting fresh terminal." }, "worktree": { "create": "Worktree", diff --git a/apps/frontend/src/shared/i18n/locales/fr/terminal.json b/apps/frontend/src/shared/i18n/locales/fr/terminal.json index 00a957cf..062ce680 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/fr/terminal.json @@ -17,7 +17,8 @@ "resumingSession": "Reprise de la session Claude...", "sessionResumed": "Session reprise sous le nouveau profil", "resumeFailed": "Impossible de reprendre la session. Vous pouvez démarrer une nouvelle session.", - "noSession": "Profil changé. Aucune session active à reprendre." + "noSession": "Profil changé. Aucune session active à reprendre.", + "migrationFailed": "Profil changé, mais la migration de session a échoué. Démarrage d'un nouveau terminal." }, "worktree": { "create": "Worktree", diff --git a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts index 7a988823..d7cc6341 100644 --- a/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts +++ b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts @@ -495,13 +495,22 @@ describe('terminalMachine', () => { const snapshot = runEvents( [{ type: 'RESET' }], state, - { claudeSessionId: 'session-1', profileId: 'profile-1', isBusy: true, error: 'err' } + { + claudeSessionId: 'session-1', + profileId: 'profile-1', + isBusy: true, + error: 'err', + swapTargetProfileId: 'profile-2', + swapPhase: 'migrating' + } ); expect(snapshot.value).toBe('idle'); expect(snapshot.context.claudeSessionId).toBeUndefined(); expect(snapshot.context.profileId).toBeUndefined(); expect(snapshot.context.isBusy).toBe(false); expect(snapshot.context.error).toBeUndefined(); + expect(snapshot.context.swapTargetProfileId).toBeUndefined(); + expect(snapshot.context.swapPhase).toBeUndefined(); }); } }); diff --git a/apps/frontend/src/shared/state-machines/terminal-machine.ts b/apps/frontend/src/shared/state-machines/terminal-machine.ts index dfb5e6ac..d8f85726 100644 --- a/apps/frontend/src/shared/state-machines/terminal-machine.ts +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -148,7 +148,16 @@ export const terminalMachine = createMachine( if (event.type === 'RESUME_REQUESTED') return event.claudeSessionId; return undefined; }, - isBusy: () => false, + // Preserve isBusy during self-transitions (CLAUDE_ACTIVE in claude_active state) + // Only clear it when transitioning into claude_active from another state + isBusy: ({ context, event }) => { + if (event.type === 'CLAUDE_ACTIVE' && context.claudeSessionId) { + // Self-transition: preserve existing isBusy state + return context.isBusy; + } + // Transition into claude_active: clear isBusy + return false; + }, error: () => undefined, }), setBusy: assign({ From 9fa8532bb2aa37252c4351476f19edf7c535fe03 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Tue, 17 Feb 2026 13:37:55 +0100 Subject: [PATCH 18/20] fix: address PR #1819 review findings for terminal session persistence - Use setClaudeMode() instead of updateTerminal() in onTerminalClaudeExit handler to properly send CLAUDE_EXITED to XState machine - Remove premature migratedSessionFlags cleanup from destroy() since resumeClaudeAsync already consumes flags and killAll() clears the map - Add swap phase ordering guards (isCapturingPhase, isMigratingPhase, isRecreatingPhase) to prevent out-of-order swap events - Add YOLO mode flag to deprecated sync resumeClaude for consistency - Fix isBusy preservation heuristic by using dedicated updateClaudeSessionId action for self-transitions in claude_active state - Add debugLog when setPendingClaudeResume silently drops request due to missing claudeSessionId - Handle CLAUDE_BUSY events in claude_starting and pending_resume states Co-Authored-By: Claude Opus 4.6 --- .../terminal/claude-integration-handler.ts | 5 +++- .../src/main/terminal/terminal-manager.ts | 6 ---- .../components/terminal/useTerminalEvents.ts | 6 ++-- .../src/renderer/stores/terminal-store.ts | 1 + .../shared/state-machines/terminal-machine.ts | 30 ++++++++++++------- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index deb6e9df..3dfa8df8 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -1211,7 +1211,10 @@ export function resumeClaude( console.warn('[ClaudeIntegration:resumeClaude] sessionId parameter is deprecated and ignored; using claude --continue instead'); } - const command = `${pathPrefix}${escapedClaudeCmd} --continue`; + // Preserve YOLO mode flag from terminal's stored state + const extraFlags = terminal.dangerouslySkipPermissions ? YOLO_MODE_FLAG : ''; + + const command = `${pathPrefix}${escapedClaudeCmd} --continue${extraFlags}`; // Use PtyManager.writeToPty for safer write with error handling PtyManager.writeToPty(terminal, `${command}\r`); diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index cb6a763b..7f12a7f2 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -103,12 +103,6 @@ export class TerminalManager { * Destroy a terminal process */ async destroy(id: string): Promise { - // Clean up migrated session flags if this terminal has a pending migrated session - const terminal = this.terminals.get(id); - if (terminal?.claudeSessionId) { - this.migratedSessionFlags.delete(terminal.claudeSessionId); - } - return TerminalLifecycle.destroyTerminal( id, this.terminals, diff --git a/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts index 0fd26af7..61c96ab5 100644 --- a/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts +++ b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts @@ -146,12 +146,12 @@ export function useTerminalEvents({ return; } // Reset Claude mode - Claude has exited but terminal is still running - // Use updateTerminal to set all Claude-related state at once + // Use setClaudeMode which properly sends CLAUDE_EXITED to the XState machine, + // then clear residual Claude state separately + store.setClaudeMode(terminalId, false); store.updateTerminal(terminalId, { - isClaudeMode: false, isClaudeBusy: undefined, claudeSessionId: undefined, - status: 'running' // Terminal is still running, just not in Claude mode }); console.warn('[Terminal] Claude exited, reset mode for terminal:', terminalId); } diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 8248c4ed..9ef51adb 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -481,6 +481,7 @@ export const useTerminalStore = create((set, get) => ({ } else { // No claudeSessionId - can't send RESUME_REQUESTED, so don't set pendingClaudeResume // to avoid XState/Zustand divergence (UI would show pending but machine wouldn't know) + debugLog('[terminal-store] setPendingClaudeResume: dropping request for terminal', id, '- no claudeSessionId'); shouldUpdateZustand = false; } } else { diff --git a/apps/frontend/src/shared/state-machines/terminal-machine.ts b/apps/frontend/src/shared/state-machines/terminal-machine.ts index d8f85726..f002ec61 100644 --- a/apps/frontend/src/shared/state-machines/terminal-machine.ts +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -71,6 +71,7 @@ export const terminalMachine = createMachine( claude_starting: { on: { CLAUDE_ACTIVE: { target: 'claude_active', actions: 'setClaudeSessionId' }, + CLAUDE_BUSY: { actions: 'setBusy' }, CLAUDE_EXITED: { target: 'shell_ready', actions: ['setError', 'clearSession'] }, SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, RESET: { target: 'idle', actions: 'resetContext' }, @@ -78,7 +79,7 @@ export const terminalMachine = createMachine( }, claude_active: { on: { - CLAUDE_ACTIVE: { actions: 'setClaudeSessionId' }, + CLAUDE_ACTIVE: { actions: 'updateClaudeSessionId' }, CLAUDE_BUSY: { actions: 'setBusy' }, CLAUDE_EXITED: { target: 'shell_ready', actions: ['setError', 'clearSession'] }, SWAP_INITIATED: { @@ -94,12 +95,15 @@ export const terminalMachine = createMachine( swapping: { on: { SWAP_SESSION_CAPTURED: { + guard: 'isCapturingPhase', actions: ['setCapturedSession', 'setSwapPhaseMigrating'], }, SWAP_MIGRATED: { + guard: 'isMigratingPhase', actions: 'setSwapPhaseRecreating', }, SWAP_TERMINAL_RECREATED: { + guard: 'isRecreatingPhase', actions: 'setSwapPhaseResuming', }, SWAP_RESUME_COMPLETE: { @@ -117,6 +121,7 @@ export const terminalMachine = createMachine( pending_resume: { on: { CLAUDE_ACTIVE: { target: 'claude_active', actions: 'setClaudeSessionId' }, + CLAUDE_BUSY: { actions: 'setBusy' }, RESUME_COMPLETE: { target: 'claude_active', actions: 'setClaudeSessionId' }, RESUME_FAILED: { target: 'shell_ready', actions: ['setError', 'clearSession'] }, SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, @@ -134,6 +139,9 @@ export const terminalMachine = createMachine( { guards: { hasActiveSession: ({ context }) => context.claudeSessionId !== undefined, + isCapturingPhase: ({ context }) => context.swapPhase === 'capturing', + isMigratingPhase: ({ context }) => context.swapPhase === 'migrating', + isRecreatingPhase: ({ context }) => context.swapPhase === 'recreating', }, actions: { setProfileId: assign({ @@ -148,16 +156,16 @@ export const terminalMachine = createMachine( if (event.type === 'RESUME_REQUESTED') return event.claudeSessionId; return undefined; }, - // Preserve isBusy during self-transitions (CLAUDE_ACTIVE in claude_active state) - // Only clear it when transitioning into claude_active from another state - isBusy: ({ context, event }) => { - if (event.type === 'CLAUDE_ACTIVE' && context.claudeSessionId) { - // Self-transition: preserve existing isBusy state - return context.isBusy; - } - // Transition into claude_active: clear isBusy - return false; - }, + // Clear isBusy when entering claude_active from another state + isBusy: () => false, + error: () => undefined, + }), + // Self-transition action for CLAUDE_ACTIVE within claude_active state: + // Updates sessionId but preserves isBusy (avoids resetting busy indicator + // when the session ID is refreshed without a state change) + updateClaudeSessionId: assign({ + claudeSessionId: ({ event }) => + event.type === 'CLAUDE_ACTIVE' ? event.claudeSessionId : undefined, error: () => undefined, }), setBusy: assign({ From faf6148f82ec90360a6fe22c87ce841e32683b53 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Tue, 17 Feb 2026 15:36:55 +0100 Subject: [PATCH 19/20] fix: use setClaudeMode instead of updateTerminal in onTerminalExit to prevent XState divergence The onTerminalExit handler used store.updateTerminal() to reset isClaudeMode, which is a plain Zustand setter that bypasses XState notification. Replace with store.setClaudeMode() which properly checks XState state before sending events. Since setTerminalStatus('exited') already sends SHELL_EXITED to XState (handling the claude_active -> exited transition), the setClaudeMode(false) call here only updates Zustand - its XState guard correctly skips sending CLAUDE_EXITED since the machine is already in 'exited' state. Fixes: NCR-R7-001 (HIGH) - Claude exit via updateTerminal bypasses XState machine Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/components/terminal/useTerminalEvents.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts index 61c96ab5..e5d19a22 100644 --- a/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts +++ b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts @@ -52,10 +52,12 @@ export function useTerminalEvents({ const store = useTerminalStore.getState(); store.setTerminalStatus(terminalId, 'exited'); // Reset Claude mode when terminal exits - the Claude process has ended - // Use updateTerminal instead of setClaudeMode to avoid changing status back to 'running' + // setTerminalStatus('exited') already sends SHELL_EXITED to XState (which handles + // claude_active -> exited transition), so setClaudeMode(false) here only updates Zustand + // (its XState guard skips CLAUDE_EXITED since the machine is already in 'exited') const terminal = store.getTerminal(terminalId); if (terminal?.isClaudeMode) { - store.updateTerminal(terminalId, { isClaudeMode: false }); + store.setClaudeMode(terminalId, false); } onExitRef.current?.(exitCode); From 7f7e845cd527dad12e81e3942e01d0ed19e7ad48 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Wed, 18 Feb 2026 09:49:35 +0100 Subject: [PATCH 20/20] fix: resolve 3 remaining PR #1819 review issues - Preserve 'exited' status in setClaudeMode(false) instead of overwriting to 'running' - Add isResumingPhase guard to SWAP_RESUME_COMPLETE for consistency with other swap events - Clean up stale migratedSessionFlags when resumeClaudeAsync finds no terminal Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/main/terminal/terminal-manager.ts | 4 ++++ apps/frontend/src/renderer/stores/terminal-store.ts | 2 +- apps/frontend/src/shared/state-machines/terminal-machine.ts | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index 7f12a7f2..78cd6f3d 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -229,6 +229,10 @@ export class TerminalManager { async resumeClaudeAsync(id: string, sessionId?: string, options?: { migratedSession?: boolean }): Promise { const terminal = this.terminals.get(id); if (!terminal) { + // Clean up stale migratedSessionFlags if terminal no longer exists + if (options?.migratedSession && sessionId) { + this.migratedSessionFlags.delete(sessionId); + } return; } diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 9ef51adb..1764f475 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -417,7 +417,7 @@ export const useTerminalStore = create((set, get) => ({ ? { ...t, isClaudeMode, - status: isClaudeMode ? 'claude-active' : 'running', + status: isClaudeMode ? 'claude-active' : (t.status === 'exited' ? 'exited' : 'running'), // Reset busy state and naming flag when leaving Claude mode isClaudeBusy: isClaudeMode ? t.isClaudeBusy : undefined, claudeNamedOnce: isClaudeMode ? t.claudeNamedOnce : undefined diff --git a/apps/frontend/src/shared/state-machines/terminal-machine.ts b/apps/frontend/src/shared/state-machines/terminal-machine.ts index f002ec61..4a990fae 100644 --- a/apps/frontend/src/shared/state-machines/terminal-machine.ts +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -108,6 +108,7 @@ export const terminalMachine = createMachine( }, SWAP_RESUME_COMPLETE: { target: 'claude_active', + guard: 'isResumingPhase', actions: 'applySwapComplete', }, SWAP_FAILED: { @@ -142,6 +143,7 @@ export const terminalMachine = createMachine( isCapturingPhase: ({ context }) => context.swapPhase === 'capturing', isMigratingPhase: ({ context }) => context.swapPhase === 'migrating', isRecreatingPhase: ({ context }) => context.swapPhase === 'recreating', + isResumingPhase: ({ context }) => context.swapPhase === 'resuming', }, actions: { setProfileId: assign({