Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ns>/*.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 `<project>/.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-<slug>.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 `<project>/.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-<slug>.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
Expand Down
2 changes: 2 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ns>/*.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/` 和 `<project>/.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-<slug>.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
Expand Down
8 changes: 8 additions & 0 deletions skill-data/core/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
96 changes: 96 additions & 0 deletions src/__tests__/e2e/opencode-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<void>((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<typeof spawn> | 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<string, unknown>;
return (response.data ?? response) as Record<string, unknown>;
}
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<Record<string, unknown>>;
};
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<string, unknown> }) : [];
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 });
}
});
});
Loading
Loading