diff --git a/apps/frontend/src/main/claude-profile/session-utils.ts b/apps/frontend/src/main/claude-profile/session-utils.ts index fae29e2de9..1f4aedd0e0 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,14 @@ 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 + // Note: copyFile silently overwrites by default (no COPYFILE_EXCL flag) try { - copyFileSync(sourceFile, targetFile); + await copyFile(sourceFile, targetFile); result.filesCopied++; console.warn('[SessionUtils] Copied session file:', sourceFile, '->', targetFile); } catch (copyError) { @@ -132,12 +134,6 @@ export 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}`; } @@ -153,7 +149,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 +178,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 0bfef37956..5aca822539 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -252,6 +252,8 @@ export function registerTerminalHandlers( id: string; sessionId?: string; sessionMigrated?: boolean; + isClaudeMode?: boolean; + dangerouslySkipPermissions?: boolean; }> = []; // Process each terminal @@ -273,7 +275,7 @@ export function registerTerminalHandlers( to: targetConfigDir }); - const migrationResult = migrateSession( + const migrationResult = await migrateSession( sourceConfigDir, targetConfigDir, terminal.cwd, @@ -284,11 +286,19 @@ 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, sessionId: terminal.claudeSessionId, - sessionMigrated + sessionMigrated, + isClaudeMode: terminal.isClaudeMode, + dangerouslySkipPermissions: terminal.dangerouslySkipPermissions }); } @@ -613,9 +623,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/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index ef4c92b903..3dfa8df899 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`); @@ -1386,7 +1389,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 +1423,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`); diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index a81f6e1e8f..78cd6f3d72 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -11,7 +11,7 @@ import type { TerminalProcess, WindowGetter, TerminalOperationResult, - TerminalProfileChangeInfo + TerminalProfileChangeInfo, } from './types'; import * as PtyManager from './pty-manager'; import * as SessionHandler from './session-handler'; @@ -26,6 +26,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; @@ -114,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 @@ -223,13 +226,36 @@ 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) { + // Clean up stale migratedSessionFlags if terminal no longer exists + if (options?.migratedSession && sessionId) { + this.migratedSessionFlags.delete(sessionId); + } return; } - await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow); + // 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); } /** @@ -387,7 +413,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 d68ca2c12d..0f2956e9a4 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -99,4 +99,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 67171091ee..fe09cb0f95 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/components/terminal/useTerminalEvents.ts b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts index 0fd26af7ef..e5d19a228c 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); @@ -146,12 +148,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/hooks/useTerminalProfileChange.ts b/apps/frontend/src/renderer/hooks/useTerminalProfileChange.ts index 4b7683b7a7..ad709dbad2 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'; @@ -8,16 +10,18 @@ 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 { + 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)) { @@ -101,25 +105,39 @@ 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( + + // 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). + // 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, - `# Profile switched. Previous Claude session available.\n# Run: claude --resume ${sessionId}\n` + 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) => { @@ -134,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 ffdc246992..1764f475bf 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -1,10 +1,50 @@ import { create } from 'zustand'; +import { createActor } 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'; +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})`); +} + /** * Module-level Map to store terminal ID -> xterm write callback mappings. * @@ -301,9 +341,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); @@ -331,6 +377,13 @@ export const useTerminalStore = create((set, get) => ({ }, setTerminalStatus: (id: string, status: TerminalStatus) => { + // 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) => ({ terminals: state.terminals.map((t) => t.id === id ? { ...t, status } : t @@ -339,13 +392,32 @@ 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' }); + } + // 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 { + // 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) => ({ terminals: state.terminals.map((t) => t.id === id ? { ...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 @@ -356,6 +428,14 @@ 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 }); + set((state) => ({ terminals: state.terminals.map((t) => t.id === id ? { ...t, claudeSessionId: sessionId } : t @@ -380,6 +460,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,11 +471,37 @@ export const useTerminalStore = create((set, get) => ({ }, setPendingClaudeResume: (id: string, pending: boolean) => { - set((state) => ({ - terminals: state.terminals.map((t) => - t.id === id ? { ...t, pendingClaudeResume: pending } : t - ), - })); + // 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) + debugLog('[terminal-store] setPendingClaudeResume: dropping request for terminal', id, '- no claudeSessionId'); + shouldUpdateZustand = false; + } + } else { + // Resume cleared - either completed or cancelled + const actor = terminalActors.get(id); + if (actor && String(actor.getSnapshot().value) === 'pending_resume') { + // 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 }); + } + } + + // 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) => { @@ -404,6 +513,18 @@ 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(); + } + terminalActors.clear(); set({ terminals: [], activeTerminalId: null, hasRestoredSessions: false }); }, diff --git a/apps/frontend/src/shared/i18n/locales/en/terminal.json b/apps/frontend/src/shared/i18n/locales/en/terminal.json index 7d87ea7d8b..d6e4c38eae 100644 --- a/apps/frontend/src/shared/i18n/locales/en/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/en/terminal.json @@ -12,6 +12,14 @@ "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.", + "migrationFailed": "Profile switched, but session migration failed. Starting fresh terminal." + }, "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 a13c0ab050..062ce6802c 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/fr/terminal.json @@ -12,6 +12,14 @@ "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 démarrer une nouvelle session.", + "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", "createNew": "Nouveau 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 new file mode 100644 index 0000000000..d7cc63416b --- /dev/null +++ b/apps/frontend/src/shared/state-machines/__tests__/terminal-machine.test.ts @@ -0,0 +1,582 @@ +import { describe, it, expect } from 'vitest'; +import { createActor } from 'xstate'; +import { terminalMachine, type TerminalEvent, type TerminalContext } 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?: Partial +) { + 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 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' }, + { 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 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' }], + '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 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' }], + '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('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([ + { 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 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' }], + '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', + 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(); + }); + } + }); + + 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'); + }); + }); +}); diff --git a/apps/frontend/src/shared/state-machines/index.ts b/apps/frontend/src/shared/state-machines/index.ts index 68cd1f3abb..a2f8afed76 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'; 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 0000000000..4a990faed7 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/terminal-machine.ts @@ -0,0 +1,227 @@ +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' }, + 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' }, + }, + }, + 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' }, + }, + }, + claude_active: { + on: { + CLAUDE_ACTIVE: { actions: 'updateClaudeSessionId' }, + CLAUDE_BUSY: { actions: 'setBusy' }, + CLAUDE_EXITED: { target: 'shell_ready', actions: ['setError', 'clearSession'] }, + SWAP_INITIATED: { + target: 'swapping', + guard: 'hasActiveSession', + actions: 'setSwapTarget', + }, + RESUME_REQUESTED: { target: 'pending_resume', actions: 'setClaudeSessionId' }, + SHELL_EXITED: { target: 'exited', actions: 'clearSession' }, + RESET: { target: 'idle', actions: 'resetContext' }, + }, + }, + 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: { + target: 'claude_active', + guard: 'isResumingPhase', + 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: { + 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' }, + 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, + isCapturingPhase: ({ context }) => context.swapPhase === 'capturing', + isMigratingPhase: ({ context }) => context.swapPhase === 'migrating', + isRecreatingPhase: ({ context }) => context.swapPhase === 'recreating', + isResumingPhase: ({ context }) => context.swapPhase === 'resuming', + }, + 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; + if (event.type === 'RESUME_REQUESTED') return event.claudeSessionId; + return undefined; + }, + // 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({ + 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, + }), + }, + } +); diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index 2fb39902b4..e4448450bd 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -255,6 +255,10 @@ 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; + /** 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 b1fc2c4b63..82168ffa56 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>;