mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-15 03:01:39 +00:00
refactor: extract remote launcher base class for code reuse
This commit is contained in:
@@ -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<void> | 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<void> {
|
||||
if (this.abortController && !this.abortController.signal.aborted) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
await this.abortFuture?.promise;
|
||||
}
|
||||
|
||||
private async handleAbortRequest(): Promise<void> {
|
||||
logger.debug('[remote]: doAbort');
|
||||
await this.abort();
|
||||
}
|
||||
|
||||
private async handleSwitchRequest(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> | null = null;
|
||||
|
||||
async function abort() {
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
await abortFuture?.promise;
|
||||
private async handleSwitchFromUi(): Promise<void> {
|
||||
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<RemoteLauncherExitReason> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
let ongoingToolCalls = new Map<string, { parentToolCallId: string | null }>();
|
||||
let planModeToolCalls = new Set<string>();
|
||||
let ongoingToolCalls = new Map<string, { parentToolCallId: string | null }>();
|
||||
|
||||
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<void>();
|
||||
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<void> {
|
||||
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<void>();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof startHappyServer>>;
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.exitReason = 'switch';
|
||||
this.shouldExit = true;
|
||||
await this.handleAbort();
|
||||
}
|
||||
|
||||
public async launch(): Promise<RemoteLauncherExitReason> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
onSwitchToLocal: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type RemoteLauncherTerminalHandlers = {
|
||||
onExit: () => void | Promise<void>;
|
||||
onSwitchToLocal: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type RemoteLauncherAbortHandlers = {
|
||||
onAbort: () => void | Promise<void>;
|
||||
onSwitch: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type RpcHandlerManagerLike = {
|
||||
registerHandler<TRequest = unknown, TResponse = unknown>(
|
||||
method: string,
|
||||
handler: (params: TRequest) => Promise<TResponse> | 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<typeof render> | 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<void>;
|
||||
|
||||
protected abstract cleanup(): Promise<void>;
|
||||
|
||||
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<void>
|
||||
): Promise<void> {
|
||||
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<RemoteLauncherExitReason> {
|
||||
this.setupTerminal(handlers);
|
||||
try {
|
||||
await this.runMainLoop();
|
||||
} finally {
|
||||
await this.cleanup();
|
||||
this.finalizeTerminal();
|
||||
}
|
||||
|
||||
return this.exitReason || 'exit';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user