From e8252c601f5bc5bc1c6d3544b142dee7b1be76aa Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 5 Jan 2026 16:50:22 +0800 Subject: [PATCH] refactor: extract remote launcher base class for code reuse --- cli/src/claude/claudeRemoteLauncher.ts | 714 +++++++------- cli/src/codex/codexRemoteLauncher.ts | 890 +++++++++--------- .../common/remote/RemoteLauncherBase.ts | 129 +++ 3 files changed, 918 insertions(+), 815 deletions(-) create mode 100644 cli/src/modules/common/remote/RemoteLauncherBase.ts diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 110f0d20..ba65fdac 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -1,8 +1,6 @@ -import { render } from "ink"; -import { Session } from "./session"; -import { MessageBuffer } from "@/ui/ink/messageBuffer"; -import { RemoteModeDisplay } from "@/ui/ink/RemoteModeDisplay"; import React from "react"; +import { Session } from "./session"; +import { RemoteModeDisplay } from "@/ui/ink/RemoteModeDisplay"; import { claudeRemote } from "./claudeRemote"; import { PermissionHandler } from "./utils/permissionHandler"; import { Future } from "@/utils/future"; @@ -12,11 +10,13 @@ import { logger } from "@/ui/logger"; import { SDKToLogConverter } from "./utils/sdkToLogConverter"; import { PLAN_FAKE_REJECT } from "./sdk/prompts"; import { EnhancedMode } from "./loop"; -import { RawJSONLines } from "@/claude/types"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; -import { getToolName } from "./utils/getToolName"; -import { restoreTerminalState } from "@/ui/terminalState"; import type { ClaudePermissionMode } from "@hapi/protocol/types"; +import { + RemoteLauncherBase, + type RemoteLauncherDisplayContext, + type RemoteLauncherExitReason +} from "@/modules/common/remote/RemoteLauncherBase"; interface PermissionsField { date: number; @@ -25,439 +25,399 @@ interface PermissionsField { allowedTools?: string[]; } -export async function claudeRemoteLauncher(session: Session): Promise<'switch' | 'exit'> { - logger.debug('[claudeRemoteLauncher] Starting remote launcher'); +class ClaudeRemoteLauncher extends RemoteLauncherBase { + private readonly session: Session; + private abortController: AbortController | null = null; + private abortFuture: Future | null = null; + private permissionHandler: PermissionHandler | null = null; + private handleSessionFound: ((sessionId: string) => void) | null = null; - // Check if we have a TTY for UI rendering - const hasTTY = process.stdout.isTTY && process.stdin.isTTY; - logger.debug(`[claudeRemoteLauncher] TTY available: ${hasTTY}`); + constructor(session: Session) { + super(process.env.DEBUG ? session.logPath : undefined); + this.session = session; + } - // Configure terminal - let messageBuffer = new MessageBuffer(); - let inkInstance: any = null; + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(RemoteModeDisplay, context); + } - if (hasTTY) { - console.clear(); - inkInstance = render(React.createElement(RemoteModeDisplay, { - messageBuffer, - logPath: process.env.DEBUG ? session.logPath : undefined, - onExit: async () => { - // Exit the entire client - logger.debug('[remote]: Exiting client via Ctrl-C'); - if (!exitReason) { - exitReason = 'exit'; - } - await abort(); - }, - onSwitchToLocal: () => { - // Switch to local mode - logger.debug('[remote]: Switching to local mode via double space'); - doSwitch(); - } - }), { - exitOnCtrlC: false, - patchConsole: false + private async abort(): Promise { + if (this.abortController && !this.abortController.signal.aborted) { + this.abortController.abort(); + } + await this.abortFuture?.promise; + } + + private async handleAbortRequest(): Promise { + logger.debug('[remote]: doAbort'); + await this.abort(); + } + + private async handleSwitchRequest(): Promise { + logger.debug('[remote]: doSwitch'); + await this.requestExit('switch', async () => { + await this.abort(); }); } - if (hasTTY) { - process.stdin.resume(); - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.setEncoding("utf8"); + private async handleExitFromUi(): Promise { + logger.debug('[remote]: Exiting client via Ctrl-C'); + await this.requestExit('exit', async () => { + await this.abort(); + }); } - // Handle abort - let exitReason: 'switch' | 'exit' | null = null; - let abortController: AbortController | null = null; - let abortFuture: Future | null = null; - - async function abort() { - if (abortController && !abortController.signal.aborted) { - abortController.abort(); - } - await abortFuture?.promise; + private async handleSwitchFromUi(): Promise { + logger.debug('[remote]: Switching to local mode via double space'); + await this.handleSwitchRequest(); } - async function doAbort() { - logger.debug('[remote]: doAbort'); - await abort(); + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }); } - async function doSwitch() { - logger.debug('[remote]: doSwitch'); - if (!exitReason) { - exitReason = 'switch'; - } - await abort(); - } + protected async runMainLoop(): Promise { + logger.debug('[claudeRemoteLauncher] Starting remote launcher'); + logger.debug(`[claudeRemoteLauncher] TTY available: ${this.hasTTY}`); - // When to abort - session.client.rpcHandlerManager.registerHandler('abort', doAbort); // When abort clicked - session.client.rpcHandlerManager.registerHandler('switch', doSwitch); // When switch clicked - // Removed catch-all stdin handler - now handled by RemoteModeDisplay keyboard handlers + const session = this.session; + const messageBuffer = this.messageBuffer; - // Create permission handler - const permissionHandler = new PermissionHandler(session); + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbortRequest(), + onSwitch: () => this.handleSwitchRequest() + }); - // Create outgoing message queue - const messageQueue = new OutgoingMessageQueue( - (logMessage) => session.client.sendClaudeSessionMessage(logMessage) - ); + const permissionHandler = new PermissionHandler(session); + this.permissionHandler = permissionHandler; - // Set up callback to release delayed messages when permission is requested - permissionHandler.setOnPermissionRequest((toolCallId: string) => { - messageQueue.releaseToolCall(toolCallId); - }); + const messageQueue = new OutgoingMessageQueue( + (logMessage) => session.client.sendClaudeSessionMessage(logMessage) + ); - // Create SDK to Log converter (pass responses from permissions) - const sdkToLogConverter = new SDKToLogConverter({ - sessionId: session.sessionId || 'unknown', - cwd: session.path, - version: process.env.npm_package_version - }, permissionHandler.getResponses()); + permissionHandler.setOnPermissionRequest((toolCallId: string) => { + messageQueue.releaseToolCall(toolCallId); + }); - const handleSessionFound = (sessionId: string) => { - sdkToLogConverter.updateSessionId(sessionId); - }; - session.addSessionFoundCallback(handleSessionFound); + const sdkToLogConverter = new SDKToLogConverter({ + sessionId: session.sessionId || 'unknown', + cwd: session.path, + version: process.env.npm_package_version + }, permissionHandler.getResponses()); + const handleSessionFound = (sessionId: string) => { + sdkToLogConverter.updateSessionId(sessionId); + }; + this.handleSessionFound = handleSessionFound; + session.addSessionFoundCallback(handleSessionFound); - // Handle messages - let planModeToolCalls = new Set(); - let ongoingToolCalls = new Map(); + let planModeToolCalls = new Set(); + let ongoingToolCalls = new Map(); - function onMessage(message: SDKMessage) { + function onMessage(message: SDKMessage) { + formatClaudeMessageForInk(message, messageBuffer); + permissionHandler.onMessage(message); - // Write to message log - formatClaudeMessageForInk(message, messageBuffer); - - // Write to permission handler for tool id resolving - permissionHandler.onMessage(message); - - // Detect plan mode tool call - if (message.type === 'assistant') { - let umessage = message as SDKAssistantMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_use' && (c.name === 'exit_plan_mode' || c.name === 'ExitPlanMode')) { - logger.debug('[remote]: detected plan mode tool call ' + c.id!); - planModeToolCalls.add(c.id! as string); + if (message.type === 'assistant') { + let umessage = message as SDKAssistantMessage; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (let c of umessage.message.content) { + if (c.type === 'tool_use' && (c.name === 'exit_plan_mode' || c.name === 'ExitPlanMode')) { + logger.debug('[remote]: detected plan mode tool call ' + c.id!); + planModeToolCalls.add(c.id! as string); + } } } } - } - // Track active tool calls - if (message.type === 'assistant') { - let umessage = message as SDKAssistantMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_use') { - logger.debug('[remote]: detected tool use ' + c.id! + ' parent: ' + umessage.parent_tool_use_id); - ongoingToolCalls.set(c.id!, { parentToolCallId: umessage.parent_tool_use_id ?? null }); + if (message.type === 'assistant') { + let umessage = message as SDKAssistantMessage; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (let c of umessage.message.content) { + if (c.type === 'tool_use') { + logger.debug('[remote]: detected tool use ' + c.id! + ' parent: ' + umessage.parent_tool_use_id); + ongoingToolCalls.set(c.id!, { parentToolCallId: umessage.parent_tool_use_id ?? null }); + } } } } - } - if (message.type === 'user') { - let umessage = message as SDKUserMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_result' && c.tool_use_id) { - ongoingToolCalls.delete(c.tool_use_id); - - // When tool result received, release any delayed messages for this tool call - messageQueue.releaseToolCall(c.tool_use_id); + if (message.type === 'user') { + let umessage = message as SDKUserMessage; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (let c of umessage.message.content) { + if (c.type === 'tool_result' && c.tool_use_id) { + ongoingToolCalls.delete(c.tool_use_id); + messageQueue.releaseToolCall(c.tool_use_id); + } } } } - } - // Convert SDK message to log format and send to client - let msg = message; + let msg = message; - // Hack plan mode exit - if (message.type === 'user') { - let umessage = message as SDKUserMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - msg = { - ...umessage, - message: { - ...umessage.message, - content: umessage.message.content.map((c) => { - if (c.type === 'tool_result' && c.tool_use_id && planModeToolCalls.has(c.tool_use_id!)) { - if (c.content === PLAN_FAKE_REJECT) { - logger.debug('[remote]: hack plan mode exit'); - logger.debugLargeJson('[remote]: hack plan mode exit', c); - return { - ...c, - is_error: false, - content: 'Plan approved', - mode: c.mode + if (message.type === 'user') { + let umessage = message as SDKUserMessage; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + msg = { + ...umessage, + message: { + ...umessage.message, + content: umessage.message.content.map((c) => { + if (c.type === 'tool_result' && c.tool_use_id && planModeToolCalls.has(c.tool_use_id!)) { + if (c.content === PLAN_FAKE_REJECT) { + logger.debug('[remote]: hack plan mode exit'); + logger.debugLargeJson('[remote]: hack plan mode exit', c); + return { + ...c, + is_error: false, + content: 'Plan approved', + mode: c.mode + }; + } else { + return c; } - } else { - return c; } + return c; + }) + } + }; + } + } + + const logMessage = sdkToLogConverter.convert(msg); + if (logMessage) { + if (logMessage.type === 'user' && logMessage.message?.content) { + const content = Array.isArray(logMessage.message.content) + ? logMessage.message.content + : []; + + for (let i = 0; i < content.length; i++) { + const c = content[i]; + if (c.type === 'tool_result' && c.tool_use_id) { + const responses = permissionHandler.getResponses(); + const response = responses.get(c.tool_use_id); + + if (response) { + const permissions: PermissionsField = { + date: response.receivedAt || Date.now(), + result: response.approved ? 'approved' : 'denied' + }; + + if (response.mode) { + permissions.mode = response.mode; + } + + if (response.allowTools && response.allowTools.length > 0) { + permissions.allowedTools = response.allowTools; + } + + content[i] = { + ...c, + permissions + }; } - return c; - }) + } + } + } + + if (logMessage.type === 'assistant' && message.type === 'assistant') { + const assistantMsg = message as SDKAssistantMessage; + const toolCallIds: string[] = []; + + if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) { + for (const block of assistantMsg.message.content) { + if (block.type === 'tool_use' && block.id) { + toolCallIds.push(block.id); + } + } + } + + if (toolCallIds.length > 0) { + const isSidechain = assistantMsg.parent_tool_use_id !== undefined; + + if (!isSidechain) { + messageQueue.enqueue(logMessage, { + delay: 250, + toolCallIds + }); + return; + } + } + } + + messageQueue.enqueue(logMessage); + } + + if (message.type === 'assistant') { + let umessage = message as SDKAssistantMessage; + if (umessage.message.content && Array.isArray(umessage.message.content)) { + for (let c of umessage.message.content) { + if (c.type === 'tool_use' && c.name === 'Task' && c.input && typeof (c.input as any).prompt === 'string') { + const logMessage2 = sdkToLogConverter.convertSidechainUserMessage(c.id!, (c.input as any).prompt); + if (logMessage2) { + messageQueue.enqueue(logMessage2); + } + } } } } } - const logMessage = sdkToLogConverter.convert(msg); - if (logMessage) { - // Add permissions field to tool result content - if (logMessage.type === 'user' && logMessage.message?.content) { - const content = Array.isArray(logMessage.message.content) - ? logMessage.message.content - : []; + try { + let pending: { + message: string; + mode: EnhancedMode; + } | null = null; - // Modify the content array to add permissions to each tool_result - for (let i = 0; i < content.length; i++) { - const c = content[i]; - if (c.type === 'tool_result' && c.tool_use_id) { - const responses = permissionHandler.getResponses(); - const response = responses.get(c.tool_use_id); + let previousSessionId: string | null = null; + while (!this.exitReason) { + logger.debug('[remote]: launch'); + messageBuffer.addMessage('═'.repeat(40), 'status'); - if (response) { - const permissions: PermissionsField = { - date: response.receivedAt || Date.now(), - result: response.approved ? 'approved' : 'denied' - }; + const isNewSession = session.sessionId !== previousSessionId; + if (isNewSession) { + messageBuffer.addMessage('Starting new Claude session...', 'status'); + permissionHandler.reset(); + sdkToLogConverter.resetParentChain(); + logger.debug(`[remote]: New session detected (previous: ${previousSessionId}, current: ${session.sessionId})`); + } else { + messageBuffer.addMessage('Continuing Claude session...', 'status'); + logger.debug(`[remote]: Continuing existing session: ${session.sessionId}`); + } - // Add optional fields if they exist - if (response.mode) { - permissions.mode = response.mode; + previousSessionId = session.sessionId; + const controller = new AbortController(); + this.abortController = controller; + this.abortFuture = new Future(); + let modeHash: string | null = null; + let mode: EnhancedMode | null = null; + try { + await claudeRemote({ + sessionId: session.sessionId, + path: session.path, + allowedTools: session.allowedTools ?? [], + mcpServers: session.mcpServers, + hookSettingsPath: session.hookSettingsPath, + canCallTool: permissionHandler.handleToolCall, + isAborted: (toolCallId: string) => { + return permissionHandler.isAborted(toolCallId); + }, + nextMessage: async () => { + if (pending) { + let p = pending; + pending = null; + permissionHandler.handleModeChange(p.mode.permissionMode); + return p; } - if (response.allowTools && response.allowTools.length > 0) { - permissions.allowedTools = response.allowTools; + let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal); + + if (msg) { + if ((modeHash && msg.hash !== modeHash) || msg.isolate) { + logger.debug('[remote]: mode has changed, pending message'); + pending = msg; + return null; + } + modeHash = msg.hash; + mode = msg.mode; + permissionHandler.handleModeChange(mode.permissionMode); + return { + message: msg.message, + mode: msg.mode + }; } - // Add permissions directly to the tool_result content object - content[i] = { - ...c, - permissions - }; + return null; + }, + onSessionFound: (sessionId) => { + session.onSessionFound(sessionId); + }, + onThinkingChange: session.onThinkingChange, + claudeEnvVars: session.claudeEnvVars, + claudeArgs: session.claudeArgs, + onMessage, + onCompletionEvent: (message: string) => { + logger.debug(`[remote]: Completion event: ${message}`); + session.client.sendSessionEvent({ type: 'message', message }); + }, + onSessionReset: () => { + logger.debug('[remote]: Session reset'); + session.clearSessionId(); + }, + onReady: () => { + if (!pending && session.queue.size() === 0) { + session.client.sendSessionEvent({ type: 'ready' }); + } + }, + signal: controller.signal, + }); + + session.consumeOneTimeFlags(); + + if (!this.exitReason && controller.signal.aborted) { + session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } + } catch (e) { + logger.debug('[remote]: launch error', e); + if (!this.exitReason) { + session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + continue; + } + } finally { + logger.debug('[remote]: launch finally'); + + for (let [toolCallId, { parentToolCallId }] of ongoingToolCalls) { + const converted = sdkToLogConverter.generateInterruptedToolResult(toolCallId, parentToolCallId); + if (converted) { + logger.debug('[remote]: terminating tool call ' + toolCallId + ' parent: ' + parentToolCallId); + session.client.sendClaudeSessionMessage(converted); } } + ongoingToolCalls.clear(); + + logger.debug('[remote]: flushing message queue'); + await messageQueue.flush(); + messageQueue.destroy(); + logger.debug('[remote]: message queue flushed'); + + this.abortController = null; + this.abortFuture?.resolve(undefined); + this.abortFuture = null; + logger.debug('[remote]: launch done'); + permissionHandler.reset(); + modeHash = null; + mode = null; } } - - // Queue message with optional delay for tool calls - if (logMessage.type === 'assistant' && message.type === 'assistant') { - const assistantMsg = message as SDKAssistantMessage; - const toolCallIds: string[] = []; - - if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) { - for (const block of assistantMsg.message.content) { - if (block.type === 'tool_use' && block.id) { - toolCallIds.push(block.id); - } - } - } - - if (toolCallIds.length > 0) { - // Check if this is a sidechain tool call (has parent_tool_use_id) - const isSidechain = assistantMsg.parent_tool_use_id !== undefined; - - if (!isSidechain) { - // Top-level tool call - queue with delay - messageQueue.enqueue(logMessage, { - delay: 250, - toolCallIds - }); - return; // Don't queue again below - } - } - } - - // Queue all other messages immediately (no delay) - messageQueue.enqueue(logMessage); - } - - // Insert a fake message to start the sidechain - if (message.type === 'assistant') { - let umessage = message as SDKAssistantMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_use' && c.name === 'Task' && c.input && typeof (c.input as any).prompt === 'string') { - const logMessage2 = sdkToLogConverter.convertSidechainUserMessage(c.id!, (c.input as any).prompt); - if (logMessage2) { - messageQueue.enqueue(logMessage2); - } - } - } + } finally { + if (this.permissionHandler) { + this.permissionHandler.reset(); } } } - try { - let pending: { - message: string; - mode: EnhancedMode; - } | null = null; + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager); - // Track session ID to detect when it actually changes - // This prevents context loss when mode changes (permission mode, model, etc.) - // without starting a new session. Only reset parent chain when session ID - // 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; - while (!exitReason) { - logger.debug('[remote]: launch'); - messageBuffer.addMessage('═'.repeat(40), 'status'); - - // Only reset parent chain and show "new session" message when session ID actually changes - const isNewSession = session.sessionId !== previousSessionId; - if (isNewSession) { - messageBuffer.addMessage('Starting new Claude session...', 'status'); - permissionHandler.reset(); // Reset permissions before starting new session - sdkToLogConverter.resetParentChain(); // Reset parent chain for new conversation - logger.debug(`[remote]: New session detected (previous: ${previousSessionId}, current: ${session.sessionId})`); - } else { - messageBuffer.addMessage('Continuing Claude session...', 'status'); - logger.debug(`[remote]: Continuing existing session: ${session.sessionId}`); - } - - previousSessionId = session.sessionId; - const controller = new AbortController(); - abortController = controller; - abortFuture = new Future(); - let modeHash: string | null = null; - let mode: EnhancedMode | null = null; - try { - const remoteResult = await claudeRemote({ - sessionId: session.sessionId, - path: session.path, - allowedTools: session.allowedTools ?? [], - mcpServers: session.mcpServers, - hookSettingsPath: session.hookSettingsPath, - canCallTool: permissionHandler.handleToolCall, - isAborted: (toolCallId: string) => { - return permissionHandler.isAborted(toolCallId); - }, - nextMessage: async () => { - if (pending) { - let p = pending; - pending = null; - permissionHandler.handleModeChange(p.mode.permissionMode); - return p; - } - - let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal); - - // Check if mode has changed - if (msg) { - if ((modeHash && msg.hash !== modeHash) || msg.isolate) { - logger.debug('[remote]: mode has changed, pending message'); - pending = msg; - return null; - } - modeHash = msg.hash; - mode = msg.mode; - permissionHandler.handleModeChange(mode.permissionMode); - return { - message: msg.message, - mode: msg.mode - } - } - - // Exit - return null; - }, - onSessionFound: (sessionId) => { - session.onSessionFound(sessionId); - }, - onThinkingChange: session.onThinkingChange, - claudeEnvVars: session.claudeEnvVars, - claudeArgs: session.claudeArgs, - onMessage, - onCompletionEvent: (message: string) => { - logger.debug(`[remote]: Completion event: ${message}`); - session.client.sendSessionEvent({ type: 'message', message }); - }, - onSessionReset: () => { - logger.debug('[remote]: Session reset'); - session.clearSessionId(); - }, - onReady: () => { - if (!pending && session.queue.size() === 0) { - session.client.sendSessionEvent({ type: 'ready' }); - } - }, - signal: abortController.signal, - }); - - // Consume one-time Claude flags after spawn - session.consumeOneTimeFlags(); - - if (!exitReason && abortController.signal.aborted) { - session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); - } - } catch (e) { - logger.debug('[remote]: launch error', e); - if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); - continue; - } - } finally { - - logger.debug('[remote]: launch finally'); - - // Terminate all ongoing tool calls - for (let [toolCallId, { parentToolCallId }] of ongoingToolCalls) { - const converted = sdkToLogConverter.generateInterruptedToolResult(toolCallId, parentToolCallId); - if (converted) { - logger.debug('[remote]: terminating tool call ' + toolCallId + ' parent: ' + parentToolCallId); - session.client.sendClaudeSessionMessage(converted); - } - } - ongoingToolCalls.clear(); - - // Flush any remaining messages in the queue - logger.debug('[remote]: flushing message queue'); - await messageQueue.flush(); - messageQueue.destroy(); - logger.debug('[remote]: message queue flushed'); - - // Reset abort controller and future - abortController = null; - abortFuture?.resolve(undefined); - abortFuture = null; - logger.debug('[remote]: launch done'); - permissionHandler.reset(); - modeHash = null; - mode = null; - } + if (this.handleSessionFound) { + this.session.removeSessionFoundCallback(this.handleSessionFound); + this.handleSessionFound = null; } - } finally { - session.removeSessionFoundCallback(handleSessionFound); - - // Clean up permission handler - permissionHandler.reset(); - - // Reset Terminal - process.stdin.off('data', abort); - restoreTerminalState(); - if (hasTTY) { - try { process.stdin.pause(); } catch {} + if (this.permissionHandler) { + this.permissionHandler.reset(); } - if (inkInstance) { - inkInstance.unmount(); - } - messageBuffer.clear(); - // Resolve abort future - if (abortFuture) { // Just in case of error - abortFuture.resolve(undefined); + if (this.abortFuture) { + this.abortFuture.resolve(undefined); } } +} - return exitReason || 'exit'; +export async function claudeRemoteLauncher(session: Session): Promise<'switch' | 'exit'> { + const launcher = new ClaudeRemoteLauncher(session); + return launcher.launch(); } diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 12f43efd..8464c663 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -1,4 +1,3 @@ -import { render } from 'ink'; import React from 'react'; import { randomUUID } from 'node:crypto'; import os from 'node:os'; @@ -10,7 +9,6 @@ import { CodexPermissionHandler } from './utils/permissionHandler'; import { ReasoningProcessor } from './utils/reasoningProcessor'; import { DiffProcessor } from './utils/diffProcessor'; import { logger } from '@/ui/logger'; -import { MessageBuffer } from '@/ui/ink/messageBuffer'; import { CodexDisplay } from '@/ui/ink/CodexDisplay'; import type { CodexSessionConfig } from './types'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; @@ -18,437 +16,448 @@ import { startHappyServer } from '@/claude/utils/startHappyServer'; import { emitReadyIfIdle } from './utils/emitReadyIfIdle'; import type { CodexSession } from './session'; import type { EnhancedMode } from './loop'; -import { restoreTerminalState } from '@/ui/terminalState'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; import { buildCodexStartConfig } from './utils/codexStartConfig'; import { convertCodexEvent } from './utils/codexEventConverter'; +import { + RemoteLauncherBase, + type RemoteLauncherDisplayContext, + type RemoteLauncherExitReason +} from '@/modules/common/remote/RemoteLauncherBase'; -export async function codexRemoteLauncher(session: CodexSession): Promise<'switch' | 'exit'> { - // Warn if CLI args were passed that won't apply in remote mode - if (session.codexArgs && session.codexArgs.length > 0) { - if (hasCodexCliOverrides(session.codexCliOverrides)) { - logger.debug(`[codex-remote] CLI args include sandbox/approval overrides; other args ` + - `are ignored in remote mode.`); - } else { - logger.debug(`[codex-remote] Warning: CLI args [${session.codexArgs.join(', ')}] are ignored in remote mode. ` + - `Remote mode uses message-based configuration (model/sandbox set via web interface).`); - } +type HappyServer = Awaited>; + +class CodexRemoteLauncher extends RemoteLauncherBase { + private readonly session: CodexSession; + private readonly client: CodexMcpClient; + private permissionHandler: CodexPermissionHandler | null = null; + private reasoningProcessor: ReasoningProcessor | null = null; + private diffProcessor: DiffProcessor | null = null; + private happyServer: HappyServer | null = null; + private abortController: AbortController = new AbortController(); + private storedSessionIdForResume: string | null = null; + + constructor(session: CodexSession) { + super(process.env.DEBUG ? session.logPath : undefined); + this.session = session; + this.client = new CodexMcpClient(); } - const hasTTY = process.stdout.isTTY && process.stdin.isTTY; - const messageBuffer = new MessageBuffer(); - let inkInstance: any = null; - - let exitReason: 'switch' | 'exit' | null = null; - let shouldExit = false; - - if (hasTTY) { - console.clear(); - inkInstance = render(React.createElement(CodexDisplay, { - messageBuffer, - logPath: process.env.DEBUG ? session.logPath : undefined, - onExit: async () => { - logger.debug('[codex-remote]: Exiting agent via Ctrl-C'); - exitReason = 'exit'; - shouldExit = true; - await handleAbort(); - }, - onSwitchToLocal: async () => { - logger.debug('[codex-remote]: Switching to local mode via double space'); - exitReason = 'switch'; - shouldExit = true; - await handleAbort(); - } - }), { - exitOnCtrlC: false, - patchConsole: false - }); + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(CodexDisplay, context); } - if (hasTTY) { - process.stdin.resume(); - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.setEncoding('utf8'); - } - - const client = new CodexMcpClient(); - - function findCodexResumeFile(sessionId: string | null): string | null { - if (!sessionId) return null; - try { - const codexHomeDir = process.env.CODEX_HOME || join(os.homedir(), '.codex'); - const rootDir = join(codexHomeDir, 'sessions'); - - function collectFilesRecursive(dir: string, acc: string[] = []): string[] { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return acc; - } - for (const entry of entries) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - collectFilesRecursive(full, acc); - } else if (entry.isFile()) { - acc.push(full); - } - } - return acc; - } - - const candidates = collectFilesRecursive(rootDir) - .filter((full) => full.endsWith(`-${sessionId}.jsonl`)) - .filter((full) => { - try { return fs.statSync(full).isFile(); } catch { return false; } - }) - .sort((a, b) => { - const sa = fs.statSync(a).mtimeMs; - const sb = fs.statSync(b).mtimeMs; - return sb - sa; - }); - return candidates[0] || null; - } catch { - return null; - } - } - - const RESUME_CONTEXT_MAX_ITEMS = 40; - const RESUME_CONTEXT_MAX_CHARS = 16000; - const RESUME_CONTEXT_TOOL_MAX_CHARS = 2000; - const RESUME_CONTEXT_REASONING_MAX_CHARS = 2000; - - function readResumeFileContent(resumeFile: string): { content: string; truncated: boolean } | null { - try { - const stat = fs.statSync(resumeFile); - if (!stat.isFile()) { - return null; - } - return { content: fs.readFileSync(resumeFile, 'utf8'), truncated: false }; - } catch (error) { - logger.debug('[Codex] Failed to read resume file:', error); - return null; - } - } - - function safeStringify(value: unknown): string | null { - if (value === null || value === undefined) { - return null; - } - if (typeof value === 'string') { - return value; - } - try { - return JSON.stringify(value); - } catch { - return null; - } - } - - function formatResumeValue(value: unknown, maxChars: number, singleLine = false): string | null { - const raw = safeStringify(value); - if (!raw) { - return null; - } - const normalized = singleLine ? raw.replace(/\s+/g, ' ').trim() : raw; - if (!normalized) { - return null; - } - if (normalized.length <= maxChars) { - return normalized; - } - return `${normalized.slice(0, maxChars)}...`; - } - - function buildResumeInstructionsFromFile(resumeFile: string): string | undefined { - const result = readResumeFileContent(resumeFile); - if (!result) { - return undefined; - } - - const items: { role: 'user' | 'assistant' | 'tool'; text: string }[] = []; - let truncated = result.truncated; - - const lines = result.content.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - try { - const parsed = JSON.parse(trimmed); - const converted = convertCodexEvent(parsed); - if (converted?.userMessage) { - items.push({ role: 'user', text: converted.userMessage }); - } - if (converted?.message?.type === 'message') { - items.push({ role: 'assistant', text: converted.message.message }); - } - if (converted?.message?.type === 'reasoning') { - const reasoning = formatResumeValue(converted.message.message, RESUME_CONTEXT_REASONING_MAX_CHARS); - if (reasoning) { - items.push({ role: 'assistant', text: `Reasoning: ${reasoning}` }); - } - } - if (converted?.message?.type === 'tool-call') { - const input = formatResumeValue(converted.message.input, RESUME_CONTEXT_TOOL_MAX_CHARS, true); - const text = input - ? `Call ${converted.message.name} ${input}` - : `Call ${converted.message.name}`; - items.push({ role: 'tool', text }); - } - if (converted?.message?.type === 'tool-call-result') { - const output = formatResumeValue(converted.message.output, RESUME_CONTEXT_TOOL_MAX_CHARS, true); - if (output) { - items.push({ role: 'tool', text: `Result ${output}` }); - } - } - } catch { - continue; - } - } - - if (items.length === 0) { - return undefined; - } - - if (items.length > RESUME_CONTEXT_MAX_ITEMS) { - items.splice(0, items.length - RESUME_CONTEXT_MAX_ITEMS); - truncated = true; - } - - const rendered = items.map((item) => { - if (item.role === 'user') { - return `User: ${item.text}`; - } - if (item.role === 'tool') { - return `Tool: ${item.text}`; - } - return `Assistant: ${item.text}`; - }); - let totalChars = rendered.reduce((sum, line) => sum + line.length + 1, 0); - while (rendered.length > 1 && totalChars > RESUME_CONTEXT_MAX_CHARS) { - const removed = rendered.shift(); - totalChars -= (removed?.length ?? 0) + 1; - truncated = true; - } - - if (rendered.length === 0) { - return undefined; - } - - const header = truncated - ? 'Continue from the prior session context below (transcript truncated):' - : 'Continue from the prior session context below:'; - return `${header}\n${rendered.join('\n')}`; - } - - const permissionHandler = new CodexPermissionHandler(session.client); - const reasoningProcessor = new ReasoningProcessor((message) => { - session.sendCodexMessage(message); - }); - const diffProcessor = new DiffProcessor((message) => { - session.sendCodexMessage(message); - }); - - client.setPermissionHandler(permissionHandler); - client.setHandler((msg) => { - logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`); - - if (msg.type === 'agent_message') { - messageBuffer.addMessage(msg.message, 'assistant'); - } else if (msg.type === 'agent_reasoning_delta') { - } else if (msg.type === 'agent_reasoning') { - messageBuffer.addMessage(`[Thinking] ${msg.text.substring(0, 100)}...`, 'system'); - } else if (msg.type === 'exec_command_begin') { - messageBuffer.addMessage(`Executing: ${msg.command}`, 'tool'); - } else if (msg.type === 'exec_command_end') { - const output = msg.output || msg.error || 'Command completed'; - const truncatedOutput = output.substring(0, 200); - messageBuffer.addMessage( - `Result: ${truncatedOutput}${output.length > 200 ? '...' : ''}`, - 'result' - ); - } else if (msg.type === 'task_started') { - messageBuffer.addMessage('Starting task...', 'status'); - } else if (msg.type === 'task_complete') { - messageBuffer.addMessage('Task completed', 'status'); - sendReady(); - } else if (msg.type === 'turn_aborted') { - messageBuffer.addMessage('Turn aborted', 'status'); - sendReady(); - } - - if (msg.type === 'task_started') { - if (!session.thinking) { - logger.debug('thinking started'); - session.onThinkingChange(true); - } - } - if (msg.type === 'task_complete' || msg.type === 'turn_aborted') { - if (session.thinking) { - logger.debug('thinking completed'); - session.onThinkingChange(false); - } - diffProcessor.reset(); - } - if (msg.type === 'agent_reasoning_section_break') { - reasoningProcessor.handleSectionBreak(); - } - if (msg.type === 'agent_reasoning_delta') { - reasoningProcessor.processDelta(msg.delta); - } - if (msg.type === 'agent_reasoning') { - reasoningProcessor.complete(msg.text); - } - if (msg.type === 'agent_message') { - session.sendCodexMessage({ - type: 'message', - message: msg.message, - id: randomUUID() - }); - } - if (msg.type === 'exec_command_begin' || msg.type === 'exec_approval_request') { - const { call_id, type, ...inputs } = msg; - session.sendCodexMessage({ - type: 'tool-call', - name: 'CodexBash', - callId: call_id, - input: inputs, - id: randomUUID() - }); - } - if (msg.type === 'exec_command_end') { - const { call_id, type, ...output } = msg; - session.sendCodexMessage({ - type: 'tool-call-result', - callId: call_id, - output: output, - id: randomUUID() - }); - } - if (msg.type === 'token_count') { - session.sendCodexMessage({ - ...msg, - id: randomUUID() - }); - } - if (msg.type === 'patch_apply_begin') { - const { call_id, auto_approved, changes } = msg; - - const changeCount = Object.keys(changes).length; - const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`; - messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool'); - - session.sendCodexMessage({ - type: 'tool-call', - name: 'CodexPatch', - callId: call_id, - input: { - auto_approved, - changes - }, - id: randomUUID() - }); - } - if (msg.type === 'patch_apply_end') { - const { call_id, stdout, stderr, success } = msg; - - if (success) { - const message = stdout || 'Files modified successfully'; - messageBuffer.addMessage(message.substring(0, 200), 'result'); - } else { - const errorMsg = stderr || 'Failed to modify files'; - messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, 'result'); - } - - session.sendCodexMessage({ - type: 'tool-call-result', - callId: call_id, - output: { - stdout, - stderr, - success - }, - id: randomUUID() - }); - } - if (msg.type === 'turn_diff') { - if (msg.unified_diff) { - diffProcessor.processDiff(msg.unified_diff); - } - } - }); - - const happyServer = await startHappyServer(session.client); - const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); - const mcpServers = { - hapi: { - command: bridgeCommand.command, - args: bridgeCommand.args - } - } as const; - - let abortController = new AbortController(); - let storedSessionIdForResume: string | null = null; - - async function handleAbort() { + private async handleAbort(): Promise { logger.debug('[Codex] Abort requested - stopping current task'); try { - if (client.hasActiveSession()) { - storedSessionIdForResume = client.storeSessionForResume(); - logger.debug('[Codex] Stored session for resume:', storedSessionIdForResume); + if (this.client.hasActiveSession()) { + this.storedSessionIdForResume = this.client.storeSessionForResume(); + logger.debug('[Codex] Stored session for resume:', this.storedSessionIdForResume); } - abortController.abort(); - session.queue.reset(); - permissionHandler.reset(); - reasoningProcessor.abort(); - diffProcessor.reset(); + this.abortController.abort(); + this.session.queue.reset(); + this.permissionHandler?.reset(); + this.reasoningProcessor?.abort(); + this.diffProcessor?.reset(); logger.debug('[Codex] Abort completed - session remains active'); } catch (error) { logger.debug('[Codex] Error during abort:', error); } finally { - abortController = new AbortController(); + this.abortController = new AbortController(); } } - session.client.rpcHandlerManager.registerHandler('abort', async () => { - await handleAbort(); - }); - - session.client.rpcHandlerManager.registerHandler('switch', async () => { - exitReason = 'switch'; - shouldExit = true; - await handleAbort(); - }); - - function logActiveHandles(tag: string) { - if (!process.env.DEBUG) return; - const anyProc: any = process as any; - const handles = typeof anyProc._getActiveHandles === 'function' ? anyProc._getActiveHandles() : []; - const requests = typeof anyProc._getActiveRequests === 'function' ? anyProc._getActiveRequests() : []; - logger.debug(`[codex][handles] ${tag}: handles=${handles.length} requests=${requests.length}`); - try { - const kinds = handles.map((h: any) => (h && h.constructor ? h.constructor.name : typeof h)); - logger.debug(`[codex][handles] kinds=${JSON.stringify(kinds)}`); - } catch {} + private async handleExitFromUi(): Promise { + logger.debug('[codex-remote]: Exiting agent via Ctrl-C'); + this.exitReason = 'exit'; + this.shouldExit = true; + await this.handleAbort(); } - const sendReady = () => { - session.sendSessionEvent({ type: 'ready' }); - }; + private async handleSwitchFromUi(): Promise { + logger.debug('[codex-remote]: Switching to local mode via double space'); + this.exitReason = 'switch'; + this.shouldExit = true; + await this.handleAbort(); + } - const syncSessionId = () => { - const clientSessionId = client.getSessionId(); - if (clientSessionId && clientSessionId !== session.sessionId) { - session.onSessionFound(clientSessionId); + private async handleSwitchRequest(): Promise { + this.exitReason = 'switch'; + this.shouldExit = true; + await this.handleAbort(); + } + + public async launch(): Promise { + if (this.session.codexArgs && this.session.codexArgs.length > 0) { + if (hasCodexCliOverrides(this.session.codexCliOverrides)) { + logger.debug(`[codex-remote] CLI args include sandbox/approval overrides; other args ` + + `are ignored in remote mode.`); + } else { + logger.debug(`[codex-remote] Warning: CLI args [${this.session.codexArgs.join(', ')}] are ignored in remote mode. ` + + `Remote mode uses message-based configuration (model/sandbox set via web interface).`); + } } - }; - try { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }); + } + + protected async runMainLoop(): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + const client = this.client; + + function findCodexResumeFile(sessionId: string | null): string | null { + if (!sessionId) return null; + try { + const codexHomeDir = process.env.CODEX_HOME || join(os.homedir(), '.codex'); + const rootDir = join(codexHomeDir, 'sessions'); + + function collectFilesRecursive(dir: string, acc: string[] = []): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return acc; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + collectFilesRecursive(full, acc); + } else if (entry.isFile()) { + acc.push(full); + } + } + return acc; + } + + const candidates = collectFilesRecursive(rootDir) + .filter((full) => full.endsWith(`-${sessionId}.jsonl`)) + .filter((full) => { + try { return fs.statSync(full).isFile(); } catch { return false; } + }) + .sort((a, b) => { + const sa = fs.statSync(a).mtimeMs; + const sb = fs.statSync(b).mtimeMs; + return sb - sa; + }); + return candidates[0] || null; + } catch { + return null; + } + } + + const RESUME_CONTEXT_MAX_ITEMS = 40; + const RESUME_CONTEXT_MAX_CHARS = 16000; + const RESUME_CONTEXT_TOOL_MAX_CHARS = 2000; + const RESUME_CONTEXT_REASONING_MAX_CHARS = 2000; + + function readResumeFileContent(resumeFile: string): { content: string; truncated: boolean } | null { + try { + const stat = fs.statSync(resumeFile); + if (!stat.isFile()) { + return null; + } + return { content: fs.readFileSync(resumeFile, 'utf8'), truncated: false }; + } catch (error) { + logger.debug('[Codex] Failed to read resume file:', error); + return null; + } + } + + function safeStringify(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'string') { + return value; + } + try { + return JSON.stringify(value); + } catch { + return null; + } + } + + function formatResumeValue(value: unknown, maxChars: number, singleLine = false): string | null { + const raw = safeStringify(value); + if (!raw) { + return null; + } + const normalized = singleLine ? raw.replace(/\s+/g, ' ').trim() : raw; + if (!normalized) { + return null; + } + if (normalized.length <= maxChars) { + return normalized; + } + return `${normalized.slice(0, maxChars)}...`; + } + + function buildResumeInstructionsFromFile(resumeFile: string): string | undefined { + const result = readResumeFileContent(resumeFile); + if (!result) { + return undefined; + } + + const items: { role: 'user' | 'assistant' | 'tool'; text: string }[] = []; + let truncated = result.truncated; + + const lines = result.content.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const parsed = JSON.parse(trimmed); + const converted = convertCodexEvent(parsed); + if (converted?.userMessage) { + items.push({ role: 'user', text: converted.userMessage }); + } + if (converted?.message?.type === 'message') { + items.push({ role: 'assistant', text: converted.message.message }); + } + if (converted?.message?.type === 'reasoning') { + const reasoning = formatResumeValue(converted.message.message, RESUME_CONTEXT_REASONING_MAX_CHARS); + if (reasoning) { + items.push({ role: 'assistant', text: `Reasoning: ${reasoning}` }); + } + } + if (converted?.message?.type === 'tool-call') { + const input = formatResumeValue(converted.message.input, RESUME_CONTEXT_TOOL_MAX_CHARS, true); + const text = input + ? `Call ${converted.message.name} ${input}` + : `Call ${converted.message.name}`; + items.push({ role: 'tool', text }); + } + if (converted?.message?.type === 'tool-call-result') { + const output = formatResumeValue(converted.message.output, RESUME_CONTEXT_TOOL_MAX_CHARS, true); + if (output) { + items.push({ role: 'tool', text: `Result ${output}` }); + } + } + } catch { + continue; + } + } + + if (items.length === 0) { + return undefined; + } + + if (items.length > RESUME_CONTEXT_MAX_ITEMS) { + items.splice(0, items.length - RESUME_CONTEXT_MAX_ITEMS); + truncated = true; + } + + const rendered = items.map((item) => { + if (item.role === 'user') { + return `User: ${item.text}`; + } + if (item.role === 'tool') { + return `Tool: ${item.text}`; + } + return `Assistant: ${item.text}`; + }); + let totalChars = rendered.reduce((sum, line) => sum + line.length + 1, 0); + while (rendered.length > 1 && totalChars > RESUME_CONTEXT_MAX_CHARS) { + const removed = rendered.shift(); + totalChars -= (removed?.length ?? 0) + 1; + truncated = true; + } + + if (rendered.length === 0) { + return undefined; + } + + const header = truncated + ? 'Continue from the prior session context below (transcript truncated):' + : 'Continue from the prior session context below:'; + return `${header}\n${rendered.join('\n')}`; + } + + const permissionHandler = new CodexPermissionHandler(session.client); + const reasoningProcessor = new ReasoningProcessor((message) => { + session.sendCodexMessage(message); + }); + const diffProcessor = new DiffProcessor((message) => { + session.sendCodexMessage(message); + }); + this.permissionHandler = permissionHandler; + this.reasoningProcessor = reasoningProcessor; + this.diffProcessor = diffProcessor; + + client.setPermissionHandler(permissionHandler); + client.setHandler((msg) => { + logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`); + + if (msg.type === 'agent_message') { + messageBuffer.addMessage(msg.message, 'assistant'); + } else if (msg.type === 'agent_reasoning_delta') { + } else if (msg.type === 'agent_reasoning') { + messageBuffer.addMessage(`[Thinking] ${msg.text.substring(0, 100)}...`, 'system'); + } else if (msg.type === 'exec_command_begin') { + messageBuffer.addMessage(`Executing: ${msg.command}`, 'tool'); + } else if (msg.type === 'exec_command_end') { + const output = msg.output || msg.error || 'Command completed'; + const truncatedOutput = output.substring(0, 200); + messageBuffer.addMessage( + `Result: ${truncatedOutput}${output.length > 200 ? '...' : ''}`, + 'result' + ); + } else if (msg.type === 'task_started') { + messageBuffer.addMessage('Starting task...', 'status'); + } else if (msg.type === 'task_complete') { + messageBuffer.addMessage('Task completed', 'status'); + sendReady(); + } else if (msg.type === 'turn_aborted') { + messageBuffer.addMessage('Turn aborted', 'status'); + sendReady(); + } + + if (msg.type === 'task_started') { + if (!session.thinking) { + logger.debug('thinking started'); + session.onThinkingChange(true); + } + } + if (msg.type === 'task_complete' || msg.type === 'turn_aborted') { + if (session.thinking) { + logger.debug('thinking completed'); + session.onThinkingChange(false); + } + diffProcessor.reset(); + } + if (msg.type === 'agent_reasoning_section_break') { + reasoningProcessor.handleSectionBreak(); + } + if (msg.type === 'agent_reasoning_delta') { + reasoningProcessor.processDelta(msg.delta); + } + if (msg.type === 'agent_reasoning') { + reasoningProcessor.complete(msg.text); + } + if (msg.type === 'agent_message') { + session.sendCodexMessage({ + type: 'message', + message: msg.message, + id: randomUUID() + }); + } + if (msg.type === 'exec_command_begin' || msg.type === 'exec_approval_request') { + const { call_id, type, ...inputs } = msg; + session.sendCodexMessage({ + type: 'tool-call', + name: 'CodexBash', + callId: call_id, + input: inputs, + id: randomUUID() + }); + } + if (msg.type === 'exec_command_end') { + const { call_id, type, ...output } = msg; + session.sendCodexMessage({ + type: 'tool-call-result', + callId: call_id, + output: output, + id: randomUUID() + }); + } + if (msg.type === 'token_count') { + session.sendCodexMessage({ + ...msg, + id: randomUUID() + }); + } + if (msg.type === 'patch_apply_begin') { + const { call_id, auto_approved, changes } = msg; + + const changeCount = Object.keys(changes).length; + const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`; + messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool'); + + session.sendCodexMessage({ + type: 'tool-call', + name: 'CodexPatch', + callId: call_id, + input: { + auto_approved, + changes + }, + id: randomUUID() + }); + } + if (msg.type === 'patch_apply_end') { + const { call_id, stdout, stderr, success } = msg; + + if (success) { + const message = stdout || 'Files modified successfully'; + messageBuffer.addMessage(message.substring(0, 200), 'result'); + } else { + const errorMsg = stderr || 'Failed to modify files'; + messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, 'result'); + } + + session.sendCodexMessage({ + type: 'tool-call-result', + callId: call_id, + output: { + stdout, + stderr, + success + }, + id: randomUUID() + }); + } + if (msg.type === 'turn_diff') { + if (msg.unified_diff) { + diffProcessor.processDiff(msg.unified_diff); + } + } + }); + + const happyServer = await startHappyServer(session.client); + this.happyServer = happyServer; + const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); + const mcpServers = { + hapi: { + command: bridgeCommand.command, + args: bridgeCommand.args + } + } as const; + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }); + + function logActiveHandles(tag: string) { + if (!process.env.DEBUG) return; + const anyProc: any = process as any; + const handles = typeof anyProc._getActiveHandles === 'function' ? anyProc._getActiveHandles() : []; + const requests = typeof anyProc._getActiveRequests === 'function' ? anyProc._getActiveRequests() : []; + logger.debug(`[codex][handles] ${tag}: handles=${handles.length} requests=${requests.length}`); + try { + const kinds = handles.map((h: any) => (h && h.constructor ? h.constructor.name : typeof h)); + logger.debug(`[codex][handles] kinds=${JSON.stringify(kinds)}`); + } catch {} + } + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + }; + + const syncSessionId = () => { + const clientSessionId = client.getSessionId(); + if (clientSessionId && clientSessionId !== session.sessionId) { + session.onSessionFound(clientSessionId); + } + }; + await client.connect(); let wasCreated = false; @@ -457,19 +466,19 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc let nextExperimentalResume: string | null = null; let first = true; - while (!shouldExit) { + while (!this.shouldExit) { logActiveHandles('loop-top'); let message: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = pending; pending = null; if (!message) { - const waitSignal = abortController.signal; + const waitSignal = this.abortController.signal; const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal); if (!batch) { - if (waitSignal.aborted && !shouldExit) { + if (waitSignal.aborted && !this.shouldExit) { logger.debug('[codex]: Wait aborted while idle; ignoring and continuing'); continue; } - logger.debug(`[codex]: batch=${!!batch}, shouldExit=${shouldExit}`); + logger.debug(`[codex]: batch=${!!batch}, shouldExit=${this.shouldExit}`); break; } message = batch; @@ -516,19 +525,19 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc resumeFile = nextExperimentalResume; nextExperimentalResume = null; logger.debug('[Codex] Using resume file from mode change:', resumeFile); - } else if (storedSessionIdForResume) { - const abortResumeFile = findCodexResumeFile(storedSessionIdForResume); + } else if (this.storedSessionIdForResume) { + const abortResumeFile = findCodexResumeFile(this.storedSessionIdForResume); if (abortResumeFile) { resumeFile = abortResumeFile; logger.debug('[Codex] Using resume file from aborted session:', resumeFile); messageBuffer.addMessage('Resuming from aborted session...', 'status'); } - storedSessionIdForResume = null; + this.storedSessionIdForResume = null; } else if (first && session.sessionId) { const localResumeFile = findCodexResumeFile(session.sessionId); if (localResumeFile) { resumeFile = localResumeFile; - logger.debug('[Codex] Using resume file from local session:', resumeFile); + logger.debug('[Codex] Using resume file from local session:', localResumeFile); messageBuffer.addMessage('Resuming from local session log...', 'status'); } } @@ -549,12 +558,12 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc (startConfig.config as any).experimental_resume = resumeFile; } - await client.startSession(startConfig, { signal: abortController.signal }); + await client.startSession(startConfig, { signal: this.abortController.signal }); wasCreated = true; first = false; syncSessionId(); } else { - await client.continueSession(message.message, { signal: abortController.signal }); + await client.continueSession(message.message, { signal: this.abortController.signal }); syncSessionId(); } } catch (error) { @@ -571,8 +580,8 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc messageBuffer.addMessage('Process exited unexpectedly', 'status'); session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); if (client.hasActiveSession()) { - storedSessionIdForResume = client.storeSessionForResume(); - logger.debug('[Codex] Stored session after unexpected error:', storedSessionIdForResume); + this.storedSessionIdForResume = client.storeSessionForResume(); + logger.debug('[Codex] Stored session after unexpected error:', this.storedSessionIdForResume); } } } finally { @@ -583,36 +592,41 @@ export async function codexRemoteLauncher(session: CodexSession): Promise<'switc emitReadyIfIdle({ pending, queueSize: () => session.queue.size(), - shouldExit, + shouldExit: this.shouldExit, sendReady }); logActiveHandles('after-turn'); } } - } finally { + } + + protected async cleanup(): Promise { logger.debug('[codex-remote]: cleanup start'); try { - await client.disconnect(); + await this.client.disconnect(); } catch (error) { logger.debug('[codex-remote]: Error disconnecting client', error); } - session.client.rpcHandlerManager.registerHandler('abort', async () => {}); - session.client.rpcHandlerManager.registerHandler('switch', async () => {}); - happyServer.stop(); - permissionHandler.reset(); - reasoningProcessor.abort(); - diffProcessor.reset(); - restoreTerminalState(); - if (hasTTY) { - try { process.stdin.pause(); } catch {} + this.clearAbortHandlers(this.session.client.rpcHandlerManager); + + if (this.happyServer) { + this.happyServer.stop(); + this.happyServer = null; } - if (inkInstance) { - inkInstance.unmount(); - } - messageBuffer.clear(); + + this.permissionHandler?.reset(); + this.reasoningProcessor?.abort(); + this.diffProcessor?.reset(); + this.permissionHandler = null; + this.reasoningProcessor = null; + this.diffProcessor = null; + logger.debug('[codex-remote]: cleanup done'); } +} - return exitReason || 'exit'; +export async function codexRemoteLauncher(session: CodexSession): Promise<'switch' | 'exit'> { + const launcher = new CodexRemoteLauncher(session); + return launcher.launch(); } diff --git a/cli/src/modules/common/remote/RemoteLauncherBase.ts b/cli/src/modules/common/remote/RemoteLauncherBase.ts new file mode 100644 index 00000000..bb2a3224 --- /dev/null +++ b/cli/src/modules/common/remote/RemoteLauncherBase.ts @@ -0,0 +1,129 @@ +import { render } from 'ink'; +import type { ReactElement } from 'react'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { restoreTerminalState } from '@/ui/terminalState'; + +export type RemoteLauncherExitReason = 'switch' | 'exit'; + +export type RemoteLauncherDisplayContext = { + messageBuffer: MessageBuffer; + logPath?: string; + onExit: () => void | Promise; + onSwitchToLocal: () => void | Promise; +}; + +export type RemoteLauncherTerminalHandlers = { + onExit: () => void | Promise; + onSwitchToLocal: () => void | Promise; +}; + +export type RemoteLauncherAbortHandlers = { + onAbort: () => void | Promise; + onSwitch: () => void | Promise; +}; + +type RpcHandlerManagerLike = { + registerHandler( + method: string, + handler: (params: TRequest) => Promise | TResponse + ): void; +}; + +export abstract class RemoteLauncherBase { + protected readonly messageBuffer: MessageBuffer; + protected readonly hasTTY: boolean; + protected readonly logPath?: string; + protected exitReason: RemoteLauncherExitReason | null = null; + protected shouldExit: boolean = false; + private inkInstance: ReturnType | null = null; + + protected constructor(logPath?: string) { + this.logPath = logPath; + this.hasTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY); + this.messageBuffer = new MessageBuffer(); + } + + protected abstract createDisplay(context: RemoteLauncherDisplayContext): ReactElement; + + protected abstract runMainLoop(): Promise; + + protected abstract cleanup(): Promise; + + protected setupTerminal(handlers: RemoteLauncherTerminalHandlers): void { + if (this.hasTTY) { + console.clear(); + this.inkInstance = render(this.createDisplay({ + messageBuffer: this.messageBuffer, + logPath: this.logPath, + onExit: handlers.onExit, + onSwitchToLocal: handlers.onSwitchToLocal + }), { + exitOnCtrlC: false, + patchConsole: false + }); + } + + if (this.hasTTY) { + process.stdin.resume(); + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.setEncoding('utf8'); + } + } + + protected setupAbortHandlers( + rpcHandlerManager: RpcHandlerManagerLike, + handlers: RemoteLauncherAbortHandlers + ): void { + rpcHandlerManager.registerHandler('abort', async () => { + await handlers.onAbort(); + }); + + rpcHandlerManager.registerHandler('switch', async () => { + await handlers.onSwitch(); + }); + } + + protected clearAbortHandlers(rpcHandlerManager: RpcHandlerManagerLike): void { + rpcHandlerManager.registerHandler('abort', async () => {}); + rpcHandlerManager.registerHandler('switch', async () => {}); + } + + protected async requestExit( + reason: RemoteLauncherExitReason, + handler: () => void | Promise + ): Promise { + if (!this.exitReason) { + this.exitReason = reason; + } + this.shouldExit = true; + await handler(); + } + + protected finalizeTerminal(): void { + restoreTerminalState(); + if (this.hasTTY) { + try { + process.stdin.pause(); + } catch { + } + } + if (this.inkInstance) { + this.inkInstance.unmount(); + } + this.messageBuffer.clear(); + } + + protected async start(handlers: RemoteLauncherTerminalHandlers): Promise { + this.setupTerminal(handlers); + try { + await this.runMainLoop(); + } finally { + await this.cleanup(); + this.finalizeTerminal(); + } + + return this.exitReason || 'exit'; + } +}