diff --git a/packages/happy-app/sources/app/(app)/session/[id]/info.tsx b/packages/happy-app/sources/app/(app)/session/[id]/info.tsx index 9bedf2212..b69a0244f 100644 --- a/packages/happy-app/sources/app/(app)/session/[id]/info.tsx +++ b/packages/happy-app/sources/app/(app)/session/[id]/info.tsx @@ -11,7 +11,10 @@ import { useSession, useIsDataReady } from '@/sync/storage'; import { getSessionName, useSessionStatus, formatOSPlatform, formatPathRelativeToHome, getSessionAvatarId } from '@/utils/sessionUtils'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; -import { sessionKill, sessionDelete } from '@/sync/ops'; +import { sessionKill, sessionDelete, machineResumeSession } from '@/sync/ops'; +import { useMachine } from '@/sync/storage'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { sync } from '@/sync/sync'; import { useUnistyles } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { t } from '@/text'; @@ -200,6 +203,33 @@ function SessionInfoContent({ session }: { session: Session }) { ); }, [performDelete]); + // Resume session + const machine = useMachine(session.metadata?.machineId || ''); + const machineOnline = machine ? isMachineOnline(machine) : false; + const canResume = !sessionStatus.isConnected && !session.active + && !!session.metadata?.claudeSessionId && !!session.metadata?.machineId; + + const [resumingSession, performResume] = useHappyAction(async () => { + if (!session.metadata?.claudeSessionId || !session.metadata?.machineId || !session.metadata?.path) return; + + const result = await machineResumeSession({ + machineId: session.metadata.machineId, + directory: session.metadata.path, + claudeSessionId: session.metadata.claudeSessionId, + agent: (session.metadata?.flavor as 'claude' | 'codex' | 'gemini') || 'claude', + }); + + if (result.type !== 'success') { + throw new HappyError( + 'errorMessage' in result ? result.errorMessage : t('sessionInfo.failedToResumeSession'), + false + ); + } + + await sync.refreshSessions(); + router.replace(`/session/${result.sessionId}`); + }); + const formatDate = useCallback((timestamp: number) => { return new Date(timestamp).toLocaleString(); }, []); @@ -309,6 +339,17 @@ function SessionInfoContent({ session }: { session: Session }) { {/* Quick Actions */} + {canResume && ( + } + onPress={machineOnline ? performResume : undefined} + loading={resumingSession} + /> + )} {session.metadata?.machineId && ( ({ @@ -179,12 +182,19 @@ const stylesheet = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.groupped.background, }, swipeAction: { - width: 112, + width: 80, height: '100%', alignItems: 'center', justifyContent: 'center', backgroundColor: theme.colors.status.error, }, + swipeActionResume: { + width: 80, + height: '100%', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#34C759', + }, swipeActionText: { marginTop: 4, fontSize: 12, @@ -362,6 +372,34 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle } ); }, [performDelete]); + const canResume = !sessionStatus.isConnected && !session.active + && !!session.metadata?.claudeSessionId && !!session.metadata?.machineId; + const machine = useMachine(session.metadata?.machineId || ''); + const machineOnline = machine ? isMachineOnline(machine) : false; + + const [resumingSession, performResume] = useHappyAction(async () => { + if (!session.metadata?.claudeSessionId || !session.metadata?.machineId || !session.metadata?.path) return; + const result = await machineResumeSession({ + machineId: session.metadata.machineId, + directory: session.metadata.path, + claudeSessionId: session.metadata.claudeSessionId, + agent: (session.metadata?.flavor as 'claude' | 'codex' | 'gemini') || 'claude', + }); + if (result.type !== 'success') { + throw new HappyError( + 'errorMessage' in result ? result.errorMessage : t('sessionInfo.failedToResumeSession'), + false + ); + } + await sync.refreshSessions(); + navigateToSession(result.sessionId); + }); + + const handleResume = React.useCallback(() => { + swipeableRef.current?.close(); + performResume(); + }, [performResume]); + const avatarId = React.useMemo(() => { return getSessionAvatarId(session); }, [session]); @@ -446,16 +484,30 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle } } const renderRightActions = () => ( - - - - {t('sessionInfo.deleteSession')} - - + + {canResume && machineOnline && ( + + + + {t('sessionInfo.resumeSession')} + + + )} + + + + {t('sessionInfo.deleteSession')} + + + ); return ( @@ -464,7 +516,7 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle } ref={swipeableRef} renderRightActions={renderRightActions} overshootRight={false} - enabled={!deletingSession} + enabled={!deletingSession && !resumingSession} > {itemContent} diff --git a/packages/happy-app/sources/sync/ops.ts b/packages/happy-app/sources/sync/ops.ts index 07f70e694..f132c8a31 100644 --- a/packages/happy-app/sources/sync/ops.ts +++ b/packages/happy-app/sources/sync/ops.ts @@ -184,6 +184,41 @@ export async function machineSpawnNewSession(options: SpawnSessionOptions): Prom } } +/** + * Resume a previous Claude session on a specific machine. + * Creates a new Happy session but continues the Claude conversation via --resume. + */ +export async function machineResumeSession(options: { + machineId: string; + directory: string; + claudeSessionId: string; + agent?: 'codex' | 'claude' | 'gemini'; + environmentVariables?: Record; +}): Promise { + const { machineId, directory, claudeSessionId, agent, environmentVariables } = options; + + try { + const result = await apiSocket.machineRPC + }>( + machineId, + 'spawn-happy-session', + { type: 'spawn-in-directory', directory, sessionId: claudeSessionId, approvedNewDirectoryCreation: false, agent, environmentVariables } + ); + return result; + } catch (error) { + return { + type: 'error', + errorMessage: error instanceof Error ? error.message : 'Failed to resume session' + }; + } +} + /** * Stop the daemon on a specific machine */ diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index c3a0d673a..ec056632b 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -374,7 +374,11 @@ export const en = { deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', failedToDeleteSession: 'Failed to delete session', sessionDeleted: 'Session deleted successfully', - + resumeSession: 'Resume Session', + resumeSessionSubtitle: 'Continue this conversation in a new session', + resumeSessionMachineOffline: 'Machine is offline', + failedToResumeSession: 'Failed to resume session', + }, components: { diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index 76d928500..d706bb227 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -375,6 +375,10 @@ export const ca: TranslationStructure = { deleteSessionWarning: 'Aquesta acció no es pot desfer. Tots els missatges i dades associats amb aquesta sessió s\'eliminaran permanentment.', failedToDeleteSession: 'Error en eliminar la sessió', sessionDeleted: 'Sessió eliminada amb èxit', + resumeSession: 'Reprendre la sessió', + resumeSessionSubtitle: 'Continuar aquesta conversa en una nova sessió', + resumeSessionMachineOffline: 'La màquina està fora de línia', + failedToResumeSession: 'Error en reprendre la sessió', }, diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index b86aa4af6..1377aefb4 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -390,6 +390,10 @@ export const en: TranslationStructure = { deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', failedToDeleteSession: 'Failed to delete session', sessionDeleted: 'Session deleted successfully', + resumeSession: 'Resume Session', + resumeSessionSubtitle: 'Continue this conversation in a new session', + resumeSessionMachineOffline: 'Machine is offline', + failedToResumeSession: 'Failed to resume session', }, diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 41c17664e..522d50075 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -375,6 +375,10 @@ export const es: TranslationStructure = { deleteSessionWarning: 'Esta acción no se puede deshacer. Todos los mensajes y datos asociados con esta sesión se eliminarán permanentemente.', failedToDeleteSession: 'Error al eliminar la sesión', sessionDeleted: 'Sesión eliminada exitosamente', + resumeSession: 'Reanudar sesión', + resumeSessionSubtitle: 'Continuar esta conversación en una nueva sesión', + resumeSessionMachineOffline: 'La máquina está sin conexión', + failedToResumeSession: 'Error al reanudar la sesión', }, diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 6dbfb52ac..438fa9422 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -404,6 +404,10 @@ export const it: TranslationStructure = { deleteSessionWarning: 'Questa azione non può essere annullata. Tutti i messaggi e i dati associati a questa sessione verranno eliminati definitivamente.', failedToDeleteSession: 'Impossibile eliminare la sessione', sessionDeleted: 'Sessione eliminata con successo', + resumeSession: 'Riprendi sessione', + resumeSessionSubtitle: 'Continua questa conversazione in una nuova sessione', + resumeSessionMachineOffline: 'La macchina è offline', + failedToResumeSession: 'Impossibile riprendere la sessione', }, diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 090480d7a..3dcd7ffbd 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -407,6 +407,10 @@ export const ja: TranslationStructure = { deleteSessionWarning: 'この操作は取り消せません。このセッションに関連するすべてのメッセージとデータが完全に削除されます。', failedToDeleteSession: 'セッションの削除に失敗しました', sessionDeleted: 'セッションが正常に削除されました', + resumeSession: 'セッションを再開', + resumeSessionSubtitle: 'この会話を新しいセッションで続ける', + resumeSessionMachineOffline: 'マシンがオフラインです', + failedToResumeSession: 'セッションの再開に失敗しました', }, diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index 55401af6a..7c91a0f3a 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -386,6 +386,10 @@ export const pl: TranslationStructure = { deleteSessionWarning: 'Ta operacja jest nieodwracalna. Wszystkie wiadomości i dane powiązane z tą sesją zostaną trwale usunięte.', failedToDeleteSession: 'Nie udało się usunąć sesji', sessionDeleted: 'Sesja została pomyślnie usunięta', + resumeSession: 'Wznów sesję', + resumeSessionSubtitle: 'Kontynuuj tę rozmowę w nowej sesji', + resumeSessionMachineOffline: 'Maszyna jest offline', + failedToResumeSession: 'Nie udało się wznowić sesji', }, components: { diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index 75d9afed2..30f4f6674 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -375,6 +375,10 @@ export const pt: TranslationStructure = { deleteSessionWarning: 'Esta ação não pode ser desfeita. Todas as mensagens e dados associados a esta sessão serão excluídos permanentemente.', failedToDeleteSession: 'Falha ao excluir sessão', sessionDeleted: 'Sessão excluída com sucesso', + resumeSession: 'Retomar sessão', + resumeSessionSubtitle: 'Continuar esta conversa em uma nova sessão', + resumeSessionMachineOffline: 'A máquina está offline', + failedToResumeSession: 'Falha ao retomar a sessão', }, diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index 930474bb7..d854720ef 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -349,6 +349,10 @@ export const ru: TranslationStructure = { deleteSessionWarning: 'Это действие нельзя отменить. Все сообщения и данные, связанные с этой сессией, будут удалены навсегда.', failedToDeleteSession: 'Не удалось удалить сессию', sessionDeleted: 'Сессия успешно удалена', + resumeSession: 'Возобновить сессию', + resumeSessionSubtitle: 'Продолжить этот разговор в новой сессии', + resumeSessionMachineOffline: 'Машина не в сети', + failedToResumeSession: 'Не удалось возобновить сессию', }, components: { diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index a9db3ead3..2d538d1c6 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -377,6 +377,10 @@ export const zhHans: TranslationStructure = { deleteSessionWarning: '此操作无法撤销。与此会话相关的所有消息和数据将被永久删除。', failedToDeleteSession: '删除会话失败', sessionDeleted: '会话删除成功', + resumeSession: '恢复会话', + resumeSessionSubtitle: '在新会话中继续此对话', + resumeSessionMachineOffline: '机器离线', + failedToResumeSession: '恢复会话失败', }, diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index e09ca6f3c..b557be8cd 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -376,6 +376,10 @@ export const zhHant: TranslationStructure = { deleteSessionWarning: '此操作無法復原。與此工作階段相關的所有訊息和資料將被永久刪除。', failedToDeleteSession: '刪除工作階段失敗', sessionDeleted: '工作階段刪除成功', + resumeSession: '恢復工作階段', + resumeSessionSubtitle: '在新的工作階段中繼續此對話', + resumeSessionMachineOffline: '機器離線', + failedToResumeSession: '恢復工作階段失敗', }, diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 75889d14e..b51002dcc 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -215,13 +215,22 @@ export async function startDaemon(): Promise { } }; - // Spawn a new session (sessionId reserved for future --resume functionality) + // Spawn a new session (or resume an existing one if sessionId is provided) const spawnSession = async (options: SpawnSessionOptions): Promise => { logger.debugLargeJson('[DAEMON RUN] Spawning session', options); - const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; + const { directory, sessionId: rawSessionId, machineId, approvedNewDirectoryCreation = true } = options; let directoryCreated = false; + // Validate sessionId format to prevent command injection (especially in tmux path) + // Claude session IDs are UUIDs — reject anything that doesn't match + const sessionId = rawSessionId && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawSessionId) + ? rawSessionId + : undefined; + if (rawSessionId && !sessionId) { + logger.warn(`[DAEMON RUN] Invalid sessionId format rejected: ${rawSessionId}`); + } + try { await fs.access(directory); logger.debug(`[DAEMON RUN] Directory exists: ${directory}`); @@ -388,7 +397,11 @@ export async function startDaemon(): Promise { const cliPath = join(projectPath(), 'dist', 'index.mjs'); // Determine agent command - support claude, codex, and gemini const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude'); - const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon`; + let fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon`; + if (sessionId) { + fullCommand += ` --resume ${sessionId}`; + logger.debug(`[DAEMON RUN] Resuming Claude session in tmux: ${sessionId}`); + } // Spawn in tmux with environment variables // IMPORTANT: Pass complete environment (process.env + extraEnv) because: @@ -495,8 +508,12 @@ export async function startDaemon(): Promise { '--started-by', 'daemon' ]; - // TODO: In future, sessionId could be used with --resume to continue existing sessions - // For now, we ignore it - each spawn creates a new session + // If sessionId (Claude session ID) is provided, resume that conversation + if (sessionId) { + args.push('--resume', sessionId); + logger.debug(`[DAEMON RUN] Resuming Claude session: ${sessionId}`); + } + const happyProcess = spawnHappyCLI(args, { cwd: directory, detached: true, // Sessions stay alive when daemon stops