feat: support opencode

This commit is contained in:
weishu
2026-01-29 10:34:57 +08:00
parent 66ab96bf38
commit 70b5c22c8f
47 changed files with 2737 additions and 28 deletions
+5
View File
@@ -0,0 +1,5 @@
export function buildOpencodeEnv(): NodeJS.ProcessEnv {
return {
...process.env
};
}
+135
View File
@@ -0,0 +1,135 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const PLUGIN_FILENAME = 'hapi-hook.ts';
function buildPluginSource(hookUrl: string, token: string): string {
const escapedUrl = JSON.stringify(hookUrl);
const escapedToken = JSON.stringify(token);
return [
'// Generated by HAPI. Do not edit manually.',
'',
`const DEFAULT_HOOK_URL = ${escapedUrl};`,
`const DEFAULT_HOOK_TOKEN = ${escapedToken};`,
'const HOOK_URL = process.env.HAPI_OPENCODE_HOOK_URL || DEFAULT_HOOK_URL;',
'const HOOK_TOKEN = process.env.HAPI_OPENCODE_HOOK_TOKEN || DEFAULT_HOOK_TOKEN;',
'',
'const EVENT_NAMES = new Set([',
" 'message.updated',",
" 'message.part.updated',",
" 'permission.updated',",
" 'permission.asked',",
" 'permission.replied',",
" 'session.created',",
" 'session.updated',",
" 'tool.execute.before',",
" 'tool.execute.after',",
']);',
'',
'function pickString(value) {',
" return typeof value === 'string' && value.length > 0 ? value : null;",
'}',
'',
'function extractSessionId(value) {',
" if (!value || typeof value !== 'object') return null;",
' const record = value;',
' const direct = (',
' pickString(record.sessionId)',
' || pickString(record.sessionID)',
' || pickString(record.session_id)',
' || (record.session && pickString(record.session.id))',
' );',
' if (direct) return direct;',
' if (record.part && typeof record.part === \'object\') {',
' const nested = extractSessionId(record.part);',
' if (nested) return nested;',
' }',
' if (record.info && typeof record.info === \'object\') {',
' const nested = extractSessionId(record.info);',
' if (nested) return nested;',
' }',
' return null;',
'}',
'',
'function extractSessionIdFromEvent(eventName, payload) {',
' const direct = extractSessionId(payload);',
' if (direct) return direct;',
' if (!payload || typeof payload !== \'object\') return null;',
' const record = payload;',
' if (record.info && typeof record.info === \'object\') {',
' const fromInfo = extractSessionId(record.info);',
' if (fromInfo) return fromInfo;',
' if (eventName.startsWith(\'session.\')) {',
' return pickString(record.info.id);',
' }',
' }',
' return null;',
'}',
'',
'async function sendHook(eventName, payload, sessionId) {',
' if (!HOOK_URL || !HOOK_TOKEN) {',
' return;',
' }',
'',
' const body = JSON.stringify({',
' event: eventName,',
' payload,',
' sessionId',
' });',
'',
' try {',
' await fetch(HOOK_URL, {',
" method: 'POST',",
' headers: {',
" 'Content-Type': 'application/json',",
" 'x-hapi-hook-token': HOOK_TOKEN",
' },',
' body',
' });',
' } catch {',
' // Ignore hook errors to avoid disrupting OpenCode',
' }',
'}',
'',
'export const HapiHookPlugin = async () => {',
' return {',
' event: async ({ event }) => {',
' if (!event || typeof event.type !== \'string\') {',
' return;',
' }',
' if (EVENT_NAMES.size > 0 && !EVENT_NAMES.has(event.type)) {',
' return;',
' }',
' const sessionId = extractSessionIdFromEvent(event.type, event.properties);',
' await sendHook(event.type, event.properties, sessionId);',
' }',
' };',
'};',
''
].join('\\n');
}
function resolvePluginDir(rootPath: string): string {
return join(rootPath, 'plugins');
}
export function ensureOpencodeHookPlugin(rootPath: string, hookUrl: string, token: string): string {
const pluginDir = resolvePluginDir(rootPath);
mkdirSync(pluginDir, { recursive: true });
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
const nextSource = buildPluginSource(hookUrl, token);
try {
const current = readFileSync(pluginPath, 'utf-8');
if (current === nextSource) {
return pluginPath;
}
} catch {
// Ignore missing or unreadable file.
}
writeFileSync(pluginPath, nextSource, 'utf-8');
return pluginPath;
}
+25
View File
@@ -0,0 +1,25 @@
import { AcpSdkBackend } from '@/agent/backends/acp';
import { buildOpencodeEnv } from './config';
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
export function createOpencodeBackend(opts: {
cwd?: string;
}): AcpSdkBackend {
const env = buildOpencodeEnv();
const args = ['acp', '--cwd', opts.cwd ?? process.cwd()];
return new AcpSdkBackend({
command: 'opencode',
args,
env: filterEnv(env)
});
}
@@ -0,0 +1,522 @@
import { logger } from '@/ui/logger';
import { readdir, readFile, stat } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { isObject } from '@hapi/protocol';
import type { OpencodeHookEvent } from '../types';
export type OpencodeStorageScannerHandle = {
cleanup: () => Promise<void>;
onNewSession: (sessionId: string) => void;
};
type OpencodeStorageScannerOptions = {
sessionId: string | null;
cwd: string;
onEvent: (event: OpencodeHookEvent) => void;
onSessionFound?: (sessionId: string) => void;
onSessionMatchFailed?: (message: string) => void;
storageDir?: string;
intervalMs?: number;
sessionStartWindowMs?: number;
startupTimestampMs?: number;
};
type SessionCandidate = {
sessionId: string;
score: number;
};
const DEFAULT_SESSION_START_WINDOW_MS = 2 * 60 * 1000;
const DEFAULT_SCAN_INTERVAL_MS = 2000;
const REPLAY_CLOCK_SKEW_MS = 2000;
export async function createOpencodeStorageScanner(
opts: OpencodeStorageScannerOptions
): Promise<OpencodeStorageScannerHandle> {
const scanner = new OpencodeStorageScanner(opts);
await scanner.start();
return {
cleanup: async () => {
await scanner.cleanup();
},
onNewSession: (sessionId: string) => {
void scanner.onNewSession(sessionId);
}
};
}
class OpencodeStorageScanner {
private readonly storageDir: string;
private readonly targetCwd: string | null;
private readonly onEvent: (event: OpencodeHookEvent) => void;
private readonly onSessionFound?: (sessionId: string) => void;
private readonly onSessionMatchFailed?: (message: string) => void;
private readonly referenceTimestampMs: number;
private readonly sessionStartWindowMs: number;
private readonly matchDeadlineMs: number;
private readonly intervalMs: number;
private readonly seedSessionId: string | null;
private intervalId: ReturnType<typeof setInterval> | null = null;
private activeSessionId: string | null = null;
private matchFailed = false;
private warnedMissingStorage = false;
private scanning = false;
private readonly messageRoles = new Map<string, string>();
private readonly messageFileMtime = new Map<string, number>();
private readonly partFileMtime = new Map<string, number>();
constructor(opts: OpencodeStorageScannerOptions) {
this.storageDir = opts.storageDir ?? resolveOpencodeStorageDir();
this.targetCwd = opts.cwd ? normalizePath(opts.cwd) : null;
this.onEvent = opts.onEvent;
this.onSessionFound = opts.onSessionFound;
this.onSessionMatchFailed = opts.onSessionMatchFailed;
this.referenceTimestampMs = opts.startupTimestampMs ?? Date.now();
this.sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS;
this.matchDeadlineMs = this.referenceTimestampMs + this.sessionStartWindowMs;
this.intervalMs = opts.intervalMs ?? DEFAULT_SCAN_INTERVAL_MS;
this.seedSessionId = opts.sessionId;
this.activeSessionId = opts.sessionId;
if (!this.targetCwd && !this.seedSessionId) {
const message = 'No cwd/sessionId available for OpenCode storage matching; scanner disabled.';
logger.warn(`[opencode-storage] ${message}`);
this.matchFailed = true;
this.onSessionMatchFailed?.(message);
}
}
async start(): Promise<void> {
if (this.matchFailed) {
return;
}
await this.scan();
this.intervalId = setInterval(() => {
void this.scan();
}, this.intervalMs);
}
async cleanup(): Promise<void> {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
async onNewSession(sessionId: string): Promise<void> {
if (!sessionId || sessionId === this.activeSessionId) {
return;
}
await this.setActiveSession(sessionId);
}
private async scan(): Promise<void> {
if (this.scanning || this.matchFailed) {
return;
}
this.scanning = true;
try {
const storageReady = await this.ensureStorageDir();
if (!storageReady) {
return;
}
if (!this.activeSessionId) {
await this.discoverSessionId();
}
if (this.activeSessionId) {
await this.scanMessagesAndParts(this.activeSessionId);
}
} finally {
this.scanning = false;
}
}
private async ensureStorageDir(): Promise<boolean> {
try {
const stats = await stat(this.storageDir);
if (!stats.isDirectory()) {
if (!this.warnedMissingStorage) {
this.warnedMissingStorage = true;
logger.debug(`[opencode-storage] Storage path is not a directory: ${this.storageDir}`);
}
return false;
}
} catch {
if (!this.warnedMissingStorage) {
this.warnedMissingStorage = true;
logger.debug(`[opencode-storage] Storage path missing: ${this.storageDir}`);
}
return false;
}
if (this.warnedMissingStorage) {
logger.debug(`[opencode-storage] Storage path ready: ${this.storageDir}`);
this.warnedMissingStorage = false;
}
return true;
}
private async discoverSessionId(): Promise<void> {
if (this.activeSessionId || this.matchFailed) {
return;
}
if (this.seedSessionId) {
await this.setActiveSession(this.seedSessionId);
return;
}
if (!this.targetCwd) {
const message = 'Missing cwd for OpenCode storage matching; refusing to guess session.';
logger.warn(`[opencode-storage] ${message}`);
this.matchFailed = true;
this.onSessionMatchFailed?.(message);
return;
}
const sessionFiles = await listSessionInfoFiles(this.storageDir);
let best: SessionCandidate | null = null;
for (const filePath of sessionFiles) {
const info = await readSessionInfo(filePath);
if (!info || !info.id || !info.directory || info.timeCreated === null) {
continue;
}
if (normalizePath(info.directory) !== this.targetCwd) {
continue;
}
if (info.timeCreated < this.referenceTimestampMs) {
continue;
}
const diff = info.timeCreated - this.referenceTimestampMs;
if (diff > this.sessionStartWindowMs) {
continue;
}
if (!best || diff < best.score) {
best = { sessionId: info.id, score: diff };
}
}
if (best) {
await this.setActiveSession(best.sessionId);
return;
}
if (Date.now() > this.matchDeadlineMs) {
const message = `No OpenCode session found within ${this.sessionStartWindowMs}ms for cwd ${this.targetCwd}`;
logger.warn(`[opencode-storage] ${message}`);
this.matchFailed = true;
this.onSessionMatchFailed?.(message);
}
}
private async setActiveSession(sessionId: string): Promise<void> {
if (this.activeSessionId === sessionId) {
return;
}
this.activeSessionId = sessionId;
this.messageRoles.clear();
this.messageFileMtime.clear();
this.partFileMtime.clear();
await this.primeSessionFiles(sessionId);
this.onSessionFound?.(sessionId);
logger.debug(`[opencode-storage] Tracking session ${sessionId}`);
}
private async primeSessionFiles(sessionId: string): Promise<void> {
const messageDir = join(this.storageDir, 'message', sessionId);
const messageFiles = await listJsonFiles(messageDir);
const messageIds: string[] = [];
const replayMessageIds = new Set<string>();
const replayThresholdMs = this.referenceTimestampMs - REPLAY_CLOCK_SKEW_MS;
for (const filePath of messageFiles) {
const mtime = await readMtime(filePath);
if (mtime !== null) {
this.messageFileMtime.set(filePath, mtime);
}
const info = await readJsonRecord(filePath);
const messageId = getString(info?.id) ?? filenameToId(filePath);
if (messageId) {
messageIds.push(messageId);
const role = getString(info?.role);
if (role) {
this.messageRoles.set(messageId, role);
}
}
const timestamp = getMessageTimestamp(info, mtime);
if (messageId && info && timestamp !== null && timestamp >= replayThresholdMs) {
replayMessageIds.add(messageId);
const eventSessionId = getString(info.sessionID) ?? sessionId;
this.onEvent({
event: 'message.updated',
payload: { info },
sessionId: eventSessionId || undefined
});
}
}
for (const messageId of messageIds) {
const partDir = join(this.storageDir, 'part', messageId);
const partFiles = await listJsonFiles(partDir);
for (const partPath of partFiles) {
const mtime = await readMtime(partPath);
if (mtime !== null) {
this.partFileMtime.set(partPath, mtime);
}
if (!replayMessageIds.has(messageId)) {
continue;
}
const part = await readJsonRecord(partPath);
if (!part) {
continue;
}
if (!this.shouldEmitPart(part, messageId)) {
continue;
}
const eventSessionId = getString(part.sessionID) ?? sessionId;
this.onEvent({
event: 'message.part.updated',
payload: { part },
sessionId: eventSessionId || undefined
});
}
}
}
private async scanMessagesAndParts(sessionId: string): Promise<void> {
const messageDir = join(this.storageDir, 'message', sessionId);
const messageFiles = await listJsonFiles(messageDir);
const messageIds: string[] = [];
for (const filePath of messageFiles) {
const messageIdFromPath = filenameToId(filePath);
if (messageIdFromPath) {
messageIds.push(messageIdFromPath);
}
const mtime = await readMtime(filePath);
if (mtime === null) {
continue;
}
const previous = this.messageFileMtime.get(filePath) ?? 0;
if (mtime <= previous) {
continue;
}
const info = await readJsonRecord(filePath);
this.messageFileMtime.set(filePath, mtime);
if (!info) {
continue;
}
const messageId = getString(info.id) ?? messageIdFromPath;
if (messageId) {
const role = getString(info.role);
if (role) {
this.messageRoles.set(messageId, role);
}
}
const eventSessionId = getString(info.sessionID) ?? sessionId;
this.onEvent({
event: 'message.updated',
payload: { info },
sessionId: eventSessionId || undefined
});
}
for (const messageId of messageIds) {
const partDir = join(this.storageDir, 'part', messageId);
const partFiles = await listJsonFiles(partDir);
for (const partPath of partFiles) {
const mtime = await readMtime(partPath);
if (mtime === null) {
continue;
}
const previous = this.partFileMtime.get(partPath) ?? 0;
if (mtime <= previous) {
continue;
}
const part = await readJsonRecord(partPath);
this.partFileMtime.set(partPath, mtime);
if (!part) {
continue;
}
if (!this.shouldEmitPart(part, messageId)) {
continue;
}
const eventSessionId = getString(part.sessionID) ?? sessionId;
this.onEvent({
event: 'message.part.updated',
payload: { part },
sessionId: eventSessionId || undefined
});
}
}
}
private shouldEmitPart(part: Record<string, unknown>, messageId: string): boolean {
const partType = getString(part.type);
if (!partType) {
return false;
}
if (partType === 'text') {
const text = getString(part.text);
if (!text) {
return false;
}
const role = this.messageRoles.get(messageId);
if (role === 'user') {
return true;
}
if (part.synthetic === true) {
return true;
}
const time = isObject(part.time) ? part.time as Record<string, unknown> : null;
const end = time ? getNumber(time.end) : null;
return end !== null;
}
if (partType === 'tool') {
return true;
}
return false;
}
}
type ParsedSessionInfo = {
id: string | null;
directory: string | null;
timeCreated: number | null;
};
async function readSessionInfo(filePath: string): Promise<ParsedSessionInfo | null> {
const record = await readJsonRecord(filePath);
if (!record) {
return null;
}
const time = isObject(record.time) ? record.time as Record<string, unknown> : null;
return {
id: getString(record.id),
directory: getString(record.directory),
timeCreated: time ? getNumber(time.created) : null
};
}
async function listSessionInfoFiles(storageDir: string): Promise<string[]> {
const sessionRoot = join(storageDir, 'session');
const entries = await safeReadDir(sessionRoot);
const results: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const projectDir = join(sessionRoot, entry.name);
const files = await listJsonFiles(projectDir);
results.push(...files);
}
return results;
}
async function listJsonFiles(dirPath: string): Promise<string[]> {
const entries = await safeReadDir(dirPath);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => join(dirPath, entry.name));
}
async function safeReadDir(dirPath: string): Promise<Dirent[]> {
try {
return await readdir(dirPath, { withFileTypes: true });
} catch {
return [] as Dirent[];
}
}
async function readJsonRecord(filePath: string): Promise<Record<string, unknown> | null> {
try {
const raw = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed as Record<string, unknown>;
} catch (error) {
logger.debug(`[opencode-storage] Failed to read ${filePath}: ${error}`);
return null;
}
}
async function readMtime(filePath: string): Promise<number | null> {
try {
const stats = await stat(filePath);
return stats.mtimeMs;
} catch {
return null;
}
}
function resolveOpencodeStorageDir(): string {
const base = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
return join(base, 'opencode', 'storage');
}
function normalizePath(value: string): string {
const resolved = resolve(value);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function filenameToId(filePath: string): string | null {
if (!filePath.endsWith('.json')) {
return null;
}
const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
const name = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath;
return name.slice(0, -5) || null;
}
function getString(value: unknown): string | null {
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
return null;
}
function getNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
return null;
}
function getMessageTimestamp(info: Record<string, unknown> | null, mtime: number | null): number | null {
if (info) {
const time = isObject(info.time) ? info.time as Record<string, unknown> : null;
const createdAt = time ? getNumber(time.created) : null;
if (createdAt !== null) {
return createdAt;
}
}
return mtime;
}
+170
View File
@@ -0,0 +1,170 @@
import type { ApiSessionClient } from '@/api/apiSession';
import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types';
import type { OpencodePermissionMode } from '@hapi/protocol/types';
import { deriveToolName } from '@/agent/utils';
import { logger } from '@/ui/logger';
import {
BasePermissionHandler,
type AutoApprovalDecision,
type PendingPermissionRequest,
type PermissionCompletion
} from '@/modules/common/permission/BasePermissionHandler';
interface PermissionResponseMessage {
id: string;
approved: boolean;
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort';
reason?: string;
}
function deriveToolInput(request: PermissionRequest): unknown {
if (request.rawInput !== undefined) {
return request.rawInput;
}
return request.rawOutput;
}
function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null {
for (const kind of preferredKinds) {
const match = request.options.find((option) => option.kind === kind);
if (match) {
return match.optionId;
}
}
return request.options.length > 0 ? request.options[0].optionId : null;
}
function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse {
if (decision === 'abort') {
return { outcome: 'cancelled' };
}
if (decision === 'approved_for_session') {
const optionId = pickOptionId(request, ['allow_always', 'allow_once']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
if (decision === 'approved') {
const optionId = pickOptionId(request, ['allow_once', 'allow_always']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
const optionId = pickOptionId(request, ['reject_once', 'reject_always']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
export class OpencodePermissionHandler extends BasePermissionHandler<PermissionResponseMessage, void> {
private readonly pendingBackendRequests = new Map<string, PermissionRequest>();
constructor(
session: ApiSessionClient,
private readonly backend: AgentBackend,
private readonly getPermissionMode: () => OpencodePermissionMode | undefined
) {
super(session);
this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request));
}
private handlePermissionRequest(request: PermissionRequest): void {
const toolName = deriveToolName({
title: request.title,
kind: request.kind,
rawInput: request.rawInput
});
const toolInput = deriveToolInput(request);
const mode = this.getPermissionMode() ?? 'default';
const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId);
if (autoDecision) {
void this.autoApprove(request, toolName, toolInput, autoDecision);
return;
}
this.pendingBackendRequests.set(request.id, request);
this.addPendingRequest(request.id, toolName, toolInput, {
resolve: () => {},
reject: () => {}
});
logger.debug(`[Opencode] Permission request queued for ${toolName} (${request.id})`);
}
private async autoApprove(
request: PermissionRequest,
toolName: string,
toolInput: unknown,
decision: AutoApprovalDecision
): Promise<void> {
const outcome = mapDecisionToOutcome(request, decision);
await this.backend.respondToPermission(request.sessionId, request, outcome);
this.client.updateAgentState((currentState) => ({
...currentState,
completedRequests: {
...currentState.completedRequests,
[request.id]: {
tool: toolName,
arguments: toolInput,
createdAt: Date.now(),
completedAt: Date.now(),
status: 'approved',
decision
}
}
}));
logger.debug(`[Opencode] Auto-approved ${toolName} (${request.id}) mode=${decision}`);
}
protected async handlePermissionResponse(
response: PermissionResponseMessage,
pending: PendingPermissionRequest<void>
): Promise<PermissionCompletion> {
const pendingRequest = this.pendingBackendRequests.get(response.id);
if (pendingRequest) {
this.pendingBackendRequests.delete(response.id);
} else {
logger.debug('[Opencode] Permission response missing backend request', response.id);
}
const decision = response.decision ?? (response.approved ? 'approved' : 'denied');
if (decision === 'abort' && pendingRequest) {
await this.backend.cancelPrompt(pendingRequest.sessionId);
}
if (pendingRequest) {
const outcome = mapDecisionToOutcome(pendingRequest, decision);
await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome);
}
pending.resolve();
logger.debug(`[Opencode] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`);
return {
status: response.approved ? 'approved' : 'denied',
decision,
reason: response.reason
};
}
protected handleMissingPendingResponse(response: PermissionResponseMessage): void {
logger.debug('[Opencode] Permission response received for unknown request', response.id);
}
async cancelAll(reason: string): Promise<void> {
const pending = Array.from(this.pendingBackendRequests.values());
this.pendingBackendRequests.clear();
for (const request of pending) {
await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' });
}
this.cancelPendingRequests({
completedReason: reason,
rejectMessage: reason,
decision: 'abort'
});
}
}
@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { request } from 'node:http'
import { startOpencodeHookServer } from './startOpencodeHookServer'
const sendHookRequest = async (
port: number,
body: string,
token?: string
): Promise<{ statusCode?: number; body: string }> => {
return await new Promise((resolve, reject) => {
const headers: Record<string, string | number> = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
if (token) {
headers['x-hapi-hook-token'] = token
}
const req = request({
host: '127.0.0.1',
port,
path: '/hook/opencode',
method: 'POST',
headers
}, (res) => {
const chunks: Buffer[] = []
res.on('data', (chunk) => chunks.push(chunk as Buffer))
res.on('error', reject)
res.on('end', () => {
resolve({
statusCode: res.statusCode,
body: Buffer.concat(chunks).toString('utf-8')
})
})
})
req.on('error', reject)
req.end(body)
})
}
describe('startOpencodeHookServer', () => {
it('forwards hook payload to callback', async () => {
let received: { event?: string; payload?: unknown; sessionId?: string } = {}
const server = await startOpencodeHookServer({
onEvent: (event) => {
received = event
}
})
try {
const body = JSON.stringify({
event: 'message.updated',
payload: { message: 'ok' },
sessionId: 'session-123'
})
const response = await sendHookRequest(server.port, body, server.token)
expect(response.statusCode).toBe(200)
} finally {
server.stop()
}
expect(received.event).toBe('message.updated')
expect(received.sessionId).toBe('session-123')
expect(received.payload).toEqual({ message: 'ok' })
})
it('returns 400 for invalid JSON payloads', async () => {
let hookCalled = false
const server = await startOpencodeHookServer({
onEvent: () => {
hookCalled = true
}
})
try {
const response = await sendHookRequest(server.port, '{"event":', server.token)
expect(response.statusCode).toBe(400)
expect(response.body).toBe('invalid json')
} finally {
server.stop()
}
expect(hookCalled).toBe(false)
})
it('returns 422 when event is missing', async () => {
let hookCalled = false
const server = await startOpencodeHookServer({
onEvent: () => {
hookCalled = true
}
})
try {
const body = JSON.stringify({ payload: { ok: true } })
const response = await sendHookRequest(server.port, body, server.token)
expect(response.statusCode).toBe(422)
expect(response.body).toBe('missing event')
} finally {
server.stop()
}
expect(hookCalled).toBe(false)
})
it('returns 401 when hook token is missing', async () => {
let hookCalled = false
const server = await startOpencodeHookServer({
onEvent: () => {
hookCalled = true
}
})
try {
const body = JSON.stringify({ event: 'message.updated', payload: { ok: true } })
const response = await sendHookRequest(server.port, body)
expect(response.statusCode).toBe(401)
expect(response.body).toBe('unauthorized')
} finally {
server.stop()
}
expect(hookCalled).toBe(false)
})
})
@@ -0,0 +1,133 @@
import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http';
import { randomBytes } from 'node:crypto';
import { logger } from '@/ui/logger';
import type { OpencodeHookEvent } from '../types';
export interface OpencodeHookServerOptions {
onEvent: (event: OpencodeHookEvent) => void;
token?: string;
}
export interface OpencodeHookServer {
port: number;
token: string;
stop: () => void;
}
function readHookToken(req: IncomingMessage): string | null {
const header = req.headers['x-hapi-hook-token'];
if (Array.isArray(header)) {
return header[0] ?? null;
}
return header ?? null;
}
export async function startOpencodeHookServer(options: OpencodeHookServerOptions): Promise<OpencodeHookServer> {
const hookToken = options.token || randomBytes(16).toString('hex');
return new Promise((resolve, reject) => {
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const requestPath = req.url?.split('?')[0];
if (req.method === 'POST' && requestPath === '/hook/opencode') {
const providedToken = readHookToken(req);
if (providedToken !== hookToken) {
logger.debug('[opencode-hook] Unauthorized hook request');
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized');
req.resume();
return;
}
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
if (!res.headersSent) {
logger.debug('[opencode-hook] Request timeout');
res.writeHead(408).end('timeout');
}
req.destroy(new Error('Request timeout'));
}, 5000);
try {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
clearTimeout(timeout);
if (timedOut || res.headersSent || res.writableEnded) {
return;
}
const body = Buffer.concat(chunks).toString('utf-8');
logger.debug('[opencode-hook] Received hook:', body);
let data: Record<string, unknown> = {};
try {
const parsed = JSON.parse(body);
if (!parsed || typeof parsed !== 'object') {
logger.debug('[opencode-hook] Parsed hook data is not an object');
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json');
return;
}
data = parsed as Record<string, unknown>;
} catch (parseError) {
logger.debug('[opencode-hook] Failed to parse hook data as JSON:', parseError);
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json');
return;
}
const eventValue = data.event;
if (typeof eventValue !== 'string' || eventValue.length === 0) {
res.writeHead(422, { 'Content-Type': 'text/plain' }).end('missing event');
return;
}
const payload = data.payload;
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
options.onEvent({ event: eventValue, payload, sessionId });
if (!res.headersSent && !res.writableEnded) {
res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok');
}
} catch (error) {
clearTimeout(timeout);
if (timedOut) {
return;
}
logger.debug('[opencode-hook] Error handling hook:', error);
if (!res.headersSent && !res.writableEnded) {
res.writeHead(500).end('error');
}
}
return;
}
res.writeHead(404).end('not found');
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
return;
}
const port = address.port;
logger.debug(`[opencode-hook] Started on port ${port}`);
resolve({
port,
token: hookToken,
stop: () => {
server.close();
logger.debug('[opencode-hook] Stopped');
}
});
});
server.on('error', (err) => {
logger.debug('[opencode-hook] Server error:', err);
reject(err);
});
});
}