mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-09 07:29:51 +00:00
feat: support opencode
This commit is contained in:
@@ -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