Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export async function claudeRemote(opts: {
// Fixed parameters
sessionId: string | null,
path: string,
sessionPath?: string,
mcpServers?: Record<string, any>,
claudeEnvVars?: Record<string, string>,
claudeArgs?: string[],
Expand All @@ -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,
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -235,4 +237,4 @@ export async function claudeRemote(opts: {
} finally {
updateThinking(false);
}
}
}
129 changes: 120 additions & 9 deletions src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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');

Expand Down Expand Up @@ -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');
Expand All @@ -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,
Expand All @@ -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;
}

Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -457,4 +568,4 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
}

return exitReason || 'exit';
}
}
28 changes: 22 additions & 6 deletions src/claude/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -33,6 +38,7 @@ export class Session {
path: string,
logPath: string,
sessionId: string | null,
sessionPath?: string,
claudeEnvVars?: Record<string, string>,
claudeArgs?: string[],
mcpServers: Record<string, any>,
Expand All @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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);
}
}
}
6 changes: 3 additions & 3 deletions src/claude/utils/claudeCheckSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -25,4 +25,4 @@ export function claudeCheckSession(sessionId: string, path: string) {
});

return hasGoodMessage;
}
}
15 changes: 12 additions & 3 deletions src/ui/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)
});
}
return JSON.stringify(arg);
}).join(' ')}\n`

// Send to remote server if configured
if (this.dangerouslyUnencryptedServerLoggingUrl) {
Expand Down