diff --git a/src/claude/claudeRemote.ts b/src/claude/claudeRemote.ts index 5c9698dc..ed253c85 100644 --- a/src/claude/claudeRemote.ts +++ b/src/claude/claudeRemote.ts @@ -16,6 +16,7 @@ export async function claudeRemote(opts: { // Fixed parameters sessionId: string | null, path: string, + sessionPath?: string, mcpServers?: Record, claudeEnvVars?: Record, claudeArgs?: string[], @@ -31,7 +32,7 @@ export async function claudeRemote(opts: { isAborted: (toolCallId: string) => boolean, // Callbacks - onSessionFound: (id: string) => void, + onSessionFound: (id: string, sessionPath?: string) => void, onThinkingChange?: (thinking: boolean) => void, onMessage: (message: SDKMessage) => void, onCompletionEvent?: (message: string) => void, @@ -40,7 +41,7 @@ export async function claudeRemote(opts: { // Check if session is valid let startFrom = opts.sessionId; - if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { + if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path, opts.sessionPath)) { startFrom = null; } @@ -178,10 +179,11 @@ export async function claudeRemote(opts: { // Start a watcher for to detect the session id if (systemInit.session_id) { logger.debug(`[claudeRemote] Waiting for session file to be written to disk: ${systemInit.session_id}`); - const projectDir = getProjectPath(opts.path); + const sessionCwd = systemInit.cwd ?? opts.path; + const projectDir = getProjectPath(sessionCwd); const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`)); logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`); - opts.onSessionFound(systemInit.session_id); + opts.onSessionFound(systemInit.session_id, sessionCwd); } } @@ -235,4 +237,4 @@ export async function claudeRemote(opts: { } finally { updateThinking(false); } -} \ No newline at end of file +} diff --git a/src/claude/claudeRemoteLauncher.ts b/src/claude/claudeRemoteLauncher.ts index b1888804..25e7cf58 100644 --- a/src/claude/claudeRemoteLauncher.ts +++ b/src/claude/claudeRemoteLauncher.ts @@ -6,7 +6,7 @@ import React from "react"; import { claudeRemote } from "./claudeRemote"; import { PermissionHandler } from "./utils/permissionHandler"; import { Future } from "@/utils/future"; -import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; +import { AbortError, SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk"; import { logger } from "@/ui/logger"; import { SDKToLogConverter } from "./utils/sdkToLogConverter"; @@ -23,6 +23,50 @@ interface PermissionsField { allowedTools?: string[]; } +type LaunchErrorInfo = { + asString: string; + name?: string; + message?: string; + code?: string; + stack?: string; +}; + +function getLaunchErrorInfo(e: unknown): LaunchErrorInfo { + let asString = '[unprintable error]'; + try { + asString = typeof e === 'string' ? e : String(e); + } catch { + // Ignore + } + + if (!e || typeof e !== 'object') { + return { asString }; + } + + const err = e as { name?: unknown; message?: unknown; code?: unknown; stack?: unknown }; + + const name = typeof err.name === 'string' ? err.name : undefined; + const message = typeof err.message === 'string' ? err.message : undefined; + const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : undefined; + const stack = typeof err.stack === 'string' ? err.stack : undefined; + + return { asString, name, message, code, stack }; +} + +function isAbortError(e: unknown): boolean { + if (e instanceof AbortError) return true; + + if (!e || typeof e !== 'object') { + return false; + } + + const err = e as { name?: unknown; code?: unknown }; + if (typeof err.name === 'string' && err.name === 'AbortError') return true; + if (typeof err.code === 'string' && err.code === 'ABORT_ERR') return true; + + return false; +} + export async function claudeRemoteLauncher(session: Session): Promise<'switch' | 'exit'> { logger.debug('[claudeRemoteLauncher] Starting remote launcher'); @@ -301,6 +345,15 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // actually changes (e.g., new session started or /clear command used). // See: https://github.com/anthropics/happy-cli/issues/143 let previousSessionId: string | null = null; + + // Track consecutive crashes to prevent infinite restart loops + // Resets to 0 on successful message exchange + let consecutiveCrashes = 0; + const MAX_CONSECUTIVE_CRASHES = 3; + + // Track last message sent to Claude for crash recovery resend + let lastSentMessage: { message: string; mode: EnhancedMode } | null = null; + while (!exitReason) { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); @@ -327,6 +380,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | const remoteResult = await claudeRemote({ sessionId: session.sessionId, path: session.path, + sessionPath: session.sessionInfo?.path, allowedTools: session.allowedTools ?? [], mcpServers: session.mcpServers, hookSettingsPath: session.hookSettingsPath, @@ -335,10 +389,21 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | return permissionHandler.isAborted(toolCallId); }, nextMessage: async () => { + // On crash recovery, resend the last message that was being processed (once only) + if (consecutiveCrashes > 0 && lastSentMessage) { + logger.debug('[remote]: resending last message after crash recovery (one-time)'); + const resend = lastSentMessage; + // Clear immediately - only resend once, not on every subsequent crash + lastSentMessage = null; + permissionHandler.handleModeChange(resend.mode.permissionMode); + return resend; + } + if (pending) { let p = pending; pending = null; permissionHandler.handleModeChange(p.mode.permissionMode); + lastSentMessage = p; return p; } @@ -354,19 +419,21 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | modeHash = msg.hash; mode = msg.mode; permissionHandler.handleModeChange(mode.permissionMode); - return { + const result = { message: msg.message, mode: msg.mode - } + }; + lastSentMessage = result; + return result; } // Exit return null; }, - onSessionFound: (sessionId) => { + onSessionFound: (sessionId, sessionPath) => { // Update converter's session ID when new session is found sdkToLogConverter.updateSessionId(sessionId); - session.onSessionFound(sessionId); + session.onSessionFound(sessionId, sessionPath); }, onThinkingChange: session.onThinkingChange, claudeEnvVars: session.claudeEnvVars, @@ -395,14 +462,58 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Consume one-time Claude flags after spawn session.consumeOneTimeFlags(); - + + // Reset crash counter and clear last message on successful completion + consecutiveCrashes = 0; + lastSentMessage = null; + if (!exitReason && abortController.signal.aborted) { session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } } catch (e) { - logger.debug('[remote]: launch error', e); + const abortError = isAbortError(e); + const errorInfo = getLaunchErrorInfo(e); + logger.debug('[remote]: launch error', { + ...errorInfo, + abortError, + consecutiveCrashes, + }); + if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + if (abortError) { + if (controller.signal.aborted) { + session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } + continue; + } + + consecutiveCrashes++; + + // Check if we've hit the crash limit + if (consecutiveCrashes >= MAX_CONSECUTIVE_CRASHES) { + logger.debug(`[remote]: Max consecutive crashes (${MAX_CONSECUTIVE_CRASHES}) reached, stopping`); + session.client.sendSessionEvent({ + type: 'message', + message: `Session stopped after ${MAX_CONSECUTIVE_CRASHES} consecutive crashes. Please try again.` + }); + break; // Exit the while loop instead of continuing + } + + // Provide more helpful message based on error + const isProcessExit = errorInfo.message?.includes('exited with code'); + const willResend = lastSentMessage !== null; + const resendNote = willResend ? ' Your message will be resent.' : ''; + if (isProcessExit) { + session.client.sendSessionEvent({ + type: 'message', + message: `Claude process crashed, restarting... (attempt ${consecutiveCrashes}/${MAX_CONSECUTIVE_CRASHES})${resendNote}` + }); + } else { + session.client.sendSessionEvent({ + type: 'message', + message: `Process exited unexpectedly, restarting... (attempt ${consecutiveCrashes}/${MAX_CONSECUTIVE_CRASHES})${resendNote}` + }); + } continue; } } finally { @@ -457,4 +568,4 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | } return exitReason || 'exit'; -} \ No newline at end of file +} diff --git a/src/claude/session.ts b/src/claude/session.ts index 7a5b7fe7..27fa56c5 100644 --- a/src/claude/session.ts +++ b/src/claude/session.ts @@ -3,6 +3,11 @@ import { MessageQueue2 } from "@/utils/MessageQueue2"; import { EnhancedMode } from "./loop"; import { logger } from "@/ui/logger"; +interface SessionInfo { + id: string; + path: string; +} + export class Session { readonly path: string; readonly logPath: string; @@ -17,7 +22,7 @@ export class Session { /** Path to temporary settings file with SessionStart hook (required for session tracking) */ readonly hookSettingsPath: string; - sessionId: string | null; + sessionInfo: SessionInfo | null; mode: 'local' | 'remote' = 'local'; thinking: boolean = false; @@ -33,6 +38,7 @@ export class Session { path: string, logPath: string, sessionId: string | null, + sessionPath?: string, claudeEnvVars?: Record, claudeArgs?: string[], mcpServers: Record, @@ -46,7 +52,10 @@ export class Session { this.api = opts.api; this.client = opts.client; this.logPath = opts.logPath; - this.sessionId = opts.sessionId; + this.sessionInfo = opts.sessionId ? { + id: opts.sessionId, + path: opts.sessionPath ?? opts.path + } : null; this.queue = opts.messageQueue; this.claudeEnvVars = opts.claudeEnvVars; this.claudeArgs = opts.claudeArgs; @@ -61,6 +70,10 @@ export class Session { this.client.keepAlive(this.thinking, this.mode); }, 2000); } + + get sessionId(): string | null { + return this.sessionInfo?.id ?? null; + } /** * Cleanup resources (call when session is no longer needed) @@ -93,8 +106,11 @@ export class Session { * Updates internal state, syncs to API metadata, and notifies * all registered callbacks (e.g., SessionScanner) about the change. */ - onSessionFound = (sessionId: string) => { - this.sessionId = sessionId; + onSessionFound = (sessionId: string, sessionPath?: string) => { + this.sessionInfo = { + id: sessionId, + path: sessionPath ?? this.path + }; // Update metadata with Claude Code session ID this.client.updateMetadata((metadata) => ({ @@ -130,7 +146,7 @@ export class Session { * Clear the current session ID (used by /clear command) */ clearSessionId = (): void => { - this.sessionId = null; + this.sessionInfo = null; logger.debug('[Session] Session ID cleared'); } @@ -176,4 +192,4 @@ export class Session { this.claudeArgs = filteredArgs.length > 0 ? filteredArgs : undefined; logger.debug(`[Session] Consumed one-time flags, remaining args:`, this.claudeArgs); } -} \ No newline at end of file +} diff --git a/src/claude/utils/claudeCheckSession.ts b/src/claude/utils/claudeCheckSession.ts index 27661205..2cd88378 100644 --- a/src/claude/utils/claudeCheckSession.ts +++ b/src/claude/utils/claudeCheckSession.ts @@ -3,8 +3,8 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getProjectPath } from "./path"; -export function claudeCheckSession(sessionId: string, path: string) { - const projectDir = getProjectPath(path); +export function claudeCheckSession(sessionId: string, path: string, sessionPath?: string) { + const projectDir = getProjectPath(sessionPath ?? path); // Check if session id is in the project dir const sessionFile = join(projectDir, `${sessionId}.jsonl`); @@ -25,4 +25,4 @@ export function claudeCheckSession(sessionId: string, path: string) { }); return hasGoodMessage; -} \ No newline at end of file +} diff --git a/src/ui/logger.ts b/src/ui/logger.ts index ecf61936..63964d76 100644 --- a/src/ui/logger.ts +++ b/src/ui/logger.ts @@ -200,9 +200,18 @@ class Logger { } private logToFile(prefix: string, message: string, ...args: unknown[]): void { - const logLine = `${prefix} ${message} ${args.map(arg => - typeof arg === 'string' ? arg : JSON.stringify(arg) - ).join(' ')}\n` + const logLine = `${prefix} ${message} ${args.map(arg => { + if (typeof arg === 'string') return arg; + if (arg instanceof Error) { + return JSON.stringify({ + name: arg.name, + message: arg.message, + stack: arg.stack, + ...(arg as unknown as Record) + }); + } + return JSON.stringify(arg); + }).join(' ')}\n` // Send to remote server if configured if (this.dangerouslyUnencryptedServerLoggingUrl) {