diff --git a/cli/src/agent/localLaunchPolicy.ts b/cli/src/agent/localLaunchPolicy.ts new file mode 100644 index 00000000..c879b3a2 --- /dev/null +++ b/cli/src/agent/localLaunchPolicy.ts @@ -0,0 +1,16 @@ +export type StartedBy = 'daemon' | 'terminal'; + +export type LocalLaunchExitReason = 'switch' | 'exit'; + +export type LocalLaunchContext = { + startedBy?: StartedBy; + startingMode?: 'local' | 'remote'; +}; + +export function getLocalLaunchExitReason(context: LocalLaunchContext): LocalLaunchExitReason { + if (context.startedBy === 'daemon' || context.startingMode === 'remote') { + return 'switch'; + } + + return 'exit'; +} diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index 43ce6a96..678ed924 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -3,6 +3,7 @@ import { claudeLocal } from "./claudeLocal"; import { Session } from "./session"; import { Future } from "@/utils/future"; import { createSessionScanner } from "./utils/sessionScanner"; +import { getLocalLaunchExitReason } from "@/agent/localLaunchPolicy"; export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { @@ -112,12 +113,20 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | } } catch (e) { logger.debug('[local]: launch error', e); + const message = e instanceof Error ? e.message : String(e); + session.client.sendSessionEvent({ type: 'message', message: `Local Claude process failed: ${message}` }); + const failureExitReason = exitReason ?? getLocalLaunchExitReason({ + startedBy: session.startedBy, + startingMode: session.startingMode + }); + session.recordLocalLaunchFailure(message, failureExitReason); if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); - continue; - } else { - break; + exitReason = failureExitReason; } + if (failureExitReason === 'exit') { + logger.warn(`[local]: Local Claude process failed: ${message}`); + } + break; } logger.debug('[local]: launch done'); } diff --git a/cli/src/claude/loop.ts b/cli/src/claude/loop.ts index de4c4e31..6871383e 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/claude/loop.ts @@ -24,6 +24,7 @@ interface LoopOptions { model?: string permissionMode?: PermissionMode startingMode?: 'local' | 'remote' + startedBy?: 'daemon' | 'terminal' onModeChange: (mode: 'local' | 'remote') => void mcpServers: Record session: ApiSessionClient @@ -40,6 +41,8 @@ export async function loop(opts: LoopOptions) { // Get log path for debug display const logPath = logger.logFilePath; + const startedBy = opts.startedBy ?? 'terminal'; + const startingMode = opts.startingMode ?? 'local'; let session = new Session({ api: opts.api, client: opts.session, @@ -52,7 +55,9 @@ export async function loop(opts: LoopOptions) { messageQueue: opts.messageQueue, allowedTools: opts.allowedTools, onModeChange: opts.onModeChange, - mode: opts.startingMode, + mode: startingMode, + startedBy, + startingMode, hookSettingsPath: opts.hookSettingsPath }); diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 583f26b2..4d9d7d28 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -37,13 +37,14 @@ export interface StartOptions { export async function runClaude(options: StartOptions = {}): Promise { const workingDirectory = process.cwd(); const sessionTag = randomUUID(); + const startedBy = options.startedBy ?? 'terminal'; // Log environment info at startup logger.debugLargeJson('[START] HAPI process started', getEnvironmentInfo()); - logger.debug(`[START] Options: startedBy=${options.startedBy}, startingMode=${options.startingMode}`); + logger.debug(`[START] Options: startedBy=${startedBy}, startingMode=${options.startingMode}`); // Validate daemon spawn requirements - if (options.startedBy === 'daemon' && options.startingMode === 'local') { + if (startedBy === 'daemon' && options.startingMode === 'local') { logger.debug('Daemon spawn requested with local mode - forcing remote mode'); options.startingMode = 'remote'; // TODO: Eventually we should error here instead of silently switching @@ -81,9 +82,9 @@ export async function runClaude(options: StartOptions = {}): Promise { happyHomeDir: configuration.happyHomeDir, happyLibDir: runtimePath(), happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'), - startedFromDaemon: options.startedBy === 'daemon', + startedFromDaemon: startedBy === 'daemon', hostPid: process.pid, - startedBy: options.startedBy || 'terminal', + startedBy, // Initialize lifecycle state lifecycleState: 'running', lifecycleStateSince: Date.now(), @@ -130,6 +131,16 @@ export async function runClaude(options: StartOptions = {}): Promise { // Variable to track current session instance (updated via onSessionReady callback) let currentSession: Session | null = null; + let exitCode = 0; + let archiveReason: string | null = null; + + const formatFailureReason = (message: string): string => { + const maxLength = 200; + if (message.length <= maxLength) { + return message; + } + return `${message.slice(0, maxLength)}...`; + }; // Start Hook server for receiving Claude session notifications const hookServer = await startHookServer({ @@ -156,9 +167,10 @@ export async function runClaude(options: StartOptions = {}): Promise { logger.infoDeveloper(`Logs: ${logPath}`); // Set initial agent state + const startingMode = options.startingMode ?? (startedBy === 'daemon' ? 'remote' : 'local'); session.updateAgentState((currentState) => ({ ...currentState, - controlledByUser: options.startingMode !== 'remote' + controlledByUser: startingMode !== 'remote' })); // Import MessageQueue2 and create message queue @@ -314,12 +326,13 @@ export async function runClaude(options: StartOptions = {}): Promise { try { // Update lifecycle state to archived before closing if (session) { + const reason = archiveReason ?? 'User terminated'; session.updateMetadata((currentMetadata) => ({ ...currentMetadata, lifecycleState: 'archived', lifecycleStateSince: Date.now(), archivedBy: 'cli', - archiveReason: 'User terminated' + archiveReason: reason })); // Send session death message @@ -336,7 +349,7 @@ export async function runClaude(options: StartOptions = {}): Promise { cleanupHookSettingsFile(hookSettingsPath); logger.debug('[START] Cleanup complete, exiting'); - process.exit(0); + process.exit(exitCode); } catch (error) { logger.debug('[START] Error during cleanup:', error); process.exit(1); @@ -350,11 +363,15 @@ export async function runClaude(options: StartOptions = {}): Promise { // Handle uncaught exceptions and rejections process.on('uncaughtException', (error) => { logger.debug('[START] Uncaught exception:', error); + exitCode = 1; + archiveReason = 'Session crashed'; cleanup(); }); process.on('unhandledRejection', (reason) => { logger.debug('[START] Unhandled rejection:', reason); + exitCode = 1; + archiveReason = 'Session crashed'; cleanup(); }); @@ -365,7 +382,7 @@ export async function runClaude(options: StartOptions = {}): Promise { path: workingDirectory, model: options.model, permissionMode: options.permissionMode, - startingMode: options.startingMode, + startingMode, messageQueue, api, allowedTools: happyServer.toolNames.map(toolName => `mcp__hapi__${toolName}`), @@ -388,9 +405,23 @@ export async function runClaude(options: StartOptions = {}): Promise { session, claudeEnvVars: options.claudeEnvVars, claudeArgs: options.claudeArgs, + startedBy, hookSettingsPath }); + const localFailure = currentSession?.localLaunchFailure; + if (localFailure?.exitReason === 'exit') { + exitCode = 1; + archiveReason = `Local launch failed: ${formatFailureReason(localFailure.message)}`; + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason + })); + } + // Send session death message session.sendSessionDeath(); @@ -412,5 +443,5 @@ export async function runClaude(options: StartOptions = {}): Promise { logger.debug('Stopped Hook server and cleaned up settings file'); // Exit - process.exit(0); + process.exit(exitCode); } diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 2d3015e9..ced5ee3a 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -3,6 +3,12 @@ import { MessageQueue2 } from '@/utils/MessageQueue2'; import { logger } from '@/ui/logger'; import { AgentSessionBase } from '@/agent/sessionBase'; import type { EnhancedMode } from './loop'; +import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; + +type LocalLaunchFailure = { + message: string; + exitReason: LocalLaunchExitReason; +}; export class Session extends AgentSessionBase { readonly claudeEnvVars?: Record; @@ -10,6 +16,9 @@ export class Session extends AgentSessionBase { readonly mcpServers: Record; readonly allowedTools?: string[]; readonly hookSettingsPath: string; + readonly startedBy: 'daemon' | 'terminal'; + readonly startingMode: 'local' | 'remote'; + localLaunchFailure: LocalLaunchFailure | null = null; constructor(opts: { api: ApiClient; @@ -24,6 +33,8 @@ export class Session extends AgentSessionBase { onModeChange: (mode: 'local' | 'remote') => void; allowedTools?: string[]; mode?: 'local' | 'remote'; + startedBy: 'daemon' | 'terminal'; + startingMode: 'local' | 'remote'; hookSettingsPath: string; }) { super({ @@ -48,8 +59,14 @@ export class Session extends AgentSessionBase { this.mcpServers = opts.mcpServers; this.allowedTools = opts.allowedTools; this.hookSettingsPath = opts.hookSettingsPath; + this.startedBy = opts.startedBy; + this.startingMode = opts.startingMode; } + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { + this.localLaunchFailure = { message, exitReason }; + }; + /** * Clear the current session ID (used by /clear command) */ diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index d9a8ce05..9d15ff53 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -4,6 +4,7 @@ import { CodexSession } from './session'; import { Future } from '@/utils/future'; import { createCodexSessionScanner } from './utils/codexSessionScanner'; import { convertCodexEvent } from './utils/codexEventConverter'; +import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy'; export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { const scanner = await createCodexSessionScanner({ @@ -93,8 +94,16 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch logger.debug('[codex-local]: launch error', error); const message = error instanceof Error ? error.message : String(error); session.sendSessionEvent({ type: 'message', message: `Local Codex process failed: ${message}` }); + const failureExitReason = exitReason ?? getLocalLaunchExitReason({ + startedBy: session.startedBy, + startingMode: session.startingMode + }); + session.recordLocalLaunchFailure(message, failureExitReason); if (!exitReason) { - exitReason = 'switch'; + exitReason = failureExitReason; + } + if (failureExitReason === 'exit') { + logger.warn(`[codex-local]: Local Codex process failed: ${message}`); } break; } diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 39121361..fe754cb7 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -17,6 +17,7 @@ export interface EnhancedMode { interface LoopOptions { path: string; startingMode?: 'local' | 'remote'; + startedBy?: 'daemon' | 'terminal'; onModeChange: (mode: 'local' | 'remote') => void; messageQueue: MessageQueue2; session: ApiSessionClient; @@ -28,6 +29,8 @@ interface LoopOptions { export async function loop(opts: LoopOptions): Promise { const logPath = logger.getLogPath(); + const startedBy = opts.startedBy ?? 'terminal'; + const startingMode = opts.startingMode ?? 'local'; const session = new CodexSession({ api: opts.api, client: opts.session, @@ -36,7 +39,9 @@ export async function loop(opts: LoopOptions): Promise { logPath, messageQueue: opts.messageQueue, onModeChange: opts.onModeChange, - mode: opts.startingMode ?? 'local', + mode: startingMode, + startedBy, + startingMode, codexArgs: opts.codexArgs, codexCliOverrides: opts.codexCliOverrides }); diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 406c2a3c..9c9e397a 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -27,8 +27,9 @@ export async function runCodex(opts: { }): Promise { const workingDirectory = process.cwd(); const sessionTag = randomUUID(); + const startedBy = opts.startedBy ?? 'terminal'; - logger.debug(`[codex] Starting with options: startedBy=${opts.startedBy || 'terminal'}`); + logger.debug(`[codex] Starting with options: startedBy=${startedBy}`); const api = await ApiClient.create(); @@ -59,9 +60,9 @@ export async function runCodex(opts: { happyHomeDir: configuration.happyHomeDir, happyLibDir: runtimePath(), happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'), - startedFromDaemon: opts.startedBy === 'daemon', + startedFromDaemon: startedBy === 'daemon', hostPid: process.pid, - startedBy: opts.startedBy || 'terminal', + startedBy, lifecycleState: 'running', lifecycleStateSince: Date.now(), flavor: 'codex' @@ -82,7 +83,7 @@ export async function runCodex(opts: { logger.debug('[START] Failed to report to daemon (may not be running):', error); } - const startingMode: 'local' | 'remote' = opts.startedBy === 'daemon' ? 'remote' : 'local'; + const startingMode: 'local' | 'remote' = startedBy === 'daemon' ? 'remote' : 'local'; session.updateAgentState((currentState) => ({ ...currentState, @@ -134,6 +135,15 @@ export async function runCodex(opts: { let cleanupStarted = false; let exitCode = 0; + let archiveReason = 'User terminated'; + + const formatFailureReason = (message: string): string => { + const maxLength = 200; + if (message.length <= maxLength) { + return message; + } + return `${message.slice(0, maxLength)}...`; + }; const cleanup = async (code: number = exitCode) => { if (cleanupStarted) { @@ -152,7 +162,7 @@ export async function runCodex(opts: { lifecycleState: 'archived', lifecycleStateSince: Date.now(), archivedBy: 'cli', - archiveReason: 'User terminated' + archiveReason })); session.sendSessionDeath(); @@ -173,12 +183,14 @@ export async function runCodex(opts: { process.on('uncaughtException', (error) => { logger.debug('[codex] Uncaught exception:', error); exitCode = 1; + archiveReason = 'Session crashed'; cleanup(1); }); process.on('unhandledRejection', (reason) => { logger.debug('[codex] Unhandled rejection:', reason); exitCode = 1; + archiveReason = 'Session crashed'; cleanup(1); }); @@ -194,6 +206,7 @@ export async function runCodex(opts: { session, codexArgs: opts.codexArgs, codexCliOverrides, + startedBy, onModeChange: (newMode) => { session.sendSessionEvent({ type: 'switch', mode: newMode }); session.updateAgentState((currentState) => ({ @@ -208,8 +221,14 @@ export async function runCodex(opts: { } catch (error) { loopError = error; exitCode = 1; + archiveReason = 'Session crashed'; logger.debug('[codex] Loop error:', error); } finally { + const localFailure = sessionWrapper?.localLaunchFailure; + if (localFailure?.exitReason === 'exit') { + exitCode = 1; + archiveReason = `Local launch failed: ${formatFailureReason(localFailure.message)}`; + } await cleanup(loopError ? 1 : exitCode); } } diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index ef05f03f..2ab0833d 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -3,10 +3,19 @@ import { MessageQueue2 } from '@/utils/MessageQueue2'; import { AgentSessionBase } from '@/agent/sessionBase'; import type { EnhancedMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; +import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; + +type LocalLaunchFailure = { + message: string; + exitReason: LocalLaunchExitReason; +}; export class CodexSession extends AgentSessionBase { readonly codexArgs?: string[]; readonly codexCliOverrides?: CodexCliOverrides; + readonly startedBy: 'daemon' | 'terminal'; + readonly startingMode: 'local' | 'remote'; + localLaunchFailure: LocalLaunchFailure | null = null; constructor(opts: { api: ApiClient; @@ -17,6 +26,8 @@ export class CodexSession extends AgentSessionBase { messageQueue: MessageQueue2; onModeChange: (mode: 'local' | 'remote') => void; mode?: 'local' | 'remote'; + startedBy: 'daemon' | 'terminal'; + startingMode: 'local' | 'remote'; codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; }) { @@ -39,8 +50,14 @@ export class CodexSession extends AgentSessionBase { this.codexArgs = opts.codexArgs; this.codexCliOverrides = opts.codexCliOverrides; + this.startedBy = opts.startedBy; + this.startingMode = opts.startingMode; } + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { + this.localLaunchFailure = { message, exitReason }; + }; + sendCodexMessage = (message: unknown): void => { this.client.sendCodexMessage(message); };