mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-16 20:40:01 +00:00
feat: support opencode
This commit is contained in:
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export function buildOpencodeEnv(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user