Merge pull request #1819 from AndyMik90/auto-claude/229-implement-account-aware-terminal-session-persisten

auto-claude: 229-implement-account-aware-terminal-session-persisten
This commit is contained in:
Andy
2026-02-20 11:52:21 +01:00
committed by GitHub
16 changed files with 1074 additions and 55 deletions
@@ -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<SessionMigrationResult> {
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
@@ -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);
});
}
@@ -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<void> {
// 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`);
@@ -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<string, string> = new Map();
private eventCallbacks: TerminalEventHandler.EventHandlerCallbacks;
/** Server-side storage for YOLO mode flags during profile migration (sessionId → flag) */
private migratedSessionFlags: Map<string, boolean> = new Map();
constructor(getWindow: WindowGetter) {
this.getWindow = getWindow;
@@ -114,6 +116,7 @@ export class TerminalManager {
* Kill all terminal processes
*/
async killAll(): Promise<void> {
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<void> {
async resumeClaudeAsync(id: string, sessionId?: string, options?: { migratedSession?: boolean }): Promise<void> {
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
});
}
+1
View File
@@ -99,4 +99,5 @@ export interface TerminalProfileChangeInfo {
claudeSessionId?: string;
claudeProfileId?: string;
isClaudeMode: boolean;
dangerouslySkipPermissions?: boolean;
}
@@ -47,7 +47,7 @@ export interface TerminalAPI {
rows?: number
) => Promise<IPCResult<import('../../shared/types').TerminalRestoreResult>>;
clearTerminalSessions: (projectPath: string) => Promise<IPCResult>;
resumeClaudeInTerminal: (id: string, sessionId?: string) => void;
resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }) => void;
activateDeferredClaudeResume: (id: string) => void;
getTerminalSessionDates: (projectPath?: string) => Promise<IPCResult<import('../../shared/types').SessionDateInfo[]>>;
getTerminalSessionsForDate: (
@@ -166,8 +166,8 @@ export const createTerminalAPI = (): TerminalAPI => ({
clearTerminalSessions: (projectPath: string): Promise<IPCResult> =>
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),
@@ -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);
}
@@ -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<Set<string>>(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
);
}
@@ -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<typeof terminalMachine>;
/**
* 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<string, TerminalActor>();
/**
* 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<TerminalState>((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<TerminalState>((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<TerminalState>((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<TerminalState>((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<TerminalState>((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<TerminalState>((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<TerminalState>((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 });
},
@@ -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",
@@ -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",
@@ -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<TerminalContext>
) {
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');
});
});
});
@@ -8,6 +8,9 @@ export {
} from './task-state-utils';
export type { TaskStateName } from './task-state-utils';
export { terminalMachine } from './terminal-machine';
export type { TerminalContext, TerminalEvent } from './terminal-machine';
export { roadmapGenerationMachine } from './roadmap-generation-machine';
export type {
RoadmapGenerationContext,
@@ -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,
}),
},
}
);
+4
View File
@@ -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;
}>;
}
+1 -1
View File
@@ -253,7 +253,7 @@ export interface ElectronAPI {
getTerminalSessions: (projectPath: string) => Promise<IPCResult<TerminalSession[]>>;
restoreTerminalSession: (session: TerminalSession, cols?: number, rows?: number) => Promise<IPCResult<TerminalRestoreResult>>;
clearTerminalSessions: (projectPath: string) => Promise<IPCResult>;
resumeClaudeInTerminal: (id: string, sessionId?: string) => void;
resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }) => void;
activateDeferredClaudeResume: (id: string) => void;
getTerminalSessionDates: (projectPath?: string) => Promise<IPCResult<SessionDateInfo[]>>;
getTerminalSessionsForDate: (date: string, projectPath: string) => Promise<IPCResult<TerminalSession[]>>;