feat: support opencode

This commit is contained in:
weishu
2026-01-29 10:34:57 +08:00
parent 66ab96bf38
commit 70b5c22c8f
47 changed files with 2737 additions and 28 deletions
+60
View File
@@ -0,0 +1,60 @@
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { logger } from '@/ui/logger';
import { runLocalRemoteSession } from '@/agent/loopBase';
import { OpencodeSession } from './session';
import { opencodeLocalLauncher } from './opencodeLocalLauncher';
import { opencodeRemoteLauncher } from './opencodeRemoteLauncher';
import { ApiClient, ApiSessionClient } from '@/lib';
import type { OpencodeMode, PermissionMode } from './types';
import type { OpencodeHookServer } from './utils/startOpencodeHookServer';
interface OpencodeLoopOptions {
path: string;
startingMode?: 'local' | 'remote';
startedBy?: 'runner' | 'terminal';
onModeChange: (mode: 'local' | 'remote') => void;
messageQueue: MessageQueue2<OpencodeMode>;
session: ApiSessionClient;
api: ApiClient;
permissionMode?: PermissionMode;
resumeSessionId?: string;
hookServer: OpencodeHookServer;
hookUrl: string;
onSessionReady?: (session: OpencodeSession) => void;
}
export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
const logPath = logger.getLogPath();
const startedBy = opts.startedBy ?? 'terminal';
const startingMode = opts.startingMode ?? 'local';
const session = new OpencodeSession({
api: opts.api,
client: opts.session,
path: opts.path,
sessionId: opts.resumeSessionId ?? null,
logPath,
messageQueue: opts.messageQueue,
onModeChange: opts.onModeChange,
mode: startingMode,
startedBy,
startingMode,
permissionMode: opts.permissionMode ?? 'default'
});
if (opts.resumeSessionId) {
session.onSessionFound(opts.resumeSessionId);
}
await runLocalRemoteSession({
session,
startingMode: opts.startingMode,
logTag: 'opencode-loop',
runLocal: (instance) => opencodeLocalLauncher(instance, {
hookServer: opts.hookServer,
hookUrl: opts.hookUrl
}),
runRemote: (instance) => opencodeRemoteLauncher(instance),
onSessionReady: opts.onSessionReady
});
}
+37
View File
@@ -0,0 +1,37 @@
import { logger } from '@/ui/logger';
import { restoreTerminalState } from '@/ui/terminalState';
import { spawnWithAbort } from '@/utils/spawnWithAbort';
export async function opencodeLocal(opts: {
path: string;
abort: AbortSignal;
env: NodeJS.ProcessEnv;
sessionId?: string;
}): Promise<void> {
const args: string[] = [];
if (opts.sessionId) {
args.push('--session', opts.sessionId);
}
logger.debug(`[OpencodeLocal] Spawning opencode with args: ${JSON.stringify(args)}`);
process.stdin.pause();
try {
await spawnWithAbort({
command: 'opencode',
args,
cwd: opts.path,
env: opts.env,
signal: opts.abort,
shell: process.platform === 'win32',
logLabel: 'OpencodeLocal',
spawnName: 'opencode',
installHint: 'OpenCode CLI',
includeCause: true,
logExit: true
});
} finally {
process.stdin.resume();
restoreTerminalState();
}
}
+665
View File
@@ -0,0 +1,665 @@
import { logger } from '@/ui/logger';
import { opencodeLocal } from './opencodeLocal';
import { OpencodeSession } from './session';
import { Future } from '@/utils/future';
import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy';
import { ensureOpencodeHookPlugin } from './utils/hookPlugin';
import { buildOpencodeEnv } from './utils/config';
import type { OpencodeHookEvent } from './types';
import type { OpencodeHookServer } from './utils/startOpencodeHookServer';
import { createOpencodeStorageScanner, type OpencodeStorageScannerHandle } from './utils/opencodeStorageScanner';
import { randomUUID } from 'node:crypto';
import { isObject } from '@hapi/protocol';
import { join } from 'node:path';
import { configuration } from '@/configuration';
import type { PermissionCompletion } from '@/modules/common/permission/BasePermissionHandler';
import { hashObject } from '@/utils/deterministicJson';
type OpencodeLocalLauncherOptions = {
hookServer: OpencodeHookServer;
hookUrl: string;
};
type ParsedToolCall = {
callId: string;
name: string;
input: unknown;
};
type ParsedToolResult = {
callId: string;
output: unknown;
};
type PermissionDecision = PermissionCompletion['decision'];
function getString(value: unknown): string | null {
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
return null;
}
function parseMaybeJson(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return value;
}
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
return value;
}
function getNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
return null;
}
function getTextDelta(payloadRecord: Record<string, unknown> | null): string | null {
const delta = payloadRecord?.delta;
return typeof delta === 'string' && delta.length > 0 ? delta : null;
}
function buildToolSignature(name: string, input: unknown): string {
return `${name}:${hashObject(input ?? null)}`;
}
function pushQueue(map: Map<string, string[]>, key: string, value: string): void {
const queue = map.get(key) ?? [];
queue.push(value);
map.set(key, queue);
}
function shiftQueue(map: Map<string, string[]>, key: string): string | null {
const queue = map.get(key);
if (!queue || queue.length === 0) {
return null;
}
const value = queue.shift() ?? null;
if (!queue.length) {
map.delete(key);
} else {
map.set(key, queue);
}
return value;
}
function removeFromQueue(map: Map<string, string[]>, key: string, value: string): void {
const queue = map.get(key);
if (!queue || queue.length === 0) {
return;
}
const nextQueue = queue.filter((entry) => entry !== value);
if (!nextQueue.length) {
map.delete(key);
} else {
map.set(key, nextQueue);
}
}
function extractSessionId(value: unknown): string | null {
if (!isObject(value)) {
return null;
}
const record = value as Record<string, unknown>;
const direct = getString(record.sessionId)
|| getString(record.sessionID)
|| getString(record.session_id)
|| (isObject(record.session) ? getString((record.session as Record<string, unknown>).id) : null);
if (direct) {
return direct;
}
if (isObject(record.part)) {
const nested = extractSessionId(record.part);
if (nested) {
return nested;
}
}
if (isObject(record.info)) {
const nested = extractSessionId(record.info);
if (nested) {
return nested;
}
}
return null;
}
function unwrapMessage(payload: unknown): Record<string, unknown> | null {
if (!isObject(payload)) {
return null;
}
const record = payload as Record<string, unknown>;
if (isObject(record.message)) {
return record.message as Record<string, unknown>;
}
if (isObject(record.info)) {
return record.info as Record<string, unknown>;
}
return record;
}
function unwrapPart(payload: unknown): Record<string, unknown> | null {
if (isObject(payload)) {
const record = payload as Record<string, unknown>;
if (isObject(record.part)) {
return record.part as Record<string, unknown>;
}
return record;
}
return null;
}
function parseToolCall(part: unknown): ParsedToolCall | null {
if (!isObject(part)) {
return null;
}
const record = part as Record<string, unknown>;
const name = getString(record.tool) || getString(record.name);
const callId = getString(record.callID)
|| getString(record.callId)
|| getString(record.id)
|| getString(record.tool_call_id)
|| getString(record.toolCallId);
if (!name || !callId) {
return null;
}
if (getString(record.type) === 'tool' && isObject(record.state)) {
const state = record.state as Record<string, unknown>;
const status = getString(state.status);
if (status !== 'pending' && status !== 'running') {
return null;
}
const input = parseMaybeJson(state.input ?? state.raw ?? record.input ?? record.args ?? record.arguments);
return { callId, name, input };
}
const input = parseMaybeJson(record.input ?? record.args ?? record.arguments ?? record.raw);
return { callId, name, input };
}
function parseToolResult(part: unknown): ParsedToolResult | null {
if (!isObject(part)) {
return null;
}
const record = part as Record<string, unknown>;
const callId = getString(record.callID)
|| getString(record.callId)
|| getString(record.tool_call_id)
|| getString(record.toolCallId)
|| getString(record.id);
if (!callId) {
return null;
}
if (getString(record.type) === 'tool' && isObject(record.state)) {
const state = record.state as Record<string, unknown>;
const status = getString(state.status);
if (status === 'completed') {
const output = {
content: state.output ?? state.title,
metadata: state.metadata,
title: state.title,
attachments: state.attachments
};
return { callId, output };
}
if (status === 'error') {
const output = {
content: state.error,
isError: true
};
return { callId, output };
}
return null;
}
const output = {
content: record.content,
metadata: record.metadata,
isError: record.is_error
};
return { callId, output };
}
function normalizeDecision(response: string | null, approved: boolean): PermissionDecision {
if (response === 'always' || response === 'approved_for_session') {
return 'approved_for_session';
}
if (response === 'once' || response === 'approved') {
return 'approved';
}
if (response === 'reject' || response === 'denied') {
return 'denied';
}
if (response === 'abort' || response === 'cancel' || response === 'canceled') {
return 'abort';
}
return approved ? 'approved' : 'denied';
}
function resolveOpencodeConfigDir(session: OpencodeSession): string {
if (process.env.OPENCODE_CONFIG_DIR) {
return process.env.OPENCODE_CONFIG_DIR;
}
return join(configuration.happyHomeDir, 'tmp', 'opencode', session.client.sessionId, '.opencode');
}
export async function opencodeLocalLauncher(
session: OpencodeSession,
opts: OpencodeLocalLauncherOptions
): Promise<'switch' | 'exit'> {
let exitReason: 'switch' | 'exit' | null = null;
const processAbortController = new AbortController();
const exitFuture = new Future<void>();
const hookUrl = opts.hookUrl;
const opencodeConfigDir = resolveOpencodeConfigDir(session);
ensureOpencodeHookPlugin(opencodeConfigDir, hookUrl, opts.hookServer.token);
let storageScanner: OpencodeStorageScannerHandle | null = null;
const messageRoles = new Map<string, string>();
const sentTextParts = new Set<string>();
const sentToolCalls = new Set<string>();
const sentToolResults = new Set<string>();
const textBuffers = new Map<string, string>();
const toolExecutionQueues = new Map<string, string[]>();
const handleHookEvent = (event: OpencodeHookEvent) => {
const payload = event.payload;
const eventType = event.event;
const payloadRecord = isObject(payload) ? payload as Record<string, unknown> : null;
const sessionId = event.sessionId
|| extractSessionId(payload)
|| (payloadRecord ? extractSessionId(payloadRecord.info) : null);
if (sessionId) {
session.onSessionFound(sessionId);
storageScanner?.onNewSession(sessionId);
}
if (eventType === 'session.created' || eventType === 'session.updated') {
if (payloadRecord) {
const info = isObject(payloadRecord.info) ? payloadRecord.info as Record<string, unknown> : payloadRecord;
const sessionIdValue = extractSessionId(info) || getString(info.id);
if (sessionIdValue) {
session.onSessionFound(sessionIdValue);
storageScanner?.onNewSession(sessionIdValue);
}
}
return;
}
if (eventType === 'message.updated') {
const message = unwrapMessage(payload);
if (!message) {
return;
}
const messageId = getString(message.id) || getString(message.messageId);
const role = getString(message.role);
if (messageId && role) {
messageRoles.set(messageId, role);
}
return;
}
if (eventType === 'message.part.updated') {
const part = unwrapPart(payload);
if (!part) {
return;
}
const partType = getString(part.type);
const partId = getString(part.id);
const messageId = getString(part.messageID)
|| getString(part.messageId)
|| (payloadRecord ? getString(payloadRecord.messageID) || getString(payloadRecord.messageId) : null);
const delta = getTextDelta(payloadRecord);
if (partType === 'text') {
if (partId && sentTextParts.has(partId)) {
return;
}
const role = (messageId && messageRoles.get(messageId)) ?? 'assistant';
const key = partId ?? messageId;
const bufferValue = key ? textBuffers.get(key) ?? '' : '';
const textFromPart = getString(part.text);
const nextBuffer = delta ? bufferValue + delta : bufferValue;
if (key && (delta || textFromPart)) {
textBuffers.set(key, textFromPart ?? nextBuffer);
}
const time = isObject(part.time) ? part.time as Record<string, unknown> : null;
const hasEnd = time ? getNumber(time.end) !== null : false;
const shouldFlush = role === 'user'
|| part.synthetic === true
|| hasEnd
|| (!delta && Boolean(textFromPart));
const text = textFromPart ?? (key ? textBuffers.get(key) : null);
if (shouldFlush && text) {
if (role === 'user') {
session.sendUserMessage(text);
} else {
session.sendCodexMessage({ type: 'message', message: text });
}
if (partId) {
sentTextParts.add(partId);
}
if (key) {
textBuffers.delete(key);
}
}
return;
}
const toolCall = parseToolCall(part);
if (toolCall && !sentToolCalls.has(toolCall.callId)) {
sentToolCalls.add(toolCall.callId);
session.sendCodexMessage({
type: 'tool-call',
name: toolCall.name,
callId: toolCall.callId,
input: toolCall.input
});
}
const toolResult = parseToolResult(part);
if (toolResult && !sentToolResults.has(toolResult.callId)) {
sentToolResults.add(toolResult.callId);
session.sendCodexMessage({
type: 'tool-call-result',
callId: toolResult.callId,
output: toolResult.output
});
}
return;
}
if (eventType === 'tool.execute.before' || eventType === 'tool.execute.after') {
if (!isObject(payload)) {
return;
}
const record = payload as Record<string, unknown>;
const tool = isObject(record.tool) ? record.tool as Record<string, unknown> : record;
const name = getString(tool.name) || getString(record.name);
if (!name) {
return;
}
const toolInput = parseMaybeJson(tool.input ?? tool.args ?? record.input ?? record.args);
const signature = buildToolSignature(name, toolInput);
const fallbackSignature = buildToolSignature(name, null);
const existingId = getString(tool.id)
|| getString(tool.tool_call_id)
|| getString(tool.toolCallId);
const isBefore = eventType === 'tool.execute.before';
let callId = existingId;
if (!callId) {
callId = isBefore
? randomUUID()
: shiftQueue(toolExecutionQueues, signature)
?? shiftQueue(toolExecutionQueues, fallbackSignature)
?? randomUUID();
}
if (isBefore) {
pushQueue(toolExecutionQueues, signature, callId);
if (fallbackSignature !== signature) {
pushQueue(toolExecutionQueues, fallbackSignature, callId);
}
} else {
removeFromQueue(toolExecutionQueues, signature, callId);
if (fallbackSignature !== signature) {
removeFromQueue(toolExecutionQueues, fallbackSignature, callId);
}
}
if (eventType === 'tool.execute.before' && !sentToolCalls.has(callId)) {
sentToolCalls.add(callId);
session.sendCodexMessage({
type: 'tool-call',
name,
callId,
input: toolInput
});
return;
}
if (eventType === 'tool.execute.after' && !sentToolResults.has(callId)) {
sentToolResults.add(callId);
session.sendCodexMessage({
type: 'tool-call-result',
callId,
output: {
content: tool.content ?? record.content ?? record.output,
metadata: tool.metadata ?? record.metadata,
isError: tool.is_error ?? record.is_error
}
});
return;
}
}
if (eventType === 'permission.updated' || eventType === 'permission.asked') {
if (!isObject(payload)) {
return;
}
const record = payload as Record<string, unknown>;
const metadata = isObject(record.metadata) ? record.metadata as Record<string, unknown> : undefined;
const id = getString(record.id)
|| getString(record.permissionID)
|| getString(record.permissionId)
|| getString(record.requestID)
|| getString(record.requestId);
if (!id) {
return;
}
const toolName = getString(record.permission)
|| getString(record.type)
|| (metadata ? getString(metadata.tool) : null)
|| 'Permission';
const toolInput = metadata?.input
?? record.pattern
?? record.message
?? record.metadata;
session.client.updateAgentState((currentState) => ({
...currentState,
requests: {
...currentState.requests,
[id]: {
tool: toolName,
arguments: toolInput,
createdAt: Date.now()
}
}
}));
return;
}
if (eventType === 'permission.replied') {
if (!isObject(payload)) {
return;
}
const record = payload as Record<string, unknown>;
const metadata = isObject(record.metadata) ? record.metadata as Record<string, unknown> : undefined;
const id = getString(record.permissionID)
|| getString(record.permissionId)
|| getString(record.requestID)
|| getString(record.requestId)
|| getString(record.id);
if (!id) {
return;
}
const toolName = getString(record.permission)
|| getString(record.type)
|| (metadata ? getString(metadata.tool) : null)
|| 'Permission';
const toolInput = metadata?.input
?? record.pattern
?? record.message
?? record.metadata;
const response = getString(record.response)
|| getString(record.reply)
|| getString(record.decision);
const approved = record.approved === true
|| response === 'once'
|| response === 'always'
|| response === 'approved';
const decision = normalizeDecision(response, approved);
const status = decision === 'approved' || decision === 'approved_for_session'
? 'approved'
: decision === 'abort'
? 'canceled'
: 'denied';
const reason = getString(record.reason) ?? undefined;
const allowTools = Array.isArray(record.allowTools) ? record.allowTools : undefined;
session.client.updateAgentState((currentState) => {
const request = currentState.requests?.[id] ?? {
tool: toolName,
arguments: toolInput,
createdAt: Date.now()
};
const nextRequests = { ...(currentState.requests || {}) };
delete nextRequests[id];
return {
...currentState,
requests: nextRequests,
completedRequests: {
...currentState.completedRequests,
[id]: {
...request,
completedAt: Date.now(),
status,
decision,
reason,
allowTools
}
}
};
});
return;
}
};
session.addHookEventHandler(handleHookEvent);
try {
try {
storageScanner = await createOpencodeStorageScanner({
sessionId: session.sessionId,
cwd: session.path,
onEvent: (event) => session.emitHookEvent(event),
onSessionFound: (sessionId) => {
session.onSessionFound(sessionId);
},
onSessionMatchFailed: (message) => {
session.sendSessionEvent({ type: 'message', message });
}
});
} catch (error) {
logger.debug('[opencode-local]: Failed to start storage scanner', error);
}
const abortProcess = async () => {
if (!processAbortController.signal.aborted) {
processAbortController.abort();
}
await exitFuture.promise;
};
const doAbort = async () => {
logger.debug('[opencode-local]: abort requested');
if (!exitReason) {
exitReason = 'switch';
}
session.queue.reset();
await abortProcess();
};
const doSwitch = async () => {
logger.debug('[opencode-local]: switch requested');
if (!exitReason) {
exitReason = 'switch';
}
await abortProcess();
};
session.client.rpcHandlerManager.registerHandler('abort', doAbort);
session.client.rpcHandlerManager.registerHandler('switch', doSwitch);
session.queue.setOnMessage(() => {
void doSwitch();
});
if (session.queue.size() > 0) {
return 'switch';
}
while (true) {
if (exitReason) {
return exitReason;
}
logger.debug('[opencode-local]: launch');
try {
const env = buildOpencodeEnv();
env.HAPI_OPENCODE_HOOK_URL = hookUrl;
env.HAPI_OPENCODE_HOOK_TOKEN = opts.hookServer.token;
if (!env.OPENCODE_CONFIG_DIR) {
env.OPENCODE_CONFIG_DIR = opencodeConfigDir;
}
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 {
exitFuture.resolve(undefined);
session.client.rpcHandlerManager.registerHandler('abort', async () => {});
session.client.rpcHandlerManager.registerHandler('switch', async () => {});
session.queue.setOnMessage(null);
session.removeHookEventHandler(handleHookEvent);
if (storageScanner) {
await storageScanner.cleanup();
}
}
return exitReason || 'exit';
}
+241
View File
@@ -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();
}
+145
View File
@@ -0,0 +1,145 @@
import { logger } from '@/ui/logger';
import { opencodeLoop } from './loop';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
import type { AgentState } from '@/api/types';
import type { OpencodeSession } from './session';
import type { OpencodeMode, PermissionMode } from './types';
import { bootstrapSession } from '@/agent/sessionFactory';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { PermissionModeSchema } from '@hapi/protocol/schemas';
import { startOpencodeHookServer } from './utils/startOpencodeHookServer';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
export async function runOpencode(opts: {
startedBy?: 'runner' | 'terminal';
startingMode?: 'local' | 'remote';
permissionMode?: PermissionMode;
resumeSessionId?: string;
} = {}): Promise<void> {
const workingDirectory = process.cwd();
const startedBy = opts.startedBy ?? 'terminal';
logger.debug(`[opencode] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
if (startedBy === 'runner' && opts.startingMode === 'local') {
logger.debug('[opencode] Runner spawn requested with local mode; forcing remote mode');
opts.startingMode = 'remote';
}
const initialState: AgentState = {
controlledByUser: false
};
const { api, session } = await bootstrapSession({
flavor: 'opencode',
startedBy,
workingDirectory,
agentState: initialState
});
const startingMode: 'local' | 'remote' = opts.startingMode
?? (startedBy === 'runner' ? 'remote' : 'local');
setControlledByUser(session, startingMode);
const messageQueue = new MessageQueue2<OpencodeMode>((mode) => hashObject({
permissionMode: mode.permissionMode
}));
const sessionWrapperRef: { current: OpencodeSession | null } = { current: null };
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
const hookServer = await startOpencodeHookServer({
onEvent: (event) => {
const currentSession = sessionWrapperRef.current;
if (!currentSession) {
return;
}
currentSession.emitHookEvent(event);
}
});
const hookUrl = `http://127.0.0.1:${hookServer.port}/hook/opencode`;
const lifecycle = createRunnerLifecycle({
session,
logTag: 'opencode',
stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive(),
onAfterClose: () => {
hookServer.stop();
}
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
const syncSessionMode = () => {
const sessionInstance = sessionWrapperRef.current;
if (!sessionInstance) {
return;
}
sessionInstance.setPermissionMode(currentPermissionMode);
logger.debug(`[opencode] Synced session permission mode for keepalive: ${currentPermissionMode}`);
};
session.onUserMessage((message) => {
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
const mode: OpencodeMode = {
permissionMode: currentPermissionMode
};
messageQueue.push(formattedText, mode);
});
const resolvePermissionMode = (value: unknown): PermissionMode => {
const parsed = PermissionModeSchema.safeParse(value);
if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'opencode')) {
throw new Error('Invalid permission mode');
}
return parsed.data as PermissionMode;
};
session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => {
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid session config payload');
}
const config = payload as { permissionMode?: unknown };
if (config.permissionMode !== undefined) {
currentPermissionMode = resolvePermissionMode(config.permissionMode);
}
syncSessionMode();
return { applied: { permissionMode: currentPermissionMode } };
});
try {
await opencodeLoop({
path: workingDirectory,
startingMode,
startedBy,
messageQueue,
session,
api,
permissionMode: currentPermissionMode,
resumeSessionId: opts.resumeSessionId,
hookServer,
hookUrl,
onModeChange: createModeChangeHandler(session),
onSessionReady: (instance) => {
sessionWrapperRef.current = instance;
syncSessionMode();
}
});
} catch (error) {
lifecycle.markCrash(error);
logger.debug('[opencode] Loop error:', error);
} finally {
const localFailure = sessionWrapperRef.current?.localLaunchFailure;
if (localFailure?.exitReason === 'exit') {
lifecycle.setExitCode(1);
lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`);
}
await lifecycle.cleanupAndExit();
}
}
+91
View File
@@ -0,0 +1,91 @@
import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { AgentSessionBase } from '@/agent/sessionBase';
import type { OpencodeHookEvent, OpencodeMode, PermissionMode } from './types';
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
type LocalLaunchFailure = {
message: string;
exitReason: LocalLaunchExitReason;
};
export class OpencodeSession extends AgentSessionBase<OpencodeMode> {
readonly startedBy: 'runner' | 'terminal';
readonly startingMode: 'local' | 'remote';
localLaunchFailure: LocalLaunchFailure | null = null;
private hookEventHandlers: Array<(event: OpencodeHookEvent) => void> = [];
constructor(opts: {
api: ApiClient;
client: ApiSessionClient;
path: string;
logPath: string;
sessionId: string | null;
messageQueue: MessageQueue2<OpencodeMode>;
onModeChange: (mode: 'local' | 'remote') => void;
mode?: 'local' | 'remote';
startedBy: 'runner' | 'terminal';
startingMode: 'local' | 'remote';
permissionMode?: PermissionMode;
}) {
super({
api: opts.api,
client: opts.client,
path: opts.path,
logPath: opts.logPath,
sessionId: opts.sessionId,
messageQueue: opts.messageQueue,
onModeChange: opts.onModeChange,
mode: opts.mode,
sessionLabel: 'OpencodeSession',
sessionIdLabel: 'OpenCode',
applySessionIdToMetadata: (metadata, sessionId) => ({
...metadata,
opencodeSessionId: sessionId
}),
permissionMode: opts.permissionMode
});
this.startedBy = opts.startedBy;
this.startingMode = opts.startingMode;
this.permissionMode = opts.permissionMode;
}
addHookEventHandler(cb: (event: OpencodeHookEvent) => void): void {
this.hookEventHandlers.push(cb);
}
removeHookEventHandler(cb: (event: OpencodeHookEvent) => void): void {
const index = this.hookEventHandlers.indexOf(cb);
if (index !== -1) {
this.hookEventHandlers.splice(index, 1);
}
}
emitHookEvent(event: OpencodeHookEvent): void {
for (const handler of this.hookEventHandlers) {
handler(event);
}
}
setPermissionMode = (mode: PermissionMode): void => {
this.permissionMode = mode;
};
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
this.localLaunchFailure = { message, exitReason };
};
sendCodexMessage = (message: unknown): void => {
this.client.sendCodexMessage(message);
};
sendUserMessage = (text: string): void => {
this.client.sendUserMessage(text);
};
sendSessionEvent = (event: Parameters<ApiSessionClient['sendSessionEvent']>[0]): void => {
this.client.sendSessionEvent(event);
};
}
+13
View File
@@ -0,0 +1,13 @@
import type { OpencodePermissionMode } from '@hapi/protocol/types';
export type PermissionMode = OpencodePermissionMode;
export interface OpencodeMode {
permissionMode: PermissionMode;
}
export type OpencodeHookEvent = {
event: string;
payload: unknown;
sessionId?: string;
};
+5
View File
@@ -0,0 +1,5 @@
export function buildOpencodeEnv(): NodeJS.ProcessEnv {
return {
...process.env
};
}
+135
View File
@@ -0,0 +1,135 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const PLUGIN_FILENAME = 'hapi-hook.ts';
function buildPluginSource(hookUrl: string, token: string): string {
const escapedUrl = JSON.stringify(hookUrl);
const escapedToken = JSON.stringify(token);
return [
'// Generated by HAPI. Do not edit manually.',
'',
`const DEFAULT_HOOK_URL = ${escapedUrl};`,
`const DEFAULT_HOOK_TOKEN = ${escapedToken};`,
'const HOOK_URL = process.env.HAPI_OPENCODE_HOOK_URL || DEFAULT_HOOK_URL;',
'const HOOK_TOKEN = process.env.HAPI_OPENCODE_HOOK_TOKEN || DEFAULT_HOOK_TOKEN;',
'',
'const EVENT_NAMES = new Set([',
" 'message.updated',",
" 'message.part.updated',",
" 'permission.updated',",
" 'permission.asked',",
" 'permission.replied',",
" 'session.created',",
" 'session.updated',",
" 'tool.execute.before',",
" 'tool.execute.after',",
']);',
'',
'function pickString(value) {',
" return typeof value === 'string' && value.length > 0 ? value : null;",
'}',
'',
'function extractSessionId(value) {',
" if (!value || typeof value !== 'object') return null;",
' const record = value;',
' const direct = (',
' pickString(record.sessionId)',
' || pickString(record.sessionID)',
' || pickString(record.session_id)',
' || (record.session && pickString(record.session.id))',
' );',
' if (direct) return direct;',
' if (record.part && typeof record.part === \'object\') {',
' const nested = extractSessionId(record.part);',
' if (nested) return nested;',
' }',
' if (record.info && typeof record.info === \'object\') {',
' const nested = extractSessionId(record.info);',
' if (nested) return nested;',
' }',
' return null;',
'}',
'',
'function extractSessionIdFromEvent(eventName, payload) {',
' const direct = extractSessionId(payload);',
' if (direct) return direct;',
' if (!payload || typeof payload !== \'object\') return null;',
' const record = payload;',
' if (record.info && typeof record.info === \'object\') {',
' const fromInfo = extractSessionId(record.info);',
' if (fromInfo) return fromInfo;',
' if (eventName.startsWith(\'session.\')) {',
' return pickString(record.info.id);',
' }',
' }',
' return null;',
'}',
'',
'async function sendHook(eventName, payload, sessionId) {',
' if (!HOOK_URL || !HOOK_TOKEN) {',
' return;',
' }',
'',
' const body = JSON.stringify({',
' event: eventName,',
' payload,',
' sessionId',
' });',
'',
' try {',
' await fetch(HOOK_URL, {',
" method: 'POST',",
' headers: {',
" 'Content-Type': 'application/json',",
" 'x-hapi-hook-token': HOOK_TOKEN",
' },',
' body',
' });',
' } catch {',
' // Ignore hook errors to avoid disrupting OpenCode',
' }',
'}',
'',
'export const HapiHookPlugin = async () => {',
' return {',
' event: async ({ event }) => {',
' if (!event || typeof event.type !== \'string\') {',
' return;',
' }',
' if (EVENT_NAMES.size > 0 && !EVENT_NAMES.has(event.type)) {',
' return;',
' }',
' const sessionId = extractSessionIdFromEvent(event.type, event.properties);',
' await sendHook(event.type, event.properties, sessionId);',
' }',
' };',
'};',
''
].join('\\n');
}
function resolvePluginDir(rootPath: string): string {
return join(rootPath, 'plugins');
}
export function ensureOpencodeHookPlugin(rootPath: string, hookUrl: string, token: string): string {
const pluginDir = resolvePluginDir(rootPath);
mkdirSync(pluginDir, { recursive: true });
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
const nextSource = buildPluginSource(hookUrl, token);
try {
const current = readFileSync(pluginPath, 'utf-8');
if (current === nextSource) {
return pluginPath;
}
} catch {
// Ignore missing or unreadable file.
}
writeFileSync(pluginPath, nextSource, 'utf-8');
return pluginPath;
}
+25
View File
@@ -0,0 +1,25 @@
import { AcpSdkBackend } from '@/agent/backends/acp';
import { buildOpencodeEnv } from './config';
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
export function createOpencodeBackend(opts: {
cwd?: string;
}): AcpSdkBackend {
const env = buildOpencodeEnv();
const args = ['acp', '--cwd', opts.cwd ?? process.cwd()];
return new AcpSdkBackend({
command: 'opencode',
args,
env: filterEnv(env)
});
}
@@ -0,0 +1,522 @@
import { logger } from '@/ui/logger';
import { readdir, readFile, stat } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { isObject } from '@hapi/protocol';
import type { OpencodeHookEvent } from '../types';
export type OpencodeStorageScannerHandle = {
cleanup: () => Promise<void>;
onNewSession: (sessionId: string) => void;
};
type OpencodeStorageScannerOptions = {
sessionId: string | null;
cwd: string;
onEvent: (event: OpencodeHookEvent) => void;
onSessionFound?: (sessionId: string) => void;
onSessionMatchFailed?: (message: string) => void;
storageDir?: string;
intervalMs?: number;
sessionStartWindowMs?: number;
startupTimestampMs?: number;
};
type SessionCandidate = {
sessionId: string;
score: number;
};
const DEFAULT_SESSION_START_WINDOW_MS = 2 * 60 * 1000;
const DEFAULT_SCAN_INTERVAL_MS = 2000;
const REPLAY_CLOCK_SKEW_MS = 2000;
export async function createOpencodeStorageScanner(
opts: OpencodeStorageScannerOptions
): Promise<OpencodeStorageScannerHandle> {
const scanner = new OpencodeStorageScanner(opts);
await scanner.start();
return {
cleanup: async () => {
await scanner.cleanup();
},
onNewSession: (sessionId: string) => {
void scanner.onNewSession(sessionId);
}
};
}
class OpencodeStorageScanner {
private readonly storageDir: string;
private readonly targetCwd: string | null;
private readonly onEvent: (event: OpencodeHookEvent) => void;
private readonly onSessionFound?: (sessionId: string) => void;
private readonly onSessionMatchFailed?: (message: string) => void;
private readonly referenceTimestampMs: number;
private readonly sessionStartWindowMs: number;
private readonly matchDeadlineMs: number;
private readonly intervalMs: number;
private readonly seedSessionId: string | null;
private intervalId: ReturnType<typeof setInterval> | null = null;
private activeSessionId: string | null = null;
private matchFailed = false;
private warnedMissingStorage = false;
private scanning = false;
private readonly messageRoles = new Map<string, string>();
private readonly messageFileMtime = new Map<string, number>();
private readonly partFileMtime = new Map<string, number>();
constructor(opts: OpencodeStorageScannerOptions) {
this.storageDir = opts.storageDir ?? resolveOpencodeStorageDir();
this.targetCwd = opts.cwd ? normalizePath(opts.cwd) : null;
this.onEvent = opts.onEvent;
this.onSessionFound = opts.onSessionFound;
this.onSessionMatchFailed = opts.onSessionMatchFailed;
this.referenceTimestampMs = opts.startupTimestampMs ?? Date.now();
this.sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS;
this.matchDeadlineMs = this.referenceTimestampMs + this.sessionStartWindowMs;
this.intervalMs = opts.intervalMs ?? DEFAULT_SCAN_INTERVAL_MS;
this.seedSessionId = opts.sessionId;
this.activeSessionId = opts.sessionId;
if (!this.targetCwd && !this.seedSessionId) {
const message = 'No cwd/sessionId available for OpenCode storage matching; scanner disabled.';
logger.warn(`[opencode-storage] ${message}`);
this.matchFailed = true;
this.onSessionMatchFailed?.(message);
}
}
async start(): Promise<void> {
if (this.matchFailed) {
return;
}
await this.scan();
this.intervalId = setInterval(() => {
void this.scan();
}, this.intervalMs);
}
async cleanup(): Promise<void> {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
async onNewSession(sessionId: string): Promise<void> {
if (!sessionId || sessionId === this.activeSessionId) {
return;
}
await this.setActiveSession(sessionId);
}
private async scan(): Promise<void> {
if (this.scanning || this.matchFailed) {
return;
}
this.scanning = true;
try {
const storageReady = await this.ensureStorageDir();
if (!storageReady) {
return;
}
if (!this.activeSessionId) {
await this.discoverSessionId();
}
if (this.activeSessionId) {
await this.scanMessagesAndParts(this.activeSessionId);
}
} finally {
this.scanning = false;
}
}
private async ensureStorageDir(): Promise<boolean> {
try {
const stats = await stat(this.storageDir);
if (!stats.isDirectory()) {
if (!this.warnedMissingStorage) {
this.warnedMissingStorage = true;
logger.debug(`[opencode-storage] Storage path is not a directory: ${this.storageDir}`);
}
return false;
}
} catch {
if (!this.warnedMissingStorage) {
this.warnedMissingStorage = true;
logger.debug(`[opencode-storage] Storage path missing: ${this.storageDir}`);
}
return false;
}
if (this.warnedMissingStorage) {
logger.debug(`[opencode-storage] Storage path ready: ${this.storageDir}`);
this.warnedMissingStorage = false;
}
return true;
}
private async discoverSessionId(): Promise<void> {
if (this.activeSessionId || this.matchFailed) {
return;
}
if (this.seedSessionId) {
await this.setActiveSession(this.seedSessionId);
return;
}
if (!this.targetCwd) {
const message = 'Missing cwd for OpenCode storage matching; refusing to guess session.';
logger.warn(`[opencode-storage] ${message}`);
this.matchFailed = true;
this.onSessionMatchFailed?.(message);
return;
}
const sessionFiles = await listSessionInfoFiles(this.storageDir);
let best: SessionCandidate | null = null;
for (const filePath of sessionFiles) {
const info = await readSessionInfo(filePath);
if (!info || !info.id || !info.directory || info.timeCreated === null) {
continue;
}
if (normalizePath(info.directory) !== this.targetCwd) {
continue;
}
if (info.timeCreated < this.referenceTimestampMs) {
continue;
}
const diff = info.timeCreated - this.referenceTimestampMs;
if (diff > this.sessionStartWindowMs) {
continue;
}
if (!best || diff < best.score) {
best = { sessionId: info.id, score: diff };
}
}
if (best) {
await this.setActiveSession(best.sessionId);
return;
}
if (Date.now() > this.matchDeadlineMs) {
const message = `No OpenCode session found within ${this.sessionStartWindowMs}ms for cwd ${this.targetCwd}`;
logger.warn(`[opencode-storage] ${message}`);
this.matchFailed = true;
this.onSessionMatchFailed?.(message);
}
}
private async setActiveSession(sessionId: string): Promise<void> {
if (this.activeSessionId === sessionId) {
return;
}
this.activeSessionId = sessionId;
this.messageRoles.clear();
this.messageFileMtime.clear();
this.partFileMtime.clear();
await this.primeSessionFiles(sessionId);
this.onSessionFound?.(sessionId);
logger.debug(`[opencode-storage] Tracking session ${sessionId}`);
}
private async primeSessionFiles(sessionId: string): Promise<void> {
const messageDir = join(this.storageDir, 'message', sessionId);
const messageFiles = await listJsonFiles(messageDir);
const messageIds: string[] = [];
const replayMessageIds = new Set<string>();
const replayThresholdMs = this.referenceTimestampMs - REPLAY_CLOCK_SKEW_MS;
for (const filePath of messageFiles) {
const mtime = await readMtime(filePath);
if (mtime !== null) {
this.messageFileMtime.set(filePath, mtime);
}
const info = await readJsonRecord(filePath);
const messageId = getString(info?.id) ?? filenameToId(filePath);
if (messageId) {
messageIds.push(messageId);
const role = getString(info?.role);
if (role) {
this.messageRoles.set(messageId, role);
}
}
const timestamp = getMessageTimestamp(info, mtime);
if (messageId && info && timestamp !== null && timestamp >= replayThresholdMs) {
replayMessageIds.add(messageId);
const eventSessionId = getString(info.sessionID) ?? sessionId;
this.onEvent({
event: 'message.updated',
payload: { info },
sessionId: eventSessionId || undefined
});
}
}
for (const messageId of messageIds) {
const partDir = join(this.storageDir, 'part', messageId);
const partFiles = await listJsonFiles(partDir);
for (const partPath of partFiles) {
const mtime = await readMtime(partPath);
if (mtime !== null) {
this.partFileMtime.set(partPath, mtime);
}
if (!replayMessageIds.has(messageId)) {
continue;
}
const part = await readJsonRecord(partPath);
if (!part) {
continue;
}
if (!this.shouldEmitPart(part, messageId)) {
continue;
}
const eventSessionId = getString(part.sessionID) ?? sessionId;
this.onEvent({
event: 'message.part.updated',
payload: { part },
sessionId: eventSessionId || undefined
});
}
}
}
private async scanMessagesAndParts(sessionId: string): Promise<void> {
const messageDir = join(this.storageDir, 'message', sessionId);
const messageFiles = await listJsonFiles(messageDir);
const messageIds: string[] = [];
for (const filePath of messageFiles) {
const messageIdFromPath = filenameToId(filePath);
if (messageIdFromPath) {
messageIds.push(messageIdFromPath);
}
const mtime = await readMtime(filePath);
if (mtime === null) {
continue;
}
const previous = this.messageFileMtime.get(filePath) ?? 0;
if (mtime <= previous) {
continue;
}
const info = await readJsonRecord(filePath);
this.messageFileMtime.set(filePath, mtime);
if (!info) {
continue;
}
const messageId = getString(info.id) ?? messageIdFromPath;
if (messageId) {
const role = getString(info.role);
if (role) {
this.messageRoles.set(messageId, role);
}
}
const eventSessionId = getString(info.sessionID) ?? sessionId;
this.onEvent({
event: 'message.updated',
payload: { info },
sessionId: eventSessionId || undefined
});
}
for (const messageId of messageIds) {
const partDir = join(this.storageDir, 'part', messageId);
const partFiles = await listJsonFiles(partDir);
for (const partPath of partFiles) {
const mtime = await readMtime(partPath);
if (mtime === null) {
continue;
}
const previous = this.partFileMtime.get(partPath) ?? 0;
if (mtime <= previous) {
continue;
}
const part = await readJsonRecord(partPath);
this.partFileMtime.set(partPath, mtime);
if (!part) {
continue;
}
if (!this.shouldEmitPart(part, messageId)) {
continue;
}
const eventSessionId = getString(part.sessionID) ?? sessionId;
this.onEvent({
event: 'message.part.updated',
payload: { part },
sessionId: eventSessionId || undefined
});
}
}
}
private shouldEmitPart(part: Record<string, unknown>, messageId: string): boolean {
const partType = getString(part.type);
if (!partType) {
return false;
}
if (partType === 'text') {
const text = getString(part.text);
if (!text) {
return false;
}
const role = this.messageRoles.get(messageId);
if (role === 'user') {
return true;
}
if (part.synthetic === true) {
return true;
}
const time = isObject(part.time) ? part.time as Record<string, unknown> : null;
const end = time ? getNumber(time.end) : null;
return end !== null;
}
if (partType === 'tool') {
return true;
}
return false;
}
}
type ParsedSessionInfo = {
id: string | null;
directory: string | null;
timeCreated: number | null;
};
async function readSessionInfo(filePath: string): Promise<ParsedSessionInfo | null> {
const record = await readJsonRecord(filePath);
if (!record) {
return null;
}
const time = isObject(record.time) ? record.time as Record<string, unknown> : null;
return {
id: getString(record.id),
directory: getString(record.directory),
timeCreated: time ? getNumber(time.created) : null
};
}
async function listSessionInfoFiles(storageDir: string): Promise<string[]> {
const sessionRoot = join(storageDir, 'session');
const entries = await safeReadDir(sessionRoot);
const results: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const projectDir = join(sessionRoot, entry.name);
const files = await listJsonFiles(projectDir);
results.push(...files);
}
return results;
}
async function listJsonFiles(dirPath: string): Promise<string[]> {
const entries = await safeReadDir(dirPath);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => join(dirPath, entry.name));
}
async function safeReadDir(dirPath: string): Promise<Dirent[]> {
try {
return await readdir(dirPath, { withFileTypes: true });
} catch {
return [] as Dirent[];
}
}
async function readJsonRecord(filePath: string): Promise<Record<string, unknown> | null> {
try {
const raw = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed as Record<string, unknown>;
} catch (error) {
logger.debug(`[opencode-storage] Failed to read ${filePath}: ${error}`);
return null;
}
}
async function readMtime(filePath: string): Promise<number | null> {
try {
const stats = await stat(filePath);
return stats.mtimeMs;
} catch {
return null;
}
}
function resolveOpencodeStorageDir(): string {
const base = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
return join(base, 'opencode', 'storage');
}
function normalizePath(value: string): string {
const resolved = resolve(value);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function filenameToId(filePath: string): string | null {
if (!filePath.endsWith('.json')) {
return null;
}
const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
const name = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath;
return name.slice(0, -5) || null;
}
function getString(value: unknown): string | null {
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
return null;
}
function getNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
return null;
}
function getMessageTimestamp(info: Record<string, unknown> | null, mtime: number | null): number | null {
if (info) {
const time = isObject(info.time) ? info.time as Record<string, unknown> : null;
const createdAt = time ? getNumber(time.created) : null;
if (createdAt !== null) {
return createdAt;
}
}
return mtime;
}
+170
View File
@@ -0,0 +1,170 @@
import type { ApiSessionClient } from '@/api/apiSession';
import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types';
import type { OpencodePermissionMode } from '@hapi/protocol/types';
import { deriveToolName } from '@/agent/utils';
import { logger } from '@/ui/logger';
import {
BasePermissionHandler,
type AutoApprovalDecision,
type PendingPermissionRequest,
type PermissionCompletion
} from '@/modules/common/permission/BasePermissionHandler';
interface PermissionResponseMessage {
id: string;
approved: boolean;
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort';
reason?: string;
}
function deriveToolInput(request: PermissionRequest): unknown {
if (request.rawInput !== undefined) {
return request.rawInput;
}
return request.rawOutput;
}
function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null {
for (const kind of preferredKinds) {
const match = request.options.find((option) => option.kind === kind);
if (match) {
return match.optionId;
}
}
return request.options.length > 0 ? request.options[0].optionId : null;
}
function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse {
if (decision === 'abort') {
return { outcome: 'cancelled' };
}
if (decision === 'approved_for_session') {
const optionId = pickOptionId(request, ['allow_always', 'allow_once']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
if (decision === 'approved') {
const optionId = pickOptionId(request, ['allow_once', 'allow_always']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
const optionId = pickOptionId(request, ['reject_once', 'reject_always']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
export class OpencodePermissionHandler extends BasePermissionHandler<PermissionResponseMessage, void> {
private readonly pendingBackendRequests = new Map<string, PermissionRequest>();
constructor(
session: ApiSessionClient,
private readonly backend: AgentBackend,
private readonly getPermissionMode: () => OpencodePermissionMode | undefined
) {
super(session);
this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request));
}
private handlePermissionRequest(request: PermissionRequest): void {
const toolName = deriveToolName({
title: request.title,
kind: request.kind,
rawInput: request.rawInput
});
const toolInput = deriveToolInput(request);
const mode = this.getPermissionMode() ?? 'default';
const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId);
if (autoDecision) {
void this.autoApprove(request, toolName, toolInput, autoDecision);
return;
}
this.pendingBackendRequests.set(request.id, request);
this.addPendingRequest(request.id, toolName, toolInput, {
resolve: () => {},
reject: () => {}
});
logger.debug(`[Opencode] Permission request queued for ${toolName} (${request.id})`);
}
private async autoApprove(
request: PermissionRequest,
toolName: string,
toolInput: unknown,
decision: AutoApprovalDecision
): Promise<void> {
const outcome = mapDecisionToOutcome(request, decision);
await this.backend.respondToPermission(request.sessionId, request, outcome);
this.client.updateAgentState((currentState) => ({
...currentState,
completedRequests: {
...currentState.completedRequests,
[request.id]: {
tool: toolName,
arguments: toolInput,
createdAt: Date.now(),
completedAt: Date.now(),
status: 'approved',
decision
}
}
}));
logger.debug(`[Opencode] Auto-approved ${toolName} (${request.id}) mode=${decision}`);
}
protected async handlePermissionResponse(
response: PermissionResponseMessage,
pending: PendingPermissionRequest<void>
): Promise<PermissionCompletion> {
const pendingRequest = this.pendingBackendRequests.get(response.id);
if (pendingRequest) {
this.pendingBackendRequests.delete(response.id);
} else {
logger.debug('[Opencode] Permission response missing backend request', response.id);
}
const decision = response.decision ?? (response.approved ? 'approved' : 'denied');
if (decision === 'abort' && pendingRequest) {
await this.backend.cancelPrompt(pendingRequest.sessionId);
}
if (pendingRequest) {
const outcome = mapDecisionToOutcome(pendingRequest, decision);
await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome);
}
pending.resolve();
logger.debug(`[Opencode] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`);
return {
status: response.approved ? 'approved' : 'denied',
decision,
reason: response.reason
};
}
protected handleMissingPendingResponse(response: PermissionResponseMessage): void {
logger.debug('[Opencode] Permission response received for unknown request', response.id);
}
async cancelAll(reason: string): Promise<void> {
const pending = Array.from(this.pendingBackendRequests.values());
this.pendingBackendRequests.clear();
for (const request of pending) {
await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' });
}
this.cancelPendingRequests({
completedReason: reason,
rejectMessage: reason,
decision: 'abort'
});
}
}
@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { request } from 'node:http'
import { startOpencodeHookServer } from './startOpencodeHookServer'
const sendHookRequest = async (
port: number,
body: string,
token?: string
): Promise<{ statusCode?: number; body: string }> => {
return await new Promise((resolve, reject) => {
const headers: Record<string, string | number> = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
if (token) {
headers['x-hapi-hook-token'] = token
}
const req = request({
host: '127.0.0.1',
port,
path: '/hook/opencode',
method: 'POST',
headers
}, (res) => {
const chunks: Buffer[] = []
res.on('data', (chunk) => chunks.push(chunk as Buffer))
res.on('error', reject)
res.on('end', () => {
resolve({
statusCode: res.statusCode,
body: Buffer.concat(chunks).toString('utf-8')
})
})
})
req.on('error', reject)
req.end(body)
})
}
describe('startOpencodeHookServer', () => {
it('forwards hook payload to callback', async () => {
let received: { event?: string; payload?: unknown; sessionId?: string } = {}
const server = await startOpencodeHookServer({
onEvent: (event) => {
received = event
}
})
try {
const body = JSON.stringify({
event: 'message.updated',
payload: { message: 'ok' },
sessionId: 'session-123'
})
const response = await sendHookRequest(server.port, body, server.token)
expect(response.statusCode).toBe(200)
} finally {
server.stop()
}
expect(received.event).toBe('message.updated')
expect(received.sessionId).toBe('session-123')
expect(received.payload).toEqual({ message: 'ok' })
})
it('returns 400 for invalid JSON payloads', async () => {
let hookCalled = false
const server = await startOpencodeHookServer({
onEvent: () => {
hookCalled = true
}
})
try {
const response = await sendHookRequest(server.port, '{"event":', server.token)
expect(response.statusCode).toBe(400)
expect(response.body).toBe('invalid json')
} finally {
server.stop()
}
expect(hookCalled).toBe(false)
})
it('returns 422 when event is missing', async () => {
let hookCalled = false
const server = await startOpencodeHookServer({
onEvent: () => {
hookCalled = true
}
})
try {
const body = JSON.stringify({ payload: { ok: true } })
const response = await sendHookRequest(server.port, body, server.token)
expect(response.statusCode).toBe(422)
expect(response.body).toBe('missing event')
} finally {
server.stop()
}
expect(hookCalled).toBe(false)
})
it('returns 401 when hook token is missing', async () => {
let hookCalled = false
const server = await startOpencodeHookServer({
onEvent: () => {
hookCalled = true
}
})
try {
const body = JSON.stringify({ event: 'message.updated', payload: { ok: true } })
const response = await sendHookRequest(server.port, body)
expect(response.statusCode).toBe(401)
expect(response.body).toBe('unauthorized')
} finally {
server.stop()
}
expect(hookCalled).toBe(false)
})
})
@@ -0,0 +1,133 @@
import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http';
import { randomBytes } from 'node:crypto';
import { logger } from '@/ui/logger';
import type { OpencodeHookEvent } from '../types';
export interface OpencodeHookServerOptions {
onEvent: (event: OpencodeHookEvent) => void;
token?: string;
}
export interface OpencodeHookServer {
port: number;
token: string;
stop: () => void;
}
function readHookToken(req: IncomingMessage): string | null {
const header = req.headers['x-hapi-hook-token'];
if (Array.isArray(header)) {
return header[0] ?? null;
}
return header ?? null;
}
export async function startOpencodeHookServer(options: OpencodeHookServerOptions): Promise<OpencodeHookServer> {
const hookToken = options.token || randomBytes(16).toString('hex');
return new Promise((resolve, reject) => {
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const requestPath = req.url?.split('?')[0];
if (req.method === 'POST' && requestPath === '/hook/opencode') {
const providedToken = readHookToken(req);
if (providedToken !== hookToken) {
logger.debug('[opencode-hook] Unauthorized hook request');
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized');
req.resume();
return;
}
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
if (!res.headersSent) {
logger.debug('[opencode-hook] Request timeout');
res.writeHead(408).end('timeout');
}
req.destroy(new Error('Request timeout'));
}, 5000);
try {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
clearTimeout(timeout);
if (timedOut || res.headersSent || res.writableEnded) {
return;
}
const body = Buffer.concat(chunks).toString('utf-8');
logger.debug('[opencode-hook] Received hook:', body);
let data: Record<string, unknown> = {};
try {
const parsed = JSON.parse(body);
if (!parsed || typeof parsed !== 'object') {
logger.debug('[opencode-hook] Parsed hook data is not an object');
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json');
return;
}
data = parsed as Record<string, unknown>;
} catch (parseError) {
logger.debug('[opencode-hook] Failed to parse hook data as JSON:', parseError);
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json');
return;
}
const eventValue = data.event;
if (typeof eventValue !== 'string' || eventValue.length === 0) {
res.writeHead(422, { 'Content-Type': 'text/plain' }).end('missing event');
return;
}
const payload = data.payload;
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
options.onEvent({ event: eventValue, payload, sessionId });
if (!res.headersSent && !res.writableEnded) {
res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok');
}
} catch (error) {
clearTimeout(timeout);
if (timedOut) {
return;
}
logger.debug('[opencode-hook] Error handling hook:', error);
if (!res.headersSent && !res.writableEnded) {
res.writeHead(500).end('error');
}
}
return;
}
res.writeHead(404).end('not found');
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
return;
}
const port = address.port;
logger.debug(`[opencode-hook] Started on port ${port}`);
resolve({
port,
token: hookToken,
stop: () => {
server.close();
logger.debug('[opencode-hook] Stopped');
}
});
});
server.on('error', (err) => {
logger.debug('[opencode-hook] Server error:', err);
reject(err);
});
});
}