From c5190b407975ff21d4790ab740b117efef689b48 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 29 Jan 2026 16:43:46 +0800 Subject: [PATCH] refactor: extract local launcher logic into shared BaseLocalLauncher class --- bun.lock | 2 + cli/src/claude/claudeLocalLauncher.ts | 152 ++++------------ cli/src/codex/codexLocalLauncher.ts | 139 ++++---------- cli/src/gemini/geminiLocalLauncher.ts | 116 +++--------- .../common/launcher/BaseLocalLauncher.ts | 169 ++++++++++++++++++ cli/src/opencode/opencodeLocalLauncher.ts | 131 ++++---------- 6 files changed, 305 insertions(+), 404 deletions(-) create mode 100644 cli/src/modules/common/launcher/BaseLocalLauncher.ts diff --git a/bun.lock b/bun.lock index a6638e25..6eddd503 100644 --- a/bun.lock +++ b/bun.lock @@ -858,6 +858,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.15.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-Z3sskDR2DDLGXEOWSKzNXFLOwiul5a1IjNx7ChEm7dYMWGfbZoweuYLTEzDd+7BEao1Ca+kUMNe4hHKnkL3yhQ=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.15.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-eRS1u8zMi/GQa99JMLG6kFl3ModSBgHYqMrVNogI3RoM9F9LL0T3mkwekeawWrKhI4dn++xmoqrC1Zl4z/xHDA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index abaccb0b..5c1c5ac6 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -1,9 +1,7 @@ -import { logger } from "@/ui/logger"; import { claudeLocal } from "./claudeLocal"; import { Session } from "./session"; -import { Future } from "@/utils/future"; import { createSessionScanner } from "./utils/sessionScanner"; -import { getLocalLaunchExitReason } from "@/agent/localLaunchPolicy"; +import { BaseLocalLauncher } from "@/modules/common/launcher/BaseLocalLauncher"; export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { @@ -25,126 +23,42 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | session.addSessionFoundCallback(handleSessionFound); - // Handle abort - let exitReason: 'switch' | 'exit' | null = null; - const processAbortController = new AbortController(); - const exitFuture = new Future(); + const launcher = new BaseLocalLauncher({ + label: 'local', + failureLabel: 'Local Claude process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + await claudeLocal({ + path: session.path, + sessionId: session.sessionId, + abort: abortSignal, + claudeEnvVars: session.claudeEnvVars, + claudeArgs: session.claudeArgs, + mcpServers: session.mcpServers, + allowedTools: session.allowedTools, + hookSettingsPath: session.hookSettingsPath, + }); + }, + onLaunchSuccess: () => { + session.consumeOneTimeFlags(); + }, + sendFailureMessage: (message) => { + session.client.sendSessionEvent({ type: 'message', message }); + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason); + }, + abortLogMessage: 'doAbort', + switchLogMessage: 'doSwitch' + }); try { - async function abort() { - - // Send abort signal - if (!processAbortController.signal.aborted) { - processAbortController.abort(); - } - - // Await full exit - await exitFuture.promise; - } - - async function doAbort() { - logger.debug('[local]: doAbort'); - - // Switching to remote mode - if (!exitReason) { - exitReason = 'switch'; - } - - // Reset sent messages - session.queue.reset(); - - // Abort - await abort(); - } - - async function doSwitch() { - logger.debug('[local]: doSwitch'); - - // Switching to remote mode - if (!exitReason) { - exitReason = 'switch'; - } - - // Abort - await abort(); - } - - // When to abort - session.client.rpcHandlerManager.registerHandler('abort', doAbort); // Abort current process, clean queue and switch to remote mode - session.client.rpcHandlerManager.registerHandler('switch', doSwitch); // When user wants to switch to remote mode - session.queue.setOnMessage((message: string, mode) => { - // Switch to remote mode when message received - doSwitch(); - }); // When any message is received, abort current process, clean queue and switch to remote mode - - // Exit if there are messages in the queue - if (session.queue.size() > 0) { - return 'switch'; - } - - // Run local mode - while (true) { - // If we already have an exit reason, return it - if (exitReason) { - return exitReason; - } - - // Launch - logger.debug('[local]: launch'); - try { - await claudeLocal({ - path: session.path, - sessionId: session.sessionId, - abort: processAbortController.signal, - claudeEnvVars: session.claudeEnvVars, - claudeArgs: session.claudeArgs, - mcpServers: session.mcpServers, - allowedTools: session.allowedTools, - hookSettingsPath: session.hookSettingsPath, - }); - - // Consume one-time Claude flags after spawn - // For example we don't want to pass --resume flag after first spawn - session.consumeOneTimeFlags(); - - // Normal exit - if (!exitReason) { - exitReason = 'exit'; - break; - } - } 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) { - exitReason = failureExitReason; - } - if (failureExitReason === 'exit') { - logger.warn(`[local]: Local Claude process failed: ${message}`); - } - break; - } - logger.debug('[local]: launch done'); - } + return await launcher.run(); } finally { - - // Resolve future - exitFuture.resolve(undefined); - - // Set handlers to no-op - session.client.rpcHandlerManager.registerHandler('abort', async () => { }); - session.client.rpcHandlerManager.registerHandler('switch', async () => { }); - session.queue.setOnMessage(null); - // Cleanup session.removeSessionFoundCallback(handleSessionFound); await scanner.cleanup(); } - - // Return - return exitReason || 'exit'; } diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 69f8bc74..482803c2 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -1,34 +1,58 @@ import { logger } from '@/ui/logger'; import { codexLocal } from './codexLocal'; import { CodexSession } from './session'; -import { Future } from '@/utils/future'; import { createCodexSessionScanner } from './utils/codexSessionScanner'; import { convertCodexEvent } from './utils/codexEventConverter'; -import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge'; +import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { - let exitReason: 'switch' | 'exit' | null = null; - const processAbortController = new AbortController(); - const exitFuture = new Future(); const resumeSessionId = session.sessionId; + let scanner: Awaited> | null = null; // Start hapi hub for MCP bridge (same as remote mode) const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); logger.debug(`[codex-local]: Started hapi MCP bridge server at ${happyServer.url}`); + const handleSessionFound = (sessionId: string) => { + session.onSessionFound(sessionId); + scanner?.onNewSession(sessionId); + }; + + const launcher = new BaseLocalLauncher({ + label: 'codex-local', + failureLabel: 'Local Codex process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + await codexLocal({ + path: session.path, + sessionId: resumeSessionId, + onSessionFound: handleSessionFound, + abort: abortSignal, + codexArgs: session.codexArgs, + mcpServers + }); + }, + sendFailureMessage: (message) => { + session.sendSessionEvent({ type: 'message', message }); + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason); + }, + abortLogMessage: 'doAbort', + switchLogMessage: 'doSwitch' + }); + const handleSessionMatchFailed = (message: string) => { logger.warn(`[codex-local]: ${message}`); session.sendSessionEvent({ type: 'message', message }); - if (!exitReason) { - exitReason = 'exit'; - } - if (!processAbortController.signal.aborted) { - processAbortController.abort(); - } + launcher.control.requestExit(); }; - const scanner = await createCodexSessionScanner({ + scanner = await createCodexSessionScanner({ sessionId: resumeSessionId, cwd: session.path, startupTimestampMs: Date.now(), @@ -40,7 +64,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch const converted = convertCodexEvent(event); if (converted?.sessionId) { session.onSessionFound(converted.sessionId); - scanner.onNewSession(converted.sessionId); + scanner?.onNewSession(converted.sessionId); } if (converted?.userMessage) { session.sendUserMessage(converted.userMessage); @@ -52,95 +76,10 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch }); try { - async function abortProcess() { - if (!processAbortController.signal.aborted) { - processAbortController.abort(); - } - await exitFuture.promise; - } - - async function doAbort() { - logger.debug('[codex-local]: doAbort'); - if (!exitReason) { - exitReason = 'switch'; - } - session.queue.reset(); - await abortProcess(); - } - - async function doSwitch() { - logger.debug('[codex-local]: doSwitch'); - if (!exitReason) { - exitReason = 'switch'; - } - await abortProcess(); - } - - session.client.rpcHandlerManager.registerHandler('abort', doAbort); - session.client.rpcHandlerManager.registerHandler('switch', doSwitch); - session.queue.setOnMessage(() => { - void doSwitch(); - }); - - if (exitReason) { - return exitReason; - } - if (session.queue.size() > 0) { - return 'switch'; - } - - const handleSessionFound = (sessionId: string) => { - session.onSessionFound(sessionId); - scanner.onNewSession(sessionId); - }; - - while (true) { - if (exitReason) { - return exitReason; - } - - logger.debug('[codex-local]: launch'); - try { - await codexLocal({ - path: session.path, - sessionId: resumeSessionId, - onSessionFound: handleSessionFound, - abort: processAbortController.signal, - codexArgs: session.codexArgs, - mcpServers - }); - - if (!exitReason) { - exitReason = 'exit'; - break; - } - } catch (error) { - 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 = failureExitReason; - } - if (failureExitReason === 'exit') { - logger.warn(`[codex-local]: Local Codex process failed: ${message}`); - } - break; - } - } + return await launcher.run(); } finally { - exitFuture.resolve(undefined); - session.client.rpcHandlerManager.registerHandler('abort', async () => {}); - session.client.rpcHandlerManager.registerHandler('switch', async () => {}); - session.queue.setOnMessage(null); - await scanner.cleanup(); + await scanner?.cleanup(); happyServer.stop(); logger.debug('[codex-local]: Stopped hapi MCP bridge server'); } - - return exitReason || 'exit'; } diff --git a/cli/src/gemini/geminiLocalLauncher.ts b/cli/src/gemini/geminiLocalLauncher.ts index 4b301f87..8664a15a 100644 --- a/cli/src/gemini/geminiLocalLauncher.ts +++ b/cli/src/gemini/geminiLocalLauncher.ts @@ -1,11 +1,9 @@ -import { logger } from '@/ui/logger'; import { geminiLocal } from './geminiLocal'; import { GeminiSession } from './session'; -import { Future } from '@/utils/future'; import { createGeminiSessionScanner } from './utils/sessionScanner'; -import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import type { PermissionMode } from './types'; import { randomUUID } from 'node:crypto'; +import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; type GeminiScannerHandle = Awaited>; @@ -27,9 +25,31 @@ export async function geminiLocalLauncher( hookSettingsPath?: string; } ): Promise<'switch' | 'exit'> { - let exitReason: 'switch' | 'exit' | null = null; - const processAbortController = new AbortController(); - const exitFuture = new Future(); + const launcher = new BaseLocalLauncher({ + label: 'gemini-local', + failureLabel: 'Local Gemini process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + await geminiLocal({ + path: session.path, + sessionId: session.sessionId, + abort: abortSignal, + model: opts.model, + approvalMode: mapApprovalMode(session.getPermissionMode() as PermissionMode | undefined), + allowedTools: opts.allowedTools, + hookSettingsPath: opts.hookSettingsPath + }); + }, + sendFailureMessage: (message) => { + session.sendSessionEvent({ type: 'message', message }); + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason); + } + }); let scanner: GeminiScannerHandle | null = null; @@ -71,88 +91,8 @@ export async function geminiLocalLauncher( } try { - const abortProcess = async () => { - if (!processAbortController.signal.aborted) { - processAbortController.abort(); - } - await exitFuture.promise; - }; - - const doAbort = async () => { - logger.debug('[gemini-local]: abort requested'); - if (!exitReason) { - exitReason = 'switch'; - } - session.queue.reset(); - await abortProcess(); - }; - - const doSwitch = async () => { - logger.debug('[gemini-local]: switch requested'); - if (!exitReason) { - exitReason = 'switch'; - } - await abortProcess(); - }; - - session.client.rpcHandlerManager.registerHandler('abort', doAbort); - session.client.rpcHandlerManager.registerHandler('switch', doSwitch); - session.queue.setOnMessage(() => { - void doSwitch(); - }); - - if (session.queue.size() > 0) { - return 'switch'; - } - - while (true) { - if (exitReason) { - return exitReason; - } - - logger.debug('[gemini-local]: launch'); - try { - await geminiLocal({ - path: session.path, - sessionId: session.sessionId, - abort: processAbortController.signal, - model: opts.model, - approvalMode: mapApprovalMode(session.getPermissionMode() as PermissionMode | undefined), - allowedTools: opts.allowedTools, - hookSettingsPath: opts.hookSettingsPath - }); - - if (!exitReason) { - exitReason = 'exit'; - break; - } - } catch (error) { - logger.debug('[gemini-local]: launch error', error); - const message = error instanceof Error ? error.message : String(error); - session.sendSessionEvent({ - type: 'message', - message: `Local Gemini process failed: ${message}` - }); - const failureExitReason = exitReason ?? getLocalLaunchExitReason({ - startedBy: session.startedBy, - startingMode: session.startingMode - }); - session.recordLocalLaunchFailure(message, failureExitReason); - if (!exitReason) { - exitReason = failureExitReason; - } - if (failureExitReason === 'exit') { - logger.warn(`[gemini-local]: Local Gemini process failed: ${message}`); - } - break; - } - } + return await launcher.run(); } finally { - exitFuture.resolve(undefined); - session.client.rpcHandlerManager.registerHandler('abort', async () => {}); - session.client.rpcHandlerManager.registerHandler('switch', async () => {}); - session.queue.setOnMessage(null); - if (!hadTranscriptPath) { session.removeTranscriptPathCallback(handleTranscriptPath); } @@ -161,6 +101,4 @@ export async function geminiLocalLauncher( await (scanner as GeminiScannerHandle).cleanup(); } } - - return exitReason || 'exit'; } diff --git a/cli/src/modules/common/launcher/BaseLocalLauncher.ts b/cli/src/modules/common/launcher/BaseLocalLauncher.ts new file mode 100644 index 00000000..c208fab2 --- /dev/null +++ b/cli/src/modules/common/launcher/BaseLocalLauncher.ts @@ -0,0 +1,169 @@ +import { logger } from '@/ui/logger' +import { Future } from '@/utils/future' +import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy' +import type { LocalLaunchExitReason, StartedBy } from '@/agent/localLaunchPolicy' + +type QueueLike = { + size(): number + reset(): void + setOnMessage(callback: ((...args: unknown[]) => void) | null): void +} + +type RpcHandlerManagerLike = { + registerHandler(method: string, handler: () => Promise | void): void +} + +export type LocalLauncherControl = { + abortSignal: AbortSignal + requestExit: () => void + requestSwitch: () => void + getExitReason: () => LocalLaunchExitReason | null +} + +export type LocalLauncherOptions = { + label: string + failureLabel: string + queue: QueueLike + rpcHandlerManager: RpcHandlerManagerLike + startedBy?: StartedBy + startingMode?: 'local' | 'remote' + launch: (signal: AbortSignal) => Promise + onLaunchSuccess?: () => Promise | void + sendFailureMessage: (message: string) => void + recordLocalLaunchFailure: (message: string, exitReason: LocalLaunchExitReason) => void + abortLogMessage?: string + switchLogMessage?: string +} + +export class BaseLocalLauncher { + private exitReason: LocalLaunchExitReason | null = null + private readonly abortController = new AbortController() + private readonly exitFuture = new Future() + + constructor(private readonly options: LocalLauncherOptions) {} + + get control(): LocalLauncherControl { + return { + abortSignal: this.abortController.signal, + requestExit: this.requestExit, + requestSwitch: this.requestSwitch, + getExitReason: () => this.exitReason + } + } + + async run(): Promise { + const { + label, + failureLabel, + queue, + rpcHandlerManager, + startedBy, + startingMode, + launch, + onLaunchSuccess, + sendFailureMessage, + recordLocalLaunchFailure, + abortLogMessage = 'abort requested', + switchLogMessage = 'switch requested' + } = this.options + + try { + const abortProcess = async () => { + if (!this.abortController.signal.aborted) { + this.abortController.abort() + } + await this.exitFuture.promise + } + + const doAbort = async () => { + logger.debug(`[${label}]: ${abortLogMessage}`) + this.setExitReason('switch') + queue.reset() + await abortProcess() + } + + const doSwitch = async () => { + logger.debug(`[${label}]: ${switchLogMessage}`) + this.setExitReason('switch') + await abortProcess() + } + + rpcHandlerManager.registerHandler('abort', doAbort) + rpcHandlerManager.registerHandler('switch', doSwitch) + queue.setOnMessage(() => { + void doSwitch() + }) + + if (this.exitReason) { + return this.exitReason + } + + if (queue.size() > 0) { + return 'switch' + } + + while (true) { + if (this.exitReason) { + return this.exitReason + } + + logger.debug(`[${label}]: launch`) + try { + await launch(this.abortController.signal) + if (onLaunchSuccess) { + await onLaunchSuccess() + } + + if (!this.exitReason) { + this.exitReason = 'exit' + break + } + } catch (error) { + logger.debug(`[${label}]: launch error`, error) + const message = error instanceof Error ? error.message : String(error) + const failureMessage = `${failureLabel}: ${message}` + sendFailureMessage(failureMessage) + const failureExitReason = this.exitReason ?? getLocalLaunchExitReason({ + startedBy, + startingMode + }) + recordLocalLaunchFailure(message, failureExitReason) + if (!this.exitReason) { + this.exitReason = failureExitReason + } + if (failureExitReason === 'exit') { + logger.warn(`[${label}]: ${failureMessage}`) + } + break + } + } + } finally { + this.exitFuture.resolve(undefined) + rpcHandlerManager.registerHandler('abort', async () => {}) + rpcHandlerManager.registerHandler('switch', async () => {}) + queue.setOnMessage(null) + } + + return this.exitReason || 'exit' + } + + private requestExit = (): void => { + this.setExitReason('exit') + if (!this.abortController.signal.aborted) { + this.abortController.abort() + } + } + + private requestSwitch = (): void => { + this.setExitReason('switch') + if (!this.abortController.signal.aborted) { + this.abortController.abort() + } + } + + private setExitReason(reason: LocalLaunchExitReason): void { + if (!this.exitReason) { + this.exitReason = reason + } + } +} diff --git a/cli/src/opencode/opencodeLocalLauncher.ts b/cli/src/opencode/opencodeLocalLauncher.ts index e806a5a7..2204361e 100644 --- a/cli/src/opencode/opencodeLocalLauncher.ts +++ b/cli/src/opencode/opencodeLocalLauncher.ts @@ -1,8 +1,6 @@ import { logger } from '@/ui/logger'; import { opencodeLocal } from './opencodeLocal'; import { OpencodeSession } from './session'; -import { Future } from '@/utils/future'; -import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import { ensureOpencodeHookPlugin } from './utils/hookPlugin'; import { buildOpencodeEnv } from './utils/config'; import { ensureOpencodeConfig } from './utils/opencodeConfig'; @@ -17,6 +15,7 @@ import { join } from 'node:path'; import { configuration } from '@/configuration'; import type { PermissionCompletion } from '@/modules/common/permission/BasePermissionHandler'; import { hashObject } from '@/utils/deterministicJson'; +import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; type OpencodeLocalLauncherOptions = { hookServer: OpencodeHookServer; @@ -258,9 +257,6 @@ export async function opencodeLocalLauncher( session: OpencodeSession, opts: OpencodeLocalLauncherOptions ): Promise<'switch' | 'exit'> { - let exitReason: 'switch' | 'exit' | null = null; - const processAbortController = new AbortController(); - const exitFuture = new Future(); const hookUrl = opts.hookUrl; const opencodeConfigDir = resolveOpencodeConfigDir(session); @@ -281,6 +277,39 @@ export async function opencodeLocalLauncher( logger.debug('[opencode-local]: Failed to start hapi MCP server (change_title will be unavailable)', error); } + const launcher = new BaseLocalLauncher({ + label: 'opencode-local', + failureLabel: 'Local OpenCode process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + const env = buildOpencodeEnv(); + env.HAPI_OPENCODE_HOOK_URL = hookUrl; + env.HAPI_OPENCODE_HOOK_TOKEN = opts.hookServer.token; + if (!env.OPENCODE_CONFIG_DIR) { + env.OPENCODE_CONFIG_DIR = opencodeConfigDir; + } + if (!env.OPENCODE_CONFIG && opencodeConfigPath) { + env.OPENCODE_CONFIG = opencodeConfigPath; + } + + await opencodeLocal({ + path: session.path, + abort: abortSignal, + env, + sessionId: session.sessionId ?? undefined + }); + }, + sendFailureMessage: (message) => { + session.sendSessionEvent({ type: 'message', message }); + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason); + } + }); + let storageScanner: OpencodeStorageScannerHandle | null = null; const messageRoles = new Map(); const sentTextParts = new Set(); @@ -587,96 +616,8 @@ export async function opencodeLocalLauncher( } catch (error) { logger.debug('[opencode-local]: Failed to start storage scanner', error); } - - const abortProcess = async () => { - if (!processAbortController.signal.aborted) { - processAbortController.abort(); - } - await exitFuture.promise; - }; - - const doAbort = async () => { - logger.debug('[opencode-local]: abort requested'); - if (!exitReason) { - exitReason = 'switch'; - } - session.queue.reset(); - await abortProcess(); - }; - - const doSwitch = async () => { - logger.debug('[opencode-local]: switch requested'); - if (!exitReason) { - exitReason = 'switch'; - } - await abortProcess(); - }; - - session.client.rpcHandlerManager.registerHandler('abort', doAbort); - session.client.rpcHandlerManager.registerHandler('switch', doSwitch); - session.queue.setOnMessage(() => { - void doSwitch(); - }); - - if (session.queue.size() > 0) { - return 'switch'; - } - - while (true) { - if (exitReason) { - return exitReason; - } - - logger.debug('[opencode-local]: launch'); - try { - const env = buildOpencodeEnv(); - env.HAPI_OPENCODE_HOOK_URL = hookUrl; - env.HAPI_OPENCODE_HOOK_TOKEN = opts.hookServer.token; - if (!env.OPENCODE_CONFIG_DIR) { - env.OPENCODE_CONFIG_DIR = opencodeConfigDir; - } - // Set OPENCODE_CONFIG to point to our generated config file (if MCP server started) - if (!env.OPENCODE_CONFIG && opencodeConfigPath) { - env.OPENCODE_CONFIG = opencodeConfigPath; - } - - await opencodeLocal({ - path: session.path, - abort: processAbortController.signal, - env, - sessionId: session.sessionId ?? undefined - }); - - if (!exitReason) { - exitReason = 'exit'; - break; - } - } catch (error) { - logger.debug('[opencode-local]: launch error', error); - const message = error instanceof Error ? error.message : String(error); - session.sendSessionEvent({ - type: 'message', - message: `Local OpenCode process failed: ${message}` - }); - const failureExitReason = exitReason ?? getLocalLaunchExitReason({ - startedBy: session.startedBy, - startingMode: session.startingMode - }); - session.recordLocalLaunchFailure(message, failureExitReason); - if (!exitReason) { - exitReason = failureExitReason; - } - if (failureExitReason === 'exit') { - logger.warn(`[opencode-local]: Local OpenCode process failed: ${message}`); - } - break; - } - } + return await launcher.run(); } finally { - exitFuture.resolve(undefined); - session.client.rpcHandlerManager.registerHandler('abort', async () => {}); - session.client.rpcHandlerManager.registerHandler('switch', async () => {}); - session.queue.setOnMessage(null); session.removeHookEventHandler(handleHookEvent); if (storageScanner) { await storageScanner.cleanup(); @@ -686,6 +627,4 @@ export async function opencodeLocalLauncher( logger.debug('[opencode-local]: Stopped hapi MCP server'); } } - - return exitReason || 'exit'; }