From a10a2c019736246a4e060cbea2f8f4dd5ba7061f Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 11 Sep 2026 20:33:18 +0000 Subject: [PATCH 01/33] feat: bridge hook-guard policy into native OMP hooks --- CHANGELOG.md | 1 + README.md | 49 ++++++++++- graphify/__main__.py | 3 +- graphify/install.py | 20 +++++ graphify/omp/index.ts | 134 ++++++++++++++++++++++++++++++ graphify/omp/package.json | 13 +++ pyproject.toml | 3 +- tests/omp.test.ts | 168 ++++++++++++++++++++++++++++++++++++++ tests/test_omp_install.py | 45 ++++++++++ 9 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 graphify/omp/index.ts create mode 100644 graphify/omp/package.json create mode 100644 tests/omp.test.ts create mode 100644 tests/test_omp_install.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8523ffb..7e10ffe4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -418,6 +418,7 @@ Intra-class call binding across five languages, new language-structure coverage, ## 0.9.58 (2026-09-10) +- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-run context guidance without building indexes. - Fix: a call to a Python function defined nested inside another function now resolves to that inner definition per lexical scope, instead of leaking to a same-named function elsewhere; direct recursion is preserved as a self-loop (#3410, thanks @hopstreax). - Fix: submodule imports inside a PEP 420 namespace package (a directory with no `__init__.py`) now resolve to the target module instead of being dropped (#3429, thanks @flaukowski). - Fix: a bare-name import of a module sitting next to the importing file (a flat script dir with no package) now resolves to that sibling — matching CPython's `sys.path[0]` behavior — without over-resolving a genuine third-party name (#3430, thanks @hopstreax). diff --git a/README.md b/README.md index 3f8f9f5e0..da803e9df 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,7 @@ for example `graphify claude install --project` or `graphify codex install --pro | Agent Skills (cross-framework) | `graphify install --platform agents` (alias `--platform skills`) | | Kiro IDE/CLI | `graphify kiro install` | | Pi coding agent | `graphify install --platform pi` | +| Oh My Pi (native guard) | `graphify omp install` | | Cursor | `graphify cursor install` | | Devin CLI | `graphify devin install` | | Google Antigravity | `graphify antigravity install` | @@ -319,7 +320,53 @@ This writes a small config file that tells your assistant to consult the knowled **Cursor** writes `.cursor/rules/graphify.mdc` with `alwaysApply: true`, so Cursor includes it in every conversation automatically, no hook needed. -To remove graphify from all platforms at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`). +### Oh My Pi (OMP) native guard + +Install Graphify into a persistent environment with `uv tool install graphifyy` or +`pipx install graphifyy`, and install OMP separately. Then run: + +```bash +graphify omp install +``` + +This delegates to OMP's supported `omp plugin install ` +route. The Python wheel and source distribution contain `graphify/omp/package.json` +with explicit `omp.extensions` and its TypeScript entry point; OMP links that +directory and discovers the extension. No npm adapter package, shell hook, Pi API +shim, or hand-edited OMP settings are required. `graphify omp path` prints the same +directory for manual `omp plugin install` or one-session `omp -e` use. Restart OMP +after installing/upgrading, and rerun the installer if the Python environment +moves. Do not link from an ephemeral `uvx` environment. + +Before native `read`, `glob`, `grep`, and search-style `bash` calls, the extension +runs the installed `graphify hook-guard read|search` CLI. The existing Python +policy owns fresh/stale graph decisions and strict-mode denials: start OMP with +`GRAPHIFY_HOOK_STRICT=1` to enable its once-per-session indexed-read block. +Denials become OMP `block`/`reason`; guidance becomes one deduplicated context +message, cleared for each new user run and session navigation. In-flight hooks +are cancelled on these boundaries. No graph is created or updated automatically. +The guard package intentionally declares no skills; the existing cross-framework +skill remains available separately through `graphify agents install`. + +Only local filesystem targets are inspected, using OMP's selector/path helpers. +URLs and internal resources are excluded. Hooks require an installed `graphify` +on an absolute PATH entry outside the project (including outside a project-local +virtualenv); there is no project Python or command fallback. Each native tool call +has a 256 KiB JSON-input cap, a 64 KiB output cap and a two-second subprocess budget +with forced termination. Missing commands, invalid output and failures fail open. + +The extension checks `ctx.isProjectTrusted()` before execution and context +injection, but **current OMP exposes this compatibility method as always true**: +it is not an enforced trust sandbox. Enable this integration only in projects you +trust. The lifecycle contract is based on OMP integration commit +`6aef0e8ad51b3bc5ea7a5f2a255c3d48e4c5af72`; the `18.1.17` version string alone +does not establish those lifecycle fixes. + +Remove this host-managed link with `omp plugin uninstall graphify-omp` **before** +uninstalling the Python package. `graphify uninstall` handles Graphify-managed +platform files, not OMP's plugin registry. + +To remove Graphify-managed platform files at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`). OMP links are removed separately as described above. ### CLI skills or MCP instructions? diff --git a/graphify/__main__.py b/graphify/__main__.py index 4de338ec8..db8e7adcd 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -830,7 +830,7 @@ def _run_cli() -> None: # Skip during install/uninstall (hook writes trigger a fresh check anyway). # Skip during hook-check — it runs on every editor tool use and must be silent. # Deduplicate paths so platforms sharing the same install dir don't warn twice. - _silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"} + _silent_cmds = {"install", "uninstall", "hook-check", "hook-guard", "omp"} if not any(arg in _silent_cmds for arg in sys.argv): # A package upgrade leaves every installed skill at the old version; # refresh them first, so the check below only fires for copies the @@ -855,6 +855,7 @@ def _run_cli() -> None: print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)") print(" uninstall remove graphify from all detected platforms in one shot") print(" --purge also delete graphify-out/ directory") + print(" omp [install|path] install the native Oh My Pi guard, or print its package path") print(" path \"A\" \"B\" shortest path between two nodes in graph.json") print(" --graph path to graph.json (default graphify-out/graph.json)") print(" --directed force a directed search (the default;") diff --git a/graphify/install.py b/graphify/install.py index 85c690ac5..0032eafdf 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -2367,6 +2367,7 @@ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = Fals "kilo", "kiro", "opencode", + "omp", "pi", "skills", "trae", @@ -2384,6 +2385,25 @@ def dispatch_install_cli(cmd: str) -> bool: """ if cmd not in _CLI_INSTALL_COMMANDS: return False + if cmd == "omp": + # OMP owns package registration; do not duplicate its config/paths here. + args = sys.argv[2:] + if args not in (["path"], ["install"]): + print("Usage: graphify omp [path|install]", file=sys.stderr) + sys.exit(1) + package_path = Path(__file__).resolve().parent / "omp" + if args == ["path"]: + print(package_path) + return True + omp = shutil.which("omp") + if not omp: + print("error: install Oh My Pi (omp) and add it to PATH first", file=sys.stderr) + sys.exit(1) + import subprocess + result = subprocess.run([omp, "plugin", "install", str(package_path)], check=False) + if result.returncode: + sys.exit(result.returncode) + return True if cmd == "install": # Default to windows platform on Windows, claude elsewhere default_platform = "windows" if platform.system() == "Windows" else "claude" diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts new file mode 100644 index 000000000..a018b20a1 --- /dev/null +++ b/graphify/omp/index.ts @@ -0,0 +1,134 @@ +import { execFile } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { delimiter, isAbsolute, relative, sep } from "node:path"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import { + expandDelimitedPathEntries, + isInternalUrlPath, + isReadableUrlPath, + normalizePathLikeInput, + parseSearchPath, + resolveReadPath, + splitPathAndSelPreferringLiteral, +} from "@oh-my-pi/pi-coding-agent/tools/path-utils"; + +const INPUT_LIMIT = 256 * 1024; +const OUTPUT_LIMIT = 64 * 1024; +const TIMEOUT_MS = 2000; +const CONTEXT_TYPE = "graphify-guard"; +const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const; + +function isRemote(path: string): boolean { + return isInternalUrlPath(path) || isReadableUrlPath(path) || path.includes("://"); +} + +function installedCommand(cwd: string): string | undefined { + // Never resolve a bare/relative PATH entry against the project or use a + // project-supplied Python module as a fallback for an absent installation. + const path = (process.env.PATH ?? "").split(delimiter).filter(isAbsolute).join(delimiter); + const found = path ? Bun.which("graphify", { PATH: path }) : null; + if (!found) return; + const command = realpathSync(found); + const within = relative(realpathSync(cwd), command); + if (!within || (!within.startsWith(`..${sep}`) && !isAbsolute(within))) return; + return command; +} + +function runGuard(command: string, kind: string, payload: string, cwd: string, signal: AbortSignal, timeout: number, maxBuffer: number): Promise { + if (Buffer.byteLength(payload) > INPUT_LIMIT || signal.aborted) return Promise.resolve(undefined); + const { promise, resolve } = Promise.withResolvers(); + const child = execFile(command, ["hook-guard", kind], { + cwd, encoding: "utf8", timeout, killSignal: "SIGKILL", maxBuffer, signal, + env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }, + }, (error, stdout) => resolve(error ? undefined : stdout.trim() || undefined)); + child.stdin?.on("error", () => {}); + child.stdin?.end(payload); + return promise; +} + +export default function graphify(api: ExtensionAPI): void { + const guidance = new Set(); + let guidanceBytes = 0; + let generation = 0; + let controller = new AbortController(); + const reset = () => { + generation++; + controller.abort(); + controller = new AbortController(); + guidance.clear(); + guidanceBytes = 0; + }; + api.on("session_start", reset); + api.on("session_switch", reset); + api.on("session_branch", reset); + api.on("session_tree", reset); + api.on("session_shutdown", reset); + api.on("before_agent_start", reset); + + api.on("tool_call", async (event, ctx) => { + if (!ctx.isProjectTrusted()) { reset(); return; } + if (!Object.hasOwn(TOOL_NAMES, event.toolName)) return; + const current = generation; + const signal = controller.signal; + const deadline = performance.now() + TIMEOUT_MS; + try { + if (Buffer.byteLength(JSON.stringify(event.input)) > INPUT_LIMIT) return; + const command = installedCommand(ctx.cwd); + if (!command) return; + const toolName = TOOL_NAMES[event.toolName as keyof typeof TOOL_NAMES]; + const rawPath = typeof event.input.path === "string" ? normalizePathLikeInput(event.input.path) : "."; + if (isRemote(rawPath)) return; + const paths = toolName === "Read" || toolName === "Bash" ? [rawPath] : + await expandDelimitedPathEntries([rawPath], ctx.cwd, { splitter: parseSearchPath }); + let remainingOutput = OUTPUT_LIMIT; + for (const path of paths) { + if (isRemote(path)) continue; + const input: Record = { ...event.input }; + if (toolName !== "Bash") { + const target = toolName === "Glob" ? path : (await splitPathAndSelPreferringLiteral(path, ctx.cwd)).path; + const resolved = resolveReadPath(target, ctx.cwd); + delete input.path; + if (toolName === "Read") input.file_path = resolved; + else if (toolName === "Glob") input.pattern = resolved; + else input.path = resolved; + } + const timeout = Math.floor(deadline - performance.now()); + if (current !== generation || !ctx.isProjectTrusted() || timeout <= 0 || remainingOutput <= 0) return; + const output = await runGuard(command, toolName === "Bash" || toolName === "Grep" ? "search" : "read", JSON.stringify({ + session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, tool_name: toolName, tool_input: input, + }), ctx.cwd, signal, timeout, remainingOutput); + if (current !== generation || !ctx.isProjectTrusted()) return; + if (!output) continue; + remainingOutput -= Buffer.byteLength(output); + const hook = JSON.parse(output)?.hookSpecificOutput; + if (hook?.hookEventName !== "PreToolUse") continue; + if (hook.permissionDecision === "deny" && typeof hook.permissionDecisionReason === "string" && hook.permissionDecisionReason.trim()) { + return { block: true, reason: hook.permissionDecisionReason }; + } + if (typeof hook.additionalContext === "string" && hook.additionalContext.trim() && !guidance.has(hook.additionalContext)) { + const bytes = Buffer.byteLength(hook.additionalContext) + 2; + if (guidanceBytes + bytes <= OUTPUT_LIMIT) { + guidance.add(hook.additionalContext); + guidanceBytes += bytes; + } + } + // The search CLI does not inspect individual targets; one call suffices. + if (toolName === "Grep") break; + } + } catch { + // Optional guidance must not break native tools on missing executables, + // invalid paths, malformed hook output, timeout, or cancellation. + } + }); + + api.on("context", (event, ctx) => { + if (!ctx.isProjectTrusted()) reset(); + // Context transforms are not persisted. Keep one current-run message across + // provider requests, and discard any prior generation's injected message. + const messages = event.messages.filter(message => message.role !== "custom" || message.customType !== CONTEXT_TYPE); + if (guidance.size) messages.push({ + role: "custom", customType: CONTEXT_TYPE, content: [...guidance].join("\n\n"), display: false, timestamp: Date.now(), + }); + if (guidance.size || messages.length !== event.messages.length) return { messages }; + }); +} diff --git a/graphify/omp/package.json b/graphify/omp/package.json new file mode 100644 index 000000000..7decfb369 --- /dev/null +++ b/graphify/omp/package.json @@ -0,0 +1,13 @@ +{ + "name": "graphify-omp", + "version": "0.9.63", + "private": true, + "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", + "license": "Apache-2.0", + "type": "module", + "files": ["index.ts"], + "omp": { + "extensions": ["./index.ts"], + "skills": [] + } +} diff --git a/pyproject.toml b/pyproject.toml index 48801a41d..0648b3b5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -162,7 +162,8 @@ include-package-data = false # under graphify/skills//references/, and the always-on injection blocks # under graphify/always_on/. There is no graphify/skills//SKILL.md in the # repo, so no SKILL.md glob is needed here. -graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md"] +# Native OMP package: linked by OMP itself from the Python installation. +graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md", "omp/package.json", "omp/index.ts"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/omp.test.ts b/tests/omp.test.ts new file mode 100644 index 000000000..095d6cb8b --- /dev/null +++ b/tests/omp.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import graphify from "../graphify/omp/index.ts"; + +// Use the real installed Python CLI for policy tests. No mock of OMP path helpers. +const realCLI = process.env.GRAPHIFY_TEST_CLI ?? Bun.which("graphify"); +if (!realCLI) throw new Error("Install graphifyy or set GRAPHIFY_TEST_CLI to its executable before running this test"); +const savedPath = process.env.PATH; +const savedStrict = process.env.GRAPHIFY_HOOK_STRICT; +let root: string; +let cwd: string; +let control: string; +let started: string; +let fixture: string; + +type Result = { block?: boolean; reason?: string; messages?: { content: string }[] } | undefined; +type Handler = (event: Record, ctx: object) => Result | Promise; +function harness() { + const handlers = new Map(); + let trusted = true; + const ctx = { cwd, isProjectTrusted: () => trusted, sessionManager: { getSessionId: () => "omp-test-session" } }; + // Only the event registry is needed; every callback is the real extension's. + const api = { on: (event: string, handler: Handler) => handlers.set(event, handler) } as unknown as ExtensionAPI; + graphify(api); + return { + trust(value: boolean) { trusted = value; }, + async emit(event: string, payload: Record = {}) { return await handlers.get(event)?.({ type: event, ...payload }, ctx); }, + }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "graphify-omp-")); + cwd = join(root, "project"); + mkdirSync(cwd); + mkdirSync(join(root, "bin")); + control = join(root, "control.json"); + started = join(root, "started"); + fixture = join(root, "bin", "graphify"); + writeFileSync(control, JSON.stringify({ mode: "real" })); + // A controlled installed executable outside the project exercises the real + // subprocess boundary (including cancellation and output limits). + // Real subprocess timeouts cannot be advanced by the parent test's fake clock. + writeFileSync(fixture, `#!${process.execPath}\nimport {execFileSync} from "node:child_process"; +import {readFileSync,writeFileSync,existsSync} from "node:fs"; +const input = await Bun.stdin.text(); +const config = JSON.parse(readFileSync(${JSON.stringify(control)}, "utf8")); +writeFileSync(${JSON.stringify(started)}, input); +if (config.mode === "real") process.stdout.write(execFileSync(${JSON.stringify(realCLI)}, process.argv.slice(2), {input})); +else if (config.mode === "invalid") process.stdout.write("not json"); +else if (config.mode === "overflow") process.stdout.write("x".repeat(65537)); +else if (config.mode === "delayed") { + while (!existsSync(${JSON.stringify(join(root, "release"))})) await Bun.sleep(5); + process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "stale guidance"}})); +} else if (config.mode === "hung") { + process.on("SIGTERM", () => {}); + await Bun.sleep(60000); +} +`); + chmodSync(fixture, 0o755); + process.env.PATH = dirname(fixture); + delete process.env.GRAPHIFY_HOOK_STRICT; + writeFileSync(join(cwd, "source.py"), "def example(): pass\n"); + mkdirSync(join(cwd, "graphify-out")); + writeFileSync(join(cwd, "graphify-out", "graph.json"), JSON.stringify({ nodes: [], links: [] })); + writeFileSync(join(cwd, "graphify-out", "manifest.json"), JSON.stringify({ "source.py": {} })); + const fresh = new Date(Date.now() + 1000); + utimesSync(join(cwd, "graphify-out", "graph.json"), fresh, fresh); +}); + +afterEach(() => { + process.env.PATH = savedPath; + if (savedStrict === undefined) delete process.env.GRAPHIFY_HOOK_STRICT; + else process.env.GRAPHIFY_HOOK_STRICT = savedStrict; + rmSync(root, { recursive: true, force: true }); +}); + +test("real CLI strict denial blocks selector reads; subsequent guidance dedupes and resets", async () => { + process.env.GRAPHIFY_HOOK_STRICT = "1"; + const api = harness(); + await api.emit("before_agent_start"); + const event = { toolName: "read", input: { path: "source.py:1-5" } }; + const result = await api.emit("tool_call", event); + expect(result?.block).toBe(true); + expect(result?.reason).toContain("graphify"); + expect(existsSync(join(cwd, "graphify-out", "cache", "hook_sessions", "omp-test-session.denied"))).toBe(true); + expect(await api.emit("tool_call", event)).toBeUndefined(); + const first = await api.emit("context", { messages: [] }); + expect(first?.messages).toHaveLength(1); + await api.emit("tool_call", event); + const second = await api.emit("context", { messages: first?.messages }); + expect(second?.messages).toHaveLength(1); + expect(second?.messages?.[0].content).toBe(first?.messages?.[0].content); + await api.emit("before_agent_start"); + expect((await api.emit("context", { messages: second?.messages }))?.messages).toEqual([]); +}); + +test("native grep, bash search, and glob expose the installed CLI's actual guidance", async () => { + for (const [toolName, input, kind, legacyInput] of [ + ["grep", { pattern: "example", path: "source.py:1" }, "search", { pattern: "example" }], + ["bash", { command: "rg example ." }, "search", { command: "rg example ." }], + ["glob", { path: "**/*.py" }, "read", { pattern: "**/*.py" }], + ] as const) { + const api = harness(); + const expected = JSON.parse(execFileSync(realCLI!, ["hook-guard", kind], { + cwd, input: JSON.stringify({ tool_input: legacyInput }), encoding: "utf8", + })).hookSpecificOutput.additionalContext; + expect(await api.emit("tool_call", { toolName, input })).toBeUndefined(); + expect((await api.emit("context", { messages: [] }))?.messages?.[0].content).toBe(expected); + } +}); + +test("URLs, internal resources, literal selector-like names and false trust do not run project hooks", async () => { + const api = harness(); + for (const path of ["https://example.com/source.py", "www.example.com/source.py", "skill://graphify", "local:/source.py", "source.py; ssh://host/source.py"]) { + await api.emit("tool_call", { toolName: "read", input: { path } }); + expect(existsSync(started)).toBe(false); + } + api.trust(false); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + expect(existsSync(started)).toBe(false); + api.trust(true); + writeFileSync(join(cwd, "source.py:12"), "a real filename, not a selector"); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" } }); + expect(await api.emit("context", { messages: [] })).toBeUndefined(); +}); + +test("navigation cancels in-flight guidance before the next session's context", async () => { + for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { + rmSync(started, { force: true }); + writeFileSync(control, JSON.stringify({ mode: "delayed" })); + const api = harness(); + const pending = api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + const deadline = Date.now() + 1500; + while (!existsSync(started) && Date.now() < deadline) await Bun.sleep(5); + expect(existsSync(started)).toBe(true); + await api.emit(navigation); + expect(await pending).toBeUndefined(); + expect(await api.emit("context", { messages: [] })).toBeUndefined(); + } +}); + +test("oversized input never spawns, invalid/oversized output fails open, and a hung child is killed", async () => { + const api = harness(); + await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) } }); + expect(existsSync(started)).toBe(false); + for (const mode of ["invalid", "overflow", "hung"]) { + writeFileSync(control, JSON.stringify({ mode })); + const start = performance.now(); + expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } })).toBeUndefined(); + expect(performance.now() - start).toBeLessThan(3500); + expect(await api.emit("context", { messages: [] })).toBeUndefined(); + } +}, 10000); + +test("missing or project-local executables never fall back to project Python code", async () => { + process.env.PATH = cwd; + const api = harness(); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + expect(existsSync(started)).toBe(false); + writeFileSync(join(cwd, "graphify"), readFileSync(fixture)); + chmodSync(join(cwd, "graphify"), 0o755); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + expect(existsSync(started)).toBe(false); +}); diff --git a/tests/test_omp_install.py b/tests/test_omp_install.py new file mode 100644 index 000000000..af0edc4c2 --- /dev/null +++ b/tests/test_omp_install.py @@ -0,0 +1,45 @@ +"""OMP owns registration; Graphify supplies a wheel-contained native package.""" +import json +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +from graphify.install import dispatch_install_cli + + +def test_omp_path_is_a_complete_native_package(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "path"]) + assert dispatch_install_cli("omp") + package = Path(capsys.readouterr().out.strip()) + manifest = json.loads((package / "package.json").read_text(encoding="utf-8")) + assert manifest["omp"]["extensions"] + for entry in manifest["omp"]["extensions"]: + assert (package / entry).is_file() + assert (package / entry).resolve().is_relative_to(package) + + +def test_omp_install_delegates_to_host_and_propagates_failure(monkeypatch): + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "install"]) + monkeypatch.setattr("graphify.install.shutil.which", lambda name: "/usr/bin/omp") + + def host(argv, *, check): + assert argv[:3] == ["/usr/bin/omp", "plugin", "install"] + assert (Path(argv[3]) / "package.json").is_file() + return SimpleNamespace(returncode=23) + + monkeypatch.setattr("subprocess.run", host) + with pytest.raises(SystemExit) as error: + dispatch_install_cli("omp") + assert error.value.code == 23 + + +def test_omp_install_without_host_does_not_create_configuration(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "install"]) + monkeypatch.setattr("graphify.install.shutil.which", lambda name: None) + with pytest.raises(SystemExit) as error: + dispatch_install_cli("omp") + assert error.value.code == 1 + assert not list(tmp_path.iterdir()) From 298c1d51f82d733d898a501191686988491bf75e Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 15 Sep 2026 10:13:10 +0000 Subject: [PATCH 02/33] test(omp): keep the embedded package version in sync --- tests/test_omp_install.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_omp_install.py b/tests/test_omp_install.py index af0edc4c2..98a75eded 100644 --- a/tests/test_omp_install.py +++ b/tests/test_omp_install.py @@ -8,6 +8,11 @@ from graphify.install import dispatch_install_cli +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib # type: ignore[no-redef] + def test_omp_path_is_a_complete_native_package(monkeypatch, capsys): monkeypatch.setattr(sys, "argv", ["graphify", "omp", "path"]) @@ -15,6 +20,8 @@ def test_omp_path_is_a_complete_native_package(monkeypatch, capsys): package = Path(capsys.readouterr().out.strip()) manifest = json.loads((package / "package.json").read_text(encoding="utf-8")) assert manifest["omp"]["extensions"] + pyproject = tomllib.loads((package.parents[1] / "pyproject.toml").read_text(encoding="utf-8")) + assert manifest["version"] == pyproject["project"]["version"] for entry in manifest["omp"]["extensions"]: assert (package / entry).is_file() assert (package / entry).resolve().is_relative_to(package) From f73ce5ee38feaa80d633c1ecb715779522a3072a Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 16 Sep 2026 19:28:48 +0000 Subject: [PATCH 03/33] test(omp): run the native bridge tests in CI --- .github/workflows/ci.yml | 27 +++ .gitignore | 3 + graphify/omp/bun.lock | 385 ++++++++++++++++++++++++++++++++++++++ graphify/omp/package.json | 3 + 4 files changed, 418 insertions(+) create mode 100644 graphify/omp/bun.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6f5e373f..ebc2a258d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,33 @@ jobs: uv run --frozen graphify --help uv run --frozen graphify install + omp-bridge: + # tests/omp.test.ts drives the real OMP extension: bun resolves the + # path-utils helpers it imports from the pinned @oh-my-pi/pi-coding-agent + # devDependency in graphify/omp/, and the policy assertions run against + # the real installed Python CLI, so this job needs both toolchains. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: uv sync --frozen + + - name: Install OMP extension dependencies + run: bun install --frozen-lockfile + working-directory: graphify/omp + + - name: Run OMP bridge tests + run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new # HIGH-severity finding or vulnerable dependency is caught on the PR that diff --git a/.gitignore b/.gitignore index 0a6775b2a..dba5741e3 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ paper/ # macOS Finder metadata .DS_Store + +# OMP extension test dependency tree (graphify/omp) +graphify/omp/node_modules/ diff --git a/graphify/omp/bun.lock b/graphify/omp/bun.lock new file mode 100644 index 000000000..41e249050 --- /dev/null +++ b/graphify/omp/bun.lock @@ -0,0 +1,385 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "graphify-omp", + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "^18.2.2", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@huggingface/jinja": ["@huggingface/jinja@0.5.10", "", {}, "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw=="], + + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.2.0", "", {}, "sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg=="], + + "@huggingface/transformers": ["@huggingface/transformers@4.3.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.10", "@huggingface/tokenizers": "^0.2.0", "onnxruntime-node": "1.30.0", "onnxruntime-web": "1.31.0-dev.20260914-8d85527a0", "sharp": "^0.35.4" } }, "sha512-fL1A/WUZwouPrOlYxU5dzIwD2T5J781JiB2jDR8bFe5DwCj0Gfudq+NEXCMno49kQgajHA7xQkrRLJlqG1veEA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-2NQ6Xe9M6859FMiUF9qiD2hG/Eht+N1sakJ5q1+liUdt0g/jNAmjErGTvGDKOXKq5niz362R+oLo4xrP0wJrFg=="], + + "@oh-my-pi/omptype": ["@oh-my-pi/omptype@18.2.2", "", {}, "sha512-tG9W0Acl0Sg0iONdXG744J9TRjxU9qgKmfdivD/NfZ/0fQ1mr5hajx0yk/Jm4KzfVazwZErCqiH4Qce7OL02hg=="], + + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2", "@oh-my-pi/snapcompact": "18.2.2", "@opentelemetry/api": "^1.9.1" } }, "sha512-sEzkNqbKRvBb1EMhEG5n1+AGd9q4UT3B5vto21EGyZhF7dSNdYtcMAfqUMQZLSP/kgu2RzUnvXZTDHbvKWFZxw=="], + + "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@18.2.2", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2" } }, "sha512-3fqCIzHLL/g53MdsUZHR3RvWaQYh+VLSJe2D99KnlZGGWqzFRfXFG6YgSzLQg9UdjPkdyJfivFNi+D0Gt0lYew=="], + + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@18.2.2", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" } }, "sha512-FgUqjLdWIMRfBhtC3WzOOBgC5iWIeRer59eoPclMzlXc5WeV8AxIeZoEOQuYCsCpTembgPeDfTIzFKr1zf/mug=="], + + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@18.2.2", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/omp-stats": "18.2.2", "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-agent-core": "18.2.2", "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-mnemopi": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-tui": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2", "@oh-my-pi/snapcompact": "18.2.2", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-rZmmx2f9UZkK2rRteCFwxxcDAdxXfz9AD032fZJdAdyfa1CFWixns9tsMML3DdNOytD1619QILna/IxsMspbgA=="], + + "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-+wgJYNsHwlaDXBWGqv66NpOnC+9/3XNzADoOihrlH+iZEse3P0PLsYozCvnjsXQCYViz58MFKV8Vnd0AWuqafQ=="], + + "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@18.2.2", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "18.2.2", "@oh-my-pi/pi-natives-darwin-x64": "18.2.2", "@oh-my-pi/pi-natives-linux-arm64": "18.2.2", "@oh-my-pi/pi-natives-linux-x64": "18.2.2", "@oh-my-pi/pi-natives-win32-arm64": "18.2.2", "@oh-my-pi/pi-natives-win32-x64": "18.2.2" } }, "sha512-R1qzx/zG9iob90umYFI28SzznvzFhhqzWQ+xjv0Qyi5zAv4yEGtKIsN5EQYdZXd5SRf9oN7TVTPY00y9hywhXA=="], + + "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@18.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JFpluDNuKF2RmkTNjrjiWcxRiz2CLB/zSyXaNlhVVKr4iFp/oTQ+2mufb8KaGpwaW6aIA0kAPohVONNWltQfuQ=="], + + "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@18.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-37fwjJf/HUZ9k/WAShXc38GTzP0BWtBemoAvEsvR+i58Rd8yPMr0UhV2gEQHcFcyb7syJYryG43q5sTMmE4dpA=="], + + "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@18.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jp9o4yGCND9Lki/mt+9fTThMfsOMWkm6SAKnEPZs78G6lxun0fK5wW8/7JgIjupMGQvMtVKT5Q7yMMWzTWEaLA=="], + + "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@18.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IQFGd4vxbzRaw0qTIMADa7kJqEg/q99hHynczP4DFwS5nyemHWLL24evIBCA3XRpKgVrzGObBWslN3yrHxLtMA=="], + + "@oh-my-pi/pi-natives-win32-arm64": ["@oh-my-pi/pi-natives-win32-arm64@18.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-3Ms3K+iA0i6IYm7dW9VbLLiYYBLYLRISn0INjouvXZzq56I8rnUAnmk4VSqMJV7j+yVrwsYkZdx9+rYf3UwShQ=="], + + "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@18.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DCLFXqUfiUj+DcquDMHRo/oVoR1B6UeIaVIGekBqMJBjmpZmYvwKbVjYXkRvcXwAdEyl1/MnQVj1XZg813j20A=="], + + "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" } }, "sha512-PoBJEG7xStDgCKzIrUsjslG/rcrTGTJEAYnzwl2W8gUIRifXDYNnWwO5Ism+pKZcwzzZxX07GsKBwMOvsK/obg=="], + + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.2" } }, "sha512-sRNb2VyioypyVTxStVSn7Hg4XkqIJT7ePpkFW41VQeLHkj6l0TL+GT51aCKzrlzKXEmztOWwsKm6g9NNyuYimg=="], + + "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@18.2.2", "", {}, "sha512-xyHogMnMQhp6xMVYLBZ1v0WIOrsRZ1fS+T5Rid/nuawqWVBNAgbP7dYkRrEqyDphgmi6RTPI5C09AxyJjrL42Q=="], + + "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2" } }, "sha512-aDyidXAGRpL8cPhXuj0v+XxCKipfb6J+ZJIOEfMbeoQW7SHT/x+N3iAV1Rq2eFDe4l9sPtSol2gPXtcxFxOTIQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.11.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="], + + "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="], + + "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="], + + "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="], + + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-logs": "0.220.0", "@opentelemetry/sdk-metrics": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.11.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.11.0", "@opentelemetry/core": "2.11.0", "@opentelemetry/sdk-trace-base": "2.11.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], + + "adm-zip": ["adm-zip@0.6.1", "", {}, "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "global-agent": ["global-agent@4.1.3", "", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lucide-react": ["lucide-react@1.46.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "onnxruntime-common": ["onnxruntime-common@1.30.0", "", {}, "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA=="], + + "onnxruntime-node": ["onnxruntime-node@1.30.0", "", { "dependencies": { "adm-zip": "^0.6.0", "global-agent": "^4.1.3", "onnxruntime-common": "1.30.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw=="], + + "onnxruntime-web": ["onnxruntime-web@1.31.0-dev.20260914-8d85527a0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.31.0-dev.20260911-2a43ec07e", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-Iy7rtadoBgxS/LLvDr3QW38DB1PNXRnr0GJMcL0TAt7c9qjgVQl83UlGCVyeAnK2InpmW8Uc3PL8XIuqtDeF6g=="], + + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "puppeteer-core": ["puppeteer-core@25.3.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.6", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1638949", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], + + "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FPNgJMgnWVl/KhRTIhG3KL3A4Om63Rn4YKXc9/uHY7SzLcvqLJLc/h7UBWJwduXvv7K18t5NpxHR6XgXn4sjWw=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-7BLRpjM6w4f9W46/nmkmq8lEKUayhebvcpslCVQ+6QN2uReYlZEMDZlSpXMjme+hUFrPfRz8P3UNq8ep/4d19g=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Tlg7a70b/Wge3OF8IgTHF9jhSVCsLyKQKhwc4BsJ5A+dL/SrFtGBjzuHp4XeLhiiOT7afCxX5PdSn/D4c8Lnuw=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.8", "", { "os": "linux", "cpu": "x64" }, "sha512-6plnhjagsSeTntCgnlag86hWbs/uZE9Crms1LgOb68/1nKsIQjMd+WG519m+aPwT6TrsBOiEMzrx41t8sL5L5g=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-H0Ojln9hfvM+pxWqW0cnjB/XK8/JPq4/k0IWu2JpY5Z458M3zv+XC3Hu+wmot3AoH/K1Bgt23n/tTKtPV9x8qg=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.8", "", { "os": "win32", "cpu": "x64" }, "sha512-oZF1c9VPOKtMwn83Bboc5XSWL+76BRoyB3eUuVnCknBKxwSULZU2Foia9VHWzU+n4I12rPsP6z6H9Rp1hD9o8g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.31.0-dev.20260911-2a43ec07e", "", {}, "sha512-gBuF6U32YErKIAt+yD7DeGBpjRxhJ1uJwako6ygjokSZULDU6Pd+KWComX8Tumk1hYVHRxjPj7mdMnD0ryAPOw=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + } +} diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 7decfb369..9989583fc 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -6,6 +6,9 @@ "license": "Apache-2.0", "type": "module", "files": ["index.ts"], + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "^18.2.2" + }, "omp": { "extensions": ["./index.ts"], "skills": [] From 2a1c980df1de32ddc8be03ed1cc7c79c76817377 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 18 Sep 2026 19:36:45 +0000 Subject: [PATCH 04/33] fix(omp): nudge every qualifying tool call via its persisted tool result Claude PreToolUse additionalContext parity: run the guard in tool_call as before (strict deny path unchanged), but deliver the nudge by appending it to that call's tool_result content instead of accumulating a deduped context-transform message. Every qualifying call now carries its own guidance inline, persisted across compaction like any tool output; the before_agent_start reset now only clears pending deliveries. --- .gitignore | 3 ++ CHANGELOG.md | 2 +- graphify/omp/index.ts | 42 +++++++++++------------- tests/omp.test.ts | 74 ++++++++++++++++++++++++++++--------------- 4 files changed, 71 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index dba5741e3..b5fec288b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ paper/ # OMP extension test dependency tree (graphify/omp) graphify/omp/node_modules/ + +# Local symlink for the root-level bun tests (temporary, per AGENTS.md) +/node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e10ffe4b..e60062788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -418,7 +418,7 @@ Intra-class call binding across five languages, new language-structure coverage, ## 0.9.58 (2026-09-10) -- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-run context guidance without building indexes. +- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-call guidance appended to each tool result without building indexes. - Fix: a call to a Python function defined nested inside another function now resolves to that inner definition per lexical scope, instead of leaking to a same-named function elsewhere; direct recursion is preserved as a self-loop (#3410, thanks @hopstreax). - Fix: submodule imports inside a PEP 420 namespace package (a directory with no `__init__.py`) now resolve to the target module instead of being dropped (#3429, thanks @flaukowski). - Fix: a bare-name import of a module sitting next to the importing file (a flat script dir with no package) now resolves to that sibling — matching CPython's `sys.path[0]` behavior — without over-resolving a genuine third-party name (#3430, thanks @hopstreax). diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index a018b20a1..784aa1d59 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -15,7 +15,6 @@ import { const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; const TIMEOUT_MS = 2000; -const CONTEXT_TYPE = "graphify-guard"; const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const; function isRemote(path: string): boolean { @@ -47,16 +46,19 @@ function runGuard(command: string, kind: string, payload: string, cwd: string, s } export default function graphify(api: ExtensionAPI): void { - const guidance = new Set(); - let guidanceBytes = 0; + // Claude PreToolUse additionalContext parity: `tool_call` captures the guard's + // guidance for that call, `tool_result` appends it to the persisted tool + // result. Every qualifying call carries its own nudge — no dedup — and the + // text lands inline with the call it belongs to, surviving compaction like + // any other tool output. + const pending = new Map(); let generation = 0; let controller = new AbortController(); const reset = () => { generation++; controller.abort(); controller = new AbortController(); - guidance.clear(); - guidanceBytes = 0; + pending.clear(); }; api.on("session_start", reset); api.on("session_switch", reset); @@ -80,7 +82,6 @@ export default function graphify(api: ExtensionAPI): void { if (isRemote(rawPath)) return; const paths = toolName === "Read" || toolName === "Bash" ? [rawPath] : await expandDelimitedPathEntries([rawPath], ctx.cwd, { splitter: parseSearchPath }); - let remainingOutput = OUTPUT_LIMIT; for (const path of paths) { if (isRemote(path)) continue; const input: Record = { ...event.input }; @@ -93,24 +94,20 @@ export default function graphify(api: ExtensionAPI): void { else input.path = resolved; } const timeout = Math.floor(deadline - performance.now()); - if (current !== generation || !ctx.isProjectTrusted() || timeout <= 0 || remainingOutput <= 0) return; + if (current !== generation || !ctx.isProjectTrusted() || timeout <= 0) return; const output = await runGuard(command, toolName === "Bash" || toolName === "Grep" ? "search" : "read", JSON.stringify({ session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, tool_name: toolName, tool_input: input, - }), ctx.cwd, signal, timeout, remainingOutput); + }), ctx.cwd, signal, timeout, OUTPUT_LIMIT); if (current !== generation || !ctx.isProjectTrusted()) return; if (!output) continue; - remainingOutput -= Buffer.byteLength(output); const hook = JSON.parse(output)?.hookSpecificOutput; if (hook?.hookEventName !== "PreToolUse") continue; if (hook.permissionDecision === "deny" && typeof hook.permissionDecisionReason === "string" && hook.permissionDecisionReason.trim()) { return { block: true, reason: hook.permissionDecisionReason }; } - if (typeof hook.additionalContext === "string" && hook.additionalContext.trim() && !guidance.has(hook.additionalContext)) { - const bytes = Buffer.byteLength(hook.additionalContext) + 2; - if (guidanceBytes + bytes <= OUTPUT_LIMIT) { - guidance.add(hook.additionalContext); - guidanceBytes += bytes; - } + if (typeof hook.additionalContext === "string" && hook.additionalContext.trim()) { + const previous = pending.get(event.toolCallId); + pending.set(event.toolCallId, previous ? `${previous}\n\n${hook.additionalContext}` : hook.additionalContext); } // The search CLI does not inspect individual targets; one call suffices. if (toolName === "Grep") break; @@ -121,14 +118,11 @@ export default function graphify(api: ExtensionAPI): void { } }); - api.on("context", (event, ctx) => { - if (!ctx.isProjectTrusted()) reset(); - // Context transforms are not persisted. Keep one current-run message across - // provider requests, and discard any prior generation's injected message. - const messages = event.messages.filter(message => message.role !== "custom" || message.customType !== CONTEXT_TYPE); - if (guidance.size) messages.push({ - role: "custom", customType: CONTEXT_TYPE, content: [...guidance].join("\n\n"), display: false, timestamp: Date.now(), - }); - if (guidance.size || messages.length !== event.messages.length) return { messages }; + api.on("tool_result", (event, ctx) => { + if (!ctx.isProjectTrusted()) { pending.clear(); return; } + const nudge = pending.get(event.toolCallId); + if (nudge === undefined) return; + pending.delete(event.toolCallId); + return { content: [...event.content, { type: "text" as const, text: nudge }] }; }); } diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 095d6cb8b..659cb837e 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -17,7 +17,7 @@ let control: string; let started: string; let fixture: string; -type Result = { block?: boolean; reason?: string; messages?: { content: string }[] } | undefined; +type Result = { block?: boolean; reason?: string; content?: { type: string; text: string }[] } | undefined; type Handler = (event: Record, ctx: object) => Result | Promise; function harness() { const handlers = new Map(); @@ -55,6 +55,11 @@ else if (config.mode === "overflow") process.stdout.write("x".repeat(65537)); else if (config.mode === "delayed") { while (!existsSync(${JSON.stringify(join(root, "release"))})) await Bun.sleep(5); process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "stale guidance"}})); +} else if (config.mode === "varying") { + const counterFile = ${JSON.stringify(join(root, "count"))}; + const n = existsSync(counterFile) ? Number(readFileSync(counterFile, "utf8")) || 0 : 0; + writeFileSync(counterFile, String(n + 1)); + process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "guidance " + (n + 1)}})); } else if (config.mode === "hung") { process.on("SIGTERM", () => {}); await Bun.sleep(60000); @@ -78,24 +83,26 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }); }); -test("real CLI strict denial blocks selector reads; subsequent guidance dedupes and resets", async () => { +test("real CLI strict denial blocks selector reads; every later call nudges via its tool result and resets", async () => { process.env.GRAPHIFY_HOOK_STRICT = "1"; const api = harness(); await api.emit("before_agent_start"); - const event = { toolName: "read", input: { path: "source.py:1-5" } }; + const event = { toolName: "read", input: { path: "source.py:1-5" }, toolCallId: "call-1" }; const result = await api.emit("tool_call", event); expect(result?.block).toBe(true); expect(result?.reason).toContain("graphify"); expect(existsSync(join(cwd, "graphify-out", "cache", "hook_sessions", "omp-test-session.denied"))).toBe(true); - expect(await api.emit("tool_call", event)).toBeUndefined(); - const first = await api.emit("context", { messages: [] }); - expect(first?.messages).toHaveLength(1); - await api.emit("tool_call", event); - const second = await api.emit("context", { messages: first?.messages }); - expect(second?.messages).toHaveLength(1); - expect(second?.messages?.[0].content).toBe(first?.messages?.[0].content); + // After the one-time deny, every qualifying call still carries its own guidance. + expect(await api.emit("tool_call", { ...event, toolCallId: "call-2" })).toBeUndefined(); + const second = await api.emit("tool_result", { toolCallId: "call-2", content: [{ type: "text", text: "ok" }] }); + expect(second?.content).toHaveLength(2); + expect(second?.content?.[1].text).toContain("graphify"); + expect(await api.emit("tool_call", { ...event, toolCallId: "call-3" })).toBeUndefined(); + const third = await api.emit("tool_result", { toolCallId: "call-3", content: [{ type: "text", text: "ok" }] }); + expect(third?.content).toHaveLength(2); + // Navigation resets pending guidance: a result arriving after the reset is untouched. await api.emit("before_agent_start"); - expect((await api.emit("context", { messages: second?.messages }))?.messages).toEqual([]); + expect(await api.emit("tool_result", { toolCallId: "call-3", content: [{ type: "text", text: "ok" }] })).toBeUndefined(); }); test("native grep, bash search, and glob expose the installed CLI's actual guidance", async () => { @@ -108,61 +115,78 @@ test("native grep, bash search, and glob expose the installed CLI's actual guida const expected = JSON.parse(execFileSync(realCLI!, ["hook-guard", kind], { cwd, input: JSON.stringify({ tool_input: legacyInput }), encoding: "utf8", })).hookSpecificOutput.additionalContext; - expect(await api.emit("tool_call", { toolName, input })).toBeUndefined(); - expect((await api.emit("context", { messages: [] }))?.messages?.[0].content).toBe(expected); + const toolCallId = `${toolName}-1`; + expect(await api.emit("tool_call", { toolName, input, toolCallId })).toBeUndefined(); + const result = await api.emit("tool_result", { toolCallId, content: [{ type: "text", text: "ok" }] }); + expect(result?.content).toHaveLength(2); + expect(result?.content?.[1].text).toBe(expected); } }); +test("multi-target glob calls surface every target's guidance", async () => { + // The real CLI returns identical fresh text per glob target (its stale check + // keys on file_path, which glob payloads do not carry), so a varying fixture + // mode distinguishes the per-target guard invocations the extension must join. + writeFileSync(control, JSON.stringify({ mode: "varying" })); + const api = harness(); + expect(await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" })).toBeUndefined(); + const result = await api.emit("tool_result", { toolCallId: "call-1", content: [{ type: "text", text: "ok" }] }); + expect(result?.content).toHaveLength(2); + expect(result?.content?.[1].text).toContain("guidance 1"); + expect(result?.content?.[1].text).toContain("guidance 2"); +}); + test("URLs, internal resources, literal selector-like names and false trust do not run project hooks", async () => { const api = harness(); for (const path of ["https://example.com/source.py", "www.example.com/source.py", "skill://graphify", "local:/source.py", "source.py; ssh://host/source.py"]) { - await api.emit("tool_call", { toolName: "read", input: { path } }); + await api.emit("tool_call", { toolName: "read", input: { path }, toolCallId: "call-1" }); expect(existsSync(started)).toBe(false); + expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); } api.trust(false); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" }); expect(existsSync(started)).toBe(false); api.trust(true); writeFileSync(join(cwd, "source.py:12"), "a real filename, not a selector"); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" } }); - expect(await api.emit("context", { messages: [] })).toBeUndefined(); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" }, toolCallId: "call-3" }); + expect(await api.emit("tool_result", { toolCallId: "call-3", content: [] })).toBeUndefined(); }); -test("navigation cancels in-flight guidance before the next session's context", async () => { +test("navigation cancels in-flight guidance before the next session's tool results", async () => { for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { rmSync(started, { force: true }); writeFileSync(control, JSON.stringify({ mode: "delayed" })); const api = harness(); - const pending = api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + const pending = api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); const deadline = Date.now() + 1500; while (!existsSync(started) && Date.now() < deadline) await Bun.sleep(5); expect(existsSync(started)).toBe(true); await api.emit(navigation); expect(await pending).toBeUndefined(); - expect(await api.emit("context", { messages: [] })).toBeUndefined(); + expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); } }); test("oversized input never spawns, invalid/oversized output fails open, and a hung child is killed", async () => { const api = harness(); - await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) } }); + await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) }, toolCallId: "call-1" }); expect(existsSync(started)).toBe(false); for (const mode of ["invalid", "overflow", "hung"]) { writeFileSync(control, JSON.stringify({ mode })); const start = performance.now(); - expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } })).toBeUndefined(); + expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" })).toBeUndefined(); expect(performance.now() - start).toBeLessThan(3500); - expect(await api.emit("context", { messages: [] })).toBeUndefined(); + expect(await api.emit("tool_result", { toolCallId: "call-2", content: [] })).toBeUndefined(); } }, 10000); test("missing or project-local executables never fall back to project Python code", async () => { process.env.PATH = cwd; const api = harness(); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); expect(existsSync(started)).toBe(false); writeFileSync(join(cwd, "graphify"), readFileSync(fixture)); chmodSync(join(cwd, "graphify"), 0o755); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py" } }); + await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" }); expect(existsSync(started)).toBe(false); }); From aa394fe330eceda84e5fca60df89a972c7622633 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 18 Sep 2026 20:24:44 +0000 Subject: [PATCH 05/33] docs(omp): match guidance wording to per-call tool-result delivery Copilot review finding: the README still described the replaced deduplicated context message. Describe the actual behavior: each qualifying call's guidance is appended to that call's persisted tool result. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da803e9df..7f02a04d0 100644 --- a/README.md +++ b/README.md @@ -342,8 +342,10 @@ Before native `read`, `glob`, `grep`, and search-style `bash` calls, the extensi runs the installed `graphify hook-guard read|search` CLI. The existing Python policy owns fresh/stale graph decisions and strict-mode denials: start OMP with `GRAPHIFY_HOOK_STRICT=1` to enable its once-per-session indexed-read block. -Denials become OMP `block`/`reason`; guidance becomes one deduplicated context -message, cleared for each new user run and session navigation. In-flight hooks +Denials become OMP `block`/`reason`; each qualifying call carries its own +guidance, appended to that call's persisted tool result (Claude +`PreToolUse` additionalContext parity), and pending deliveries are cleared for +each new user run and session navigation. In-flight hooks are cancelled on these boundaries. No graph is created or updated automatically. The guard package intentionally declares no skills; the existing cross-framework skill remains available separately through `graphify agents install`. From 3c5b7ac08d39c7f446781e60a6697c7bf47ce548 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 08:52:41 +0000 Subject: [PATCH 06/33] fix(omp): import isReadableUrlPath from its current pi-tui location Upstream OMP moved isReadableUrlPath from @oh-my-pi/pi-coding-agent/tools/path-utils to @oh-my-pi/pi-tui/tools/read. At the 18.2.2 devDependency floor path-utils still re-exported it, so CI stayed green, but every OMP install past that point (18.2.6 is current) throws SyntaxError: Export named 'isReadableUrlPath' not found before the extension's api.on("tool_call", ...) ever registers, silently disabling the hook-guard nudge. Import it from @oh-my-pi/pi-tui/tools/read directly, raise the @oh-my-pi/pi-coding-agent floor to ^18.2.6 (the first version pinned by this fix where the export lives at its new home), and declare @oh-my-pi/pi-tui ^18.2.6 as its own devDependency since the bridge now imports from it directly rather than relying on it as an undeclared transitive dependency of pi-coding-agent. --- graphify/omp/bun.lock | 39 ++++++++++++++++++++------------------- graphify/omp/index.ts | 2 +- graphify/omp/package.json | 3 ++- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/graphify/omp/bun.lock b/graphify/omp/bun.lock index 41e249050..3e723793b 100644 --- a/graphify/omp/bun.lock +++ b/graphify/omp/bun.lock @@ -5,7 +5,8 @@ "": { "name": "graphify-omp", "devDependencies": { - "@oh-my-pi/pi-coding-agent": "^18.2.2", + "@oh-my-pi/pi-coding-agent": "^18.2.6", + "@oh-my-pi/pi-tui": "^18.2.6", }, }, }, @@ -92,41 +93,41 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], - "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-2NQ6Xe9M6859FMiUF9qiD2hG/Eht+N1sakJ5q1+liUdt0g/jNAmjErGTvGDKOXKq5niz362R+oLo4xrP0wJrFg=="], + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-5jh/b4hDF2rK67gSpMKR7qBCTMQyI7FLU0jtDScBY6ILdCMQnrHIs6BsP09j984lR/HnnrLBGFOWZqMMkXZsvA=="], - "@oh-my-pi/omptype": ["@oh-my-pi/omptype@18.2.2", "", {}, "sha512-tG9W0Acl0Sg0iONdXG744J9TRjxU9qgKmfdivD/NfZ/0fQ1mr5hajx0yk/Jm4KzfVazwZErCqiH4Qce7OL02hg=="], + "@oh-my-pi/omptype": ["@oh-my-pi/omptype@18.2.6", "", {}, "sha512-4VUGOSr791W1lXNpU1hQXrKd4BqaEQlz2OHXYecZ6MTENb6Hx9JC00Sf7Db4dxPjkEzyibD1YjVUXqK3DQeO+g=="], - "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2", "@oh-my-pi/snapcompact": "18.2.2", "@opentelemetry/api": "^1.9.1" } }, "sha512-sEzkNqbKRvBb1EMhEG5n1+AGd9q4UT3B5vto21EGyZhF7dSNdYtcMAfqUMQZLSP/kgu2RzUnvXZTDHbvKWFZxw=="], + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6", "@oh-my-pi/snapcompact": "18.2.6", "@opentelemetry/api": "^1.9.1" } }, "sha512-BeakwQjJaF/w+8ISIQSbPRJJy+hSMM4XVB5kgx+qUKpykyrKXbw4zgG+tOF9+3VyCnDZZemv92FQ0Wdnmabchg=="], - "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@18.2.2", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2" } }, "sha512-3fqCIzHLL/g53MdsUZHR3RvWaQYh+VLSJe2D99KnlZGGWqzFRfXFG6YgSzLQg9UdjPkdyJfivFNi+D0Gt0lYew=="], + "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@18.2.6", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6" } }, "sha512-SkQ5Uc5ABS5WiKPVdPAmxawhZf1kDSH53CrXrjlgshpCT6r6jDs/CI+VvwdhQHoEQcokM8pmWDGPzGfKTa7K4w=="], - "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@18.2.2", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" } }, "sha512-FgUqjLdWIMRfBhtC3WzOOBgC5iWIeRer59eoPclMzlXc5WeV8AxIeZoEOQuYCsCpTembgPeDfTIzFKr1zf/mug=="], + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@18.2.6", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6" } }, "sha512-jYg/cqztVXPWa3soLvjGxKs/4oIm8vDwuvue0tfPv/qiEWzxPy+PsI+9zbKZZEipfVlQUAyD+8LnkQdGlbqIDg=="], - "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@18.2.2", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/omp-stats": "18.2.2", "@oh-my-pi/omptype": "18.2.2", "@oh-my-pi/pi-agent-core": "18.2.2", "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-mnemopi": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-tui": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2", "@oh-my-pi/snapcompact": "18.2.2", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-rZmmx2f9UZkK2rRteCFwxxcDAdxXfz9AD032fZJdAdyfa1CFWixns9tsMML3DdNOytD1619QILna/IxsMspbgA=="], + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@18.2.6", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/omp-stats": "18.2.6", "@oh-my-pi/omptype": "18.2.6", "@oh-my-pi/pi-agent-core": "18.2.6", "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-mnemopi": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-tui": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6", "@oh-my-pi/snapcompact": "18.2.6", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-dk298PZNnD1QigR4lKvQpWVAAbIo2bCfdNOlZwGFZsxHbP+XYrUS+AxYxfWkfqSnFRliP9Ho+h94DHV3o6lxRg=="], - "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-+wgJYNsHwlaDXBWGqv66NpOnC+9/3XNzADoOihrlH+iZEse3P0PLsYozCvnjsXQCYViz58MFKV8Vnd0AWuqafQ=="], + "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-JD4iVlqUVnoSsMJn7Fy+Hn3zUMKH8i6qpWImoTTnyuEJ0iqL1n6H1W96GqVnhfEDoBf98/mPwoZM2mDHNKEfYg=="], - "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@18.2.2", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "18.2.2", "@oh-my-pi/pi-natives-darwin-x64": "18.2.2", "@oh-my-pi/pi-natives-linux-arm64": "18.2.2", "@oh-my-pi/pi-natives-linux-x64": "18.2.2", "@oh-my-pi/pi-natives-win32-arm64": "18.2.2", "@oh-my-pi/pi-natives-win32-x64": "18.2.2" } }, "sha512-R1qzx/zG9iob90umYFI28SzznvzFhhqzWQ+xjv0Qyi5zAv4yEGtKIsN5EQYdZXd5SRf9oN7TVTPY00y9hywhXA=="], + "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@18.2.6", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "18.2.6", "@oh-my-pi/pi-natives-darwin-x64": "18.2.6", "@oh-my-pi/pi-natives-linux-arm64": "18.2.6", "@oh-my-pi/pi-natives-linux-x64": "18.2.6", "@oh-my-pi/pi-natives-win32-arm64": "18.2.6", "@oh-my-pi/pi-natives-win32-x64": "18.2.6" } }, "sha512-oZtxz7cU6CJvwdGJgpeHDIvhKOBoGVwRzmb7RQt+NdFD7tVe7PrObgbQdAOpmo3LwbJ3Md/jiicWmaxboFSRTQ=="], - "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@18.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JFpluDNuKF2RmkTNjrjiWcxRiz2CLB/zSyXaNlhVVKr4iFp/oTQ+2mufb8KaGpwaW6aIA0kAPohVONNWltQfuQ=="], + "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@18.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PVrTHpeVc8n3kOCNUd5Qq5bUyo1y8BtFRcm1UKL8LGF8OzHOrob280iz6K0LIiv8ovbfSjZSvCjYI/KSaM3YMQ=="], - "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@18.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-37fwjJf/HUZ9k/WAShXc38GTzP0BWtBemoAvEsvR+i58Rd8yPMr0UhV2gEQHcFcyb7syJYryG43q5sTMmE4dpA=="], + "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@18.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-qfZeZ0Xz9aONtp6x7eiWUQMlm2blEqauth2SntLplV9cdYTKailWHjpGBoyvfs/yptddA9Pbj6iN9GlzmpbgOQ=="], - "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@18.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jp9o4yGCND9Lki/mt+9fTThMfsOMWkm6SAKnEPZs78G6lxun0fK5wW8/7JgIjupMGQvMtVKT5Q7yMMWzTWEaLA=="], + "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@18.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-hHdJnF0pw3GlgTpscPCeRT9fcf/yXbrwcvdFgVg482y6xDq8KBT5rzG+V/ntUbcjj/qfBQQiH1do2XlrbL6nFw=="], - "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@18.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IQFGd4vxbzRaw0qTIMADa7kJqEg/q99hHynczP4DFwS5nyemHWLL24evIBCA3XRpKgVrzGObBWslN3yrHxLtMA=="], + "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@18.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-mV2DTOE1Hn96iXNIbrzYHzLy9CbeAbjLCGPtc5tzFzzuD7UJ3TMfP4pieSo0WtgZnPNxisT6ncR8c/BqNbkXdg=="], - "@oh-my-pi/pi-natives-win32-arm64": ["@oh-my-pi/pi-natives-win32-arm64@18.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-3Ms3K+iA0i6IYm7dW9VbLLiYYBLYLRISn0INjouvXZzq56I8rnUAnmk4VSqMJV7j+yVrwsYkZdx9+rYf3UwShQ=="], + "@oh-my-pi/pi-natives-win32-arm64": ["@oh-my-pi/pi-natives-win32-arm64@18.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-nk1ZUvF27HZJNwL8TwUMLUdmFuf8vsv1StAMsHQ9zdgLuRJ1NwWESol6wXzRntvDzsmpDQbnf3R8HcY5CzUf9w=="], - "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@18.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DCLFXqUfiUj+DcquDMHRo/oVoR1B6UeIaVIGekBqMJBjmpZmYvwKbVjYXkRvcXwAdEyl1/MnQVj1XZg813j20A=="], + "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@18.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-syDZJnTiTxFIkTteT4U5edq/hdPrPjOYmyG8Kq4a/LwHoD59eDpkE3+kqVy33GRfOwzJb0b6d6Hxrp8we75b9g=="], - "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2" } }, "sha512-PoBJEG7xStDgCKzIrUsjslG/rcrTGTJEAYnzwl2W8gUIRifXDYNnWwO5Ism+pKZcwzzZxX07GsKBwMOvsK/obg=="], + "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@18.2.6", "", { "dependencies": { "@oh-my-pi/omptype": "18.2.6", "@oh-my-pi/pi-agent-core": "18.2.6", "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6", "@oh-my-pi/snapcompact": "18.2.6" } }, "sha512-CsmP4SM1Qt19s2aIJ0gBTnSFLm2nXNHkaLFqL5m+3+bTcF3++G+CAEImotsjSnmajeH/Q2pq2w+FlNUD2ZjByA=="], - "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.2" } }, "sha512-sRNb2VyioypyVTxStVSn7Hg4XkqIJT7ePpkFW41VQeLHkj6l0TL+GT51aCKzrlzKXEmztOWwsKm6g9NNyuYimg=="], + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.2.6" } }, "sha512-jvj8w/Q9aECXpHVjUc4rWaQlFPV+uWCgII6QZjDfroo5xvt0d8ks64cH8gINmMwRieCLz7KU6JKGReuFT54aTw=="], - "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@18.2.2", "", {}, "sha512-xyHogMnMQhp6xMVYLBZ1v0WIOrsRZ1fS+T5Rid/nuawqWVBNAgbP7dYkRrEqyDphgmi6RTPI5C09AxyJjrL42Q=="], + "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@18.2.6", "", {}, "sha512-CfKosiyS2L3EfqVAidDYjfpBEVRuHAueYIlydEySARSLj61euYSqCZFMNhCPcA+QFXhfG0J0N5xGRuMk5FzRaA=="], - "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@18.2.2", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.2", "@oh-my-pi/pi-catalog": "18.2.2", "@oh-my-pi/pi-natives": "18.2.2", "@oh-my-pi/pi-utils": "18.2.2", "@oh-my-pi/pi-wire": "18.2.2" } }, "sha512-aDyidXAGRpL8cPhXuj0v+XxCKipfb6J+ZJIOEfMbeoQW7SHT/x+N3iAV1Rq2eFDe4l9sPtSol2gPXtcxFxOTIQ=="], + "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@18.2.6", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.2.6", "@oh-my-pi/pi-catalog": "18.2.6", "@oh-my-pi/pi-natives": "18.2.6", "@oh-my-pi/pi-utils": "18.2.6", "@oh-my-pi/pi-wire": "18.2.6" } }, "sha512-hPL6N9YNxejTY1jOm8+FbcbuLjtgPI/v591tunKed7gIcQgOOuztNvTcM/Xbc5xc50CBcWoD6q+j4Hm0iuChlQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 784aa1d59..2779fc0bf 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -5,12 +5,12 @@ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; import { expandDelimitedPathEntries, isInternalUrlPath, - isReadableUrlPath, normalizePathLikeInput, parseSearchPath, resolveReadPath, splitPathAndSelPreferringLiteral, } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; +import { isReadableUrlPath } from "@oh-my-pi/pi-tui/tools/read"; const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 9989583fc..116193ca8 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -7,7 +7,8 @@ "type": "module", "files": ["index.ts"], "devDependencies": { - "@oh-my-pi/pi-coding-agent": "^18.2.2" + "@oh-my-pi/pi-coding-agent": "^18.2.6", + "@oh-my-pi/pi-tui": "^18.2.6" }, "omp": { "extensions": ["./index.ts"], From 52d1a81b572889847a7189b85a719df40aaa659a Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 08:52:48 +0000 Subject: [PATCH 07/33] ci(omp): run the OMP bridge tests against latest OMP too (advisory) bun install --frozen-lockfile in the omp-bridge job pins an exact known-good OMP version, so CI can never observe a future upstream symbol move (this is exactly how the pi-tui export move shipped unnoticed: the lockfile pinned 18.2.2, where isReadableUrlPath still lived in pi-coding-agent). Add a step that installs whatever OMP publishes today and re-runs the suite against it, continue-on-error like the security-scan job's advisory checks, so an unrelated upstream release cannot block a merge while still surfacing a real break. --- .github/workflows/ci.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebc2a258d..c90e4b063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,9 +80,10 @@ jobs: omp-bridge: # tests/omp.test.ts drives the real OMP extension: bun resolves the - # path-utils helpers it imports from the pinned @oh-my-pi/pi-coding-agent - # devDependency in graphify/omp/, and the policy assertions run against - # the real installed Python CLI, so this job needs both toolchains. + # path-utils and pi-tui helpers it imports from the pinned + # @oh-my-pi/pi-coding-agent and @oh-my-pi/pi-tui devDependencies in + # graphify/omp/, and the policy assertions run against the real installed + # Python CLI, so this job needs both toolchains. runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -105,6 +106,19 @@ jobs: - name: Run OMP bridge tests run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + - name: Run OMP bridge tests against the latest OMP (advisory) + # bun install --frozen-lockfile above only proves the bridge works + # against the exact pinned version. Upstream OMP has moved exported + # symbols between minor releases before (isReadableUrlPath: pi-coding-agent + # -> pi-tui) without the frozen lockfile ever seeing it. Install + # whatever OMP publishes today and re-run the suite so a future move + # surfaces here. continue-on-error: an unrelated upstream release must + # not block a merge; the frozen run above stays the blocking gate. + continue-on-error: true + run: | + bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest @oh-my-pi/pi-tui@latest + GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new # HIGH-severity finding or vulnerable dependency is caught on the PR that From 1c05247daa99535b1b5b0c28f9305798c8954017 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:12:22 +0000 Subject: [PATCH 08/33] fix(hooks): stop URL-shaped read targets from bypassing the out-of-project guard _is_cwd_relative answered "no root and no drive" to decide whether a file_path/path value is cwd-anchored. A URL is rootless and driveless by that exact same test (https://x, myscheme://x, and a bare www.host/path all have no root/drive), so it short-circuited straight to "in project" and the containment check below never ran. - _normalize_hook_path: trims/de-quotes input and strips a leading file:// scheme to the local path it names (OMP's own pipeline resolves file:// to a local path, not an external URL -- it must still nudge). - _is_foreign_url_scheme: rejects any other whole-value scheme:// prefix. Deliberately does not enumerate a harness's internal-scheme vocabulary (local://, artifact://, ...) -- an unrecognized scheme is simply treated as not-a-local-source-file. - _has_embedded_url_scheme_segment: catches the same URL after an upstream host has already glued it onto an absolute prefix (/local:/x), which looks exactly like a real file to the (unchanged) containment check. - _is_external_www_target: a bare www.host/path carries no :// at all, so it is handled separately, existence-gated to mirror OMP's own "an existing local path wins over URL" precedence. Containment, the extension allow-list, and staleness/strict-deny logic are untouched. --- graphify/cli.py | 118 +++++++++++++++++++ tests/test_hook_url_paths.py | 218 +++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 tests/test_hook_url_paths.py diff --git a/graphify/cli.py b/graphify/cli.py index 0f1a3bc02..e8e6aa209 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -13,6 +13,7 @@ import time from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT from pathlib import Path, PurePosixPath, PureWindowsPath +from urllib.parse import unquote, urlsplit _SEARCH_NUDGE = json.dumps({ @@ -1066,10 +1067,26 @@ def _is_under_out(target: str) -> bool: if explicit: in_project = False for v in explicit: + v = _normalize_hook_path(v) + # A bare www.host/... value is remote only when nothing + # identically named exists locally, mirroring OMP's own + # "an existing local path wins over URL" precedence + # (resolveToolSearchScope). Decided here, not inside + # _is_cwd_relative, which has no cwd/root of its own. + if _is_external_www_target(v, root): + continue p = Path(v) if _is_cwd_relative(v): in_project = True # relative -> anchored at cwd == in project break + # _is_cwd_relative already rejects a *whole-value* + # scheme://... prefix above (a rootless URL never reaches + # here). This catches the same URL after some upstream + # host has already glued it onto an absolute prefix + # (`/local:/x`), which looks exactly like a real + # file to the containment check below. + if _has_embedded_url_scheme_segment(v): + continue try: p.resolve().relative_to(root) in_project = True @@ -1116,6 +1133,55 @@ def _is_under_out(target: str) -> bool: pass +_URL_SCHEME_PREFIX_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://") +_URL_SCHEME_SEGMENT_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:$") +_FILE_URL_RE = re.compile(r"^file://", re.IGNORECASE) +_WWW_HOST_RE = re.compile(r"^www\.", re.IGNORECASE) + + +def _normalize_hook_path(value: str) -> str: + r"""Trim padding whitespace and a matching pair of outer double quotes, + then strip a leading ``file://`` scheme down to the local path it names. + + Mirrors what OMP's own path pipeline already does to a raw tool argument + before a hook would ever see it: ``normalizePathLikeInput`` (trim + + de-quote) and ``file://`` -> ``url.fileURLToPath`` (its own + ``strictExternalUrlRe`` deliberately omits ``file``, routing it through + the ordinary local-file path instead of the external-URL one). The guard + must classify identical text identically whether or not a given host + bothered to normalize it first -- a classifier that only agrees with its + own host after trimming/de-quoting is exactly the kind of gap a prior + review flagged. + """ + value = value.strip() + if len(value) > 1 and value[0] == value[-1] == '"': + value = value[1:-1] + if _FILE_URL_RE.match(value): + path = unquote(urlsplit(value).path) or "/" + # file:///C:/proj/a.py -> C:/proj/a.py: drop the URL's extra root + # slash in front of a Windows drive letter. + if os.name == "nt" and re.match(r"^/[A-Za-z]:", path): + path = path[1:] + return path + return value + + +def _is_foreign_url_scheme(value: str) -> bool: + """Whether *value* is itself a ``scheme://...`` value -- everything + except ``file://``, which ``_normalize_hook_path`` has already reduced + to a plain local path by the time this runs, so it never matches here. + + Deliberately does not enumerate OMP's (or any other harness's) internal + scheme allow-list (``local://``, ``artifact://``, ...): this guard is + embedded by multiple hosts (#522), and hard-coding one of them would + silently stop matching the day that host adds a scheme. Any scheme this + function doesn't specifically know to be local -- only ``file://`` is -- + is treated as not-a-local-source-file: a missed nudge on a remote URL is + a far safer wrong answer than a wrong nudge on an unrelated file. + """ + return bool(_URL_SCHEME_PREFIX_RE.match(value)) + + def _is_cwd_relative(value: str) -> bool: r"""Whether *value* is anchored at the current working directory. @@ -1138,11 +1204,63 @@ def _is_cwd_relative(value: str) -> bool: so ``paths.is_absolute_any_platform`` (for stored, portable paths) is deliberately not used. On POSIX ``root`` is set exactly when the path is absolute and ``drive`` is always empty, so this is unchanged there. + + A URL is rootless and driveless by that exact same test -- ``https://x`` + and ``myscheme://x`` have no ``root`` and no ``drive`` in either flavour + -- so without checking the scheme first, this rule alone short-circuits + the caller straight to "in project" for a value that names no filesystem + path at all (the bug this guards against, #522 follow-up). + ``_normalize_hook_path`` and ``_is_foreign_url_scheme`` reject that + shape, and strip a ``file://`` wrapper down to its local path, before + the root/drive test below ever runs. """ + value = _normalize_hook_path(value) + if _is_foreign_url_scheme(value): + return False pure = PureWindowsPath(value) if os.name == "nt" else PurePosixPath(value) return not pure.root and not pure.drive +def _has_embedded_url_scheme_segment(value: str) -> bool: + """Whether an already-absolute *value* still carries a collapsed + ``scheme://`` marker as one of its OWN path segments, e.g. + ``/local:/x`` -- what a naive host-side path-join leaves behind + when it glues a rootless ``scheme://...`` value onto a root/cwd prefix + instead of routing it through a URL handler, defeating the whole-value + check in ``_is_cwd_relative`` above. + + Only meaningful once a value is already known to have a root or drive + (``_is_cwd_relative`` returned ``False``): the leading component IS that + root/drive marker, so it is skipped here on purpose -- a genuinely + relative, colon-bearing POSIX filename like ``C:/proj/a.py`` never + reaches this function at all (it takes the cwd-relative shortcut above + and stays in-project, matching POSIX semantics where a colon has no + special meaning). + """ + pure = PureWindowsPath(value) if os.name == "nt" else PurePosixPath(value) + parts = pure.parts[1:] if (pure.root or pure.drive) else pure.parts + return any(_URL_SCHEME_SEGMENT_RE.match(part) for part in parts) + + +def _is_external_www_target(value: str, root: "Path") -> bool: + """Whether *value* is a bare ``www.host/...`` external-read shape with no + identically named local file to override it. + + Mirrors OMP's own ``isReadableUrlPath``/``resolveToolSearchScope`` + precedence: a ``www.`` value carries no ``://`` at all, so no scheme rule + catches it, and it is remote only when nothing local shares its exact + name ("an existing local path wins over URL"). A project that genuinely + has a top-level path named e.g. ``www.example.com`` stays classified as + local; only the common case -- no such path -- is treated as external. + """ + if not _WWW_HOST_RE.match(value): + return False + try: + return not (root / value).exists() + except OSError: + return False + + def _target_is_indexed(file_path: str, root: "Path") -> bool: """Guard the strict deny: only block a read of a file the graph actually indexes. Reads manifest.json (cheap, capped); on any doubt (missing/corrupt/oversized diff --git a/tests/test_hook_url_paths.py b/tests/test_hook_url_paths.py new file mode 100644 index 000000000..cdd1ffaa8 --- /dev/null +++ b/tests/test_hook_url_paths.py @@ -0,0 +1,218 @@ +r"""URL-shaped read targets must not fool the out-of-project guard. + +`_is_cwd_relative` answers "no root and no drive" to decide whether a +`file_path`/`path` value is cwd-anchored. A URL is rootless and driveless by +that exact same test: `https://example.com/source.py`, +`myscheme://x.py` and even a bare `www.example.com/source.py` all have no +`root` and no `drive`, so -- unmodified -- they short-circuit `_run_hook_guard` +straight to "in project" and the (correct, untouched) containment check below +never runs. Reproduced live against `graphify hook-guard read`, cwd at a +graphed project: + + https://example.com/source.py -> NUDGE (wrong) + www.example.com/source.py -> NUDGE (wrong) + /local:/source.py -> NUDGE (wrong; a naive + host-side path-join + glues a rootless + local://source.py onto + an absolute prefix, + which the containment + check can't tell apart + from a real file) + /artifact:/5.py -> NUDGE (wrong, same) + /etc/elsewhere/source.py -> silent (right) + src/usr/local/pkg/pfblockerng/pfb_unbound.py -> NUDGE (right) + +Three traps a naive "rootless value carrying `scheme://` is not cwd-relative" +rule would get wrong: + + 1. `file://` is LOCAL. OMP's own pipeline strips it and resolves the + underlying path (its `strictExternalUrlRe` deliberately omits `file`), + so `file:////src/foo.py` must still nudge -- silencing it would + be a false negative worse than the bug this fixes. + 2. `www.host/path` carries no `://` at all, so no scheme rule fires on it. + Decision recorded here: it IS handled, existence-gated exactly like + OMP's own `resolveToolSearchScope` ("an existing local path wins over + URL") -- `_is_external_www_target` below, not `_is_cwd_relative` itself, + which has no cwd/root context to make that call. + 3. An unrecognized scheme (`myscheme://x`) is LOCAL to OMP's own + classifier, but this guard deliberately diverges: harness-agnostic + silence beats a harness-specific scheme allow-list. + +Input is normalized (trim + de-quote, `_normalize_hook_path`) before any +shape test, so a quoted or whitespace-padded value classifies identically to +its literal form. +""" +import io +import json +import sys +import time + +import pytest + +import graphify.cli as cli +from graphify.cli import ( + _has_embedded_url_scheme_segment, + _is_cwd_relative, + _is_external_www_target, + _normalize_hook_path, +) + + +# --------------------------------------------------------------------------- +# Classification units +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value", [ + "https://example.com/source.py", + "myscheme://x.py", # unrecognized scheme (trap 3): still rejected + ' "https://example.com/source.py" ', # quoted + whitespace-padded +]) +def test_url_prefixed_values_are_not_cwd_relative(value): + assert _is_cwd_relative(value) is False, value + + +def test_file_url_is_not_naively_cwd_relative(tmp_path): + """file:// (trap 1) strips to an absolute local path, so it is NOT the + naive "no root, no drive -> in project" shortcut -- it goes through the + real containment check below, exercised end-to-end further down.""" + target = tmp_path / "src" / "foo.py" + assert _is_cwd_relative(f"file://{target.as_posix()}") is False + + +def test_in_project_relative_paths_are_unaffected(): + for value in ["src/mod.py", "a.py", "./rel.py", ' "src/mod.py" ']: + assert _is_cwd_relative(value) is True, value + + +def test_absolute_out_of_project_path_is_unaffected(): + """Control: an ordinary absolute path outside the project, no URL + involved -- must keep working exactly as before.""" + assert _is_cwd_relative("/etc/elsewhere/source.py") is False + + +def test_embedded_scheme_segment_detects_a_mangled_absolute_url(): + """/local:/source.py and /artifact:/5.py: what a naive + host-side path-join leaves behind when it glues a rootless scheme://... + value onto an absolute prefix instead of routing it through a URL + handler. _is_cwd_relative alone can't catch these (they DO have a root), + so this is the companion check the call site also runs.""" + assert _has_embedded_url_scheme_segment("/root/git/pfBlockerNG/local:/source.py") is True + assert _has_embedded_url_scheme_segment("/root/git/pfBlockerNG/artifact:/5.py") is True + + +def test_embedded_scheme_segment_is_silent_on_ordinary_absolute_paths(): + assert _has_embedded_url_scheme_segment("/etc/elsewhere/source.py") is False + assert _has_embedded_url_scheme_segment("/root/git/pfBlockerNG/src/mod.py") is False + + +def test_www_target_is_external_only_without_a_matching_local_file(tmp_path): + """Trap 2, decided: www. IS handled, gated on local existence (mirrors + OMP's own "an existing local path wins over URL" precedence).""" + assert _is_external_www_target("www.example.com/source.py", tmp_path) is True + local = tmp_path / "www.example.com" / "source.py" + local.parent.mkdir(parents=True) + local.write_text("# real file", encoding="utf-8") + assert _is_external_www_target("www.example.com/source.py", tmp_path) is False + assert _is_external_www_target("src/mod.py", tmp_path) is False + + +def test_normalize_hook_path_strips_padding_quotes_and_file_scheme(): + assert _normalize_hook_path(' "src/mod.py" ') == "src/mod.py" + assert _normalize_hook_path("src/mod.py") == "src/mod.py" + assert _normalize_hook_path("file:///abs/proj/foo.py") == "/abs/proj/foo.py" + + +# --------------------------------------------------------------------------- +# End-to-end through the guard (real stdin JSON, cwd at a graphed project) +# --------------------------------------------------------------------------- + +def _project(tmp_path): + src = tmp_path / "src" + src.mkdir() + f = src / "mod.py" + f.write_text("def x():\n return 1\n", encoding="utf-8") + out = tmp_path / "graphify-out" + out.mkdir() + (out / "manifest.json").write_text( + json.dumps({"src/mod.py": {"mtime": 1}}), encoding="utf-8") + time.sleep(0.02) + (out / "graph.json").write_text('{"nodes":[],"links":[]}', encoding="utf-8") + return f + + +def _invoke(tmp_path, monkeypatch, file_path): + monkeypatch.chdir(tmp_path) + payload = {"session_id": "s1", "tool_name": "Read", + "tool_input": {"file_path": str(file_path)}} + + class _Stdin: + buffer = io.BytesIO(json.dumps(payload).encode()) + monkeypatch.setattr(sys, "stdin", _Stdin()) + buf = io.StringIO() + monkeypatch.setattr(sys, "stdout", buf) + cli._run_hook_guard("read") + return buf.getvalue() + + +def test_https_url_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, "https://example.com/source.py").strip() == "" + + +def test_www_host_with_no_local_match_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, "www.example.com/source.py").strip() == "" + + +def test_mangled_absolute_local_scheme_url_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + target = f"{tmp_path}/local:/source.py" + assert _invoke(tmp_path, monkeypatch, target).strip() == "" + + +def test_mangled_absolute_artifact_scheme_url_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + target = f"{tmp_path}/artifact:/5.py" + assert _invoke(tmp_path, monkeypatch, target).strip() == "" + + +def test_unrecognized_scheme_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, "myscheme://x.py").strip() == "" + + +def test_absolute_out_of_project_path_stays_silent(tmp_path, monkeypatch): + """Control (right today, must stay right): containment already works.""" + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, "/etc/elsewhere/source.py").strip() == "" + + +def test_in_project_relative_path_still_nudges(tmp_path, monkeypatch): + """Control (right today, must stay right).""" + _project(tmp_path) + assert "MANDATORY" in _invoke(tmp_path, monkeypatch, "src/mod.py") + + +def test_file_url_to_an_indexed_in_project_file_still_nudges(tmp_path, monkeypatch): + """Trap 1: file:// must NOT regress to silent.""" + f = _project(tmp_path) + assert "MANDATORY" in _invoke(tmp_path, monkeypatch, f"file://{f.as_posix()}") + + +def test_quoted_and_padded_https_url_is_silent(tmp_path, monkeypatch): + _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, ' "https://example.com/source.py" ').strip() == "" + + +def test_padded_in_project_path_still_nudges(tmp_path, monkeypatch): + """Leading whitespace only (not quotes, not trailing padding): the + extension allow-list this guard must not touch splits on the final "/" + and matches the tail verbatim, so trailing junk after ".py" fails IT + regardless of classification -- that is pre-existing, out-of-scope + behaviour, not the URL-classification bug this file covers. Quoted and + doubly-padded input is proven at the classification layer directly in + test_in_project_relative_paths_are_unaffected above. + """ + _project(tmp_path) + assert "MANDATORY" in _invoke(tmp_path, monkeypatch, " src/mod.py") From ac4c1ba85525af7f7a895ac25d441543c9316dc2 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:12:29 +0000 Subject: [PATCH 09/33] refactor(omp): simplify the bridge now that the guard defends itself - isRemote gets a file:// carve-out: OMP's own resolveReadPathAsync already reduces file:// to the real local path (its strictExternalUrlRe deliberately excludes file), so routing it through yields the correct absolute path instead of silently dropping every file:// read. isInternalUrlPath/isReadableUrlPath keep earning their place -- resolveReadPathAsync still pre-resolves a rootless *foreign*-scheme value onto cwd into something that reads as a real in-project file, so the bridge still must not hand it those. - resolveReadPath -> resolveReadPathAsync: same variant order/winner semantics, non-blocking probes instead of statSync per candidate on the tool_call hot path (read.ts itself already uses the async variant). - Drop the explicit { splitter: parseSearchPath } argument to expandDelimitedPathEntries -- already its default. Adds one bridge-level regression test: an in-project file:// target still nudges, an out-of-project one still stays silent. --- graphify/omp/index.ts | 21 +++++++++++++++++---- tests/omp.test.ts | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 2779fc0bf..67a3d552b 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -6,8 +6,7 @@ import { expandDelimitedPathEntries, isInternalUrlPath, normalizePathLikeInput, - parseSearchPath, - resolveReadPath, + resolveReadPathAsync, splitPathAndSelPreferringLiteral, } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; import { isReadableUrlPath } from "@oh-my-pi/pi-tui/tools/read"; @@ -16,8 +15,22 @@ const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; const TIMEOUT_MS = 2000; const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const; +const FILE_URL_RE = /^file:\/\//i; +// isInternalUrlPath/isReadableUrlPath still earn their place even though the +// guard now defends itself against URL-shaped input: resolveReadPathAsync +// below pre-resolves a rootless value to an absolute path +// (`path.resolve(cwd, x)`) before the guard ever sees it, which for a +// *foreign*-scheme value glues it onto cwd into something that looks +// exactly like a real in-project file (`/https:/example.com/x`, +// `/local:/x`) -- a guard fix the bridge then defeats by mangling its +// input first is not a fix. file:// is the one exception: OMP's own +// resolveReadPathAsync already strips it down to the real local path (its +// resolveToCwd -> expandPath -> stripFileUrl, deliberately excluded from +// the external-URL fast path), so routing it through here yields the +// correct absolute path rather than a mangled one. function isRemote(path: string): boolean { + if (FILE_URL_RE.test(path)) return false; return isInternalUrlPath(path) || isReadableUrlPath(path) || path.includes("://"); } @@ -81,13 +94,13 @@ export default function graphify(api: ExtensionAPI): void { const rawPath = typeof event.input.path === "string" ? normalizePathLikeInput(event.input.path) : "."; if (isRemote(rawPath)) return; const paths = toolName === "Read" || toolName === "Bash" ? [rawPath] : - await expandDelimitedPathEntries([rawPath], ctx.cwd, { splitter: parseSearchPath }); + await expandDelimitedPathEntries([rawPath], ctx.cwd); for (const path of paths) { if (isRemote(path)) continue; const input: Record = { ...event.input }; if (toolName !== "Bash") { const target = toolName === "Glob" ? path : (await splitPathAndSelPreferringLiteral(path, ctx.cwd)).path; - const resolved = resolveReadPath(target, ctx.cwd); + const resolved = await resolveReadPathAsync(target, ctx.cwd); delete input.path; if (toolName === "Read") input.file_path = resolved; else if (toolName === "Glob") input.pattern = resolved; diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 659cb837e..4fbfd29e1 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -152,6 +152,21 @@ test("URLs, internal resources, literal selector-like names and false trust do n expect(await api.emit("tool_result", { toolCallId: "call-3", content: [] })).toBeUndefined(); }); +test("a file:// read target resolves to the real local path and still reaches the guard", async () => { + // file:// is the one scheme isRemote lets through to resolveReadPathAsync + // (which OMP's own path pipeline also resolves down to a local path, not + // an external URL) -- an in-project file:// target must still nudge... + const api = harness(); + await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(cwd, "source.py")}` }, toolCallId: "call-1" }); + const inProject = await api.emit("tool_result", { toolCallId: "call-1", content: [] }); + expect(inProject?.content?.[0]?.text).toContain("graphify"); + // ...while one outside the project still resolves (the CLI subprocess + // runs), but the guard's own containment check stays silent, same as any + // other out-of-project absolute path. + await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(root, "outside.py")}` }, toolCallId: "call-2" }); + expect(await api.emit("tool_result", { toolCallId: "call-2", content: [] })).toBeUndefined(); +}); + test("navigation cancels in-flight guidance before the next session's tool results", async () => { for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { rmSync(started, { force: true }); From fd031130a36c7b9a5b67c6b5a8acae3e3b315529 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:59:09 +0000 Subject: [PATCH 10/33] fix(omp): drop unresolvable @oh-my-pi/pi-tui import from the bridge The OMP host loads graphify/omp/index.ts from its installed site-packages location, which ships no node_modules (see package.json's "files" list). The host provides @oh-my-pi/pi-coding-agent to legacy extensions but never @oh-my-pi/pi-tui, so the bridge's `isReadableUrlPath` import from @oh-my-pi/pi-tui/tools/read failed at runtime with `Cannot find package '@oh-my-pi/pi-tui'`, disabling the extension entirely. Vendor the two-line isReadableUrlPath predicate locally instead of importing it, drop the now-unused @oh-my-pi/pi-tui devDependency and regenerate bun.lock, and update the CI advisory step that used to bump both packages. Add a regression test asserting index.ts only imports @oh-my-pi/pi-coding-agent and node: builtins, so a future reintroduction of an unresolvable package fails the suite instead of only the host. --- .github/workflows/ci.yml | 25 +++++++++++++++---------- graphify/omp/bun.lock | 1 - graphify/omp/index.ts | 11 ++++++++++- graphify/omp/package.json | 3 +-- tests/omp.test.ts | 14 ++++++++++++++ 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c90e4b063..62c228884 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,10 +80,12 @@ jobs: omp-bridge: # tests/omp.test.ts drives the real OMP extension: bun resolves the - # path-utils and pi-tui helpers it imports from the pinned - # @oh-my-pi/pi-coding-agent and @oh-my-pi/pi-tui devDependencies in - # graphify/omp/, and the policy assertions run against the real installed - # Python CLI, so this job needs both toolchains. + # path-utils helpers it imports from the pinned @oh-my-pi/pi-coding-agent + # devDependency in graphify/omp/ (the only package the host provides; + # graphify/omp/index.ts vendors isReadableUrlPath locally instead of + # importing it from @oh-my-pi/pi-tui, which the host never resolves), and + # the policy assertions run against the real installed Python CLI, so + # this job needs both toolchains. runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -109,14 +111,17 @@ jobs: - name: Run OMP bridge tests against the latest OMP (advisory) # bun install --frozen-lockfile above only proves the bridge works # against the exact pinned version. Upstream OMP has moved exported - # symbols between minor releases before (isReadableUrlPath: pi-coding-agent - # -> pi-tui) without the frozen lockfile ever seeing it. Install - # whatever OMP publishes today and re-run the suite so a future move - # surfaces here. continue-on-error: an unrelated upstream release must - # not block a merge; the frozen run above stays the blocking gate. + # symbols between minor releases before (isReadableUrlPath moved from + # pi-coding-agent to pi-tui) without the frozen lockfile ever seeing + # it; index.ts now vendors that predicate locally instead of tracking + # whichever package currently exports it. Install whatever OMP + # publishes today and re-run the suite so a future move to + # @oh-my-pi/pi-coding-agent's exports still surfaces here. + # continue-on-error: an unrelated upstream release must not block a + # merge; the frozen run above stays the blocking gate. continue-on-error: true run: | - bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest @oh-my-pi/pi-tui@latest + bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts security-scan: diff --git a/graphify/omp/bun.lock b/graphify/omp/bun.lock index 3e723793b..88a2bc7fc 100644 --- a/graphify/omp/bun.lock +++ b/graphify/omp/bun.lock @@ -6,7 +6,6 @@ "name": "graphify-omp", "devDependencies": { "@oh-my-pi/pi-coding-agent": "^18.2.6", - "@oh-my-pi/pi-tui": "^18.2.6", }, }, }, diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 67a3d552b..d4b740734 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -9,7 +9,16 @@ import { resolveReadPathAsync, splitPathAndSelPreferringLiteral, } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; -import { isReadableUrlPath } from "@oh-my-pi/pi-tui/tools/read"; + +// Mirrors the upstream isReadableUrlPath predicate, kept as a same-named +// local copy (not imported): the wheel ships no node_modules, so this +// extension may import only host-provided packages, and the host provides +// @oh-my-pi/pi-coding-agent alone -- a scoped subpath import of any other +// package is unresolvable at runtime. Named to match so a future reader can +// diff them. +function isReadableUrlPath(value: string): boolean { + return /^https?:\/\/?/i.test(value) || /^www\./i.test(value); +} const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 116193ca8..20619dd33 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -7,8 +7,7 @@ "type": "module", "files": ["index.ts"], "devDependencies": { - "@oh-my-pi/pi-coding-agent": "^18.2.6", - "@oh-my-pi/pi-tui": "^18.2.6" + "@oh-my-pi/pi-coding-agent": "^18.2.6" }, "omp": { "extensions": ["./index.ts"], diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 4fbfd29e1..493e72081 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -205,3 +205,17 @@ test("missing or project-local executables never fall back to project Python cod await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" }); expect(existsSync(started)).toBe(false); }); + +test("index.ts imports only packages the host actually provides -- no dependency the wheel's node_modules-less install can never resolve", () => { + // The extension loads from a site-packages install with no node_modules at + // all (see graphify/omp/package.json's "files" list). The host supplies + // exactly @oh-my-pi/pi-coding-agent to legacy extensions; anything else + // bare-imported here -- however cleanly `bun test` resolves it via this + // repo's own dev node_modules -- is unresolvable in production. + const source = readFileSync(join(import.meta.dir, "../graphify/omp/index.ts"), "utf8"); + const specifiers = [...source.matchAll(/from\s+"([^"]+)"/g)].map(m => m[1]); + const allowed = (specifier: string) => + specifier.startsWith("node:") || specifier === "@oh-my-pi/pi-coding-agent" || specifier.startsWith("@oh-my-pi/pi-coding-agent/"); + expect(specifiers.length).toBeGreaterThan(0); + expect(specifiers.filter(specifier => !allowed(specifier))).toEqual([]); +}); From a0e812a904b310e913a5918a61bccc12dc3dd9f0 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 16:11:04 +0000 Subject: [PATCH 11/33] fix(omp): use resolveReadPath, not the async sibling missing from older host copies Two @oh-my-pi/pi-coding-agent copies are reachable on this machine (18.1.17 and 18.2.6) and we cannot determine from outside the host which one it binds. Auditing every bridge import against the older 18.1.17 copy found resolveReadPathAsync missing -- it was only added in a later release as a non-blocking-probe performance optimization (per its own upstream docstring: "identical variant order and winner semantics, but non-blocking probes"), not a correctness fix. Revert to the synchronous resolveReadPath, present in both copies, so the extension loads regardless of which copy the host binds. A marginally faster extension that fails to load is worth nothing. Strengthen the import allow-list test with a version-floor check: every named import the bridge takes from @oh-my-pi/pi-coding-agent/tools/path-utils must be exported by the oldest host-provided copy. Parses the real import list out of index.ts rather than hardcoding it, so a future added symbol is checked automatically. Skips cleanly when that copy is not present on the machine running the suite. --- graphify/omp/index.ts | 10 ++++++---- tests/omp.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index d4b740734..24906460f 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -2,11 +2,13 @@ import { execFile } from "node:child_process"; import { realpathSync } from "node:fs"; import { delimiter, isAbsolute, relative, sep } from "node:path"; import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +// resolveReadPath, not the async sibling: the async variant is missing from +// older host-provided pi-coding-agent copies -- a blocked statSync beats an unloadable extension. import { expandDelimitedPathEntries, isInternalUrlPath, normalizePathLikeInput, - resolveReadPathAsync, + resolveReadPath, splitPathAndSelPreferringLiteral, } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; @@ -27,14 +29,14 @@ const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const FILE_URL_RE = /^file:\/\//i; // isInternalUrlPath/isReadableUrlPath still earn their place even though the -// guard now defends itself against URL-shaped input: resolveReadPathAsync +// guard now defends itself against URL-shaped input: resolveReadPath // below pre-resolves a rootless value to an absolute path // (`path.resolve(cwd, x)`) before the guard ever sees it, which for a // *foreign*-scheme value glues it onto cwd into something that looks // exactly like a real in-project file (`/https:/example.com/x`, // `/local:/x`) -- a guard fix the bridge then defeats by mangling its // input first is not a fix. file:// is the one exception: OMP's own -// resolveReadPathAsync already strips it down to the real local path (its +// resolveReadPath already strips it down to the real local path (its // resolveToCwd -> expandPath -> stripFileUrl, deliberately excluded from // the external-URL fast path), so routing it through here yields the // correct absolute path rather than a mangled one. @@ -109,7 +111,7 @@ export default function graphify(api: ExtensionAPI): void { const input: Record = { ...event.input }; if (toolName !== "Bash") { const target = toolName === "Glob" ? path : (await splitPathAndSelPreferringLiteral(path, ctx.cwd)).path; - const resolved = await resolveReadPathAsync(target, ctx.cwd); + const resolved = resolveReadPath(target, ctx.cwd); delete input.path; if (toolName === "Read") input.file_path = resolved; else if (toolName === "Glob") input.pattern = resolved; diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 493e72081..03298e98e 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -219,3 +219,28 @@ test("index.ts imports only packages the host actually provides -- no dependency expect(specifiers.length).toBeGreaterThan(0); expect(specifiers.filter(specifier => !allowed(specifier))).toEqual([]); }); + +test("every named import from @oh-my-pi/pi-coding-agent/tools/path-utils is exported by the oldest host-provided copy", () => { + // Being on the allow-list above is not enough: @oh-my-pi/pi-coding-agent + // itself is allow-listed, but a symbol added to the bridge's import list + // can still be missing from an older copy the live host actually binds + // (see resolveReadPathAsync, absent from 18.1.17). Parse the real import + // list out of index.ts -- never hardcode it -- so a future added symbol + // is checked automatically. + const oldestRoot = "/root/.omp/plugins/node_modules/@oh-my-pi/pi-coding-agent"; + const oldestPathUtils = join(oldestRoot, "src/tools/path-utils.ts"); + if (!existsSync(oldestPathUtils)) { + console.warn(`skipping: oldest pi-coding-agent copy not found at ${oldestPathUtils}`); + return; + } + const source = readFileSync(join(import.meta.dir, "../graphify/omp/index.ts"), "utf8"); + const importBlock = source.match(/import\s*{([^}]+)}\s*from\s*"@oh-my-pi\/pi-coding-agent\/tools\/path-utils"/); + expect(importBlock).not.toBeNull(); + const imported = importBlock![1].split(",").map(name => name.trim()).filter(Boolean); + expect(imported.length).toBeGreaterThan(0); + const exportedSource = readFileSync(oldestPathUtils, "utf8"); + const exported = new Set( + [...exportedSource.matchAll(/^export\s+(?:async\s+function|function)\s+(\w+)/gm)].map(m => m[1]), + ); + expect(imported.filter(name => !exported.has(name))).toEqual([]); +}); From 82e4a442dd60854229d681f17d6fc8c15967ab48 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:01 +0000 Subject: [PATCH 12/33] fix(hooks): keep a remote file:// authority from aliasing an in-project file _normalize_hook_path unconditionally reduced any file:// value to its path component, discarding the authority. file://evil.com/ then classified identically to file:///: the same absolute path, silently treated as local. RFC 8089 / Node's url.fileURLToPath (ERR_INVALID_FILE_URL_HOST) say a file:// URL is local only when its authority is empty or localhost. Reduce only those two forms; leave any other authority intact so _is_foreign_url_scheme classifies it as the remote reference it is. --- graphify/cli.py | 32 +++++++++++++++++++++++++------- tests/test_hook_url_paths.py | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index e8e6aa209..ef9f5d8a8 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1141,7 +1141,8 @@ def _is_under_out(target: str) -> bool: def _normalize_hook_path(value: str) -> str: r"""Trim padding whitespace and a matching pair of outer double quotes, - then strip a leading ``file://`` scheme down to the local path it names. + then strip a *local* leading ``file://`` scheme down to the local path + it names. Mirrors what OMP's own path pipeline already does to a raw tool argument before a hook would ever see it: ``normalizePathLikeInput`` (trim + @@ -1152,12 +1153,24 @@ def _normalize_hook_path(value: str) -> str: bothered to normalize it first -- a classifier that only agrees with its own host after trimming/de-quoting is exactly the kind of gap a prior review flagged. + + Per RFC 8089, a ``file://`` URL is local only when its authority is + empty (``file:///path``) or ``localhost``; Node's own + ``url.fileURLToPath`` enforces exactly this, throwing + ``ERR_INVALID_FILE_URL_HOST`` for any other host. Any other authority + names a *remote* host, not a local path -- reducing it here would + discard the host and let ``file://evil.com/`` alias a + real local file, so it is left untouched for ``_is_foreign_url_scheme`` + to classify instead. """ value = value.strip() if len(value) > 1 and value[0] == value[-1] == '"': value = value[1:-1] if _FILE_URL_RE.match(value): - path = unquote(urlsplit(value).path) or "/" + split = urlsplit(value) + if split.hostname and split.hostname.lower() != "localhost": + return value + path = unquote(split.path) or "/" # file:///C:/proj/a.py -> C:/proj/a.py: drop the URL's extra root # slash in front of a Windows drive letter. if os.name == "nt" and re.match(r"^/[A-Za-z]:", path): @@ -1168,16 +1181,21 @@ def _normalize_hook_path(value: str) -> str: def _is_foreign_url_scheme(value: str) -> bool: """Whether *value* is itself a ``scheme://...`` value -- everything - except ``file://``, which ``_normalize_hook_path`` has already reduced - to a plain local path by the time this runs, so it never matches here. + except a *local* ``file://`` (empty or ``localhost`` authority, RFC + 8089), which ``_normalize_hook_path`` has already reduced to a plain + local path by the time this runs, so it never matches here. A + ``file://`` with any other authority is NOT reduced by + ``_normalize_hook_path`` and so matches here like any other foreign + scheme, naming a remote host rather than a local file. Deliberately does not enumerate OMP's (or any other harness's) internal scheme allow-list (``local://``, ``artifact://``, ...): this guard is embedded by multiple hosts (#522), and hard-coding one of them would silently stop matching the day that host adds a scheme. Any scheme this - function doesn't specifically know to be local -- only ``file://`` is -- - is treated as not-a-local-source-file: a missed nudge on a remote URL is - a far safer wrong answer than a wrong nudge on an unrelated file. + function doesn't specifically know to be local -- only a local + ``file://`` is -- is treated as not-a-local-source-file: a missed nudge + on a remote URL is a far safer wrong answer than a wrong nudge on an + unrelated file. """ return bool(_URL_SCHEME_PREFIX_RE.match(value)) diff --git a/tests/test_hook_url_paths.py b/tests/test_hook_url_paths.py index cdd1ffaa8..5d8838120 100644 --- a/tests/test_hook_url_paths.py +++ b/tests/test_hook_url_paths.py @@ -123,6 +123,18 @@ def test_normalize_hook_path_strips_padding_quotes_and_file_scheme(): assert _normalize_hook_path("file:///abs/proj/foo.py") == "/abs/proj/foo.py" +def test_normalize_hook_path_file_url_authority_rows(): + """RFC 8089 / Node's ``url.fileURLToPath`` (throws + ``ERR_INVALID_FILE_URL_HOST`` for anything else): a ``file://`` URL is + local only when its authority is empty or ``localhost``. Only those two + forms may be reduced to their bare path component; any other authority + names a remote host and must be left intact for `_is_foreign_url_scheme` + to classify.""" + assert _normalize_hook_path("file:///abs/proj/foo.py") == "/abs/proj/foo.py" + assert _normalize_hook_path("file://localhost/abs/proj/foo.py") == "/abs/proj/foo.py" + assert _normalize_hook_path("file://evil.com/abs/proj/foo.py") == "file://evil.com/abs/proj/foo.py" + + # --------------------------------------------------------------------------- # End-to-end through the guard (real stdin JSON, cwd at a graphed project) # --------------------------------------------------------------------------- @@ -200,6 +212,19 @@ def test_file_url_to_an_indexed_in_project_file_still_nudges(tmp_path, monkeypat assert "MANDATORY" in _invoke(tmp_path, monkeypatch, f"file://{f.as_posix()}") +def test_file_url_with_remote_authority_is_silent(tmp_path, monkeypatch): + """VALID #4: a non-local authority (RFC 8089) names a remote host, not + the in-project file it happens to share a path with -- must NOT nudge.""" + f = _project(tmp_path) + assert _invoke(tmp_path, monkeypatch, f"file://evil.com{f.as_posix()}").strip() == "" + + +def test_file_url_with_localhost_authority_still_nudges(tmp_path, monkeypatch): + """localhost is local per RFC 8089 / Node's url.fileURLToPath.""" + f = _project(tmp_path) + assert "MANDATORY" in _invoke(tmp_path, monkeypatch, f"file://localhost{f.as_posix()}") + + def test_quoted_and_padded_https_url_is_silent(tmp_path, monkeypatch): _project(tmp_path) assert _invoke(tmp_path, monkeypatch, ' "https://example.com/source.py" ').strip() == "" From 6aca15e82ca5abb3638abef3b43a0b327ac64d30 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:06 +0000 Subject: [PATCH 13/33] fix(omp): treat every file:// authority but local as remote in isRemote isRemote's file:// carve-out matched the scheme alone: any authority was treated as local, so file://evil.com/ reached resolveReadPath, resolved to the real local path, and reached the guard as if it named a file in the project. Mirror Node's own url.fileURLToPath (ERR_INVALID_FILE_URL_HOST): only an empty or localhost authority is local. Any other authority now falls through to the generic scheme:// remote check, same as it would for https:// or any other foreign scheme. --- graphify/omp/index.ts | 21 ++++++++++++++++++--- tests/omp.test.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 24906460f..9bb68d165 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -35,13 +35,28 @@ const FILE_URL_RE = /^file:\/\//i; // *foreign*-scheme value glues it onto cwd into something that looks // exactly like a real in-project file (`/https:/example.com/x`, // `/local:/x`) -- a guard fix the bridge then defeats by mangling its -// input first is not a fix. file:// is the one exception: OMP's own -// resolveReadPath already strips it down to the real local path (its +// input first is not a fix. A *local* file:// is the one exception: OMP's +// own resolveReadPath already strips it down to the real local path (its // resolveToCwd -> expandPath -> stripFileUrl, deliberately excluded from // the external-URL fast path), so routing it through here yields the // correct absolute path rather than a mangled one. + +// RFC 8089 / Node's url.fileURLToPath (throws ERR_INVALID_FILE_URL_HOST for +// anything else): a file:// URL is local only when its authority is empty +// or `localhost`. Any other authority names a remote host and must fall +// through to the generic `scheme://` check below like any other foreign +// scheme -- treating every file:// as local let +// `file://evil.com/` alias a real local file. function isRemote(path: string): boolean { - if (FILE_URL_RE.test(path)) return false; + if (FILE_URL_RE.test(path)) { + try { + const { hostname } = new URL(path); + if (hostname === "" || hostname.toLowerCase() === "localhost") return false; + } catch { + // Malformed file:// URL: not a trustworthy local path either, fall + // through to the generic checks below. + } + } return isInternalUrlPath(path) || isReadableUrlPath(path) || path.includes("://"); } diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 03298e98e..a5a02952a 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -167,6 +167,24 @@ test("a file:// read target resolves to the real local path and still reaches th expect(await api.emit("tool_result", { toolCallId: "call-2", content: [] })).toBeUndefined(); }); +test("a file:// read target with a non-local authority never reaches the guard", async () => { + // VALID #2: RFC 8089 / Node's url.fileURLToPath (ERR_INVALID_FILE_URL_HOST) + // -- only an empty or `localhost` authority names a local file. Any other + // authority names a remote host and must not alias an in-project file + // merely by sharing its path component. + const api = harness(); + await api.emit("tool_call", { toolName: "read", input: { path: `file://evil.com${join(cwd, "source.py")}` }, toolCallId: "call-1" }); + expect(existsSync(started)).toBe(false); + expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); +}); + +test("a file:// read target with an explicit localhost authority still reaches the guard", async () => { + const api = harness(); + await api.emit("tool_call", { toolName: "read", input: { path: `file://localhost${join(cwd, "source.py")}` }, toolCallId: "call-1" }); + const result = await api.emit("tool_result", { toolCallId: "call-1", content: [] }); + expect(result?.content?.[0]?.text).toContain("graphify"); +}); + test("navigation cancels in-flight guidance before the next session's tool results", async () => { for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { rmSync(started, { force: true }); From 168a1c9eefed4a53cdf01a1e833e440330553e04 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:11 +0000 Subject: [PATCH 14/33] ci: stop executing an unpinned latest OMP install in the advisory step The advisory job installed @oh-my-pi/pi-coding-agent@latest and ran the bridge suite against it on every CI run -- mutable, unpinned third-party code executing unconditionally. Replace the install-and- run with a download-only check: fetch the package with install scripts disabled, then statically compare index.ts's imported path-utils symbols against that file's declared exports as plain text. The downloaded package is never imported or executed, but the same upstream symbol move (isReadableUrlPath, pi-coding-agent -> pi-tui) that motivated this step still surfaces. --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62c228884..94bcde0c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,21 +108,40 @@ jobs: - name: Run OMP bridge tests run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts - - name: Run OMP bridge tests against the latest OMP (advisory) - # bun install --frozen-lockfile above only proves the bridge works - # against the exact pinned version. Upstream OMP has moved exported - # symbols between minor releases before (isReadableUrlPath moved from + - name: Check for upstream symbol moves (advisory, no execution) + # The frozen run above only proves the bridge works against the + # exact pinned version. Upstream OMP has moved exported symbols + # between minor releases before (isReadableUrlPath moved from # pi-coding-agent to pi-tui) without the frozen lockfile ever seeing - # it; index.ts now vendors that predicate locally instead of tracking - # whichever package currently exports it. Install whatever OMP - # publishes today and re-run the suite so a future move to - # @oh-my-pi/pi-coding-agent's exports still surfaces here. + # it; index.ts now vendors that predicate locally instead of + # tracking whichever package currently exports it. This step used + # to install @latest AND execute the whole suite against it -- + # unpinned, mutable third-party code running on every CI invocation. + # It now only downloads the published package (--ignore-scripts: + # no install hooks run) and statically compares index.ts's imported + # path-utils symbols against that file's declared exports as plain + # text -- the downloaded code is never imported or executed, but a + # future symbol move still surfaces here. # continue-on-error: an unrelated upstream release must not block a # merge; the frozen run above stays the blocking gate. continue-on-error: true run: | - bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest - GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest --ignore-scripts + bun -e ' + const fs = require("node:fs"); + const index = fs.readFileSync("graphify/omp/index.ts", "utf8"); + const block = index.match(/import\s*{([^}]+)}\s*from\s*"@oh-my-pi\/pi-coding-agent\/tools\/path-utils"/); + if (!block) throw new Error("no path-utils import block found in index.ts"); + const imported = block[1].split(",").map((s) => s.trim()).filter(Boolean); + const source = fs.readFileSync("graphify/omp/node_modules/@oh-my-pi/pi-coding-agent/src/tools/path-utils.ts", "utf8"); + const exported = new Set([...source.matchAll(/^export\s+(?:async\s+function|function)\s+(\w+)/gm)].map((m) => m[1])); + const missing = imported.filter((name) => !exported.has(name)); + if (missing.length) { + console.error(`@oh-my-pi/pi-coding-agent@latest no longer exports: ${missing.join(", ")}`); + process.exit(1); + } + console.log(`ok: every imported symbol is exported by @latest (${imported.join(", ")})`); + ' security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new From b2a75a2d4e762d8b3753838178fef64f5b8ea9f5 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 22 Sep 2026 14:27:17 +0000 Subject: [PATCH 15/33] build(omp): align the embedded package with Graphify 0.9.65 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 20619dd33..a1dcf851c 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.63", + "version": "0.9.65", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From bb6361a15f2f5c0b7d9600020382c28340468b8c Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 23 Sep 2026 20:32:17 +0000 Subject: [PATCH 16/33] build(omp): align the embedded package with Graphify 0.9.67 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index a1dcf851c..2cd42c1c3 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.65", + "version": "0.9.67", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From 5693c3ec4a78eb4e93b482cbf13275343e48e1d9 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 24 Sep 2026 02:56:26 +0000 Subject: [PATCH 17/33] fix(omp): deliver hook-guard guidance as a labelled system reminder Claude Code wraps PreToolUse additionalContext in a system reminder that names the hook; the OMP bridge appended the bare guidance after the tool output, where it read as text the tool printed and agents discounted it as untrusted. Lead each qualifying result with one block instead, the shape OMP's own per-tool TTSR reminders use, and keep the tool output unchanged after it. --- CHANGELOG.md | 2 +- graphify/omp/index.ts | 13 ++++++++----- tests/omp.test.ts | 37 +++++++++++++++++++++++-------------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e60062788..b0a55fe9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -418,7 +418,7 @@ Intra-class call binding across five languages, new language-structure coverage, ## 0.9.58 (2026-09-10) -- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-call guidance appended to each tool result without building indexes. +- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-call guidance delivered with each tool result as a leading `` block, matching how Claude Code labels PreToolUse context, without building indexes. - Fix: a call to a Python function defined nested inside another function now resolves to that inner definition per lexical scope, instead of leaking to a same-named function elsewhere; direct recursion is preserved as a self-loop (#3410, thanks @hopstreax). - Fix: submodule imports inside a PEP 420 namespace package (a directory with no `__init__.py`) now resolve to the target module instead of being dropped (#3429, thanks @flaukowski). - Fix: a bare-name import of a module sitting next to the importing file (a flat script dir with no package) now resolves to that sibling — matching CPython's `sys.path[0]` behavior — without over-resolving a genuine third-party name (#3430, thanks @hopstreax). diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 9bb68d165..fd316066d 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -86,10 +86,12 @@ function runGuard(command: string, kind: string, payload: string, cwd: string, s export default function graphify(api: ExtensionAPI): void { // Claude PreToolUse additionalContext parity: `tool_call` captures the guard's - // guidance for that call, `tool_result` appends it to the persisted tool - // result. Every qualifying call carries its own nudge — no dedup — and the - // text lands inline with the call it belongs to, surviving compaction like - // any other tool output. + // guidance for that call, `tool_result` delivers it with that call's result. + // Claude Code wraps the text in a system reminder naming the hook instead of + // passing it off as tool output, so it leads the result as one labelled + // block -- the shape OMP's own per-tool TTSR reminders use. + // Every qualifying call carries its own nudge (no dedup), and it survives + // compaction like any other tool output. const pending = new Map(); let generation = 0; let controller = new AbortController(); @@ -162,6 +164,7 @@ export default function graphify(api: ExtensionAPI): void { const nudge = pending.get(event.toolCallId); if (nudge === undefined) return; pending.delete(event.toolCallId); - return { content: [...event.content, { type: "text" as const, text: nudge }] }; + const reminder = `\n${nudge}\n`; + return { content: [{ type: "text" as const, text: reminder }, ...event.content] }; }); } diff --git a/tests/omp.test.ts b/tests/omp.test.ts index a5a02952a..0a3f6d822 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -32,6 +32,17 @@ function harness() { }; } +// Claude Code delivers PreToolUse additionalContext as a system reminder that names the +// hook, never as tool output. Assert that shape: the guidance leads the result as one +// labelled reminder block, and the tool's own output follows unchanged. +function guidanceOf(result: Result, output: { type: string; text: string }[]): string { + expect(result?.content).toEqual([expect.objectContaining({ type: "text" }), ...output]); + const match = result!.content![0].text.match(/^\n([\s\S]*)\n<\/system-reminder>$/); + expect(match).not.toBeNull(); + return match![1]; +} +const ok = [{ type: "text", text: "ok" }]; + beforeEach(() => { root = mkdtempSync(join(tmpdir(), "graphify-omp-")); cwd = join(root, "project"); @@ -94,12 +105,11 @@ test("real CLI strict denial blocks selector reads; every later call nudges via expect(existsSync(join(cwd, "graphify-out", "cache", "hook_sessions", "omp-test-session.denied"))).toBe(true); // After the one-time deny, every qualifying call still carries its own guidance. expect(await api.emit("tool_call", { ...event, toolCallId: "call-2" })).toBeUndefined(); - const second = await api.emit("tool_result", { toolCallId: "call-2", content: [{ type: "text", text: "ok" }] }); - expect(second?.content).toHaveLength(2); - expect(second?.content?.[1].text).toContain("graphify"); + const second = await api.emit("tool_result", { toolCallId: "call-2", content: ok }); + expect(guidanceOf(second, ok)).toContain("graphify"); expect(await api.emit("tool_call", { ...event, toolCallId: "call-3" })).toBeUndefined(); - const third = await api.emit("tool_result", { toolCallId: "call-3", content: [{ type: "text", text: "ok" }] }); - expect(third?.content).toHaveLength(2); + const third = await api.emit("tool_result", { toolCallId: "call-3", content: ok }); + expect(guidanceOf(third, ok)).toContain("graphify"); // Navigation resets pending guidance: a result arriving after the reset is untouched. await api.emit("before_agent_start"); expect(await api.emit("tool_result", { toolCallId: "call-3", content: [{ type: "text", text: "ok" }] })).toBeUndefined(); @@ -117,9 +127,8 @@ test("native grep, bash search, and glob expose the installed CLI's actual guida })).hookSpecificOutput.additionalContext; const toolCallId = `${toolName}-1`; expect(await api.emit("tool_call", { toolName, input, toolCallId })).toBeUndefined(); - const result = await api.emit("tool_result", { toolCallId, content: [{ type: "text", text: "ok" }] }); - expect(result?.content).toHaveLength(2); - expect(result?.content?.[1].text).toBe(expected); + const result = await api.emit("tool_result", { toolCallId, content: ok }); + expect(guidanceOf(result, ok)).toBe(expected); } }); @@ -130,10 +139,10 @@ test("multi-target glob calls surface every target's guidance", async () => { writeFileSync(control, JSON.stringify({ mode: "varying" })); const api = harness(); expect(await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" })).toBeUndefined(); - const result = await api.emit("tool_result", { toolCallId: "call-1", content: [{ type: "text", text: "ok" }] }); - expect(result?.content).toHaveLength(2); - expect(result?.content?.[1].text).toContain("guidance 1"); - expect(result?.content?.[1].text).toContain("guidance 2"); + const result = await api.emit("tool_result", { toolCallId: "call-1", content: ok }); + const guidance = guidanceOf(result, ok); + expect(guidance).toContain("guidance 1"); + expect(guidance).toContain("guidance 2"); }); test("URLs, internal resources, literal selector-like names and false trust do not run project hooks", async () => { @@ -159,7 +168,7 @@ test("a file:// read target resolves to the real local path and still reaches th const api = harness(); await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(cwd, "source.py")}` }, toolCallId: "call-1" }); const inProject = await api.emit("tool_result", { toolCallId: "call-1", content: [] }); - expect(inProject?.content?.[0]?.text).toContain("graphify"); + expect(guidanceOf(inProject, [])).toContain("graphify"); // ...while one outside the project still resolves (the CLI subprocess // runs), but the guard's own containment check stays silent, same as any // other out-of-project absolute path. @@ -182,7 +191,7 @@ test("a file:// read target with an explicit localhost authority still reaches t const api = harness(); await api.emit("tool_call", { toolName: "read", input: { path: `file://localhost${join(cwd, "source.py")}` }, toolCallId: "call-1" }); const result = await api.emit("tool_result", { toolCallId: "call-1", content: [] }); - expect(result?.content?.[0]?.text).toContain("graphify"); + expect(guidanceOf(result, [])).toContain("graphify"); }); test("navigation cancels in-flight guidance before the next session's tool results", async () => { From 346d52f1ef19b5edad97aa3dcee0fdfd15b1fde2 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 24 Sep 2026 03:31:55 +0000 Subject: [PATCH 18/33] feat(install): add the omp platform for Oh My Pi skill installs OMP reads its agent skills from ~/.omp/agent/skills, not the legacy ~/.pi path the pi platform writes, so the installer had no OMP target even though 'omp' was already reserved in the CLI command set. Adds the omp platform reusing pi's skill bundle (OMP mirrors pi's agent layout), the pi-twin 'graphify omp' subcommand, and the project-scope groups; tests lock destinations, bundle reuse, install/uninstall, and uninstall_all pi parity. --- graphify/install.py | 27 ++++++- tests/test_omp_platform.py | 158 +++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 tests/test_omp_platform.py diff --git a/graphify/install.py b/graphify/install.py index 0032eafdf..048adaba0 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -620,6 +620,14 @@ def _register_always_on_block(target: Path, prefix: str, registration: str) -> N "claude_md": False, "skill_refs": "pi", }, + "omp": { + # OMP (Oh My Pi) mirrors pi's agent layout under ~/.omp, which the + # legacy ~/.pi path is no longer read from. + "skill_file": "skill-pi.md", + "skill_dst": Path(".omp") / "agent" / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "pi", + }, "codebuddy": { # Reuses claude's split bundle (shares skill.md). "skill_file": "skill.md", @@ -1904,7 +1912,7 @@ def _project_install(platform_name: str, project_dir: Path | None = None, strict skill_dst = _copy_skill_file("antigravity", project=True, project_dir=project_dir) _antigravity_finalize(skill_dst, project_dir) _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".agents"]) - elif platform_name in ("copilot", "pi", "kimi", "agents"): + elif platform_name in ("copilot", "pi", "kimi", "agents", "omp"): # Skill-only project install: drop SKILL.md (+ references) at the scope # root. `agents` -> ./.agents/skills/graphify/SKILL.md. skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir) @@ -1937,7 +1945,7 @@ def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> N _devin_rules_uninstall(project_dir) if not removed: print("nothing to remove") - elif platform_name in ("copilot", "pi", "kimi", "agents"): + elif platform_name in ("copilot", "pi", "kimi", "agents", "omp"): removed = _remove_skill_file(platform_name, project=True, project_dir=project_dir) if not removed: print("nothing to remove") @@ -2609,6 +2617,21 @@ def dispatch_install_cli(cmd: str) -> bool: else: print("Usage: graphify pi [install|uninstall]", file=sys.stderr) sys.exit(1) + elif cmd == "omp": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + if "--project" in sys.argv[3:]: + _project_install("omp", Path(".")) + else: + install("omp") + elif subcmd == "uninstall": + if "--project" in sys.argv[3:]: + _project_uninstall("omp", Path(".")) + else: + _remove_skill_file("omp") + else: + print("Usage: graphify omp [install|uninstall]", file=sys.stderr) + sys.exit(1) elif cmd == "amp": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" if subcmd == "install": diff --git a/tests/test_omp_platform.py b/tests/test_omp_platform.py new file mode 100644 index 000000000..b2a06b1af --- /dev/null +++ b/tests/test_omp_platform.py @@ -0,0 +1,158 @@ +"""Tests for the OMP (Oh My Pi) install platform. + +`graphify install --platform omp` installs the skill into OMP's agent +directory — ``~/.omp/agent/skills`` globally, ``./.omp/agent/skills`` in +project scope — reusing pi's skill bundle, because OMP mirrors pi's agent +layout. The ``graphify omp`` subcommand mirrors the ``graphify pi`` twin. +""" +import os +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +import graphify.__main__ as mainmod + + +# --- destination map ----------------------------------------------------------- + + +def test_omp_user_destination_is_omp_agent_skills(tmp_path): + """Global omp skill lands at ~/.omp/agent/skills — the active OMP agent + directory, NOT the legacy ~/.pi path OMP no longer reads.""" + with patch("graphify.__main__.Path.home", return_value=tmp_path): + dst = mainmod._platform_skill_destination("omp", project=False) + assert dst == tmp_path / ".omp" / "agent" / "skills" / "graphify" / "SKILL.md" + + +def test_omp_project_destination_is_omp_agent_skills(tmp_path): + """Project omp skill lands at ./.omp/agent/skills.""" + dst = mainmod._platform_skill_destination("omp", project=True, project_dir=tmp_path) + assert dst == tmp_path / ".omp" / "agent" / "skills" / "graphify" / "SKILL.md" + + +def test_omp_reuses_the_pi_bundle(): + """omp installs pi's skill file and references sidecar verbatim: OMP is a + pi successor with the same skill format, so a second bundle would only + drift.""" + cfg = mainmod._PLATFORM_CONFIG["omp"] + assert cfg["skill_file"] == mainmod._PLATFORM_CONFIG["pi"]["skill_file"] + assert cfg["skill_refs"] == "pi" + assert cfg["claude_md"] is False + + +# --- end-to-end install / uninstall via the CLI -------------------------------- + + +def _run(tmp_path, argv, home): + """Drive main() with argv, cwd at tmp_path, and Path.home redirected.""" + old_cwd = Path.cwd() + try: + os.chdir(tmp_path) + with patch.object(sys, "argv", ["graphify", *argv]): + with patch("graphify.__main__.Path.home", return_value=home): + mainmod.main() + finally: + os.chdir(old_cwd) + + +def test_install_platform_omp_writes_user_skill(tmp_path): + """`graphify install --platform omp` writes ~/.omp/agent/skills/... + SKILL.md (+ pi's references) and nothing else — no CLAUDE.md (skill-only).""" + home = tmp_path / "home" + cwd = tmp_path / "cwd" + home.mkdir() + cwd.mkdir() + + _run(cwd, ["install", "--platform", "omp"], home) + + skill = home / ".omp" / "agent" / "skills" / "graphify" / "SKILL.md" + assert skill.exists() + assert (skill.parent / ".graphify_version").read_text() == mainmod.__version__ + assert (skill.parent / "references" / "extraction-spec.md").exists() + # Skill-only: the --platform path must not write a CLAUDE.md. + assert not (cwd / "CLAUDE.md").exists() + + +def test_uninstall_platform_flag_omp_matches_pi_parity(tmp_path): + """`graphify uninstall --platform omp` (global) is accepted. + + uninstall_all removes user-scope skills for its listed platforms; pi — + omp's bundle twin — is not among them, so omp matches pi exactly: the + flag form is accepted and the user-scope skill is removed by the + `graphify omp uninstall` subcommand (locked in below), not by + uninstall_all. Locking the pi parity stops a future edit from silently + special-casing one twin. + """ + home = tmp_path / "home" + cwd = tmp_path / "cwd" + home.mkdir() + cwd.mkdir() + + _run(cwd, ["install", "--platform", "omp"], home) + skill = home / ".omp" / "agent" / "skills" / "graphify" / "SKILL.md" + assert skill.exists() + + _run(cwd, ["uninstall", "--platform", "omp"], home) + # Same state pi leaves after its global uninstall: skill intact. + assert skill.exists() + + _run(cwd, ["omp", "uninstall"], home) + assert not skill.exists() + + +def test_install_platform_omp_project_writes_omp_agent_skills(tmp_path): + """`graphify install --project --platform omp` writes ./.omp/agent/skills and + leaves user scope untouched.""" + home = tmp_path / "home" + proj = tmp_path / "proj" + home.mkdir() + proj.mkdir() + + _run(proj, ["install", "--project", "--platform", "omp"], home) + + project_skill = proj / ".omp" / "agent" / "skills" / "graphify" / "SKILL.md" + assert project_skill.exists() + assert (project_skill.parent / "references" / "extraction-spec.md").exists() + # User scope was not touched. + assert not (home / ".omp" / "agent").exists() + + _run(proj, ["uninstall", "--project", "--platform", "omp"], home) + assert not project_skill.exists() + + +# --- the pi-twin subcommand (graphify omp install) ------------------------------ + + +def test_omp_subcommand_installs_same_skill(tmp_path): + """`graphify omp install` installs the identical user-global skill as + `--platform omp` — same destination, same version stamp.""" + home = tmp_path / "home" + cwd = tmp_path / "cwd" + home.mkdir() + cwd.mkdir() + + _run(cwd, ["omp", "install"], home) + + skill = home / ".omp" / "agent" / "skills" / "graphify" / "SKILL.md" + assert skill.exists() + assert (skill.parent / ".graphify_version").read_text() == mainmod.__version__ + + _run(cwd, ["omp", "uninstall"], home) + assert not skill.exists() + + +def test_unknown_platform_error_lists_omp(capsys, tmp_path): + """The unknown-platform error is built from _PLATFORM_CONFIG, so the new + platform must appear in its choices — a guard against a config entry the + CLI error path cannot see.""" + home = tmp_path / "home" + cwd = tmp_path / "cwd" + home.mkdir() + cwd.mkdir() + + with pytest.raises(SystemExit): + _run(cwd, ["install", "--platform", "not-a-platform"], home) + + assert "omp" in capsys.readouterr().err From 75f4dd69f8de2caf98526f611cdd1641666c545a Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 24 Sep 2026 03:48:20 +0000 Subject: [PATCH 19/33] fix(install): resolve the omp subcommand collision with the plugin installer The merged omp-platform branch added a pi-twin 'graphify omp [install|uninstall]' dispatch branch, unreachable behind the bridge PR's 'graphify omp [path|install]' plugin branch. The subcommand surface belongs to the plugin installer: drop the duplicate, and carry the user-scope skill removal in uninstall_all (like amp/agents) since omp has no subcommand twin. --- graphify/install.py | 19 ++++-------------- tests/test_omp_platform.py | 40 ++++++-------------------------------- 2 files changed, 10 insertions(+), 49 deletions(-) diff --git a/graphify/install.py b/graphify/install.py index 048adaba0..e672fac7b 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -2168,6 +2168,10 @@ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None: # The generic agents platform's user-scope skill lives at ~/.agents/skills, # which neither the AGENTS.md cleanup nor amp's removal reaches. _remove_skill_file("agents") + # OMP's user-scope skill lives at ~/.omp/agent/skills. The `graphify omp` + # subcommand is the plugin installer, so the skill's removal rides + # uninstall_all, like amp and agents. + _remove_skill_file("omp") _uninstall_opencode_plugin(pd) _uninstall_codex_hook(pd) @@ -2617,21 +2621,6 @@ def dispatch_install_cli(cmd: str) -> bool: else: print("Usage: graphify pi [install|uninstall]", file=sys.stderr) sys.exit(1) - elif cmd == "omp": - subcmd = sys.argv[2] if len(sys.argv) > 2 else "" - if subcmd == "install": - if "--project" in sys.argv[3:]: - _project_install("omp", Path(".")) - else: - install("omp") - elif subcmd == "uninstall": - if "--project" in sys.argv[3:]: - _project_uninstall("omp", Path(".")) - else: - _remove_skill_file("omp") - else: - print("Usage: graphify omp [install|uninstall]", file=sys.stderr) - sys.exit(1) elif cmd == "amp": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" if subcmd == "install": diff --git a/tests/test_omp_platform.py b/tests/test_omp_platform.py index b2a06b1af..2c804e23f 100644 --- a/tests/test_omp_platform.py +++ b/tests/test_omp_platform.py @@ -3,7 +3,7 @@ `graphify install --platform omp` installs the skill into OMP's agent directory — ``~/.omp/agent/skills`` globally, ``./.omp/agent/skills`` in project scope — reusing pi's skill bundle, because OMP mirrors pi's agent -layout. The ``graphify omp`` subcommand mirrors the ``graphify pi`` twin. +layout. The ``graphify omp`` subcommand is the plugin installer (PR #3506), so skill removal rides ``uninstall_all``. """ import os import sys @@ -75,15 +75,12 @@ def test_install_platform_omp_writes_user_skill(tmp_path): assert not (cwd / "CLAUDE.md").exists() -def test_uninstall_platform_flag_omp_matches_pi_parity(tmp_path): - """`graphify uninstall --platform omp` (global) is accepted. +def test_uninstall_platform_flag_global_removes_skill(tmp_path): + """`graphify uninstall --platform omp` (global) clears ~/.omp/agent/skills. - uninstall_all removes user-scope skills for its listed platforms; pi — - omp's bundle twin — is not among them, so omp matches pi exactly: the - flag form is accepted and the user-scope skill is removed by the - `graphify omp uninstall` subcommand (locked in below), not by - uninstall_all. Locking the pi parity stops a future edit from silently - special-casing one twin. + The global uninstall dispatch always runs uninstall_all, which carries + `_remove_skill_file("omp")` (the `graphify omp` subcommand is the plugin + installer, so the skill's removal rides uninstall_all, like amp/agents). """ home = tmp_path / "home" cwd = tmp_path / "cwd" @@ -95,10 +92,6 @@ def test_uninstall_platform_flag_omp_matches_pi_parity(tmp_path): assert skill.exists() _run(cwd, ["uninstall", "--platform", "omp"], home) - # Same state pi leaves after its global uninstall: skill intact. - assert skill.exists() - - _run(cwd, ["omp", "uninstall"], home) assert not skill.exists() @@ -122,27 +115,6 @@ def test_install_platform_omp_project_writes_omp_agent_skills(tmp_path): assert not project_skill.exists() -# --- the pi-twin subcommand (graphify omp install) ------------------------------ - - -def test_omp_subcommand_installs_same_skill(tmp_path): - """`graphify omp install` installs the identical user-global skill as - `--platform omp` — same destination, same version stamp.""" - home = tmp_path / "home" - cwd = tmp_path / "cwd" - home.mkdir() - cwd.mkdir() - - _run(cwd, ["omp", "install"], home) - - skill = home / ".omp" / "agent" / "skills" / "graphify" / "SKILL.md" - assert skill.exists() - assert (skill.parent / ".graphify_version").read_text() == mainmod.__version__ - - _run(cwd, ["omp", "uninstall"], home) - assert not skill.exists() - - def test_unknown_platform_error_lists_omp(capsys, tmp_path): """The unknown-platform error is built from _PLATFORM_CONFIG, so the new platform must appear in its choices — a guard against a config entry the From 3d1a24f34b96c6f7811f8920425af82e9cda4b2c Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 26 Sep 2026 12:09:41 +0000 Subject: [PATCH 20/33] fix(omp): stop importing isInternalUrlPath, private since OMP 18.3.1 OMP 18.3.1 made path-utils' isInternalUrlPath module-private, so the bridge failed to load ("Export named 'isInternalUrlPath' not found") and no guard ran. Use a local scheme test instead: any scheme of two or more characters followed by a slash, which keeps the single-slash alias (local:/x) and leaves Windows drive paths alone. --- graphify/omp/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index fd316066d..4bdf0d69f 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -6,7 +6,6 @@ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; // older host-provided pi-coding-agent copies -- a blocked statSync beats an unloadable extension. import { expandDelimitedPathEntries, - isInternalUrlPath, normalizePathLikeInput, resolveReadPath, splitPathAndSelPreferringLiteral, @@ -22,6 +21,15 @@ function isReadableUrlPath(value: string): boolean { return /^https?:\/\/?/i.test(value) || /^www\./i.test(value); } +// OMP 18.3.1 made path-utils' isInternalUrlPath private (it now asks the internal +// URL router), so importing it fails to load the extension. Any scheme followed by +// a slash counts: `skill://x` and the single-slash alias `local:/x` alike. The +// scheme needs two or more characters so a Windows drive (`C:/x`) stays a path. +// Unknown schemes also count, which only skips guidance for them. +function isInternalUrlPath(value: string): boolean { + return /^[a-z][a-z0-9+.-]+:\//i.test(value); +} + const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; const TIMEOUT_MS = 2000; From bfdaf1748eaef3e5daadf22d5a6ba97813d9fb38 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 26 Sep 2026 13:01:01 +0000 Subject: [PATCH 21/33] Revert "fix(omp): stop importing isInternalUrlPath, private since OMP 18.3.1" This reverts commit bb628ac6233bcc1cc31571e275206556bfd36e2e. --- graphify/omp/index.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 4bdf0d69f..fd316066d 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -6,6 +6,7 @@ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; // older host-provided pi-coding-agent copies -- a blocked statSync beats an unloadable extension. import { expandDelimitedPathEntries, + isInternalUrlPath, normalizePathLikeInput, resolveReadPath, splitPathAndSelPreferringLiteral, @@ -21,15 +22,6 @@ function isReadableUrlPath(value: string): boolean { return /^https?:\/\/?/i.test(value) || /^www\./i.test(value); } -// OMP 18.3.1 made path-utils' isInternalUrlPath private (it now asks the internal -// URL router), so importing it fails to load the extension. Any scheme followed by -// a slash counts: `skill://x` and the single-slash alias `local:/x` alike. The -// scheme needs two or more characters so a Windows drive (`C:/x`) stays a path. -// Unknown schemes also count, which only skips guidance for them. -function isInternalUrlPath(value: string): boolean { - return /^[a-z][a-z0-9+.-]+:\//i.test(value); -} - const INPUT_LIMIT = 256 * 1024; const OUTPUT_LIMIT = 64 * 1024; const TIMEOUT_MS = 2000; From 1af876cfaa4dffd69cb1f4e2a8f6da211316a657 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 28 Sep 2026 05:00:46 +0000 Subject: [PATCH 22/33] feat(omp): deliver guard context through OMP's tool-call context channel Return the hook guard's additionalContext from the tool_call handler instead of prepending a to the tool result. OMP 18.3.1+ sends it as a separate developer message after the batch, so raw tool output stays untouched and cannot forge the instruction channel. Detect internal URLs through the exported InternalUrlRouter: OMP 18.3.1 made path-utils' isInternalUrlPath module-private, which stopped the bridge from loading. --- graphify/omp/index.ts | 34 ++++++---------- tests/omp.test.ts | 91 ++++++++++++++++--------------------------- 2 files changed, 45 insertions(+), 80 deletions(-) diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index fd316066d..60b2657ce 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -2,11 +2,11 @@ import { execFile } from "node:child_process"; import { realpathSync } from "node:fs"; import { delimiter, isAbsolute, relative, sep } from "node:path"; import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls"; // resolveReadPath, not the async sibling: the async variant is missing from // older host-provided pi-coding-agent copies -- a blocked statSync beats an unloadable extension. import { expandDelimitedPathEntries, - isInternalUrlPath, normalizePathLikeInput, resolveReadPath, splitPathAndSelPreferringLiteral, @@ -28,7 +28,7 @@ const TIMEOUT_MS = 2000; const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const; const FILE_URL_RE = /^file:\/\//i; -// isInternalUrlPath/isReadableUrlPath still earn their place even though the +// InternalUrlRouter/isReadableUrlPath still earn their place even though the // guard now defends itself against URL-shaped input: resolveReadPath // below pre-resolves a rootless value to an absolute path // (`path.resolve(cwd, x)`) before the guard ever sees it, which for a @@ -57,7 +57,9 @@ function isRemote(path: string): boolean { // through to the generic checks below. } } - return isInternalUrlPath(path) || isReadableUrlPath(path) || path.includes("://"); + const router = InternalUrlRouter.instance(); + const normalized = path.replace(/^(local:)\/(?!\/)/, "$1//"); + return router.canHandle(normalized) || isReadableUrlPath(path) || path.includes("://"); } function installedCommand(cwd: string): string | undefined { @@ -85,21 +87,16 @@ function runGuard(command: string, kind: string, payload: string, cwd: string, s } export default function graphify(api: ExtensionAPI): void { - // Claude PreToolUse additionalContext parity: `tool_call` captures the guard's - // guidance for that call, `tool_result` delivers it with that call's result. - // Claude Code wraps the text in a system reminder naming the hook instead of - // passing it off as tool output, so it leads the result as one labelled - // block -- the shape OMP's own per-tool TTSR reminders use. - // Every qualifying call carries its own nudge (no dedup), and it survives - // compaction like any other tool output. - const pending = new Map(); + // OMP's passive tool-call context channel emits trusted extension guidance as + // a separate developer message after the batch. Raw tool output stays + // untouched, so a file or command cannot forge this instruction channel. + // Every qualifying call carries its own nudge (no dedup). let generation = 0; let controller = new AbortController(); const reset = () => { generation++; controller.abort(); controller = new AbortController(); - pending.clear(); }; api.on("session_start", reset); api.on("session_switch", reset); @@ -114,6 +111,7 @@ export default function graphify(api: ExtensionAPI): void { const current = generation; const signal = controller.signal; const deadline = performance.now() + TIMEOUT_MS; + const contexts: string[] = []; try { if (Buffer.byteLength(JSON.stringify(event.input)) > INPUT_LIMIT) return; const command = installedCommand(ctx.cwd); @@ -147,8 +145,7 @@ export default function graphify(api: ExtensionAPI): void { return { block: true, reason: hook.permissionDecisionReason }; } if (typeof hook.additionalContext === "string" && hook.additionalContext.trim()) { - const previous = pending.get(event.toolCallId); - pending.set(event.toolCallId, previous ? `${previous}\n\n${hook.additionalContext}` : hook.additionalContext); + contexts.push(hook.additionalContext); } // The search CLI does not inspect individual targets; one call suffices. if (toolName === "Grep") break; @@ -157,14 +154,7 @@ export default function graphify(api: ExtensionAPI): void { // Optional guidance must not break native tools on missing executables, // invalid paths, malformed hook output, timeout, or cancellation. } + if (contexts.length > 0) return { additionalContext: contexts.join("\n\n") }; }); - api.on("tool_result", (event, ctx) => { - if (!ctx.isProjectTrusted()) { pending.clear(); return; } - const nudge = pending.get(event.toolCallId); - if (nudge === undefined) return; - pending.delete(event.toolCallId); - const reminder = `\n${nudge}\n`; - return { content: [{ type: "text" as const, text: reminder }, ...event.content] }; - }); } diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 0a3f6d822..da0259449 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -17,7 +17,7 @@ let control: string; let started: string; let fixture: string; -type Result = { block?: boolean; reason?: string; content?: { type: string; text: string }[] } | undefined; +type Result = { block?: boolean; reason?: string; additionalContext?: string } | undefined; type Handler = (event: Record, ctx: object) => Result | Promise; function harness() { const handlers = new Map(); @@ -32,16 +32,6 @@ function harness() { }; } -// Claude Code delivers PreToolUse additionalContext as a system reminder that names the -// hook, never as tool output. Assert that shape: the guidance leads the result as one -// labelled reminder block, and the tool's own output follows unchanged. -function guidanceOf(result: Result, output: { type: string; text: string }[]): string { - expect(result?.content).toEqual([expect.objectContaining({ type: "text" }), ...output]); - const match = result!.content![0].text.match(/^\n([\s\S]*)\n<\/system-reminder>$/); - expect(match).not.toBeNull(); - return match![1]; -} -const ok = [{ type: "text", text: "ok" }]; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "graphify-omp-")); @@ -94,7 +84,7 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }); }); -test("real CLI strict denial blocks selector reads; every later call nudges via its tool result and resets", async () => { +test("real CLI strict denial blocks once; later calls return trusted passive context", async () => { process.env.GRAPHIFY_HOOK_STRICT = "1"; const api = harness(); await api.emit("before_agent_start"); @@ -103,16 +93,12 @@ test("real CLI strict denial blocks selector reads; every later call nudges via expect(result?.block).toBe(true); expect(result?.reason).toContain("graphify"); expect(existsSync(join(cwd, "graphify-out", "cache", "hook_sessions", "omp-test-session.denied"))).toBe(true); - // After the one-time deny, every qualifying call still carries its own guidance. - expect(await api.emit("tool_call", { ...event, toolCallId: "call-2" })).toBeUndefined(); - const second = await api.emit("tool_result", { toolCallId: "call-2", content: ok }); - expect(guidanceOf(second, ok)).toContain("graphify"); - expect(await api.emit("tool_call", { ...event, toolCallId: "call-3" })).toBeUndefined(); - const third = await api.emit("tool_result", { toolCallId: "call-3", content: ok }); - expect(guidanceOf(third, ok)).toContain("graphify"); - // Navigation resets pending guidance: a result arriving after the reset is untouched. + // After the one-time deny, every qualifying call carries its own guidance. + const second = await api.emit("tool_call", { ...event, toolCallId: "call-2" }); + expect(second?.additionalContext).toContain("graphify"); await api.emit("before_agent_start"); - expect(await api.emit("tool_result", { toolCallId: "call-3", content: [{ type: "text", text: "ok" }] })).toBeUndefined(); + const third = await api.emit("tool_call", { ...event, toolCallId: "call-3" }); + expect(third?.additionalContext).toContain("graphify"); }); test("native grep, bash search, and glob expose the installed CLI's actual guidance", async () => { @@ -125,10 +111,8 @@ test("native grep, bash search, and glob expose the installed CLI's actual guida const expected = JSON.parse(execFileSync(realCLI!, ["hook-guard", kind], { cwd, input: JSON.stringify({ tool_input: legacyInput }), encoding: "utf8", })).hookSpecificOutput.additionalContext; - const toolCallId = `${toolName}-1`; - expect(await api.emit("tool_call", { toolName, input, toolCallId })).toBeUndefined(); - const result = await api.emit("tool_result", { toolCallId, content: ok }); - expect(guidanceOf(result, ok)).toBe(expected); + const result = await api.emit("tool_call", { toolName, input, toolCallId: `${toolName}-1` }); + expect(result?.additionalContext).toBe(expected); } }); @@ -138,63 +122,56 @@ test("multi-target glob calls surface every target's guidance", async () => { // mode distinguishes the per-target guard invocations the extension must join. writeFileSync(control, JSON.stringify({ mode: "varying" })); const api = harness(); - expect(await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" })).toBeUndefined(); - const result = await api.emit("tool_result", { toolCallId: "call-1", content: ok }); - const guidance = guidanceOf(result, ok); - expect(guidance).toContain("guidance 1"); - expect(guidance).toContain("guidance 2"); + const result = await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" }); + expect(result?.additionalContext).toContain("guidance 1"); + expect(result?.additionalContext).toContain("guidance 2"); }); test("URLs, internal resources, literal selector-like names and false trust do not run project hooks", async () => { const api = harness(); for (const path of ["https://example.com/source.py", "www.example.com/source.py", "skill://graphify", "local:/source.py", "source.py; ssh://host/source.py"]) { - await api.emit("tool_call", { toolName: "read", input: { path }, toolCallId: "call-1" }); + expect(await api.emit("tool_call", { toolName: "read", input: { path }, toolCallId: "call-1" })).toBeUndefined(); expect(existsSync(started)).toBe(false); - expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); } api.trust(false); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" }); + expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" })).toBeUndefined(); expect(existsSync(started)).toBe(false); api.trust(true); writeFileSync(join(cwd, "source.py:12"), "a real filename, not a selector"); - await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" }, toolCallId: "call-3" }); - expect(await api.emit("tool_result", { toolCallId: "call-3", content: [] })).toBeUndefined(); + expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" }, toolCallId: "call-3" })).toBeUndefined(); }); test("a file:// read target resolves to the real local path and still reaches the guard", async () => { - // file:// is the one scheme isRemote lets through to resolveReadPathAsync + // file:// is the one scheme isRemote lets through to resolveReadPath // (which OMP's own path pipeline also resolves down to a local path, not // an external URL) -- an in-project file:// target must still nudge... const api = harness(); - await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(cwd, "source.py")}` }, toolCallId: "call-1" }); - const inProject = await api.emit("tool_result", { toolCallId: "call-1", content: [] }); - expect(guidanceOf(inProject, [])).toContain("graphify"); + const inProject = await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(cwd, "source.py")}` }, toolCallId: "call-1" }); + expect(inProject?.additionalContext).toContain("graphify"); // ...while one outside the project still resolves (the CLI subprocess // runs), but the guard's own containment check stays silent, same as any // other out-of-project absolute path. - await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(root, "outside.py")}` }, toolCallId: "call-2" }); - expect(await api.emit("tool_result", { toolCallId: "call-2", content: [] })).toBeUndefined(); + expect(await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(root, "outside.py")}` }, toolCallId: "call-2" })).toBeUndefined(); }); -test("a file:// read target with a non-local authority never reaches the guard", async () => { - // VALID #2: RFC 8089 / Node's url.fileURLToPath (ERR_INVALID_FILE_URL_HOST) - // -- only an empty or `localhost` authority names a local file. Any other - // authority names a remote host and must not alias an in-project file - // merely by sharing its path component. +test("file:// read targets distinguish remote authorities from localhost", async () => { const api = harness(); - await api.emit("tool_call", { toolName: "read", input: { path: `file://evil.com${join(cwd, "source.py")}` }, toolCallId: "call-1" }); + expect(await api.emit("tool_call", { + toolName: "read", + input: { path: `file://evil.com${join(cwd, "source.py")}` }, + toolCallId: "call-1", + })).toBeUndefined(); expect(existsSync(started)).toBe(false); - expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); -}); -test("a file:// read target with an explicit localhost authority still reaches the guard", async () => { - const api = harness(); - await api.emit("tool_call", { toolName: "read", input: { path: `file://localhost${join(cwd, "source.py")}` }, toolCallId: "call-1" }); - const result = await api.emit("tool_result", { toolCallId: "call-1", content: [] }); - expect(guidanceOf(result, [])).toContain("graphify"); + const local = await api.emit("tool_call", { + toolName: "read", + input: { path: `file://localhost${join(cwd, "source.py")}` }, + toolCallId: "call-2", + }); + expect(local?.additionalContext).toContain("graphify"); }); -test("navigation cancels in-flight guidance before the next session's tool results", async () => { +test("navigation cancels in-flight guidance before the next session", async () => { for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { rmSync(started, { force: true }); writeFileSync(control, JSON.stringify({ mode: "delayed" })); @@ -205,20 +182,18 @@ test("navigation cancels in-flight guidance before the next session's tool resul expect(existsSync(started)).toBe(true); await api.emit(navigation); expect(await pending).toBeUndefined(); - expect(await api.emit("tool_result", { toolCallId: "call-1", content: [] })).toBeUndefined(); } }); test("oversized input never spawns, invalid/oversized output fails open, and a hung child is killed", async () => { const api = harness(); - await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) }, toolCallId: "call-1" }); + expect(await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) }, toolCallId: "call-1" })).toBeUndefined(); expect(existsSync(started)).toBe(false); for (const mode of ["invalid", "overflow", "hung"]) { writeFileSync(control, JSON.stringify({ mode })); const start = performance.now(); expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" })).toBeUndefined(); expect(performance.now() - start).toBeLessThan(3500); - expect(await api.emit("tool_result", { toolCallId: "call-2", content: [] })).toBeUndefined(); } }, 10000); From e96db162d0ad68a3bce4ccc317da5c940a90a1bd Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 28 Sep 2026 05:01:50 +0000 Subject: [PATCH 23/33] build(omp): align the embedded package with Graphify 0.9.70 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 2cd42c1c3..f27427cc2 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.67", + "version": "0.9.70", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From 9cc3f91671ff19d491faa990fb650b43b23b2370 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 28 Sep 2026 11:02:05 +0000 Subject: [PATCH 24/33] build(omp): align the embedded package with Graphify 0.9.71 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index f27427cc2..5dc087351 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.70", + "version": "0.9.71", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From 48708b8d663ff61d7c884f3cce2ae9fed94e55b0 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 29 Sep 2026 12:04:26 +0000 Subject: [PATCH 25/33] build(omp): align the embedded package with Graphify 0.9.72 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 5dc087351..2c4dd6cca 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.71", + "version": "0.9.72", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From e1535b92c7deffe390e56ef621335996b5419ed6 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 21:52:33 +0000 Subject: [PATCH 26/33] build(omp): align the embedded package with Graphify 0.9.78 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 2c4dd6cca..efb6cffce 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.72", + "version": "0.9.78", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From 047623157ff9c80855ef3bdf307d32cdbd1e230e Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 7 Oct 2026 01:58:30 +0000 Subject: [PATCH 27/33] build(omp): align the embedded package with Graphify 0.9.79 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index efb6cffce..6b54283da 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.78", + "version": "0.9.79", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From 3c47046ad323574c347aaa83467659c53899ad98 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 10 Oct 2026 18:37:20 +0000 Subject: [PATCH 28/33] fix(omp): label passive guard context as a graphify system reminder OMP sends passive tool-call context to Anthropic models as a bare, unlabelled user turn, so agents read the MANDATORY nudge as noise and never ran graphify. Wrap it in , the shape Claude Code gives PreToolUse additionalContext. It still travels through the passive channel, so tool output cannot forge it. --- README.md | 5 +++-- graphify/omp/index.ts | 6 +++++- tests/omp.test.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7f02a04d0..290edd1e2 100644 --- a/README.md +++ b/README.md @@ -343,8 +343,9 @@ runs the installed `graphify hook-guard read|search` CLI. The existing Python policy owns fresh/stale graph decisions and strict-mode denials: start OMP with `GRAPHIFY_HOOK_STRICT=1` to enable its once-per-session indexed-read block. Denials become OMP `block`/`reason`; each qualifying call carries its own -guidance, appended to that call's persisted tool result (Claude -`PreToolUse` additionalContext parity), and pending deliveries are cleared for +guidance through OMP's passive tool-call context channel as one +`` block (Claude `PreToolUse` +additionalContext parity), and pending deliveries are cleared for each new user run and session navigation. In-flight hooks are cancelled on these boundaries. No graph is created or updated automatically. The guard package intentionally declares no skills; the existing cross-framework diff --git a/graphify/omp/index.ts b/graphify/omp/index.ts index 60b2657ce..85a185b27 100644 --- a/graphify/omp/index.ts +++ b/graphify/omp/index.ts @@ -90,6 +90,8 @@ export default function graphify(api: ExtensionAPI): void { // OMP's passive tool-call context channel emits trusted extension guidance as // a separate developer message after the batch. Raw tool output stays // untouched, so a file or command cannot forge this instruction channel. + // OMP sends that message unlabelled, so wrap it the way Claude Code labels + // PreToolUse context; bare text reads as noise and agents ignore it. // Every qualifying call carries its own nudge (no dedup). let generation = 0; let controller = new AbortController(); @@ -154,7 +156,9 @@ export default function graphify(api: ExtensionAPI): void { // Optional guidance must not break native tools on missing executables, // invalid paths, malformed hook output, timeout, or cancellation. } - if (contexts.length > 0) return { additionalContext: contexts.join("\n\n") }; + if (contexts.length > 0) { + return { additionalContext: `\n${contexts.join("\n\n")}\n` }; + } }); } diff --git a/tests/omp.test.ts b/tests/omp.test.ts index da0259449..d7ec84453 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -112,7 +112,7 @@ test("native grep, bash search, and glob expose the installed CLI's actual guida cwd, input: JSON.stringify({ tool_input: legacyInput }), encoding: "utf8", })).hookSpecificOutput.additionalContext; const result = await api.emit("tool_call", { toolName, input, toolCallId: `${toolName}-1` }); - expect(result?.additionalContext).toBe(expected); + expect(result?.additionalContext).toBe(`\n${expected}\n`); } }); From ae631439781a413c0224b82453770462b356c302 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 10 Oct 2026 18:42:16 +0000 Subject: [PATCH 29/33] build(omp): align the embedded package with Graphify 0.9.84 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 6b54283da..7d4e92cd1 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.79", + "version": "0.9.84", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From 80310be52236353cf9dc156eac9c5ef13bab0bcc Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 11 Oct 2026 01:16:39 +0000 Subject: [PATCH 30/33] build(omp): align the embedded package with Graphify 0.9.85 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 7d4e92cd1..21dcb681e 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.84", + "version": "0.9.85", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From f18b52822176d243783292cb7afe96429566555f Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 11 Oct 2026 01:40:28 +0000 Subject: [PATCH 31/33] fix(detect): prune the omp project skill like pi's nested layout graphify omp install writes the project skill to .omp/agent/skills/graphify, one level below the hidden dir like pi. Without the holder entry the scan indexed graphify's own SKILL.md and references, which upstream's per-platform prune tests (#4057) now catch. --- graphify/detect.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index 3a17041f8..b865fa225 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -975,8 +975,8 @@ def _is_regular_file(path: Path) -> bool: _JS_SNAPSHOT_TEST_ROOTS = frozenset({"__tests__", "__test__"}) # Platforms whose project-scope skills dir is one level below the hidden dir -# (install.py: pi -> .pi/agent/skills, kilo -> .config/kilo/skills). -_NESTED_SKILL_HOLDERS = frozenset({(".pi", "agent"), (".config", "kilo")}) +# (install.py: pi -> .pi/agent/skills, omp -> .omp/agent/skills, kilo -> .config/kilo/skills). +_NESTED_SKILL_HOLDERS = frozenset({(".pi", "agent"), (".omp", "agent"), (".config", "kilo")}) # Single files `graphify install` writes whole into a project (#4057): the # always-on rules/steering/workflow files and the opencode/kilo hook plugins. @@ -1158,7 +1158,8 @@ def _is_noise_dir(part: str, parent: "Path | None" = None) -> bool: return True # graphify's own skill folder, as `graphify install --project` writes it # (#4057): /skills/graphify/ (.claude, .codex, .agents, ...), - # .pi/agent/skills/graphify/, .config/kilo/skills/graphify/ and + # .pi/agent/skills/graphify/, .omp/agent/skills/graphify/, + # .config/kilo/skills/graphify/ and # .aider/graphify/. Matched on the whole path shape rather than the bare # names "skills"/"graphify" (#2479), so graphify's own source tree # (graphify/skills//), a top-level skills/graphify/ and the user's From 24ff512f9a95253d5890fd283a0557468a9d873a Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 11 Oct 2026 20:33:32 +0000 Subject: [PATCH 32/33] build(omp): align the embedded package with Graphify 0.9.86 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 21dcb681e..702a38881 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.85", + "version": "0.9.86", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0", From 18379da9d3a848d208ab62e05a0ad40254f50366 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 12 Oct 2026 14:24:56 +0000 Subject: [PATCH 33/33] build(omp): align the embedded package with Graphify 0.9.87 --- graphify/omp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/omp/package.json b/graphify/omp/package.json index 702a38881..8e39d6778 100644 --- a/graphify/omp/package.json +++ b/graphify/omp/package.json @@ -1,6 +1,6 @@ { "name": "graphify-omp", - "version": "0.9.86", + "version": "0.9.87", "private": true, "description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI", "license": "Apache-2.0",