feat(omp): plugin path with native hook enforcement (HookAPI tool_call/tool_result/session_start/session_before_compact)

Promotes OMP from MCP-only delivery to a proper plugin. `omp plugin
install context-mode` now wires programmatic enforcement equivalent to
Claude Code's PreToolUse/PostToolUse/PreCompact/SessionStart pipeline.

Verified end-to-end against the upstream OMP source cloned to
refs/platforms/oh-my-pi @ v3.20.1 (no LLM trust, every claim
file:line cited):

  - Manifest format: `omp` or `pi` field on root package.json
    Source: refs/.../extensibility/plugins/loader.ts:75
      `const manifest = pluginPkg.omp || pluginPkg.pi;`
    + line 82: `manifest.version = pluginPkg.version;` (loader stamps
      version from top-level pkg.version on load — explicit
      `omp.version` is belt-and-suspenders, kept synced by
      scripts/version-sync.mjs).

  - Install command: `omp plugin install <pkg>` runs
    `bun install <pkg>` inside ~/.omp/plugins per
    refs/.../extensibility/plugins/manager.ts:158, then reads
    `~/.omp/plugins/node_modules/<pkg>/package.json` for the manifest.

  - HookFactory contract: `(pi: HookAPI) => void` per
    refs/.../extensibility/hooks/types.ts:809.

  - Block return shape: `{ block?: boolean; reason?: string }` per
    refs/.../extensibility/hooks/types.ts:566.

  - Event payloads:
    - ToolCallEvent  (refs/.../hooks/types.ts:448): {toolName, toolCallId, input}
    - ToolResultEvent (refs/.../hooks/types.ts:461 onward): {toolName, toolCallId, input, content[], isError}

  - Example reference: refs/.../examples/hooks/permission-gate.ts.

What the plugin actually does:

  - tool_call: hard-blocks bash containing curl/wget/inline-fetch
    (`requests.get`, `http.get`, `Invoke-WebRequest`, etc.) — same
    pattern set as the Pi extension.
  - tool_result: feeds OMP-shaped events through the existing
    extractEvents pipeline → SessionDB at ~/.omp/context-mode/.
  - session_start: derives a stable 16-hex session id from
    sessionManager.getSessionFile() (or wall-clock fallback), runs
    7-day cleanup.
  - session_before_compact: persists a buildResumeSnapshot output via
    upsertResume + increments compact_count for resume-on-restart.

Reference parity:

  - Mirrors src/adapters/pi/extension.ts shape closely. OMP differs in
    two ways that justify a dedicated file:
      1. Storage at ~/.omp/context-mode/ via OMPAdapter (not ~/.pi/)
      2. OMP has native MCP via mcp.json — the Pi extension's
         mcp-bridge.ts is dead weight under OMP and is intentionally
         omitted here.
  - Mirrors src/adapters/openclaw/plugin.ts integration shape (root
    package.json field → built JS entry).

Smoke test (run locally before commit):
  - pkg.omp.hooks resolves to build/adapters/omp/plugin.js ✓
  - default export is a function ✓
  - 4 handlers register: session_start, tool_call, tool_result,
    session_before_compact ✓
  - tool_call({toolName: 'bash', input: {command: 'curl ...'}}) →
    {block: true, reason: '...'} ✓

Tests: tests/adapters/omp-plugin.test.ts adds 17 cases across 4 TDD
slices (routing, extraction, session lifecycle, resume snapshot). All
green. Full vitest run: 2642 passed, 20 skipped, 0 failed.

scripts/version-sync.mjs now also stamps package.json:omp.version
when running on `npm version` lifecycle so OMP manifest version
never drifts from top-level pkg.version (verified by simulating a
stale 0.0.0 value and watching it correct to current).

README updated:

  - OMP install section reordered: plugin path is now primary, with
    upstream file:line citations for the loader and block contract;
    MCP-only path retained as the alternative.
  - Hook coverage table (lines ~1024-1031): OMP rows promoted from
    "--" to ✓ (via tool_call event), etc.
  - Platform compatibility table: OMP PreToolUse/PostToolUse/
    SessionStart/PreCompact/CanBlockTools all marked Plugin.
  - Routing-enforcement note: OMP moved from non-hook list to
    hook-capable list.
  - All "OMP MCP-only / no hook integration" prose paragraphs
    rewritten.
This commit is contained in:
Mert Koseoglu
2026-05-10 13:22:22 +03:00
parent a8a3e6c36a
commit 2ddae394c4
5 changed files with 625 additions and 25 deletions
+35 -25
View File
@@ -847,18 +847,29 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
</details>
<details>
<summary><strong>OMP (Oh My Pi)</strong> — MCP-only via <code>mcp.json</code></summary>
<summary><strong>OMP (Oh My Pi)</strong> — plugin with full hook support</summary>
**Prerequisites:** Node.js 18+, Oh My Pi installed.
**Install:**
**Install (recommended — plugin path):**
1. Install context-mode globally:
```bash
omp plugin install context-mode
```
```bash
npm install -g context-mode
```
What this does, verified against [`oh-my-pi/packages/coding-agent/src/extensibility/plugins/manager.ts:158`](https://github.com/can1357/oh-my-pi/blob/main/packages/coding-agent/src/extensibility/plugins/manager.ts):
1. OMP runs `bun install context-mode` inside `~/.omp/plugins/`
2. OMP reads `package.json` of the installed package and looks for an `omp` (or `pi`) field — see [`extensibility/plugins/loader.ts:75`](https://github.com/can1357/oh-my-pi/blob/main/packages/coding-agent/src/extensibility/plugins/loader.ts) — `const manifest = pluginPkg.omp || pluginPkg.pi;`
3. Our [`package.json`](package.json) declares `"omp": { "hooks": "./build/adapters/omp/plugin.js" }`
4. OMP imports the file, calls its default export with `HookAPI`
5. Four handlers register: `session_start`, `tool_call`, `tool_result`, `session_before_compact`
The `tool_call` handler returns `{ block: true, reason }` for `curl`/`wget`/inline-fetch in `bash` per [`hooks/types.ts:566`](https://github.com/can1357/oh-my-pi/blob/main/packages/coding-agent/src/extensibility/hooks/types.ts) (`ToolCallEventResult`). No `mcp.json` edits needed.
**Alternative — MCP-only path:**
1. `npm install -g context-mode`
2. Add to `~/.omp/agent/mcp.json` (user scope) or `<project>/.omp/mcp.json` (project scope):
```json
@@ -871,8 +882,6 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
}
```
`PI_CODING_AGENT_DIR` overrides the agent directory; the file name `mcp.json` is fixed.
3. Copy routing instructions:
```bash
@@ -883,11 +892,11 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
4. Restart OMP.
**Verify:** In an OMP session, type `ctx stats`. Context-mode tools should appear and respond.
**Verify:** In an OMP session, type `ctx stats`. Context-mode tools should appear and respond. The plugin install can also be checked with `omp plugin doctor` or `omp plugin list`.
**Routing:** Rule-based via `SYSTEM.md` (~60% compliance — context-mode delivers via MCP for OMP; native OMP `pre`/`post` hooks are not yet wired by this adapter). Auto-detected via `PI_CODING_AGENT_DIR` env var or presence of `~/.omp/`. Storage roots at `~/.omp/context-mode/` so OMP and Pi installs never share session DBs, content indices, or stats files.
**Routing:** Plugin path — programmatic enforcement via `pi.on("tool_call", ...)` returning `{ block: true, reason }` (~98% compliance, like Claude Code). MCP-only path — rule-based via `SYSTEM.md` (~60%). Auto-detected via `PI_CODING_AGENT_DIR` env var or presence of `~/.omp/`. Storage roots at `~/.omp/context-mode/` so OMP and Pi installs never share session DBs, content indices, or stats files.
Full configs: [`configs/omp/mcp.json`](configs/omp/mcp.json) | [`configs/omp/SYSTEM.md`](configs/omp/SYSTEM.md)
Full configs: [`configs/omp/mcp.json`](configs/omp/mcp.json) | [`configs/omp/SYSTEM.md`](configs/omp/SYSTEM.md) | plugin source: [`src/adapters/omp/plugin.ts`](src/adapters/omp/plugin.ts)
</details>
@@ -1014,14 +1023,14 @@ Session continuity requires 5 hooks working together:
| Hook | Role | Claude Code | Gemini CLI | VS Code Copilot | JetBrains Copilot | Cursor | OpenCode | KiloCode | OpenClaw | Codex CLI | Antigravity | Kiro | Zed | Pi | OMP |
|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| **PreToolUse** | Enforces sandbox routing before tool execution | Yes | -- | -- | -- | Yes | -- | -- | -- | Yes | -- | Yes | -- | ✓ (via tool_call event) | -- |
| **PostToolUse** | Captures events after each tool call | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | ✓ (via tool_result event) | -- |
| **PreToolUse** | Enforces sandbox routing before tool execution | Yes | -- | -- | -- | Yes | -- | -- | -- | Yes | -- | Yes | -- | ✓ (via tool_call event) | ✓ (via tool_call event) |
| **PostToolUse** | Captures events after each tool call | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | ✓ (via tool_result event) | ✓ (via tool_result event) |
| **UserPromptSubmit** | Captures user decisions and corrections | Yes | -- | -- | -- | -- | Plugin (via chat.message) | Plugin (via chat.message) | -- | Yes | -- | -- | -- | -- | -- |
| **PreCompact** | Builds snapshot before compaction | Yes | Yes | Yes | Yes | -- | Plugin | Plugin | Plugin | Yes | -- | -- | -- | ✓ (via session_before_compact) | -- |
| **SessionStart** | Restores state after compaction or resume | Yes | Yes | Yes | Yes | -- | ✓ (via experimental.chat.system.transform) | ✓ (via experimental.chat.system.transform) | Plugin | Yes | -- | -- | -- | ✓ (via session_start event) | -- |
| | **Session completeness** | **Full** | **High** | **High** | **High** | **Partial** | **Full** | **Full** | **High** | **Partial** | **--** | **Partial** | **--** | **High** | **--** |
| **PreCompact** | Builds snapshot before compaction | Yes | Yes | Yes | Yes | -- | Plugin | Plugin | Plugin | Yes | -- | -- | -- | ✓ (via session_before_compact) | ✓ (via session_before_compact) |
| **SessionStart** | Restores state after compaction or resume | Yes | Yes | Yes | Yes | -- | ✓ (via experimental.chat.system.transform) | ✓ (via experimental.chat.system.transform) | Plugin | Yes | -- | -- | -- | ✓ (via session_start event) | ✓ (via session_start event) |
| | **Session completeness** | **Full** | **High** | **High** | **High** | **Partial** | **Full** | **Full** | **High** | **Partial** | **--** | **Partial** | **--** | **High** | **High** |
> **Note:** Full session continuity (capture + snapshot + restore) works on **Claude Code**, **Gemini CLI**, **VS Code Copilot**, **JetBrains Copilot**, **OpenCode**, and **KiloCode**. **OpenCode** and **KiloCode** use `experimental.chat.system.transform` as a SessionStart surrogate to inject the routing block and restore prior sessions, plus `chat.message` for user-prompt capture; full SessionStart hook support is not yet available ([#14808](https://github.com/sst/opencode/issues/14808), [#5409](https://github.com/sst/opencode/issues/5409)), but prior-session continuity and user-decision capture work fully. **Cursor** captures tool events via `preToolUse`/`postToolUse`, but `sessionStart` is currently rejected by Cursor's validator ([forum report](https://forum.cursor.com/t/unknown-hook-type-sessionstart/149566)), so session restore after compaction is not available yet. **OpenClaw** uses native gateway plugin hooks (`api.on()`) for full session continuity. **Pi Coding Agent** provides high session continuity via extension hooks (`tool_call`, `tool_result`, `session_start`, `session_before_compact`). **Codex CLI** provides partial hook-based session tracking through PreToolUse, PostToolUse, PreCompact, SessionStart, UserPromptSubmit, and Stop; MCP tools work. **Antigravity**, **Kiro**, and **Zed** have no hook support in the current release, so session tracking is not available. **OMP** (Oh My Pi) is also MCP-only and has no hook support — its dedicated adapter exists to keep storage isolated under `~/.omp/context-mode/` rather than leaking into another platform's directory.
> **Note:** Full session continuity (capture + snapshot + restore) works on **Claude Code**, **Gemini CLI**, **VS Code Copilot**, **JetBrains Copilot**, **OpenCode**, and **KiloCode**. **OpenCode** and **KiloCode** use `experimental.chat.system.transform` as a SessionStart surrogate to inject the routing block and restore prior sessions, plus `chat.message` for user-prompt capture; full SessionStart hook support is not yet available ([#14808](https://github.com/sst/opencode/issues/14808), [#5409](https://github.com/sst/opencode/issues/5409)), but prior-session continuity and user-decision capture work fully. **Cursor** captures tool events via `preToolUse`/`postToolUse`, but `sessionStart` is currently rejected by Cursor's validator ([forum report](https://forum.cursor.com/t/unknown-hook-type-sessionstart/149566)), so session restore after compaction is not available yet. **OpenClaw** uses native gateway plugin hooks (`api.on()`) for full session continuity. **Pi Coding Agent** provides high session continuity via extension hooks (`tool_call`, `tool_result`, `session_start`, `session_before_compact`). **Codex CLI** provides partial hook-based session tracking through PreToolUse, PostToolUse, PreCompact, SessionStart, UserPromptSubmit, and Stop; MCP tools work. **Antigravity**, **Kiro**, and **Zed** have no hook support in the current release, so session tracking is not available. **OMP** (Oh My Pi) ships full plugin-based hook support — `omp plugin install context-mode` registers `tool_call`, `tool_result`, `session_start`, and `session_before_compact` handlers and storage roots cleanly under `~/.omp/context-mode/` so OMP and Pi installs never share state.
<details>
<summary><strong>What gets captured</strong></summary>
@@ -1130,7 +1139,7 @@ Detailed event data is also indexed into FTS5 for on-demand retrieval via `ctx_s
**Pi Coding Agent** — High coverage. The extension registers all key lifecycle events: `tool_call` (PreToolUse), `tool_result` (PostToolUse), `session_start` (SessionStart), and `session_before_compact` (PreCompact). File edits, git ops, errors, and tasks are fully tracked. Session restore after compaction works via the extension's event hooks.
**OMP (Oh My Pi)** — No session support today. context-mode delivers via MCP for OMP; OMP's native `pre`/`post` tool-call hook surface (`HookAPI`, `pi.on("tool_call", ...)`) is not yet wired by this adapter. The dedicated adapter exists so OMP storage roots cleanly under `~/.omp/context-mode/` instead of leaking into another platform's directory (issue [#473](https://github.com/mksglu/context-mode/issues/473)). Auto-detected via `PI_CODING_AGENT_DIR` env var or presence of `~/.omp/`.
**OMP (Oh My Pi)** — High coverage. The plugin (installed via `omp plugin install context-mode`) registers all key lifecycle events: `tool_call` (PreToolUse), `tool_result` (PostToolUse), `session_start` (SessionStart), and `session_before_compact` (PreCompact). Storage roots cleanly under `~/.omp/context-mode/` so OMP and Pi installs never share state (issue [#473](https://github.com/mksglu/context-mode/issues/473)). Auto-detected via `PI_CODING_AGENT_DIR` env var or presence of `~/.omp/`.
</details>
@@ -1139,12 +1148,12 @@ Detailed event data is also indexed into FTS5 for on-demand retrieval via `ctx_s
| Feature | Claude Code | Qwen Code | Gemini CLI | VS Code Copilot | JetBrains Copilot | Cursor | OpenCode | KiloCode | OpenClaw | Codex CLI | Antigravity | Kiro | Zed | Pi | OMP |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| MCP Server | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| PreToolUse Hook | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | Yes (extension) | -- |
| PostToolUse Hook | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | Yes (extension) | -- |
| SessionStart Hook | Yes | Yes | Yes | Yes | Yes | -- | ✓ (via experimental.chat.system.transform) | ✓ (via experimental.chat.system.transform) | Plugin | Yes | -- | -- | -- | Yes (extension) | -- |
| PreCompact Hook | Yes | Yes | Yes | Yes | Yes | -- | Plugin | Plugin | Plugin | Yes | -- | -- | -- | Yes (extension) | -- |
| PreToolUse Hook | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | Yes (extension) | Plugin |
| PostToolUse Hook | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | Yes (extension) | Plugin |
| SessionStart Hook | Yes | Yes | Yes | Yes | Yes | -- | ✓ (via experimental.chat.system.transform) | ✓ (via experimental.chat.system.transform) | Plugin | Yes | -- | -- | -- | Yes (extension) | Plugin |
| PreCompact Hook | Yes | Yes | Yes | Yes | Yes | -- | Plugin | Plugin | Plugin | Yes | -- | -- | -- | Yes (extension) | Plugin |
| Can Modify Args | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | -- | -- | -- | -- | Yes (extension) | -- |
| Can Block Tools | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | Yes (extension) | -- |
| Can Block Tools | Yes | Yes | Yes | Yes | Yes | Yes | Plugin | Plugin | Plugin | Yes | -- | Yes | -- | Yes (extension) | Plugin |
| Utility Commands (ctx) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes (/ctx-stats, /ctx-doctor) | Yes |
| Slash Commands | Yes | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- |
| Plugin Marketplace | Yes | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- |
@@ -1161,13 +1170,13 @@ Detailed event data is also indexed into FTS5 for on-demand retrieval via `ctx_s
>
> **Pi Coding Agent** runs context-mode as an extension with full hook support. The extension registers `tool_call`, `tool_result`, `session_start`, and `session_before_compact` events, providing high session continuity coverage. The MCP server provides all 11 MCP tools.
>
> **OMP (Oh My Pi)** is MCP-only — no hook integration. The dedicated adapter exists to keep storage isolated under `~/.omp/context-mode/` rather than leaking into another harness's directory. Auto-detected via `OMP_PROCESSING_AGENT_DIR` (default `~/.omp/agent`) or `~/.omp/` directory. See [issue #473](https://github.com/mksglu/context-mode/issues/473).
> **OMP (Oh My Pi)** runs context-mode as a plugin via `omp plugin install context-mode`. The plugin registers `tool_call`, `tool_result`, `session_start`, and `session_before_compact` events for hard-block routing and full session continuity. Storage isolated under `~/.omp/context-mode/` so OMP and Pi never share state. Auto-detected via `PI_CODING_AGENT_DIR` (default agent dir `~/.omp/agent`) or `~/.omp/` directory. See [issue #473](https://github.com/mksglu/context-mode/issues/473) for the storage-isolation history.
### Routing Enforcement
Hooks intercept tool calls programmatically — they can block dangerous commands and redirect them to the sandbox before execution. Instruction files guide the model via prompt instructions but cannot block anything. **Always enable hooks where supported.**
> **Note:** Routing instruction files were previously auto-written to project directories on first session start. This was disabled to prevent git tree pollution ([#158](https://github.com/mksglu/context-mode/issues/158), [#164](https://github.com/mksglu/context-mode/issues/164)). Hook-capable platforms (Claude Code, Gemini CLI, VS Code Copilot, JetBrains Copilot, Cursor, OpenCode, OpenClaw, Codex CLI) inject routing via hooks and need no file. Non-hook platforms (Zed, Kiro, Antigravity, OMP) require a one-time manual copy — see each platform's install section.
> **Note:** Routing instruction files were previously auto-written to project directories on first session start. This was disabled to prevent git tree pollution ([#158](https://github.com/mksglu/context-mode/issues/158), [#164](https://github.com/mksglu/context-mode/issues/164)). Hook-capable platforms (Claude Code, Gemini CLI, VS Code Copilot, JetBrains Copilot, Cursor, OpenCode, OpenClaw, Codex CLI, OMP via plugin) inject routing via hooks and need no file. Non-hook platforms (Zed, Kiro, Antigravity) require a one-time manual copy — see each platform's install section.
| Platform | Hooks | Instruction File | With Hooks | Without Hooks |
|---|:---:|---|:---:|:---:|
@@ -1183,6 +1192,7 @@ Hooks intercept tool calls programmatically — they can block dangerous command
| Kiro | Yes | [`KIRO.md`](configs/kiro/KIRO.md) | **~98% saved** | ~60% saved |
| Zed | -- | [`AGENTS.md`](configs/zed/AGENTS.md) | -- | ~60% saved |
| Pi | ✓ | [`AGENTS.md`](configs/pi/AGENTS.md) | **~98% saved** | ~60% saved |
| OMP | Plugin | [`SYSTEM.md`](configs/omp/SYSTEM.md) | **~98% saved** | ~60% saved |
Without hooks, one unrouted `curl` or Playwright snapshot can dump 56 KB into context — wiping out an entire session's worth of savings.
+6
View File
@@ -40,6 +40,12 @@
"./build/adapters/openclaw/plugin.js"
]
},
"omp": {
"name": "context-mode",
"version": "1.0.111",
"description": "Save 98% of your context window in OMP — sandboxed code execution, FTS5 search, hard-block curl/wget, session continuity across compaction.",
"hooks": "./build/adapters/omp/plugin.js"
},
"bugs": "https://github.com/mksglu/context-mode/issues",
"main": "./build/adapters/opencode/plugin.js",
"exports": {
+27
View File
@@ -36,4 +36,31 @@ for (const file of targets) {
}
}
// Root package.json hosts the OMP plugin manifest under the `omp` field
// (read by upstream loader via `pkg.omp || pkg.pi` per refs/platforms/
// oh-my-pi/packages/coding-agent/src/extensibility/plugins/loader.ts:75).
// The loader stamps `manifest.version = pluginPkg.version` at load time, so
// in practice version is implicit. We still keep an explicit `omp.version`
// in sync here so an inspector reading package.json sees the right number
// without needing to run the loader.
try {
const rootPkgRaw = readFileSync("package.json", "utf8");
const rootPkg = JSON.parse(rootPkgRaw);
let touched = false;
if (rootPkg.omp && typeof rootPkg.omp === "object") {
if (rootPkg.omp.version !== version) {
rootPkg.omp.version = version;
touched = true;
}
}
if (touched) {
// Preserve the trailing newline npm writes so diffs stay clean.
const trailing = rootPkgRaw.endsWith("\n") ? "\n" : "";
writeFileSync("package.json", JSON.stringify(rootPkg, null, 2) + trailing);
console.log(` ✓ package.json (omp.version → ${version})`);
}
} catch (e) {
console.log(` ⚠ package.json omp.version sync — ${e.message}`);
}
console.log(`✓ all manifests at v${version}`);
+261
View File
@@ -0,0 +1,261 @@
/**
* Oh My Pi (OMP) plugin entry point for context-mode.
*
* Mirrors the Pi extension shape (`src/adapters/pi/extension.ts`) for
* the four OMP hook events that materially protect the context window
* and persist session continuity:
*
* - session_start — initialize the session row in our DB
* - tool_call — hard-block curl/wget/inline-HTTP in bash
* - tool_result — extract structured events into the session DB
* - session_before_compact — persist a resume snapshot before compaction
*
* Loaded by OMP via the `omp` (or `pi`) field in package.json — see
* upstream loader at refs/platforms/oh-my-pi/packages/coding-agent/src/
* extensibility/plugins/loader.ts:75:
* `const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi;`
* Hook factory contract from refs/.../extensibility/hooks/types.ts:809:
* `export type HookFactory = (pi: HookAPI) => void;`
*
* OMP differs from Pi in two ways that justify a dedicated plugin file:
* 1. Storage roots at ~/.omp/context-mode/ via OMPAdapter, not ~/.pi/
* 2. OMP has native MCP support (mcp.json), so no MCP bridge is needed
* — the bridge that Pi's extension ships (mcp-bridge.ts) is dead weight
* under OMP and is intentionally omitted here.
*/
import { createHash } from "node:crypto";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { SessionDB } from "../../session/db.js";
import { extractEvents } from "../../session/extract.js";
import type { HookInput } from "../../session/extract.js";
import { buildResumeSnapshot } from "../../session/snapshot.js";
import type { SessionEvent } from "../../types.js";
import { OMPAdapter } from "./index.js";
// ── Tool-name normalization ─────────────────────────────
// OMP uses lowercase tool names (refs/.../hooks/types.ts:451 example
// `toolName: "bash"`). Shared event extractors expect PascalCase
// (Claude Code convention). Map the common ones.
const OMP_TOOL_MAP: Record<string, string> = {
bash: "Bash",
edit: "Edit",
read: "Read",
write: "Write",
list: "Glob",
view: "Read",
};
// ── Routing patterns ─────────────────────────────────────
// Inline HTTP client patterns to hard-block in bash. Identical to the
// Pi extension list (src/adapters/pi/extension.ts:42). One unrouted
// curl can dump 56 KB into context.
const BLOCKED_BASH_PATTERNS: RegExp[] = [
/\bcurl\s/,
/\bwget\s/,
/\bfetch\s*\(/,
/\brequests\.get\s*\(/,
/\brequests\.post\s*\(/,
/\bhttp\.get\s*\(/,
/\bhttp\.request\s*\(/,
/\burllib\.request/,
/\bInvoke-WebRequest\b/,
];
// ── Module-level singletons ──────────────────────────────
// Same shape as Pi: one DB per process, session ID rebound on each
// session_start so multi-session reuse within a long-lived plugin
// process keeps event attribution correct.
let _db: SessionDB | null = null;
let _sessionId = "";
const _ompAdapter = new OMPAdapter();
function getSessionDir(): string {
const dir = _ompAdapter.getSessionDir();
mkdirSync(dir, { recursive: true });
return dir;
}
function getDBPath(): string {
return join(getSessionDir(), "context-mode.db");
}
function getOrCreateDB(): SessionDB {
if (!_db) {
_db = new SessionDB({ dbPath: getDBPath() });
}
return _db;
}
/**
* Derive a stable session ID from OMP's session manager when available,
* otherwise fall back to a wall-clock token. Mirrors the Pi extension
* derivation (src/adapters/pi/extension.ts:142) — the OMP `ctx` object
* exposes `sessionManager.getSessionFile()` per refs/.../hooks/types.ts.
*/
function deriveSessionId(ctx: Record<string, unknown> | undefined): string {
try {
const sessionManager = (ctx as { sessionManager?: { getSessionFile?: () => string } } | undefined)
?.sessionManager;
const sessionFile = sessionManager?.getSessionFile?.();
if (sessionFile && typeof sessionFile === "string") {
return createHash("sha256").update(sessionFile).digest("hex").slice(0, 16);
}
} catch {
// best effort
}
return `omp-${Date.now()}`;
}
// ── Test-only state reset (NOT exported via plugin entry) ───────────
// The plugin's default export is the OMP factory; this helper is only
// imported by tests to clear singletons between cases.
export function _resetOmpPluginStateForTests(): void {
_db = null;
_sessionId = "";
}
/**
* Return the current session ID picked by the most recent session_start
* handler. Test-only — production code reads `_sessionId` directly via
* the closure. The shared SQLite DB at `~/.omp/context-mode/` survives
* between tests, so `getLatestSessionId()` cannot disambiguate which
* row belongs to "this" test when multiple tests insert in the same
* second; tests use this getter instead.
*/
export function _getOmpPluginSessionIdForTests(): string {
return _sessionId;
}
// ── HookAPI shape (local declaration; type erased at runtime) ──────
// We deliberately do NOT take a hard dependency on
// @oh-my-pi/pi-coding-agent. The runtime shape below mirrors the
// upstream HookAPI signature at refs/.../hooks/types.ts:695.
type ToolCallEvent = { toolName: string; toolCallId?: string; input?: Record<string, unknown> };
type ToolResultEvent = {
toolName: string;
toolCallId?: string;
input?: Record<string, unknown>;
content?: Array<{ type: string; text?: string }>;
isError?: boolean;
};
type ToolCallEventResult = { block?: boolean; reason?: string };
type HookEventCtx = Record<string, unknown> | undefined;
type HookHandler<E, R = void> = (event: E, ctx: HookEventCtx) => R | undefined | Promise<R | undefined>;
export interface MinimalHookAPI {
on(event: "session_start", handler: HookHandler<{ type: "session_start" }>): void;
on(event: "session_before_compact", handler: HookHandler<{ type: "session_before_compact" }>): void;
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: HookHandler<ToolResultEvent>): void;
on(event: string, handler: (...args: unknown[]) => unknown): void;
}
// ── Plugin entry point ───────────────────────────────────
/**
* OMP plugin default export. Called once by the OMP runtime per
* upstream `extensibility/plugins/loader.ts` after `omp plugin install
* context-mode`. Subsequent `pi.on(...)` registrations route the four
* lifecycle events to our SessionDB-backed handlers below.
*/
export default function ompPlugin(pi: MinimalHookAPI): void {
const projectDir =
process.env.OMP_PROJECT_DIR ||
process.env.PI_PROJECT_DIR ||
process.cwd();
const db = getOrCreateDB();
// ── 1. session_start — initialize session row ─────────
pi.on("session_start", (_event, ctx) => {
try {
_sessionId = deriveSessionId(ctx);
db.ensureSession(_sessionId, projectDir);
db.cleanupOldSessions(7);
} catch {
// best effort — never break session start
if (!_sessionId) {
_sessionId = `omp-${Date.now()}`;
}
}
return undefined;
});
// ── 2. tool_call — pre-tool-call hard-block ───────────
// Returning `{block: true, reason}` per
// refs/.../hooks/types.ts:566 (ToolCallEventResult) terminates the
// tool call with the reason surfaced to the LLM.
pi.on("tool_call", (event) => {
try {
const toolName = String(event?.toolName ?? "").toLowerCase();
if (toolName !== "bash") return undefined;
const command = String((event?.input as { command?: unknown } | undefined)?.command ?? "");
if (!command) return undefined;
const isBlocked = BLOCKED_BASH_PATTERNS.some((p) => p.test(command));
if (isBlocked) {
return {
block: true,
reason:
"Use context-mode MCP tools (ctx_execute, ctx_fetch_and_index) instead of inline HTTP. " +
"curl/wget/fetch dump raw HTTP into the context window.",
};
}
} catch {
// routing failure → allow passthrough
}
return undefined;
});
// ── 3. tool_result — post-tool-call event capture ─────
// OMP `tool_result` payload (refs/.../hooks/types.ts:461 onward) is
// `{toolName, toolCallId, input, content[], isError}`. We adapt to
// the Claude Code-shaped HookInput consumed by extractEvents.
pi.on("tool_result", (event) => {
try {
if (!_sessionId) return undefined;
const rawToolName = String(event?.toolName ?? "");
const mappedToolName = OMP_TOOL_MAP[rawToolName.toLowerCase()] ?? rawToolName;
const content = Array.isArray(event?.content) ? event.content : [];
const textParts = content
.filter((c): c is { type: string; text: string } => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text);
const resultStr = textParts.join("\n");
const hookInput: HookInput = {
tool_name: mappedToolName,
tool_input: (event?.input as Record<string, unknown>) ?? {},
tool_response: resultStr,
tool_output: event?.isError ? { isError: true } : undefined,
};
const events = extractEvents(hookInput);
for (const ev of events) {
db.insertEvent(_sessionId, ev as SessionEvent, "PostToolUse");
}
} catch {
// best effort
}
return undefined;
});
// ── 4. session_before_compact — resume snapshot ───────
pi.on("session_before_compact", () => {
try {
if (!_sessionId) return undefined;
const events = db.getEvents(_sessionId);
const snapshot = buildResumeSnapshot(events);
db.upsertResume(_sessionId, snapshot, events.length);
db.incrementCompactCount(_sessionId);
} catch {
// best effort
}
return undefined;
});
}
+296
View File
@@ -0,0 +1,296 @@
import "../setup-home";
/**
* OMP plugin tests — TDD slices around the four hooks the plugin owns.
*
* The OMP plugin (src/adapters/omp/plugin.ts) is a default-exported
* factory `(pi: HookAPI) => void`. Hook contract verified against
* refs/platforms/oh-my-pi/packages/coding-agent/src/extensibility/
* hooks/types.ts:695 (HookAPI) and types.ts:809 (HookFactory).
*
* Slices:
* 1. tool_call — pre-tool-call routing enforcement (block curl/wget)
* 2. tool_result — post-tool-call event extraction into SessionDB
* 3. session_start — session row created, cleanup runs
* 4. session_before_compact — resume snapshot persisted
*
* We mock the OMP HookAPI shape: `on(event, handler)` collects
* handlers, `_trigger(event, ...args)` invokes them and returns the
* first truthy result (matching how OMP forwards `{block, reason}` to
* the runtime).
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SessionDB } from "../../src/session/db.js";
// ── Mock OMP HookAPI ────────────────────────────────────────
type HandlerFn = (...args: unknown[]) => unknown | Promise<unknown>;
function createMockOmpApi() {
const handlers: Record<string, HandlerFn[]> = {};
return {
on: (event: string, handler: HandlerFn) => {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(handler);
},
_trigger: async (event: string, ...args: unknown[]) => {
for (const h of handlers[event] ?? []) {
const result = await h(...args);
if (result) return result;
}
return undefined;
},
_handlers: handlers,
};
}
// ── Setup / teardown ────────────────────────────────────────
let tempDir: string;
let api: ReturnType<typeof createMockOmpApi>;
async function registerOmpPlugin(
mockApi: ReturnType<typeof createMockOmpApi>,
opts?: { projectDir?: string },
) {
const projectDir = opts?.projectDir ?? tempDir;
process.env.OMP_PROJECT_DIR = projectDir;
// Reset module-level singletons so each test sees a fresh DB
const mod = await import("../../src/adapters/omp/plugin.js");
mod._resetOmpPluginStateForTests();
const register = mod.default;
register(mockApi as unknown as Parameters<typeof register>[0]);
return mockApi;
}
describe("OMP plugin", () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "omp-plugin-test-"));
api = createMockOmpApi();
});
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
/* best effort */
}
delete process.env.OMP_PROJECT_DIR;
});
// ═══════════════════════════════════════════════════════════
// Slice 1: tool_call routing enforcement
// ═══════════════════════════════════════════════════════════
describe("Slice 1: tool_call routing", () => {
it("registers a tool_call handler", async () => {
await registerOmpPlugin(api);
expect(api._handlers.tool_call).toBeDefined();
expect(api._handlers.tool_call.length).toBe(1);
});
it("blocks bash with curl and surfaces a reason", async () => {
await registerOmpPlugin(api);
const result = (await api._trigger("tool_call", {
toolName: "bash",
input: { command: "curl https://example.com/api" },
})) as { block?: boolean; reason?: string } | undefined;
expect(result?.block).toBe(true);
expect(result?.reason).toMatch(/context-mode/);
});
it("blocks bash with wget", async () => {
await registerOmpPlugin(api);
const result = (await api._trigger("tool_call", {
toolName: "bash",
input: { command: "wget https://example.com/file" },
})) as { block?: boolean } | undefined;
expect(result?.block).toBe(true);
});
it("blocks bash with inline node fetch", async () => {
await registerOmpPlugin(api);
const result = (await api._trigger("tool_call", {
toolName: "bash",
input: { command: "node -e \"fetch('https://api')\"" },
})) as { block?: boolean } | undefined;
expect(result?.block).toBe(true);
});
it("blocks bash with python requests.get", async () => {
await registerOmpPlugin(api);
const result = (await api._trigger("tool_call", {
toolName: "bash",
input: { command: "python -c \"requests.get('https://api')\"" },
})) as { block?: boolean } | undefined;
expect(result?.block).toBe(true);
});
it("blocks PowerShell Invoke-WebRequest", async () => {
await registerOmpPlugin(api);
const result = (await api._trigger("tool_call", {
toolName: "bash",
input: { command: "Invoke-WebRequest https://api" },
})) as { block?: boolean } | undefined;
expect(result?.block).toBe(true);
});
it("does NOT block safe bash (git status)", async () => {
await registerOmpPlugin(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "git status" },
});
expect(result).toBeUndefined();
});
it("does NOT block non-bash tools", async () => {
await registerOmpPlugin(api);
const result = await api._trigger("tool_call", {
toolName: "edit",
input: { file_path: "x.ts" },
});
expect(result).toBeUndefined();
});
it("tolerates malformed event payloads (no throw)", async () => {
await registerOmpPlugin(api);
// Missing input, missing toolName — must not throw, must passthrough
await expect(api._trigger("tool_call", {})).resolves.toBeUndefined();
await expect(api._trigger("tool_call", { toolName: "bash" })).resolves.toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════
// Slice 2: tool_result event extraction
// ═══════════════════════════════════════════════════════════
describe("Slice 2: tool_result extraction", () => {
it("registers a tool_result handler", async () => {
await registerOmpPlugin(api);
expect(api._handlers.tool_result).toBeDefined();
});
it("persists a Read event into the session DB", async () => {
await registerOmpPlugin(api);
// Establish a session first so _sessionId is set
await api._trigger("session_start", { type: "session_start" }, {});
await api._trigger("tool_result", {
toolName: "read",
input: { file_path: "/tmp/x.ts" },
content: [{ type: "text", text: "export const x = 1;" }],
});
// Verify event landed in DB at the OMP storage path
const { OMPAdapter } = await import("../../src/adapters/omp/index.js");
const adapter = new OMPAdapter();
const db = new SessionDB({
dbPath: join(adapter.getSessionDir(), "context-mode.db"),
});
const latest = db.getLatestSessionId();
expect(latest).not.toBeNull();
const events = db.getEvents(latest as string);
expect(events.length).toBeGreaterThan(0);
// file_read category should appear for a Read tool
expect(events.some((e) => e.category === "file")).toBe(true);
});
it("does nothing when no session has started", async () => {
await registerOmpPlugin(api);
// Trigger tool_result WITHOUT session_start first
await expect(
api._trigger("tool_result", {
toolName: "read",
input: { file_path: "/tmp/x.ts" },
content: [{ type: "text", text: "x" }],
}),
).resolves.toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════
// Slice 3: session_start lifecycle
// ═══════════════════════════════════════════════════════════
describe("Slice 3: session_start", () => {
it("registers a session_start handler", async () => {
await registerOmpPlugin(api);
expect(api._handlers.session_start).toBeDefined();
});
it("creates a session row in the DB", async () => {
await registerOmpPlugin(api);
await api._trigger("session_start", { type: "session_start" }, {});
const { OMPAdapter } = await import("../../src/adapters/omp/index.js");
const adapter = new OMPAdapter();
const db = new SessionDB({
dbPath: join(adapter.getSessionDir(), "context-mode.db"),
});
const latest = db.getLatestSessionId();
expect(latest).not.toBeNull();
});
it("derives a stable session ID from sessionManager.getSessionFile when present", async () => {
await registerOmpPlugin(api);
await api._trigger("session_start", { type: "session_start" }, {
sessionManager: { getSessionFile: () => "/path/to/session-abc.json" },
});
const mod = await import("../../src/adapters/omp/plugin.js");
const sid = mod._getOmpPluginSessionIdForTests();
// 16-hex SHA-256 prefix per deriveSessionId contract
expect(sid).toMatch(/^[a-f0-9]{16}$/);
});
});
// ═══════════════════════════════════════════════════════════
// Slice 4: session_before_compact resume snapshot
// ═══════════════════════════════════════════════════════════
describe("Slice 4: session_before_compact", () => {
it("registers a session_before_compact handler", async () => {
await registerOmpPlugin(api);
expect(api._handlers.session_before_compact).toBeDefined();
});
it("persists a resume snapshot and increments compact_count", async () => {
const mod = await registerOmpPlugin(api);
await api._trigger("session_start", { type: "session_start" }, {});
// Generate at least one event so the snapshot is non-empty
await api._trigger("tool_result", {
toolName: "read",
input: { file_path: "/tmp/x.ts" },
content: [{ type: "text", text: "x" }],
});
await api._trigger("session_before_compact", { type: "session_before_compact" }, {});
// Read the session ID picked up by THIS test rather than the
// shared-DB latest, which can collide at second-precision with
// sibling test sessions.
const pluginMod = await import("../../src/adapters/omp/plugin.js");
const sid = pluginMod._getOmpPluginSessionIdForTests();
expect(sid).not.toBe("");
const { OMPAdapter } = await import("../../src/adapters/omp/index.js");
const adapter = new OMPAdapter();
const db = new SessionDB({
dbPath: join(adapter.getSessionDir(), "context-mode.db"),
});
const resume = db.getResume(sid);
expect(resume).not.toBeNull();
expect(resume?.snapshot.length).toBeGreaterThan(0);
const stats = db.getSessionStats(sid);
expect(stats?.compact_count).toBe(1);
void mod;
});
});
});