From 45e32f15353e46384147a554bb8ba50f336af1dd Mon Sep 17 00:00:00 2001 From: Gershom Rogers Date: Mon, 12 Jan 2026 14:51:34 -0500 Subject: [PATCH 1/3] fix(cli): add session path tracking and improve error serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Task subagents create sessions in different directories (e.g., worktrees), the session validation now uses the path where the session was created rather than the current working directory. This fixes "Process exited unexpectedly" errors when mobile users send messages during parallel prompt execution. Also fixes error logging - Error objects were being logged as `{}` because JSON.stringify doesn't capture non-enumerable Error properties. Changes: - Add SessionInfo interface with id and path fields - Update Session class to track session creation path - Modify claudeCheckSession to accept optional sessionPath - Extract cwd from SDKSystemMessage for session tracking - Logger now properly serializes Error objects with name/message/stack - Added getLaunchErrorInfo helper for structured error logging 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/claude/claudeRemote.ts | 12 +++-- src/claude/claudeRemoteLauncher.ts | 67 ++++++++++++++++++++++++-- src/claude/session.ts | 28 ++++++++--- src/claude/utils/claudeCheckSession.ts | 6 +-- src/ui/logger.ts | 15 ++++-- 5 files changed, 106 insertions(+), 22 deletions(-) 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..eb3f876b 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'); @@ -327,6 +371,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, @@ -363,10 +408,10 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // 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, @@ -400,8 +445,20 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } } catch (e) { - logger.debug('[remote]: launch error', e); + const abortError = isAbortError(e); + logger.debug('[remote]: launch error', { + ...getLaunchErrorInfo(e), + abortError, + }); + if (!exitReason) { + if (abortError) { + if (controller.signal.aborted) { + session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } + continue; + } + session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); continue; } @@ -457,4 +514,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) { From 712670788982d76a820207d0051d99f0faa2b355 Mon Sep 17 00:00:00 2001 From: Gershom Rogers Date: Mon, 12 Jan 2026 15:40:01 -0500 Subject: [PATCH 2/3] fix(cli): add crash recovery with retry limit and better error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Claude Code crashes (e.g., from MaxFileReadTokenExceededError), Happy now: - Tracks consecutive crashes and limits to 3 retries - Shows informative messages: "Claude process crashed, restarting... (attempt 1/3)" - Stops gracefully after max retries instead of infinite loop - Resets crash counter on successful completion This improves UX when Claude hits edge cases like reading files that exceed the 25K token limit, which can crash the process instead of being handled gracefully. 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/claude/claudeRemoteLauncher.ts | 41 +++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/claude/claudeRemoteLauncher.ts b/src/claude/claudeRemoteLauncher.ts index eb3f876b..6e301d28 100644 --- a/src/claude/claudeRemoteLauncher.ts +++ b/src/claude/claudeRemoteLauncher.ts @@ -345,6 +345,12 @@ 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; + while (!exitReason) { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); @@ -440,15 +446,20 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Consume one-time Claude flags after spawn session.consumeOneTimeFlags(); - + + // Reset crash counter on successful completion + consecutiveCrashes = 0; + if (!exitReason && abortController.signal.aborted) { session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } } catch (e) { const abortError = isAbortError(e); + const errorInfo = getLaunchErrorInfo(e); logger.debug('[remote]: launch error', { - ...getLaunchErrorInfo(e), + ...errorInfo, abortError, + consecutiveCrashes, }); if (!exitReason) { @@ -459,7 +470,31 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | continue; } - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + 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'); + if (isProcessExit) { + session.client.sendSessionEvent({ + type: 'message', + message: `Claude process crashed, restarting... (attempt ${consecutiveCrashes}/${MAX_CONSECUTIVE_CRASHES})` + }); + } else { + session.client.sendSessionEvent({ + type: 'message', + message: `Process exited unexpectedly, restarting... (attempt ${consecutiveCrashes}/${MAX_CONSECUTIVE_CRASHES})` + }); + } continue; } } finally { From 5d55225bf3f5d61fb969e5c06adcaf389e3cf960 Mon Sep 17 00:00:00 2001 From: Gershom Rogers Date: Mon, 12 Jan 2026 17:26:36 -0500 Subject: [PATCH 3/3] fix(cli): auto-resend message on crash recovery When Claude crashes and recovers, automatically resend the last message that was being processed. This prevents users from having to manually resend their message after a crash. - Track lastSentMessage for crash recovery resend - Clear immediately after resend (one-time only, prevents loops) - Show "Your message will be resent." in recovery notification - Reset lastSentMessage on successful completion Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/claude/claudeRemoteLauncher.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/claude/claudeRemoteLauncher.ts b/src/claude/claudeRemoteLauncher.ts index 6e301d28..25e7cf58 100644 --- a/src/claude/claudeRemoteLauncher.ts +++ b/src/claude/claudeRemoteLauncher.ts @@ -351,6 +351,9 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | 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'); @@ -386,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; } @@ -405,10 +419,12 @@ 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 @@ -447,8 +463,9 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Consume one-time Claude flags after spawn session.consumeOneTimeFlags(); - // Reset crash counter on successful completion + // 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' }); @@ -484,15 +501,17 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // 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})` + 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})` + message: `Process exited unexpectedly, restarting... (attempt ${consecutiveCrashes}/${MAX_CONSECUTIVE_CRASHES})${resendNote}` }); } continue;