import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { isAbsolute, join, relative } from 'node:path'; import { logger } from '@/ui/logger'; import { configuration } from '@/configuration'; const PLUGIN_FILENAME = 'hapi-hook.ts'; const PACKAGE_JSON_FILENAME = 'package.json'; const PLUGIN_PACKAGE = '@opencode-ai/plugin'; // Pinned to the major version HAPI has been validated against (opencode // 1.14.x). Using '*' would let an unrelated registry change pull a moving // target into a code-execution path (the plugin runtime executes inside // opencode with the session hook URL + token in scope). const PLUGIN_PACKAGE_VERSION = '^1.14.0'; 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'); } /** * `rootPath` is a HAPI-managed directory only when it lives under * `configuration.happyHomeDir` (the default `OPENCODE_CONFIG_DIR` we * synthesize per-session). If a user has exported `OPENCODE_CONFIG_DIR` * pointing at e.g. their own `~/.config/opencode`, we must not pollute it * with a placeholder `package.json`. */ function isHapiManagedDir(rootPath: string): boolean { const home = configuration.happyHomeDir; if (!home) { return false; } const rel = relative(home, rootPath); // `relative` returns a path that does NOT start with '..' and is not // absolute when rootPath is inside home. On Windows a cross-volume // input (e.g. home=`C:\\hapi`, rootPath=`D:\\…`) makes `relative` // return the absolute `D:\\…` verbatim — `startsWith('/')` would miss // that, so use `isAbsolute` which covers both POSIX `/` and win32 // `:\\`. return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); } function hasDeclaredPluginPackage(packageJsonPath: string): boolean { try { const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { dependencies?: Record; devDependencies?: Record; }; return Boolean(parsed.dependencies?.[PLUGIN_PACKAGE] ?? parsed.devDependencies?.[PLUGIN_PACKAGE]); } catch { return false; } } function buildMinimalPackageJson(): string { return `${JSON.stringify({ dependencies: { [PLUGIN_PACKAGE]: PLUGIN_PACKAGE_VERSION } }, null, 2)}\n`; } /** * Ensure the opencode runtime can resolve `@opencode-ai/plugin` from our * isolated config dir. Since opencode 1.14.x the plugin loader is a separate * npm package, and `/plugins/*.ts` files are only evaluated when * that package is resolvable from ``. HAPI's per-session * OPENCODE_CONFIG_DIR is otherwise an empty directory, so plugin discovery * never even attempts to load hapi-hook.ts. * * Writing a minimal `package.json` declaring the dependency is enough: * opencode itself materializes `node_modules` and `package-lock.json` on * the next launch (the same install path `opencode plugin install` * follows). No symlinks, no install spawn, no cross-platform branches — * just a one-line dependency declaration. * * Guards against three failure modes: * - **Non-managed dir**: when the caller pointed OPENCODE_CONFIG_DIR at a * directory outside `happyHomeDir` (e.g. the user's global opencode * config) we leave it alone — that filesystem is not ours to mutate. * - **Existing package.json missing our dep**: parse it and only short- * circuit when `@opencode-ai/plugin` is already declared. Otherwise * overwrite the placeholder so plugin discovery actually works. * - **Write failure**: log and continue; the scanner channel restored in * upstream #589 still carries messages, so a non-writable cfg dir is * degraded but not fatal. */ function ensurePluginRuntime(rootPath: string): void { if (!isHapiManagedDir(rootPath)) { logger.debug(`[opencode-hook] Skipping plugin runtime materialization for non-HAPI dir: ${rootPath}`); return; } const packageJsonPath = join(rootPath, PACKAGE_JSON_FILENAME); if (existsSync(packageJsonPath)) { if (hasDeclaredPluginPackage(packageJsonPath)) { // Existing file already declares the plugin (likely opencode- // installed with a matching lock file). Leave it untouched. return; } logger.debug(`[opencode-hook] package.json exists at ${packageJsonPath} but does not declare ${PLUGIN_PACKAGE}; overwriting placeholder.`); } try { writeFileSync(packageJsonPath, buildMinimalPackageJson(), 'utf-8'); } catch (error) { logger.warn(`[opencode-hook] Failed to materialize ${packageJsonPath}; the hook plugin channel may stay inert. Storage scanner remains as fallback. Error: ${(error as Error).message}`); } } export function ensureOpencodeHookPlugin(rootPath: string, hookUrl: string, token: string): string { const pluginDir = resolvePluginDir(rootPath); mkdirSync(pluginDir, { recursive: true }); ensurePluginRuntime(rootPath); 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; }