feat(omp): run lifecycle hooks via the OMP extension runner (#645)

OMP has no settings.json hook list — it auto-loads TS extensions from
~/.omp/agent/extensions. Add the omp-hooks.ts adapter (mirroring the
OpenCode plugin): a single generated extension forwarding OMP's
session_start / session_stop / before_agent_start / tool_result events
to `teamai hook-dispatch --tool omp`, gated on the session cwd, never
returning session_stop continuation fields, and with no matcher pass
(OMP tool ids are lowercase; it has no Skill / TodoWrite tool). Wire it
into the inject and reconcile loops, discover + remove it on uninstall,
flip the README hooks column to ✓, and document the hook-capable list
and the OMP section. Part of #550; stacked on #643.
This commit is contained in:
pablo
2026-09-18 23:54:50 +08:00
committed by GitHub
parent 97a02777ac
commit 3d5f490daf
12 changed files with 427 additions and 17 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ teamai init https://github.com/yourorg/yourrepo --scope user
<tr><td>Qoder</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Kiro</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>ZCode</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
</tbody>
</table>
+1 -1
View File
@@ -133,7 +133,7 @@ teamai init https://github.com/yourorg/yourrepo --scope user
<tr><td>Qoder</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Kiro</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>ZCode</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
</tbody>
</table>
+1 -1
View File
@@ -133,7 +133,7 @@ Once initialized, every AI session automatically pulls the latest skills / rules
<tr><td>Qoder</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Kiro</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>ZCode</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
</tbody>
</table>
+1 -1
View File
@@ -133,7 +133,7 @@ teamai init https://github.com/yourorg/yourrepo --scope user
<tr><td>Qoder</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Kiro</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>ZCode</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
</tbody>
</table>
+1 -1
View File
@@ -133,7 +133,7 @@ teamai init https://github.com/yourorg/yourrepo --scope user
<tr><td>Qoder</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Kiro</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>ZCode</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
<tr><td>Oh My Pi</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">✓</td><td align="center">—</td><td align="center">—</td><td align="center">—</td></tr>
</tbody>
</table>
+2 -2
View File
@@ -461,7 +461,7 @@ teamai skill show hai-deploy-test # View a single skill's source / contributor
`teamai init` already injected Hooks into your AI tools. **`teamai pull` runs automatically every time you start an AI session** — no manual action needed. In project scope, that SessionStart hook first creates the current agent's project root (e.g. `<project>/.claude` when Claude Code opens the repo) if it is missing, then pulls.
*(Note: Automatic sync on session start requires an agent that supports lifecycle hooks, such as [CC], Codex, GitHub Copilot CLI, Cursor, CodeBuddy, WorkBuddy, Qoder, Kiro, OpenCode, Hermes, or OpenClaw. Kiro runs the hook when a TeamAI-rendered custom agent is activated in an interactive CLI session; its in-memory built-in default agent is not writable, and non-interactive mode does not fire `agentSpawn`. For tools without a teamai-writable hooks surface such as JoyCode or Gemini CLI, run `teamai pull` manually.)*
*(Note: Automatic sync on session start requires an agent that supports lifecycle hooks, such as [CC], Codex, GitHub Copilot CLI, Cursor, CodeBuddy, WorkBuddy, Qoder, Kiro, OpenCode, Oh My Pi, Hermes, or OpenClaw. Kiro runs the hook when a TeamAI-rendered custom agent is activated in an interactive CLI session; its in-memory built-in default agent is not writable, and non-interactive mode does not fire `agentSpawn`. For tools without a teamai-writable hooks surface such as JoyCode or Gemini CLI, run `teamai pull` manually.)*
If you need to sync immediately, you can run it manually:
@@ -1476,7 +1476,7 @@ These paths are verified against the ZCode desktop app: profiles created in its
### Oh My Pi
Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Instructions (`claudemd`) deploy to the matching `AGENTS.md`, and MCP servers merge into `~/.omp/agent/mcp.json` / `<project>/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `<name>/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks are not wired yet — OMP runs lifecycle hooks as in-process TypeScript extensions rather than a settings hook list — so run `teamai pull` manually until a hook adapter ships. OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported; the default `~/.omp/agent/` layout is used.
Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Instructions (`claudemd`) deploy to the matching `AGENTS.md`, and MCP servers merge into `~/.omp/agent/mcp.json` / `<project>/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `<name>/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy — OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, …) and it has no `Skill` / `TodoWrite` tool. `teamai uninstall` removes the extension. OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported; the default `~/.omp/agent/` layout is used.
### JoyCode
+2 -2
View File
@@ -439,7 +439,7 @@ teamai skill show hai-deploy-test # 看单个 skill 的来源 / 贡献者 /
`teamai init` 时已注入 Hooks 到你的 AI 工具中。**每次启动 AI 会话时会自动执行 `teamai pull`**,无需手动操作。在 project scope 下,该 SessionStart hook 会先为当前 Agent 创建项目根目录(例如用 Claude Code 打开仓库时创建 `<project>/.claude`),然后再 pull。
*(注:会话启动自动同步依赖工具的生命周期 Hooks 支持,如 [CC]、Codex、GitHub Copilot CLI、Cursor、CodeBuddy、WorkBuddy、Qoder、Kiro、OpenCode、Hermes、OpenClaw 等。Kiro 仅在交互式 CLI 会话激活由 TeamAI 渲染的自定义 agent 时触发该 Hook;其内存中的内置默认 agent 无法写入,非交互模式也不会触发 `agentSpawn`。对于暂无 teamai 可写入 Hooks 的工具(如 JoyCode、Gemini CLI 等),需手动执行 `teamai pull`。)*
*(注:会话启动自动同步依赖工具的生命周期 Hooks 支持,如 [CC]、Codex、GitHub Copilot CLI、Cursor、CodeBuddy、WorkBuddy、Qoder、Kiro、OpenCode、Oh My Pi、Hermes、OpenClaw 等。Kiro 仅在交互式 CLI 会话激活由 TeamAI 渲染的自定义 agent 时触发该 Hook;其内存中的内置默认 agent 无法写入,非交互模式也不会触发 `agentSpawn`。对于暂无 teamai 可写入 Hooks 的工具(如 JoyCode、Gemini CLI 等),需手动执行 `teamai pull`。)*
如果需要立即同步,可以手动执行:
@@ -1436,7 +1436,7 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode
### Oh My Pi
Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。指令(`claudemd`)下发到对应的 `AGENTS.md`;MCP Server 合并进 `~/.omp/agent/mcp.json` / `<project>/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `<name>/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 暂未接入——OMP 的生命周期钩子是进程内 TypeScript extension 而非 settings 钩子列表——在 hook 适配器落地前请手动执行 `teamai pull`。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。
Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。指令(`claudemd`)下发到对应的 `AGENTS.md`;MCP Server 合并进 `~/.omp/agent/mcp.json` / `<project>/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `<name>/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 走 OMP 的 extension runner:`teamai pull` 会生成唯一的 extension 写入 `~/.omp/agent/extensions/teamai-hooks.ts`(绝不写项目副本——OMP 会同时加载两个根并导致每个事件双派发),它把 OMP 的 `session_start` / `session_stop` / `before_agent_start` / `tool_result` 事件转发给所有 agent 共用的 `teamai hook-dispatch` 入口,并按会话 `cwd` 做项目门控。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。`teamai uninstall` 会移除该 extension。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。
### JoyCode
+181
View File
@@ -0,0 +1,181 @@
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 fse from 'fs-extra';
/** Assert a generated ESM extension body parses as valid JS (strip the bun
* import — vm.Script is not an ESM context — and `export default`). */
function assertValidJs(src: string): void {
const body = src.replace(/^import .*;$/gm, '').replace(/export default /g, 'const __x = ');
expect(() => new vm.Script(body)).not.toThrow();
}
vi.mock('../utils/logger.js', () => ({
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() },
}));
import {
resolveOmpExtensionsDir,
injectOmpHooks,
removeOmpHooks,
buildOmpExtensionSource,
OMP_HOOK_FILE,
} from '../omp-hooks.js';
import { reconcileHooksToAllTools } from '../hooks.js';
describe('resolveOmpExtensionsDir', () => {
it('always targets the user agent dir (single-copy policy)', () => {
expect(resolveOmpExtensionsDir()).toBe(
path.join(os.homedir(), '.omp', 'agent', 'extensions'),
);
});
});
describe('buildOmpExtensionSource', () => {
const src = buildOmpExtensionSource();
it('maps the four Claude built-in events to OMP events and teamai dispatch', () => {
expect(src).toContain('pi.on("session_start"');
expect(src).toContain('dispatch("session-start"');
expect(src).toContain('pi.on("session_stop"');
expect(src).toContain('dispatch("stop"');
expect(src).toContain('pi.on("before_agent_start"');
expect(src).toContain('dispatch("prompt-submit"');
expect(src).toContain('pi.on("tool_result"');
expect(src).toContain('dispatch("post-tool-use"');
});
it('shells out to teamai hook-dispatch --tool omp, swallowing errors', () => {
expect(src).toContain('"hook-dispatch"');
expect(src).toContain('"--tool", "omp"');
expect(src).toContain('.quiet().nothrow()');
expect(src).toContain('} catch {');
});
it('forwards a STDIN payload (cwd + per-event fields) via a Response', () => {
// cwd comes from the extension ctx, fed on STDIN so the provider-config
// gate and track/hint handlers work.
expect(src).toContain('JSON.stringify({ cwd');
expect(src).toContain('new Response(stdin)');
expect(src).toContain('ctx.cwd');
expect(src).toContain('event.prompt');
expect(src).toContain('tool_name');
expect(src).toContain('tool_input');
});
it('returns nothing from session_stop (never forces a continuation)', () => {
// SessionStopEventResult's `continue` / `decision: "block"` fields would
// change OMP's own stop semantics — the handler must stay side-effect only.
expect(src).not.toContain('continue:');
expect(src).not.toContain('decision:');
expect(src).toContain('session_stop');
});
it('runs no matcher-scoped pass (no PascalCase tool mapping)', () => {
// OMP tool ids are lowercase (bash / read / …) and it has no Skill /
// TodoWrite tool; the TodoWrite hint's STDOUT has no way back into OMP.
expect(src).not.toContain('--matcher');
expect(src).not.toContain("'Skill'");
expect(src).not.toContain("'TodoWrite'");
});
it('carries the [teamai] marker for doctor / uninstall recognition', () => {
expect(src).toContain('// [teamai] hooks extension');
});
it('is syntactically valid JavaScript', () => {
assertValidJs(src);
});
});
describe('injectOmpHooks / removeOmpHooks', () => {
let tmp: string;
let prevHome: string | undefined;
beforeEach(async () => {
tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-hooks-'));
prevHome = process.env.HOME;
process.env.HOME = tmp;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
await fse.remove(tmp);
});
const extFile = () => path.join(tmp, '.omp', 'agent', 'extensions', OMP_HOOK_FILE);
it('writes ~/.omp/agent/extensions/teamai-hooks.ts with the marker', async () => {
await injectOmpHooks();
expect(await fse.pathExists(extFile())).toBe(true);
expect(await fse.readFile(extFile(), 'utf8')).toContain('[teamai] hooks extension');
});
it('is idempotent — re-inject produces identical bytes', async () => {
await injectOmpHooks();
const first = await fse.readFile(extFile(), 'utf8');
await injectOmpHooks();
expect(await fse.readFile(extFile(), 'utf8')).toBe(first);
});
it('remove deletes the extension file; safe when absent', async () => {
await removeOmpHooks(); // no-op, no throw
await injectOmpHooks();
expect(await fse.pathExists(extFile())).toBe(true);
await removeOmpHooks();
expect(await fse.pathExists(extFile())).toBe(false);
});
});
describe('reconcileHooksToAllTools routes omp to the extension adapter', () => {
let tmp: string;
let home: string;
let projectRoot: string;
let prevHome: string | undefined;
beforeEach(async () => {
tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-recon-'));
home = path.join(tmp, 'home');
projectRoot = path.join(tmp, 'project');
await fse.ensureDir(home);
await fse.ensureDir(projectRoot);
prevHome = process.env.HOME;
process.env.HOME = home;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
await fse.remove(tmp);
});
const toolPaths = { omp: { skills: '.omp/skills' } } as Record<string, { settings?: string }>;
const manifest = () => path.join(tmp, 'managed-hooks.json');
const extFile = () => path.join(home, '.omp', 'agent', 'extensions', OMP_HOOK_FILE);
it('does nothing when OMP is not installed (no ~/.omp)', async () => {
await reconcileHooksToAllTools(toolPaths, projectRoot, [], manifest());
expect(await fse.pathExists(path.join(home, '.omp'))).toBe(false);
});
it('injects the single user-root extension when ~/.omp exists', async () => {
await fse.ensureDir(path.join(home, '.omp', 'agent'));
await reconcileHooksToAllTools(toolPaths, home, [], manifest());
expect(await fse.pathExists(extFile())).toBe(true);
});
it('injects into the user root even when reconciling a project-scope base dir', async () => {
// OMP would load a project copy (~/.omp aside, <project>/.omp/extensions)
// alongside the user one and dispatch every event twice — the adapter must
// keep exactly one copy, in HOME.
await fse.ensureDir(path.join(home, '.omp', 'agent'));
await reconcileHooksToAllTools(toolPaths, projectRoot, [], manifest());
expect(await fse.pathExists(extFile())).toBe(true);
expect(await fse.pathExists(path.join(projectRoot, '.omp', 'extensions', OMP_HOOK_FILE))).toBe(false);
});
it('removeAll deletes the extension', async () => {
await fse.ensureDir(path.join(home, '.omp', 'agent'));
await reconcileHooksToAllTools(toolPaths, home, [], manifest());
expect(await fse.pathExists(extFile())).toBe(true);
await reconcileHooksToAllTools(toolPaths, home, [], manifest(), { removeAll: true });
expect(await fse.pathExists(extFile())).toBe(false);
});
it('settingsOnly skips the omp adapter', async () => {
await fse.ensureDir(path.join(home, '.omp', 'agent'));
await reconcileHooksToAllTools(toolPaths, home, [], manifest(), { settingsOnly: true });
expect(await fse.pathExists(extFile())).toBe(false);
});
});
+45 -5
View File
@@ -1233,6 +1233,29 @@ async function reconcileOpencodePlugin(baseDir: string, removeAll = false, insta
}
}
/**
* Reconcile the single teamai OMP extension.
*
* OMP auto-loads extensions from BOTH ~/.omp/agent/extensions (user) and
* <cwd>/.omp/extensions (project), and dedups by absolute path — two copies
* of the teamai file would dispatch every event twice. teamai therefore
* writes exactly one copy, in the user agent dir, matching the OpenCode
* plugin policy and the settings.json hooks of every other tool (which also
* live in HOME and gate on the `cwd` fed to hook-dispatch). Install only when
* ~/.omp exists, so a machine without OMP never grows a config dir.
*/
async function reconcileOmpExtension(removeAll = false): Promise<void> {
const home = getUserHome();
const { injectOmpHooks, removeOmpHooks } = await import('./omp-hooks.js');
if (removeAll) {
await removeOmpHooks();
return;
}
if (await pathExists(path.join(home, '.omp'))) {
await injectOmpHooks();
}
}
/**
* Inject teamai built-in hooks into all AI tool settings.
* Only writes to tools whose root directory already exists on disk,
@@ -1275,6 +1298,12 @@ export async function injectHooksToAllTools(toolPaths: Record<string, { settings
} catch (e) {
log.warn(`Failed to inject OpenCode hook into ${tool}: ${(e as Error).message}`);
}
} else if (tool === 'omp') {
try {
await reconcileOmpExtension();
} catch (e) {
log.warn(`Failed to inject OMP hook into ${tool}: ${(e as Error).message}`);
}
}
}
}
@@ -1285,11 +1314,11 @@ export async function injectHooksToAllTools(toolPaths: Record<string, { settings
* injection path used by `teamai pull` / `init` / `hooks inject`.
*
* `settingsOnly` restricts the pass to tools reconciled through their settings
* file, skipping Hermes and OpenCode. Those two go through global adapters that
* ignore `baseDir` — `removeHermesHooks()` takes none, and the OpenCode
* adapter's removeAll branch always targets HOME — so a caller sweeping a
* secondary location (the legacy `<projectRoot>` copy) must opt out, or it
* deletes the hooks the primary pass just installed.
* file, skipping Hermes, OpenCode, and OMP. Those three go through global
* adapters that ignore `baseDir` — `removeHermesHooks()` takes none, and the
* OpenCode / OMP adapters' removeAll branches always target HOME — so a caller
* sweeping a secondary location (the legacy `<projectRoot>` copy) must opt out,
* or it deletes the hooks the primary pass just installed.
*/
export async function reconcileHooksToAllTools(
toolPaths: Record<string, { settings?: string }>,
@@ -1341,6 +1370,17 @@ export async function reconcileHooksToAllTools(
}
continue;
}
// OMP likewise has no settings hook list: it auto-loads TS extensions from
// the agent dir. Route it to the extension adapter.
if (tool === 'omp') {
if (opts.settingsOnly) continue;
try {
await reconcileOmpExtension(opts.removeAll);
} catch (e) {
log.warn(`Failed to reconcile OMP hooks: ${(e as Error).message}`);
}
continue;
}
if (!paths.settings) continue;
// Only reconcile hooks for tools the user actually has installed. Without
// this gate, `hooks inject`/`remove` would create root directories for
+155
View File
@@ -0,0 +1,155 @@
/**
* OMP (Oh My Pi) hook injection.
*
* OMP has no settings.json-style shell-command hook list. It auto-loads
* TypeScript *extensions* from the active agent dir — `~/.omp/agent/extensions`
* by default (`--hook` is an alias of `--extension`, and the legacy
* `hooks/pre|post` TS files feed the same runner). An extension default-exports
* a factory that receives the ExtensionAPI; teamai subscribes to OMP's own
* events and shells out to the same `teamai hook-dispatch` entry point every
* other agent uses.
*
* Event mapping (OMP → teamai dispatch), chosen to mirror the Claude built-in
* hook set (names verified against the OMP 18.2.5 ExtensionAPI):
* - `session_start` → session-start (report / sync / ack)
* - `session_stop` → stop (update / votes / contribute)
* - `before_agent_start` → prompt-submit (track-slash / dashboard; carries `prompt`,
* and fires for print/RPC sessions too)
* - `tool_result` → post-tool-use (dashboard; wildcard only)
*
* The generated extension runs each dispatch for its side effects and never
* blocks the agent: all shell errors are swallowed. `session_stop` IS awaited
* by OMP before the main session settles — the dispatch carries its own
* timeout, and the handler deliberately returns nothing: the `continue` /
* `decision: "block"` fields of SessionStopEventResult would force a session
* continuation and change OMP's own stop semantics.
*
* Only ONE copy is written, in the user agent dir. OMP also auto-loads project
* extensions from `<cwd>/.omp/extensions`, and its dedup is absolute-path
* based — two copies of this file would dispatch every event twice (the same
* single-copy policy as the OpenCode plugin). Project gating happens inside
* hook-dispatch, via the `cwd` forwarded on STDIN.
*/
import path from 'node:path';
import { writeFile, ensureDir, pathExists, remove } from './utils/fs.js';
import { getUserHome } from './utils/home.js';
import { log } from './utils/logger.js';
/** Filename of the teamai-managed OMP extension. */
export const OMP_HOOK_FILE = 'teamai-hooks.ts';
/** Marker so `teamai doctor` / `uninstall` can recognize our generated file. */
const TEAMAI_MARKER = '[teamai]';
/**
* Directory OMP auto-discovers user extensions in: the active agent dir's
* `extensions/`. Only the default `~/.omp/agent/` layout is supported —
* OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`)
* relocate the agent dir and are out of scope.
*/
export function resolveOmpExtensionsDir(): string {
return path.join(getUserHome(), '.omp', 'agent', 'extensions');
}
/**
* Build the teamai OMP extension source.
*
* The extension subscribes to OMP events and shells out to `teamai
* hook-dispatch <event> --tool omp`, feeding the same JSON payload on STDIN
* that every other agent's hooks send (`cwd`, `tool_name`, `tool_input`,
* `prompt`). It uses the Bun shell (`$`) — OMP runs extensions in-process
* under Bun — with `.quiet().nothrow()` so a missing `teamai` binary or a
* non-zero exit never surfaces as an error inside the agent session.
*
* OMP-specific bridges, mirroring the OpenCode plugin:
* - STDIN payload: hook-dispatch's track / track-slash handlers read
* `tool_name` / `tool_input` / `prompt` off STDIN, and the
* provider-config gate reads `cwd` to pick the project-scope config.
* The extension forwards `ctx.cwd` plus the per-event fields.
* - Tool naming: OMP passes lowercase tool ids (`bash`, `read`, …) and has
* no `Skill` / `TodoWrite` tool to map onto Claude's PascalCase matcher
* names, so there is no matcher-scoped pass — only the wildcard
* post-tool-use dispatch (the TodoWrite hint's STDOUT channel has no way
* back into an OMP session either).
*/
export function buildOmpExtensionSource(): string {
return `// ${TEAMAI_MARKER} hooks extension — generated by teamai, do not edit by hand.
//
// Bridges OMP extension events to \`teamai hook-dispatch\`, mirroring the
// Claude built-in hook set. Feeds the same STDIN JSON payload other agents
// send (cwd / tool_name / tool_input / prompt) so the track / hint handlers
// and project-scope gating work. Errors are swallowed; dispatches run for
// their side effects (status report / sync / update) and never block the
// agent.
//
// NOTE: OMP awaits session_stop before the session settles; this handler
// returns nothing on purpose — the \`continue\` / \`decision\` result fields
// would force a session continuation.
import { $ } from "bun";
/** @param {any} pi OMP ExtensionAPI */
export default function teamaiHooks(pi) {
// Dispatch one hook event, forwarding a JSON payload on STDIN. \`payload\`
// fields (cwd / tool_name / tool_input / prompt) match what
// hook-dispatch's handlers read. No matcher pass: OMP's tool ids are
// lowercase (bash / read / edit / write / ...) and it has no Skill /
// TodoWrite tool to map onto Claude's PascalCase matchers.
const dispatch = async (event, cwd, payload) => {
try {
const args = ["hook-dispatch", event, "--tool", "omp"];
const stdin = JSON.stringify({ cwd, ...(payload || {}) });
// Redirect the payload into STDIN via a Response (Bun shell can only
// redirect Response/Buffer/Blob, not a bare string). .quiet() suppresses
// output; .nothrow() keeps a non-zero exit (e.g. no teamai on PATH)
// from throwing into the agent session.
await $\`teamai \${args} < \${new Response(stdin)}\`.quiet().nothrow();
} catch {
// never block the agent
}
};
pi.on("session_start", async (_event, ctx) => {
await dispatch("session-start", ctx.cwd);
});
pi.on("session_stop", async (_event, ctx) => {
await dispatch("stop", ctx.cwd);
});
pi.on("before_agent_start", async (event, ctx) => {
await dispatch("prompt-submit", ctx.cwd, { prompt: event.prompt });
});
pi.on("tool_result", async (event, ctx) => {
await dispatch("post-tool-use", ctx.cwd, {
tool_name: event.toolName,
tool_input: event.input,
});
});
}
`;
}
/**
* Inject (or refresh) the teamai OMP extension. Idempotent — rewrites the
* extension file each time. The install gate (~/.omp must exist) lives in the
* reconcile caller, so this never creates an OMP config dir on its own.
*/
export async function injectOmpHooks(): Promise<void> {
const dir = resolveOmpExtensionsDir();
await ensureDir(dir);
const file = path.join(dir, OMP_HOOK_FILE);
await writeFile(file, buildOmpExtensionSource());
log.success(`Injected teamai OMP hook into ${file}`);
}
/** Remove the teamai OMP extension if present. */
export async function removeOmpHooks(): Promise<void> {
const file = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE);
if (await pathExists(file)) {
await remove(file);
log.success(`Removed teamai OMP hook from ${file}`);
}
}
+4 -2
View File
@@ -361,8 +361,10 @@ export const TeamaiConfigSchema = z.object({
// plain .md, instructions land in AGENTS.md, and MCP uses the Claude-shaped
// {"mcpServers": …} mcp.json. OMP runs lifecycle hooks as in-process TS
// extensions rather than a settings hook list, so there is no `settings`
// path. Profiles (OMP_PROFILE / PI_CODING_AGENT_DIR / PI_CONFIG_DIR) move
// the agent dir and are not supported.
// path — the adapter in omp-hooks.ts writes the single user-root extension
// (~/.omp/agent/extensions/teamai-hooks.ts). Profiles (OMP_PROFILE /
// PI_CODING_AGENT_DIR / PI_CONFIG_DIR) move the agent dir and are not
// supported.
omp: {
skills: '.omp/skills',
rules: '.omp/rules',
+33 -1
View File
@@ -68,6 +68,8 @@ interface RemovalPlan {
openclawHookDirs: Array<{ hooksDir: string; tool: string }>;
/** OpenCode teamai plugin files (.opencode/plugin/teamai-*.ts) to delete. */
opencodeHookScopes: Array<{ baseDir: string; scope: Scope }>;
/** teamai-managed OMP extension file (~/.omp/agent/extensions/teamai-hooks.ts), if present. */
ompHookFile: string | null;
/** CLAUDE.md files with teamai rules blocks. */
claudeMdFiles: string[];
/** Skill directories synced from team repo. */
@@ -99,6 +101,7 @@ interface ToolResources {
hookFiles: Array<{ path: string; tool: string; manifestPath: string }>;
openclawHookDirs: Array<{ hooksDir: string; tool: string }>;
opencodeHookScopes: Array<{ baseDir: string; scope: Scope }>;
ompHookFile: string | null;
claudeMdFiles: string[];
skillDirs: string[];
ruleFiles: string[];
@@ -110,6 +113,7 @@ function hasToolResources(r: ToolResources): boolean {
r.hookFiles.length > 0 ||
r.openclawHookDirs.length > 0 ||
r.opencodeHookScopes.length > 0 ||
r.ompHookFile !== null ||
r.claudeMdFiles.length > 0 ||
r.skillDirs.length > 0 ||
r.ruleFiles.length > 0 ||
@@ -235,7 +239,7 @@ async function discoverToolResources(
scope: Scope,
): Promise<ToolResources> {
const res: ToolResources = {
hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], claudeMdFiles: [],
hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, claudeMdFiles: [],
skillDirs: [], ruleFiles: [], agentFiles: [],
};
@@ -268,6 +272,15 @@ async function discoverToolResources(
}
}
}
} else if (tool === 'omp') {
// OMP hooks are a single teamai-managed TS extension in the user agent dir
// (~/.omp/agent/extensions/teamai-hooks.ts) — the adapter never writes a
// project copy, so there is just the one place to look.
const { resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js');
const extFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE);
if (await pathExists(extFile)) {
res.ompHookFile = extFile;
}
} else if (toolPath.settings) {
// Hooks live where resolveHookScope injected them (HOME for a non-self
// project scope, per #370) — plus any legacy <projectRoot> copy. Scan every
@@ -456,6 +469,7 @@ async function buildRemovalPlan(
hookFiles: [],
openclawHookDirs: [],
opencodeHookScopes: [],
ompHookFile: null,
claudeMdFiles: [],
skillDirs: [],
ruleFiles: [],
@@ -477,6 +491,7 @@ async function buildRemovalPlan(
plan.hookFiles.push(...res.hookFiles);
plan.openclawHookDirs.push(...res.openclawHookDirs);
plan.opencodeHookScopes.push(...res.opencodeHookScopes);
if (res.ompHookFile) plan.ompHookFile = res.ompHookFile;
plan.claudeMdFiles.push(...res.claudeMdFiles);
plan.skillDirs.push(...res.skillDirs);
plan.ruleFiles.push(...res.ruleFiles);
@@ -537,6 +552,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean {
plan.hookFiles.length === 0 &&
plan.openclawHookDirs.length === 0 &&
plan.opencodeHookScopes.length === 0 &&
plan.ompHookFile === null &&
plan.claudeMdFiles.length === 0 &&
plan.skillDirs.length === 0 &&
plan.ruleFiles.length === 0 &&
@@ -585,6 +601,12 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void {
console.log('');
}
if (plan.ompHookFile !== null) {
console.log(' OMP Hook (extension):');
console.log(` ${plan.ompHookFile}`);
console.log('');
}
if (plan.claudeMdFiles.length > 0) {
console.log(` CLAUDE.md rule blocks (${plan.claudeMdFiles.length} files):`);
for (const p of plan.claudeMdFiles) {
@@ -696,6 +718,16 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> {
}
}
// (a2c) Remove the teamai OMP extension (single user-agent-dir copy).
if (plan.ompHookFile !== null) {
try {
const { removeOmpHooks } = await import('./omp-hooks.js');
await removeOmpHooks();
} catch (e) {
log.warn(`Failed to remove OMP hook: ${(e as Error).message}`);
}
}
// (a3) Remove HTTP-source agent hooks across all formats via their manifest
// (issue #238). Dynamic import mirrors teardownPlugins — keeps local-agent's
// heavy dependency graph out of uninstall's static import chain. Best-effort.