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