From 6abb9bc3cc7a410a719f70b434f83efbbc84b830 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 5 Oct 2026 10:58:33 +0200 Subject: [PATCH] fix: support OpenCode V1 and V2 hooks --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 2 + skill-data/core/references/troubleshooting.md | 8 + src/__tests__/e2e/opencode-hooks.test.ts | 96 ++++++++++++ src/__tests__/helpers/opencode-plugin.ts | 75 +++++++-- src/__tests__/opencode-hooks.test.ts | 142 ++++++++++++++++-- src/__tests__/session-id.test.ts | 6 + src/opencode-hooks.ts | 90 +++++++++-- src/utils/session-id.ts | 10 +- 9 files changed, 396 insertions(+), 37 deletions(-) create mode 100644 src/__tests__/e2e/opencode-hooks.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index d16b5ba08..dff303ce2 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2290,7 +2290,9 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th - **Skills** land in `.opencode/skills/` (project) or `~/.config/opencode/skills/` (user). OpenCode also reads `.claude/skills` natively, but teamai writes the OpenCode path too so an OpenCode-only user still gets them. - **Subagents** are rendered into OpenCode's own `agents/*.md` format: frontmatter carries `description` + `mode: subagent` (plus `model` and any `tool_extras.opencode` fields such as `temperature`); the agent name comes from the filename. OpenCode does **not** read `.claude/agents`, so this native copy is required. - **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory — the files are inert until referenced. teamai therefore adds globs to the `instructions` array in `opencode.json` and removes them again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched. In a project that is `.opencode/rules/**/*.md` in `.opencode/opencode.json`, beside the team instructions entry; OpenCode globs a relative entry from the session's working directory and each parent up to the worktree, so it loads the namespaced rules from anywhere in the project. A pull removes the `.opencode/rules/*.md` an earlier release wrote to the root `opencode.json`, which loaded no namespaced rule, and leaves that file's other keys alone. In user scope it is the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in (`~/.config/opencode/rules//*.md`): OpenCode resolves a relative entry from the session's working directory, and globs only the file name of an absolute one, so `**` never matches. A pull replaces the relative `rules/*.md` an earlier release wrote, which loaded the project's `rules/` instead, and drops a team namespace's glob once its rules no longer reach you; a glob you added for a directory of your own stays. OpenCode ignores `paths:`: it applies every rule it loads to every file. `uninstall` removes the globs, and deletes a `.opencode/opencode.json` left with nothing else in it. -- **Hooks** are delivered as an OpenCode *plugin*, not a settings-file entry — OpenCode has no `hooks` array; it auto-loads JS/TS plugins from **both** `~/.config/opencode/plugin/` and `/.opencode/plugin/`. A plugin present in both dirs is loaded twice and would dispatch every event twice, so teamai keeps exactly one copy: `teamai-hooks.ts` in the user dir, which covers every project. Any project-scope copy left by an earlier layout is deleted on the next sync. This matches the other tools, whose `settings.json` hooks also live in HOME and gate on the `cwd` handed to `hook-dispatch`. The plugin subscribes to OpenCode's own events and shelling out to the same `teamai hook-dispatch` entry point every other tool uses. The event mapping mirrors the Claude built-in set: `session.created` → session-start, `session.idle` → stop, `chat.message` → prompt-submit, `tool.execute.after` → post-tool-use. The plugin forwards the same STDIN payload other agents send (`cwd`, `session_id`, `tool_name`, `tool_input`, `prompt`, and on post-tool-use the tool's output and status), and maps OpenCode's lowercase tool ids (`skill`, `todowrite`) back to the PascalCase matchers the handler registry expects. OpenCode cannot inject a hook's stdout back into the session, so hooks run purely for their side effects (status report / sync / update). Note that OpenCode *awaits* its named hooks (`chat.message`, `tool.execute.after`), so those dispatches briefly wait on the `teamai` subprocess before the agent continues; the errors are always swallowed so a hook can never fail the session. Server-pushed agent hooks (`teamai-agent-.ts`) install into the same user plugin dir. Upvote **adoption** runs for OpenCode from the recall log, not a transcript: the plugin's `shell.env` hook sets `TEAMAI_AGENT_SESSION_ID` in the bash tool's environment, so a `teamai recall` run there joins the session its hooks carry, and a `task` call links the subagent's child session to its parent, so a doc the parent opens after a subagent's recall is upvoted. The opt-in LLM-judge needs a transcript, which `session.idle` does not carry, so it does not run for OpenCode, and the "adopted team knowledge" summary is never shown, as hook stdout is discarded. +- **Hooks** are delivered as an OpenCode *plugin*, not a settings-file entry — OpenCode has no `hooks` array; it auto-loads JS/TS plugins from **both** `~/.config/opencode/plugin/` and `/.opencode/plugin/`. A plugin present in both dirs is loaded twice and would dispatch every event twice, so teamai keeps exactly one copy: `teamai-hooks.ts` in the user dir, which covers every project. Any project-scope copy left by an earlier layout is deleted on the next sync. This matches the other tools, whose `settings.json` hooks also live in HOME and gate on the `cwd` handed to `hook-dispatch`. The plugin subscribes to OpenCode's own events and shells out to the same `teamai hook-dispatch` entry point every other tool uses. On V1, the event mapping mirrors the Claude built-in set: `session.created` → session-start, `session.idle` → stop, `chat.message` → prompt-submit, `tool.execute.after` → post-tool-use. The plugin forwards the same STDIN payload other agents send (`cwd`, `session_id`, `tool_name`, `tool_input`, `prompt`, and on post-tool-use the tool's output and status), and maps OpenCode's lowercase tool ids (`skill`, `todowrite`) back to the PascalCase matchers the handler registry expects. OpenCode cannot inject a hook's stdout back into the session, so hooks run purely for their side effects (status report / sync / update). Note that OpenCode *awaits* its named hooks (`chat.message`, `tool.execute.after`), so those dispatches briefly wait on the `teamai` subprocess before the agent continues; the errors are always swallowed so a hook can never fail the session. Server-pushed agent hooks (`teamai-agent-.ts`) install into the same user plugin dir. Upvote **adoption** runs for OpenCode from the recall log, not a transcript: on V1 the plugin's `shell.env` hook sets `TEAMAI_AGENT_SESSION_ID` in the bash tool's environment, so a `teamai recall` run there joins the session its hooks carry, and a `task` call links the subagent's child session to its parent, so a doc the parent opens after a subagent's recall is upvoted. The opt-in LLM-judge needs a transcript, which `session.idle` does not carry, so it does not run for OpenCode, and the "adopted team knowledge" summary is never shown, as hook stdout is discarded. + + Both built-in and server-pushed hooks support OpenCode **1.18.23** and **V2** (verified with 2.0.23). Each plugin default-exports one definition: V1 calls `server`, V2 calls `setup`. V2 maps `session.prompt` and `tool.execute.after` to the same dispatches, normalizes `shell` / `subagent` to `bash` / `task`, and uses the host's native `OPENCODE_SESSION_ID` for shell recall attribution. Lifecycle subscriptions are scoped to the plugin's directory and cancelled on unload. After upgrading TeamAI, run `teamai hooks inject` or `teamai pull` and restart OpenCode to replace old plugins that report “Plugin must export a default definition”. - **MCP** servers live under the `mcp` key of the shared `opencode.json` (see the MCP section above). ### Pi Coding Agent diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 498229d9e..0c37eb2de 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2134,6 +2134,8 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 - **Subagents** 会被渲染成 OpenCode 自己的 `agents/*.md` 格式:frontmatter 带 `description` + `mode: subagent`(以及 `model` 和 `tool_extras.opencode` 中的字段,如 `temperature`);agent 名取自文件名。OpenCode **不**读取 `.claude/agents`,因此这份原生副本是必需的。 - **Rules** 会被复制到 `.opencode/rules/`(或 `~/.config/opencode/rules/`),但 OpenCode 不会自动扫描 rules 目录——文件在被引用前是惰性的。因此 teamai 会往 `opencode.json` 的 `instructions` 数组里加入 glob,并在团队最后一条 rule 消失时再把它们移除,且只编辑这一个键、不动你自己的 `instructions` 条目。在项目中是 `.opencode/opencode.json` 里的 `.opencode/rules/**/*.md`,与团队 instructions 条目并列;OpenCode 会从会话的工作目录及其直到 worktree 的每一级父目录对相对条目做 glob,因此在项目任意位置都能加载 namespace 下的 rule。pull 会移除旧版本写入根目录 `opencode.json` 的 `.opencode/rules/*.md`(它加载不到任何 namespace 下的 rule),且不动该文件的其他键。user scope 下是绝对路径 `~/.config/opencode/rules/*.md`,外加 rule 所落入的每个 namespace 目录各一条(`~/.config/opencode/rules//*.md`):OpenCode 从会话的工作目录解析相对条目,对绝对条目只对文件名做 glob,因此 `**` 永远不会匹配。pull 会替换旧版本写入的相对 `rules/*.md`(它加载的是项目的 `rules/`),并在某个团队 namespace 的 rule 不再送达你时移除它的 glob;你为自己的目录添加的 glob 会保留。OpenCode 会忽略 `paths:`:它加载的每条 rule 都对所有文件生效。`uninstall` 会移除这些 glob,并删除除此之外已无其他内容的 `.opencode/opencode.json`。 - **Hooks** 以 OpenCode *plugin* 形式交付,而非配置文件条目——OpenCode 没有 `hooks` 数组,它会**同时**加载 `~/.config/opencode/plugin/` 和 `/.opencode/plugin/` 下的 JS/TS 插件。两个目录都有插件时会被加载两次,每个事件也就派发两次,因此 teamai 只保留一份:写在用户目录的 `teamai-hooks.ts`,覆盖所有项目;早期布局残留的项目级副本会在下次同步时被删除。这与其他工具一致——它们的 `settings.json` hooks 同样放在 HOME,靠传给 `hook-dispatch` 的 `cwd` 做作用域判断。插件订阅 OpenCode 自己的事件,并 shell 到其他所有工具共用的 `teamai hook-dispatch` 入口。事件映射对齐 Claude 内置集合:`session.created` → session-start、`session.idle` → stop、`chat.message` → prompt-submit、`tool.execute.after` → post-tool-use。插件会转发与其他工具一致的 STDIN 负载(`cwd`、`session_id`、`tool_name`、`tool_input`、`prompt`,post-tool-use 时还有工具输出和状态),并把 OpenCode 的小写工具 id(`skill`、`todowrite`)映射回 handler 注册表期望的 PascalCase matcher。OpenCode 无法把 hook 的 stdout 回注到会话,因此 hooks 只为副作用运行(状态上报 / 同步 / 更新)。注意 OpenCode 会 **await** 它的具名 hook(`chat.message`、`tool.execute.after`),所以这两个事件的派发会短暂等待 `teamai` 子进程后 agent 才继续;错误始终被吞掉,hook 永远不会让会话失败。服务端下发的 agent hook(`teamai-agent-.ts`)同样装在这个用户级 plugin 目录下。upvote **采纳(adoption)**在 OpenCode 上基于 recall 日志运行,不依赖 transcript:插件的 `shell.env` hook 会在 bash 工具的环境中设置 `TEAMAI_AGENT_SESSION_ID`,因此在其中运行的 `teamai recall` 会归入其 hooks 携带的同一会话;`task` 调用会把子代理的子会话关联到父会话,因此子代理 recall 之后父会话打开的文档会被 upvote。可选的 LLM-judge 需要 transcript,而 `session.idle` 不携带,所以它在 OpenCode 上不运行;hook 的 stdout 会被丢弃,因此"本次会话采纳的团队知识"摘要也不会显示。 + + 内置 hooks 和服务端下发的 hooks 均支持 OpenCode **1.18.23** 和 **V2**(已对照 2.0.23 验证)。每个插件默认导出一个定义:V1 调用 `server`,V2 调用 `setup`。上述命名 hook 与 `shell.env` 对应 V1;V2 将 `session.prompt` 和 `tool.execute.after` 映射为相同分发,将 `shell` / `subagent` 规范为 `bash` / `task`,并使用宿主原生的 `OPENCODE_SESSION_ID` 为 shell 中的 recall 归属会话。生命周期订阅限定在插件的目录内,卸载时取消。升级 TeamAI 后运行 `teamai hooks inject` 或 `teamai pull`,然后重启 OpenCode,替换报 “Plugin must export a default definition” 的旧插件。 - **MCP** server 位于共享 `opencode.json` 的 `mcp` 键下(详见上文 MCP 章节)。 ### Pi Coding Agent diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index ecdded459..a1456f72a 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -277,6 +277,14 @@ session's runs, recalled docs and adopted docs. Per agent: A read after the session's last Stop is credited at SubagentStop, at Copilot CLI's SessionEnd, or at the next `teamai pull`. +## OpenCode V2 rejects the hooks plugin + +If OpenCode reports “Plugin must export a default definition”, upgrade TeamAI, +run `teamai hooks inject` or `teamai pull`, and restart OpenCode. The generated +built-in and enterprise plugins support both OpenCode 1.18.23 (`server`) and V2 +(`setup`). V1 shell recall uses `TEAMAI_AGENT_SESSION_ID`; V2 supplies its native +`OPENCODE_SESSION_ID`. + ## Still stuck - Re-run the failing command with `-v` / `--verbose` for detail. diff --git a/src/__tests__/e2e/opencode-hooks.test.ts b/src/__tests__/e2e/opencode-hooks.test.ts new file mode 100644 index 000000000..50c5b2a36 --- /dev/null +++ b/src/__tests__/e2e/opencode-hooks.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; +import { randomBytes } from 'node:crypto'; +import { applyOpencodeAgentHook } from '../../opencode-hooks.js'; + +const ROOT = process.cwd(); +const CLI = path.join(ROOT, 'dist/index.js'); +const V1 = path.join(ROOT, 'node_modules/.bin', process.platform === 'win32' ? 'opencode.cmd' : 'opencode'); +const V2 = process.env.TEAMAI_OPENCODE_V2_BIN; + +async function freePort(): Promise { + const socket = net.createServer(); + socket.listen(0, '127.0.0.1'); + await once(socket, 'listening'); + const port = (socket.address() as net.AddressInfo).port; + await new Promise((resolve) => socket.close(() => resolve())); + return port; +} + +// V2 is distributed separately; CI can opt in with its installed binary. +describe.each([{ version: 'V1', binary: V1 }, { version: 'V2', binary: V2 }])('real OpenCode $version hooks', ({ version, binary }) => { + it.skipIf(!binary)('loads the CLI-generated plugin and dispatches session start once', async () => { + if (version === 'V1') execFileSync(process.execPath, ['node_modules/opencode-ai/postinstall.mjs'], { cwd: ROOT, stdio: 'pipe' }); + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-oc-hooks-e2e-'))); + const home = path.join(sandbox, 'home'); + const work = path.join(sandbox, 'work'); + const team = path.join(sandbox, 'team'); + const bin = path.join(sandbox, 'bin'); + const records = path.join(sandbox, 'dispatch.jsonl'); + const commands = path.join(sandbox, 'enterprise.txt'); + const env = { + ...process.env, HOME: home, USERPROFILE: home, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}`, + XDG_CONFIG_HOME: path.join(home, '.config'), XDG_DATA_HOME: path.join(sandbox, 'data'), + XDG_CACHE_HOME: path.join(sandbox, 'cache'), XDG_STATE_HOME: path.join(sandbox, 'state'), + OPENCODE_CONFIG_DIR: path.join(home, '.config/opencode'), + OPENCODE_DISABLE_AUTOUPDATE: 'true', OPENCODE_DISABLE_MODELS_FETCH: 'true', + OPENCODE_DISABLE_PROJECT_CONFIG: 'true', OPENCODE_PASSWORD: randomBytes(24).toString('hex'), + OPENCODE_SERVER_PASSWORD: randomBytes(24).toString('hex'), + }; + for (const dir of [home, work, team, bin, path.join(home, '.teamai'), env.OPENCODE_CONFIG_DIR]) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(team, 'teamai.yaml'), `team: opencode-hooks-e2e\nrepo: ${team}\nprovider: tgit\ntoolPaths:\n opencode:\n skills: .opencode/skills\n`); + fs.writeFileSync(path.join(home, '.teamai/config.yaml'), `repo:\n localPath: ${team}\n remote: ${team}\nusername: ci\nscope: user\nenabledAgents:\n - opencode\n`); + fs.writeFileSync(path.join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), '{}'); + const shim = path.join(bin, 'capture.cjs'); + fs.writeFileSync(shim, `let stdin='';process.stdin.on('data',d=>stdin+=d);process.stdin.on('end',()=>require('node:fs').appendFileSync(${JSON.stringify(records)},JSON.stringify({args:process.argv.slice(2),payload:JSON.parse(stdin)})+'\\n'));`); + fs.writeFileSync(path.join(bin, process.platform === 'win32' ? 'teamai.cmd' : 'teamai'), process.platform === 'win32' + ? `@"${process.execPath}" "${shim}" %*\r\n` + : `#!/bin/sh\nexec '${process.execPath}' '${shim}' "$@"\n`, { mode: 0o755 }); + let server: ReturnType | undefined; + let logs = ''; + try { + const output = execFileSync(process.execPath, [CLI, 'hooks', 'inject'], { env, cwd: work, encoding: 'utf8' }); + expect(output).toContain('OpenCode hook'); + await applyOpencodeAgentHook({ slug: 'start-proof', event: 'SessionStart', command: `node -e ${JSON.stringify(`require('node:fs').appendFileSync(${JSON.stringify(commands)},'start\\n')`)}`, baseDir: home, scope: 'user' }); + const port = await freePort(); + const url = `http://127.0.0.1:${port}`; + server = spawn(binary!, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], { env, cwd: work, stdio: ['ignore', 'pipe', 'pipe'] }); + server.stdout?.on('data', (data: Buffer) => { logs += data.toString(); }); + server.stderr?.on('data', (data: Buffer) => { logs += data.toString(); }); + const auth = Buffer.from(`opencode:${version === 'V1' ? env.OPENCODE_SERVER_PASSWORD : env.OPENCODE_PASSWORD}`).toString('base64'); + const request = async (route: string, body?: unknown) => { + if (version === 'V2') { + const output = execFileSync(binary!, ['api', '--server', url, body ? 'POST' : 'GET', route, ...(body ? ['--data', JSON.stringify(body)] : [])], { env, cwd: work, encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'pipe'] }); + const response = JSON.parse(output) as Record; + return (response.data ?? response) as Record; + } + const response = await fetch(`${url}${route}`, { method: body ? 'POST' : 'GET', headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(5_000), ...(body ? { body: JSON.stringify(body) } : {}) }); + if (!response.ok) throw new Error(`${route}: ${response.status}`); + return response.json() as Promise>; + }; + await expect.poll(async () => { try { return !!(await request(version === 'V1' ? '/global/health' : '/api/info')); } catch { return false; } }, { timeout: 20_000 }).toBe(true); + if (version === 'V2') { + await expect.poll(() => { + const list = execFileSync(binary!, ['api', '--server', url, 'plugin.list'], { env, cwd: work, encoding: 'utf8', timeout: 5_000 }); + const plugins = JSON.parse(list).data as Array<{ id: string; state: { status: string } }>; + return ['teamai.hooks', 'teamai.agent.start-proof'].map((id) => plugins.find((p) => p.id === id)?.state.status); + }, { timeout: 20_000 }).toEqual(['active', 'active']); + } + const session = await request(version === 'V1' ? '/session' : '/api/session', version === 'V1' ? {} : { location: { directory: work } }); + const dispatches = () => fs.existsSync(records) ? fs.readFileSync(records, 'utf8').trim().split('\n').map((line) => JSON.parse(line) as { args: string[]; payload: Record }) : []; + await expect.poll(() => dispatches().length, { timeout: 10_000 }).toBe(1); + expect(dispatches()).toEqual([{ args: ['hook-dispatch', 'session-start', '--tool', 'opencode'], payload: { cwd: work, session_id: session.id } }]); + await expect.poll(() => fs.existsSync(commands) ? fs.readFileSync(commands, 'utf8') : '', { timeout: 10_000 }).toBe('start\n'); + expect(logs).not.toContain('Plugin must export a default definition'); + } finally { + if (server && server.exitCode === null) { const closed = once(server, 'close'); server.kill(); await closed; } + fs.rmSync(sandbox, { recursive: true, force: true }); + } + }); +}); diff --git a/src/__tests__/helpers/opencode-plugin.ts b/src/__tests__/helpers/opencode-plugin.ts index 447a4c128..7b3a97a28 100644 --- a/src/__tests__/helpers/opencode-plugin.ts +++ b/src/__tests__/helpers/opencode-plugin.ts @@ -1,5 +1,6 @@ import vm from 'node:vm'; import { EventEmitter } from 'node:events'; +import { transformSync } from 'esbuild'; import { buildPluginSource } from '../../opencode-hooks.js'; /** One `teamai hook-dispatch` the generated plugin spawned: its argv and the JSON it wrote on STDIN. */ @@ -8,15 +9,27 @@ export interface PluginDispatch { payload: Record; } +type Hooks = Record Promise>; +export interface PluginDefinition { + id: string; + server: (ctx: unknown) => Promise; + setup: (ctx: unknown) => Promise<(() => void | Promise) | void>; +} + +/** Compile the original ESM exports; mock only the subprocess I/O boundary. */ +export function loadPluginDefinition(source: string, globals: Record = {}): PluginDefinition { + const module = { exports: {} as { default: PluginDefinition } }; + const executable = transformSync(source.replaceAll("await import('node:child_process')", 'globalThis.__childProcess'), { format: 'cjs' }).code; + vm.runInNewContext(executable, { module, exports: module.exports, process: { platform: 'linux' }, AbortController, ...globals }); + return module.exports.default; +} + /** * The generated OpenCode plugin, evaluated in a `vm` context whose * `child_process.spawn` records each dispatch instead of running `teamai`. * `dispatches` fills as the host calls the returned hooks. */ -export async function loadOpencodePlugin(ctx: { directory?: string; worktree?: string } = {}): Promise<{ - hooks: Record Promise>; - dispatches: PluginDispatch[]; -}> { +function capturePlugin(source: string, globals: Record = {}) { const dispatches: PluginDispatch[] = []; const spawn = (_command: string, args: string[]) => { const child = new EventEmitter() as EventEmitter & { stdin: { write: (s: string) => void; end: () => void } }; @@ -30,11 +43,51 @@ export async function loadOpencodePlugin(ctx: { directory?: string; worktree?: s }; return child; }; - const executable = buildPluginSource() - .replace('await import(\'node:child_process\')', 'globalThis.__childProcess') - .replace('export const TeamaiHooks =', 'globalThis.TeamaiHooks ='); - const context = { __childProcess: { spawn }, process: { platform: 'linux' } } as Record; - vm.runInNewContext(executable, context); - const hooks = await (context.TeamaiHooks as (c: unknown) => Promise Promise>>)(ctx); - return { hooks, dispatches }; + return { plugin: loadPluginDefinition(source, { __childProcess: { spawn }, ...globals }), dispatches }; +} + +export async function loadOpencodePlugin(ctx: { directory?: string; worktree?: string } = {}) { + const { plugin, dispatches } = capturePlugin(buildPluginSource()); + return { hooks: await plugin.server(ctx), dispatches }; +} + +/** V2's public registration/stream boundaries, with abortable event delivery. */ +export async function loadV2Plugin(source = buildPluginSource(), globals: Record = {}) { + const { plugin, dispatches } = capturePlugin(source, globals); + const callbacks: Record Promise> = {}; + const disposed: string[] = []; + let signal: AbortSignal; + let wake: (() => void) | undefined; + const queue: Array<{ event: unknown; done: () => void }> = []; + const register = (domain: string) => async (name: string, callback: (...args: unknown[]) => Promise) => { + const key = `${domain}.${name}`; + callbacks[key] = callback; + return { dispose: async () => { disposed.push(key); delete callbacks[key]; } }; + }; + const location = { directory: '/work/proj' }; + const cleanup = await plugin.setup({ + location, + session: { hook: register('session') }, + tool: { hook: register('tool') }, + event: { subscribe: (options: { signal: AbortSignal }) => { + signal = options.signal; + signal.addEventListener('abort', () => wake?.(), { once: true }); + return (async function* () { + while (!signal.aborted) { + if (!queue.length) await new Promise((resolve) => { wake = resolve; }); + if (signal.aborted) break; + const next = queue.shift()!; + yield next.event; + next.done(); + } + })(); + } }, + }); + return { + plugin, dispatches, callbacks, disposed, cleanup, + aborted: () => signal?.aborted, + emit: (type: string, data: unknown, eventLocation: unknown = location) => new Promise((done) => { + queue.push({ event: { type, data, location: eventLocation }, done }); wake?.(); + }), + }; } diff --git a/src/__tests__/opencode-hooks.test.ts b/src/__tests__/opencode-hooks.test.ts index eef0dcfa8..d51ff1765 100644 --- a/src/__tests__/opencode-hooks.test.ts +++ b/src/__tests__/opencode-hooks.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import os from 'node:os'; -import vm from 'node:vm'; import { EventEmitter } from 'node:events'; import fse from 'fs-extra'; -/** Assert a generated ESM plugin body parses as valid JS (strip `export`). */ +/** Compile the generated module without rewriting its exports. */ function assertValidJs(src: string): void { - expect(() => new vm.Script(src.replace(/export const /g, 'const '))).not.toThrow(); + expect(() => loadPluginDefinition(src)).not.toThrow(); } vi.mock('../utils/logger.js', () => ({ @@ -25,7 +24,7 @@ import { OPENCODE_HOOK_FILE, } from '../opencode-hooks.js'; import { reconcileHooksToAllTools } from '../hooks.js'; -import { loadOpencodePlugin } from './helpers/opencode-plugin.js'; +import { loadOpencodePlugin, loadPluginDefinition, loadV2Plugin } from './helpers/opencode-plugin.js'; import { log } from '../utils/logger.js'; describe('resolveOpencodePluginDir', () => { @@ -39,6 +38,14 @@ describe('resolveOpencodePluginDir', () => { describe('buildPluginSource', () => { const src = buildPluginSource(); + it('loads as a default plugin definition for both OpenCode APIs', async () => { + const plugin = loadPluginDefinition(src); + expect(plugin.id).toBe('teamai.hooks'); + expect(typeof plugin.server).toBe('function'); + expect(typeof plugin.setup).toBe('function'); + const hooks = await plugin.server({ directory: '/work/proj' }); + expect(typeof hooks.event).toBe('function'); + }); it('maps the four Claude built-in events to OpenCode events and teamai dispatch', () => { expect(src).toContain("event.type === 'session.created'"); expect(src).toContain("dispatch('session-start',"); @@ -94,15 +101,11 @@ describe('buildPluginSource', () => { spawns.push({ child, options }); return child; }); - const executable = src - .replace('await import(\'node:child_process\')', 'globalThis.__childProcess') - .replace('export const TeamaiHooks =', 'globalThis.TeamaiHooks ='); - const context = { + const plugin = loadPluginDefinition(src, { __childProcess: { spawn: fakeSpawn }, process: { platform: 'win32' }, - } as any; - vm.runInNewContext(executable, context); - const hooks = await context.TeamaiHooks({ directory: 'C:/workspace' }); + }); + const hooks = await plugin.server({ directory: 'C:/workspace' }); for (let i = 0; i < 12; i += 1) { await hooks.event({ event: { type: 'session.created' } }); @@ -391,3 +394,120 @@ describe('OpenCode plugin: hook stdout is discarded (#719 review)', () => { expect(buildPluginSource()).toContain('cannot inject a hook'); }); }); + + +describe('OpenCode V2 built-in plugin', () => { + it('dispatches lifecycle only for its location and aborts on unload', async () => { + const host = await loadV2Plugin(); + await host.emit('session.created', { sessionID: 'ses_v2', location: { directory: '/work/proj' } }); + await host.emit('session.created', { sessionID: 'ses_other' }, { directory: '/other' }); + await host.emit('session.status', { sessionID: 'ses_v2', status: { type: 'idle' } }); + await host.emit('session.idle', { sessionID: 'ses_v2' }); + expect(host.dispatches.map((d) => [d.args[1], d.payload])).toEqual([ + ['session-start', { cwd: '/work/proj', session_id: 'ses_v2' }], + ['stop', { cwd: '/work/proj', session_id: 'ses_v2' }], + ]); + await host.cleanup?.(); + expect(host.aborted()).toBe(true); + expect(Object.keys(host.callbacks)).toEqual([]); + }); + + it('forwards prompt and tool results through shared dispatch, with aliases and exactly one matcher pass', async () => { + const host = await loadV2Plugin(); + await host.callbacks['session.prompt']({ sessionID: 'ses_v2', prompt: { text: '/retry now' } }); + await host.callbacks['tool.execute.after']({ tool: 'skill', sessionID: 'ses_v2', input: { name: 'review' }, status: 'completed', result: { content: [{ type: 'text', text: 'loaded' }, { type: 'file', filename: 'skip' }, { type: 'text', text: 'next' }] } }); + await host.callbacks['tool.execute.after']({ tool: 'shell', sessionID: 'ses_v2', input: { command: 'false' }, status: 'completed', result: { content: 'failed', metadata: { exit: 1 } } }); + await host.callbacks['tool.execute.after']({ tool: 'shell', sessionID: 'ses_v2', input: {}, status: 'error', error: { message: 'denied' } }); + await host.callbacks['tool.execute.after']({ tool: 'subagent', sessionID: 'ses_v2', input: {}, status: 'completed', result: { content: 'child done', metadata: { sessionID: 'ses_child' } } }); + expect(host.dispatches.map((d) => d.args)).toEqual([ + ['hook-dispatch', 'prompt-submit', '--tool', 'opencode'], + ['hook-dispatch', 'post-tool-use', '--tool', 'opencode'], + ['hook-dispatch', 'post-tool-use', '--tool', 'opencode', '--matcher', 'Skill'], + ['hook-dispatch', 'post-tool-use', '--tool', 'opencode'], + ['hook-dispatch', 'post-tool-use', '--tool', 'opencode'], + ['hook-dispatch', 'post-tool-use', '--tool', 'opencode'], + ]); + expect(host.dispatches[0].payload).toEqual({ cwd: '/work/proj', session_id: 'ses_v2', prompt: '/retry now' }); + expect(host.dispatches[1].payload).toMatchObject({ tool_name: 'Skill', tool_input: { name: 'review' }, tool_response: 'loaded\nnext', tool_status: 'success' }); + expect(host.dispatches[3].payload).toMatchObject({ tool_name: 'bash', tool_response: 'failed', tool_status: 'failure' }); + expect(host.dispatches[4].payload).toMatchObject({ tool_response: 'denied', tool_status: 'failure' }); + expect(host.dispatches[5].payload).toMatchObject({ tool_name: 'task', session_link: { child: 'ses_child', parent: 'ses_v2' } }); + await host.cleanup?.(); + expect(host.disposed.sort()).toEqual(['session.prompt', 'tool.execute.after']); + }); +}); + +describe('enterprise dual-host dispatch', () => { + it.each(['session.created', 'session.idle', 'chat.message', 'tool.execute.after'])('runs %s once through each matching entrypoint', async (event) => { + const source = buildAgentHookPluginSource('enterprise', event, 'echo enterprise'); + for (const version of ['V1', 'V2']) { + const spawn = vi.fn(() => { + const child = new EventEmitter(); + void Promise.resolve().then(() => child.emit('close', 1)); + return child; + }); + const globals = { __childProcess: { spawn } }; + if (version === 'V1') { + const plugin = loadPluginDefinition(source, globals); + expect(plugin.id).toBe('teamai.agent.enterprise'); + const hooks = await plugin.server({ directory: '/work/proj' }); + if (event.startsWith('session.')) await hooks.event({ event: { type: event } }); + else await hooks[event]({ tool: 'bash' }); + } else { + const host = await loadV2Plugin(source, globals); + if (event.startsWith('session.')) await host.emit(event, { sessionID: 'ses_v2' }); + else if (event === 'chat.message') await host.callbacks['session.prompt']({ sessionID: 'ses_v2', prompt: { text: 'hello' } }); + else await host.callbacks['tool.execute.after']({ tool: 'shell', status: 'completed', input: {}, result: {} }); + await host.cleanup?.(); + } + expect(spawn).toHaveBeenCalledExactlyOnceWith('sh', ['-c', 'echo enterprise'], expect.objectContaining({ cwd: '/work/proj', stdio: 'ignore' })); + } + }); + + it.each([undefined, '*', 'BaSh'])('matches V2 tools with matcher %s', async (matcher) => { + const spawn = vi.fn(() => { + const child = new EventEmitter(); + void Promise.resolve().then(() => child.emit('close', 0)); + return child; + }); + const host = await loadV2Plugin(buildAgentHookPluginSource('matcher', 'tool.execute.after', 'echo matched', matcher), { __childProcess: { spawn } }); + await host.callbacks['tool.execute.after']({ tool: 'shell', status: 'completed', result: {} }); + await host.callbacks['tool.execute.after']({ tool: 'skill', status: 'completed', result: {} }); + expect(spawn).toHaveBeenCalledTimes(matcher === 'BaSh' ? 1 : 2); + await host.cleanup?.(); + }); + + it('replaces an enterprise hook in place, then removes it', async () => { + const tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-oc-update-')); + try { + const def = { slug: 'update', event: 'SessionStart', baseDir: tmp, scope: 'user' as const }; + await applyOpencodeAgentHook({ ...def, command: 'echo old' }); + await applyOpencodeAgentHook({ ...def, command: 'echo new' }); + const dir = resolveOpencodePluginDir(tmp, 'user'); + expect(await fse.readdir(dir)).toEqual(['teamai-agent-update.ts']); + expect(await fse.readFile(path.join(dir, 'teamai-agent-update.ts'), 'utf8')).not.toContain('echo old'); + await removeOpencodeAgentHook(def); + expect(await fse.readdir(dir)).toEqual([]); + } finally { await fse.remove(tmp); } + }); +}); + + +describe('OpenCode subprocess failures', () => { + it.each(['builtin', 'enterprise'])('keeps %s spawn errors non-fatal in V2', async (kind) => { + const children: EventEmitter[] = []; + const spawn = () => { + const child = new EventEmitter() as EventEmitter & { stdin: { write: () => void; end: () => void } }; + child.stdin = { write: () => {}, end: () => {} }; + children.push(child); + void Promise.resolve().then(() => child.emit('error', new Error('missing executable'))); + return child; + }; + const source = kind === 'builtin' ? buildPluginSource() : buildAgentHookPluginSource('missing', 'chat.message', 'missing-command'); + const host = await loadV2Plugin(source, { __childProcess: { spawn } }); + await expect(host.callbacks['session.prompt']({ sessionID: 'ses_v2', prompt: { text: 'hello' } })).resolves.toBeUndefined(); + expect(children[0].listenerCount('error')).toBe(0); + expect(children[0].listenerCount('close')).toBe(0); + await host.cleanup?.(); + }); +}); diff --git a/src/__tests__/session-id.test.ts b/src/__tests__/session-id.test.ts index f5a2cfc2c..f1a8d8025 100644 --- a/src/__tests__/session-id.test.ts +++ b/src/__tests__/session-id.test.ts @@ -102,6 +102,7 @@ describe('agentSessionIdFromEnv', () => { 'COPILOT_AGENT_SESSION_ID', 'CURSOR_CONVERSATION_ID', 'CLAUDE_SESSION_ID', + 'OPENCODE_SESSION_ID', 'TEAMAI_AGENT_SESSION_ID', 'PI_SESSION_ID', ]); @@ -137,6 +138,11 @@ describe('agentSessionIdFromEnv', () => { // The OpenCode plugin sets TEAMAI_AGENT_SESSION_ID in its bash tool's // environment, and its hooks carry that session (#884). + it('reads the native V2 OpenCode shell session', async () => { + vi.stubEnv('OPENCODE_SESSION_ID', 'ses_v2'); + expect(await agentSessionFromEnv()).toEqual({ id: 'ses_v2', agent: 'opencode', unambiguous: true }); + }); + it('reads the OpenCode session from TEAMAI_AGENT_SESSION_ID in an OpenCode shell', async () => { vi.stubEnv('OPENCODE', '1'); vi.stubEnv('TEAMAI_AGENT_SESSION_ID', 'ses_opencode'); diff --git a/src/opencode-hooks.ts b/src/opencode-hooks.ts index 44d53426e..c8b9e231a 100644 --- a/src/opencode-hooks.ts +++ b/src/opencode-hooks.ts @@ -4,8 +4,8 @@ * Unlike every other agent teamai targets, OpenCode has no settings.json-style * shell-command hook list. Instead it auto-loads JS/TS *plugins* — the config * loader scans `{plugin,plugins}/*.{ts,js}` under each `.opencode` dir (project - * scope) and `~/.config/opencode` (user scope). A plugin is a module exporting a - * function that returns a `Hooks` object; teamai subscribes to OpenCode's own + * scope) and `~/.config/opencode` (user scope). A plugin default-exports a + * definition with `server` (V1) and `setup` (V2); teamai subscribes to OpenCode's own * events and shells out to the same `teamai hook-dispatch` entry point every * other agent uses. * @@ -70,7 +70,8 @@ export function resolveOpencodePluginDir(baseDir: string, scope: 'project' | 'us * `session_id`, and PostToolUse the tool's output (`tool_response`), a * normalized `tool_status`, and for a `task` call the `session_link` from * the subagent's child session to its parent. `shell.env` names the session - * in the bash tool's environment (`TEAMAI_AGENT_SESSION_ID`). + * in V1's bash environment (`TEAMAI_AGENT_SESSION_ID`); V2's shell supplies + * its native `OPENCODE_SESSION_ID`, read by the same session resolver. * - Tool-id casing: OpenCode passes lowercase tool ids (`skill`, `todowrite`), * but the handler registry keys matchers on Claude's PascalCase names * (`Skill`, `TodoWrite`). We map the id back before dispatching a @@ -97,7 +98,7 @@ const TOOL_MATCHER = { skill: 'Skill', todowrite: 'TodoWrite' }; const nonEmpty = (value) => (typeof value === 'string' && value ? value : undefined); /** @param {{ directory?: string, worktree?: string }} ctx */ -export const TeamaiHooks = async ({ directory, worktree }) => { +const TeamaiHooks = async ({ directory, worktree }) => { const cwd = directory || worktree; // Dispatch one hook event, forwarding a JSON payload on STDIN. \`payload\` // fields (cwd / tool_name / tool_input / prompt) match what hook-dispatch's @@ -172,7 +173,7 @@ export const TeamaiHooks = async ({ directory, worktree }) => { tool_name: matcher || tool, tool_input: (input && input.args) || {}, tool_response: typeof (output && output.output) === 'string' ? output.output : undefined, - tool_status: typeof exit === 'number' ? (exit === 0 ? 'success' : 'failure') : 'unknown', + tool_status: typeof exit === 'number' ? (exit === 0 ? 'success' : 'failure') : (output && output.status) || 'unknown', }; const child = tool === 'task' ? nonEmpty(metadata.sessionId) : undefined; const parent = nonEmpty(metadata.parentSessionId) || payload.session_id; @@ -194,9 +195,64 @@ export const TeamaiHooks = async ({ directory, worktree }) => { }, }; }; + +${buildDualPluginDefinition('teamai.hooks', 'TeamaiHooks')} + `; } +/** Both hosts load one definition, but call only their own entrypoint. */ +function buildDualPluginDefinition(id: string, factory: string): string { + return `export default { + id: ${JSON.stringify(id)}, + server: ${factory}, + async setup(ctx) { + const hooks = await ${factory}({ directory: ctx.location.directory }); + const registrations = []; + if (hooks['chat.message']) { + registrations.push(await ctx.session.hook('prompt', async (event) => { + await hooks['chat.message']({ sessionID: event.sessionID }, { parts: [{ type: 'text', text: event.prompt.text }] }); + })); + } + if (hooks['tool.execute.after']) { + registrations.push(await ctx.tool.hook('execute.after', async (event) => { + const result = event.status === 'completed' ? event.result : event.error; + const content = result && result.content; + const output = event.status === 'error' ? result.message : typeof content === 'string' ? content : + Array.isArray(content) ? content.filter((part) => part.type === 'text').map((part) => part.text).join('\\n') : undefined; + const metadata = (result && result.metadata) || {}; + await hooks['tool.execute.after']({ + tool: event.tool === 'shell' ? 'bash' : event.tool === 'subagent' ? 'task' : event.tool, + sessionID: event.sessionID, + args: event.input, + }, { + output, + status: event.status === 'error' ? 'failure' : 'success', + metadata: { ...metadata, sessionId: metadata.sessionID }, + }); + })); + } + const controller = new AbortController(); + const events = hooks.event ? (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + const location = event.location || (event.data && event.data.location); + if (!location || location.directory !== ctx.location.directory) continue; + await hooks.event({ event: { type: event.type, properties: event.data } }); + } + } catch (error) { + if (!controller.signal.aborted) console.error('[teamai] OpenCode event subscription failed', error); + } + })() : Promise.resolve(); + return async () => { + controller.abort(); + await Promise.all(registrations.map((registration) => registration.dispose())); + await events; + }; + }, +};`; +} + /** * Inject (or refresh) the teamai OpenCode plugin for a scope. * Idempotent — writes and reports the plugin file only when its content changes. @@ -284,20 +340,34 @@ export function buildAgentHookPluginSource( }, };`; } + const factory = `TeamaiAgentHook_${slug.replace(/[^A-Za-z0-9_]/g, '_')}`; return `// ${TEAMAI_MARKER} agent hook [${slug}] — generated by teamai, do not edit by hand. -/** @param {{ $: any }} ctx */ -export const TeamaiAgentHook_${slug.replace(/[^A-Za-z0-9_]/g, '_')} = async ({ $ }) => { +const ${factory} = async ({ directory, worktree }) => { const run = async () => { try { - // .quiet() suppresses output; .nothrow() keeps a non-zero exit from - // throwing into the agent session. Fire-and-forget — never blocks. - await $\`sh -c \${${JSON.stringify(command)}}\`.quiet().nothrow(); + const { spawn } = await import('node:child_process'); + await new Promise((resolve) => { + const child = spawn('sh', ['-c', ${JSON.stringify(command)}], { + cwd: directory || worktree, + windowsHide: true, + stdio: 'ignore', + }); + const finish = () => { + child.removeListener('error', finish); + child.removeListener('close', finish); + resolve(); + }; + child.once('error', finish); + child.once('close', finish); + }); } catch { // never block the agent } }; ${body} }; + +${buildDualPluginDefinition(`teamai.agent.${slug}`, factory)} `; } diff --git a/src/utils/session-id.ts b/src/utils/session-id.ts index 00b474fc1..05d797c61 100644 --- a/src/utils/session-id.ts +++ b/src/utils/session-id.ts @@ -15,9 +15,9 @@ import { COPILOT_TOOL_ID } from '../types.js'; // Codex: CODEX_SESSION_ID (>= 0.148) is the root session, the id its hooks // get; CODEX_THREAD_ID is a subagent's own id. CodeBuddy also sets // CLAUDE_SESSION_ID as an alias, so its own variable comes first. OpenCode -// exports none of its own: TeamAI's plugin sets TEAMAI_AGENT_SESSION_ID in its -// bash tool (shell.env), the session its hooks carry. Pi's bash tool sets -// PI_SESSION_ID, the session TeamAI's Pi extension sends. OMP sets none, so a +// V2 sets OPENCODE_SESSION_ID itself. In V1 TeamAI's plugin sets +// TEAMAI_AGENT_SESSION_ID in its bash tool (shell.env). Both match its hooks. +// Pi's bash tool sets PI_SESSION_ID, the session TeamAI's Pi extension sends. OMP sets none, so a // recall from its shell settles only through its tool result's claim. export const AGENT_SESSION_ENV = [ 'CLAUDE_CODE_SESSION_ID', // Claude Code @@ -26,7 +26,8 @@ export const AGENT_SESSION_ENV = [ 'COPILOT_AGENT_SESSION_ID', // Copilot CLI >= 1.0.29 'CURSOR_CONVERSATION_ID', // Cursor: its hooks' conversation_id 'CLAUDE_SESSION_ID', // CodeBuddy's alias and older setups - 'TEAMAI_AGENT_SESSION_ID', // OpenCode, through TeamAI's plugin + 'OPENCODE_SESSION_ID', // OpenCode V2 + 'TEAMAI_AGENT_SESSION_ID', // OpenCode V1, through TeamAI's plugin 'PI_SESSION_ID', // Pi ] as const; @@ -59,6 +60,7 @@ const AGENT_FAMILY: Record = { COPILOT_AGENT_SESSION_ID: 'copilot', CURSOR_CONVERSATION_ID: 'cursor', CLAUDE_SESSION_ID: 'claude', + OPENCODE_SESSION_ID: 'opencode', TEAMAI_AGENT_SESSION_ID: 'opencode', PI_SESSION_ID: 'pi', };