Files
hapi/cli/src/opencode/opencodeLocalLauncher.ts
T

593 lines
22 KiB
TypeScript

import { logger } from '@/ui/logger';
import { opencodeLocal } from './opencodeLocal';
import { OpencodeSession } from './session';
import { ensureOpencodeHookPlugin } from './utils/hookPlugin';
import { buildOpencodeEnv } from './utils/config';
import { ensureOpencodeConfig } from './utils/opencodeConfig';
import { TITLE_INSTRUCTION } from './utils/systemPrompt';
import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
import type { OpencodeHookEvent } from './types';
import type { OpencodeHookServer } from './utils/startOpencodeHookServer';
import { createOpencodeStorageScanner, type OpencodeStorageScannerHandle } from './utils/opencodeStorageScanner';
import { isUsableToolInput, parseToolCall, parseToolResult } from './utils/opencodeLocalToolParse';
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';
import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
type OpencodeLocalLauncherOptions = {
hookServer: OpencodeHookServer;
hookUrl: string;
};
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 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'> {
const hookUrl = opts.hookUrl;
const opencodeConfigDir = resolveOpencodeConfigDir(session);
ensureOpencodeHookPlugin(opencodeConfigDir, hookUrl, opts.hookServer.token);
// Start the hapi MCP server for change_title support (optional feature)
let happyServer: { url: string; stop: () => void } | null = null;
let opencodeConfigPath: string | null = null;
try {
const bridge = await buildHapiMcpBridge(session.client, {
skillLookup: { workingDirectory: session.path, flavor: 'opencode' }
});
happyServer = bridge.server;
logger.debug(`[opencode-local]: Started hapi MCP server at ${happyServer.url}`);
// Generate opencode.json config with MCP server and instructions
const { configPath } = ensureOpencodeConfig(opencodeConfigDir, bridge.mcpServers.hapi, TITLE_INSTRUCTION);
opencodeConfigPath = configPath;
} catch (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;
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.sendAgentMessage({ type: 'message', message: text });
}
if (partId) {
sentTextParts.add(partId);
}
if (key) {
textBuffers.delete(key);
}
}
return;
}
const toolCall = parseToolCall(part);
if (toolCall && isUsableToolInput(toolCall.input) && !sentToolCalls.has(toolCall.callId)) {
// Wait for non-empty input (OpenCode pending often has input:{}).
sentToolCalls.add(toolCall.callId);
session.sendAgentMessage({
type: 'tool-call',
name: toolCall.name,
callId: toolCall.callId,
input: toolCall.input,
...(toolCall.title ? { nativeTitle: toolCall.title } : {})
});
}
const toolResult = parseToolResult(part);
if (toolResult && !sentToolResults.has(toolResult.callId)) {
// If we skipped the empty pending call, emit the call now with
// whatever input is on this completed part before the result.
if (!sentToolCalls.has(toolResult.callId) && toolCall) {
sentToolCalls.add(toolResult.callId);
session.sendAgentMessage({
type: 'tool-call',
name: toolCall.name,
callId: toolCall.callId,
input: toolCall.input,
...(toolCall.title ? { nativeTitle: toolCall.title } : {})
});
}
sentToolResults.add(toolResult.callId);
session.sendAgentMessage({
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';
const usableInput = isUsableToolInput(toolInput);
let callId = existingId;
if (!callId) {
callId = isBefore
? randomUUID()
: shiftQueue(toolExecutionQueues, signature)
?? shiftQueue(toolExecutionQueues, fallbackSignature)
?? randomUUID();
}
if (isBefore) {
// Empty `{}` before must not enqueue under the empty-input
// signature: after usually carries real args and would miss.
// Name-only fallback keeps pairing when tool ids are absent.
if (usableInput) {
pushQueue(toolExecutionQueues, signature, callId);
if (fallbackSignature !== signature) {
pushQueue(toolExecutionQueues, fallbackSignature, callId);
}
} else {
pushQueue(toolExecutionQueues, fallbackSignature, callId);
}
} else {
removeFromQueue(toolExecutionQueues, signature, callId);
if (fallbackSignature !== signature) {
removeFromQueue(toolExecutionQueues, fallbackSignature, callId);
}
}
if (eventType === 'tool.execute.before' && !sentToolCalls.has(callId)) {
// Match message.part.updated path: skip empty placeholder args.
if (!usableInput) {
return;
}
sentToolCalls.add(callId);
session.sendAgentMessage({
type: 'tool-call',
name,
callId,
input: toolInput,
...(getString(tool.title ?? record.title) ? { nativeTitle: getString(tool.title ?? record.title) } : {})
});
return;
}
if (eventType === 'tool.execute.after' && !sentToolResults.has(callId)) {
// Late tool-call recovery: before may have skipped empty `{}`.
// Mirror message.part.updated so result is never unpaired.
if (!sentToolCalls.has(callId)) {
sentToolCalls.add(callId);
session.sendAgentMessage({
type: 'tool-call',
name,
callId,
input: toolInput,
...(getString(tool.title ?? record.title) ? { nativeTitle: getString(tool.title ?? record.title) } : {})
});
}
sentToolResults.add(callId);
session.sendAgentMessage({
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);
}
return await launcher.run();
} finally {
session.removeHookEventHandler(handleHookEvent);
if (storageScanner) {
await storageScanner.cleanup();
}
if (happyServer) {
happyServer.stop();
logger.debug('[opencode-local]: Stopped hapi MCP server');
}
}
}