mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
feat: support opencode
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import React from 'react';
|
||||
import { logger } from '@/ui/logger';
|
||||
import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
|
||||
import { convertAgentMessage } from '@/agent/messageConverter';
|
||||
import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types';
|
||||
import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase';
|
||||
import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay';
|
||||
import type { OpencodeSession } from './session';
|
||||
import type { PermissionMode } from './types';
|
||||
import { createOpencodeBackend } from './utils/opencodeBackend';
|
||||
import { OpencodePermissionHandler } from './utils/permissionHandler';
|
||||
|
||||
class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
private readonly session: OpencodeSession;
|
||||
private backend: ReturnType<typeof createOpencodeBackend> | null = null;
|
||||
private permissionHandler: OpencodePermissionHandler | null = null;
|
||||
private happyServer: { stop: () => void } | null = null;
|
||||
private abortController = new AbortController();
|
||||
private displayPermissionMode: PermissionMode | null = null;
|
||||
|
||||
constructor(session: OpencodeSession) {
|
||||
super(process.env.DEBUG ? session.logPath : undefined);
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
public async launch(): Promise<RemoteLauncherExitReason> {
|
||||
return this.start({
|
||||
onExit: () => this.handleExitFromUi(),
|
||||
onSwitchToLocal: () => this.handleSwitchFromUi()
|
||||
});
|
||||
}
|
||||
|
||||
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
|
||||
return React.createElement(OpencodeDisplay, context);
|
||||
}
|
||||
|
||||
protected async runMainLoop(): Promise<void> {
|
||||
const session = this.session;
|
||||
const messageBuffer = this.messageBuffer;
|
||||
|
||||
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client);
|
||||
this.happyServer = happyServer;
|
||||
|
||||
const backend = createOpencodeBackend({
|
||||
cwd: session.path
|
||||
});
|
||||
this.backend = backend;
|
||||
|
||||
backend.onStderrError((error) => {
|
||||
logger.debug('[opencode-remote] stderr error', error);
|
||||
session.sendSessionEvent({ type: 'message', message: error.message });
|
||||
messageBuffer.addMessage(error.message, 'status');
|
||||
});
|
||||
|
||||
await backend.initialize();
|
||||
|
||||
const resumeSessionId = session.sessionId;
|
||||
const mcpServerList = toAcpMcpServers(mcpServers);
|
||||
let acpSessionId: string;
|
||||
if (resumeSessionId) {
|
||||
try {
|
||||
acpSessionId = await backend.loadSession({
|
||||
sessionId: resumeSessionId,
|
||||
cwd: session.path,
|
||||
mcpServers: mcpServerList
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('[opencode-remote] resume failed, starting new session', error);
|
||||
session.sendSessionEvent({
|
||||
type: 'message',
|
||||
message: 'OpenCode resume failed; starting a new session.'
|
||||
});
|
||||
acpSessionId = await backend.newSession({
|
||||
cwd: session.path,
|
||||
mcpServers: mcpServerList
|
||||
});
|
||||
}
|
||||
} else {
|
||||
acpSessionId = await backend.newSession({
|
||||
cwd: session.path,
|
||||
mcpServers: mcpServerList
|
||||
});
|
||||
}
|
||||
session.onSessionFound(acpSessionId);
|
||||
|
||||
this.permissionHandler = new OpencodePermissionHandler(
|
||||
session.client,
|
||||
backend,
|
||||
() => session.getPermissionMode() as PermissionMode | undefined
|
||||
);
|
||||
this.applyDisplayMode(session.getPermissionMode() as PermissionMode);
|
||||
|
||||
this.setupAbortHandlers(session.client.rpcHandlerManager, {
|
||||
onAbort: () => this.handleAbort(),
|
||||
onSwitch: () => this.handleSwitchRequest()
|
||||
});
|
||||
|
||||
const sendReady = () => {
|
||||
session.sendSessionEvent({ type: 'ready' });
|
||||
};
|
||||
|
||||
while (!this.shouldExit) {
|
||||
const waitSignal = this.abortController.signal;
|
||||
const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal);
|
||||
if (!batch) {
|
||||
if (waitSignal.aborted && !this.shouldExit) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.applyDisplayMode(batch.mode.permissionMode);
|
||||
messageBuffer.addMessage(batch.message, 'user');
|
||||
|
||||
const promptContent: PromptContent[] = [{
|
||||
type: 'text',
|
||||
text: batch.message
|
||||
}];
|
||||
|
||||
session.onThinkingChange(true);
|
||||
|
||||
try {
|
||||
await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => {
|
||||
this.handleAgentMessage(message);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('[opencode-remote] prompt failed', error);
|
||||
session.sendSessionEvent({
|
||||
type: 'message',
|
||||
message: 'OpenCode prompt failed. Check logs for details.'
|
||||
});
|
||||
messageBuffer.addMessage('OpenCode prompt failed', 'status');
|
||||
} finally {
|
||||
session.onThinkingChange(false);
|
||||
await this.permissionHandler?.cancelAll('Prompt finished');
|
||||
if (session.queue.size() === 0 && !this.shouldExit) {
|
||||
sendReady();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async cleanup(): Promise<void> {
|
||||
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
|
||||
|
||||
if (this.permissionHandler) {
|
||||
await this.permissionHandler.cancelAll('Session ended');
|
||||
this.permissionHandler = null;
|
||||
}
|
||||
|
||||
if (this.backend) {
|
||||
await this.backend.disconnect();
|
||||
this.backend = null;
|
||||
}
|
||||
|
||||
if (this.happyServer) {
|
||||
this.happyServer.stop();
|
||||
this.happyServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentMessage(message: AgentMessage): void {
|
||||
const converted = convertAgentMessage(message);
|
||||
if (converted) {
|
||||
this.session.sendCodexMessage(converted);
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
this.messageBuffer.addMessage(message.text, 'assistant');
|
||||
break;
|
||||
case 'tool_call':
|
||||
this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool');
|
||||
break;
|
||||
case 'tool_result':
|
||||
this.messageBuffer.addMessage('Tool result received', 'result');
|
||||
break;
|
||||
case 'plan':
|
||||
this.messageBuffer.addMessage('Plan updated', 'status');
|
||||
break;
|
||||
case 'error':
|
||||
this.messageBuffer.addMessage(message.message, 'status');
|
||||
break;
|
||||
case 'turn_complete':
|
||||
this.messageBuffer.addMessage('Turn complete', 'status');
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = message;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyDisplayMode(permissionMode: PermissionMode | undefined): void {
|
||||
if (permissionMode && permissionMode !== this.displayPermissionMode) {
|
||||
this.displayPermissionMode = permissionMode;
|
||||
this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAbort(): Promise<void> {
|
||||
const backend = this.backend;
|
||||
if (backend && this.session.sessionId) {
|
||||
await backend.cancelPrompt(this.session.sessionId);
|
||||
}
|
||||
await this.permissionHandler?.cancelAll('User aborted');
|
||||
this.session.queue.reset();
|
||||
this.session.onThinkingChange(false);
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
this.messageBuffer.addMessage('Turn aborted', 'status');
|
||||
}
|
||||
|
||||
private async handleExitFromUi(): Promise<void> {
|
||||
await this.requestExit('exit', () => this.handleAbort());
|
||||
}
|
||||
|
||||
private async handleSwitchFromUi(): Promise<void> {
|
||||
await this.requestExit('switch', () => this.handleAbort());
|
||||
}
|
||||
|
||||
private async handleSwitchRequest(): Promise<void> {
|
||||
await this.requestExit('switch', () => this.handleAbort());
|
||||
}
|
||||
}
|
||||
|
||||
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
|
||||
return Object.entries(config).map(([name, entry]) => ({
|
||||
name,
|
||||
command: entry.command,
|
||||
args: entry.args,
|
||||
env: []
|
||||
}));
|
||||
}
|
||||
|
||||
export async function opencodeRemoteLauncher(
|
||||
session: OpencodeSession
|
||||
): Promise<'switch' | 'exit'> {
|
||||
const launcher = new OpencodeRemoteLauncher(session);
|
||||
return launcher.launch();
|
||||
}
|
||||
Reference in New Issue
Block a user