From 448a5422a2df588d520c50bb9a641e0486e083e1 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 11 Sep 2026 20:33:18 +0000 Subject: [PATCH 01/27] feat: bridge hook-guard policy into native OMP hooks --- CHANGELOG.md | 1 + README.md | 53 +++++++++++- 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, 437 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 f9b62a5919..27324eb0b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -276,6 +276,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 5fa367209b..d40a8f012a 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,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` | @@ -314,6 +315,7 @@ Run this once in your project after building a graph: | Agent Skills (cross-framework) | `graphify agents install` (alias `graphify skills install`) | | Kiro IDE/CLI | `graphify kiro install` | | Pi coding agent | `graphify pi install` | +| Oh My Pi (native guard) | `graphify omp install` | | Devin CLI | `graphify devin install` | | Google Antigravity | `graphify antigravity install` | @@ -332,7 +334,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. --- @@ -793,6 +841,9 @@ graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md graphify kiro uninstall graphify pi install # skill file (Pi coding agent) graphify pi uninstall +graphify omp install # native guard package, registered by OMP +graphify omp path # shipped package directory for manual linking +omp plugin uninstall graphify-omp # remove the host-managed link graphify devin install # skill file + .windsurf/rules/graphify.md (Devin CLI) graphify devin uninstall graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity) diff --git a/graphify/__main__.py b/graphify/__main__.py index 5f5750b885..284a859fe4 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -718,7 +718,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 @@ -742,6 +742,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(" explain \"X\" plain-language explanation of a node and its neighbors") diff --git a/graphify/install.py b/graphify/install.py index 36a4484c87..5a984a3811 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -2272,6 +2272,7 @@ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = Fals "kilo", "kiro", "opencode", + "omp", "pi", "skills", "trae", @@ -2289,6 +2290,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 0000000000..a018b20a19 --- /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 0000000000..7decfb369e --- /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 50bec7f3c9..6f0130174a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,7 +155,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 0000000000..095d6cb8b6 --- /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 0000000000..af0edc4c2c --- /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 08a3deaf24a062496791476ae2e00f6d3363077c Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 15 Sep 2026 10:13:10 +0000 Subject: [PATCH 02/27] 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 af0edc4c2c..98a75edede 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 bf062ab737e675be69fb0bda57d0704e3495e440 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 16 Sep 2026 19:28:48 +0000 Subject: [PATCH 03/27] 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 b6f5e373f7..ebc2a258d8 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 0a6775b2a8..dba5741e31 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 0000000000..41e2490504 --- /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 7decfb369e..9989583fce 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 61cfbdbce4e685e15aa84940f0c4a552f43d66f2 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 18 Sep 2026 19:36:45 +0000 Subject: [PATCH 04/27] 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 dba5741e31..b5fec288b9 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 27324eb0b1..2c07b219fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -276,7 +276,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 a018b20a19..784aa1d59e 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 095d6cb8b6..659cb837e9 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 f28804eb9b21b07837fd92acf1d0278820b73fa2 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 18 Sep 2026 20:24:44 +0000 Subject: [PATCH 05/27] 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 d40a8f012a..b91da4561c 100644 --- a/README.md +++ b/README.md @@ -356,8 +356,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 ac691ef23734049a63cefbd1d7460047942c9c16 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 08:52:41 +0000 Subject: [PATCH 06/27] 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 41e2490504..3e723793ba 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 784aa1d59e..2779fc0bf1 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 9989583fce..116193ca8d 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 4a9a63c70c013f53552ab3727ac72971f9c6636e Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 08:52:48 +0000 Subject: [PATCH 07/27] 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 ebc2a258d8..c90e4b0633 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 7daf14748b7513b712ddfff234135a1ecf878e8f Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:12:22 +0000 Subject: [PATCH 08/27] 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 3b516cea60..b4217eca63 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({ @@ -968,10 +969,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 @@ -1016,6 +1033,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. @@ -1038,11 +1104,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 0000000000..cdd1ffaa8a --- /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 8613566070d59d528d0e4c16c646d64c0e3e52c3 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:12:29 +0000 Subject: [PATCH 09/27] 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 2779fc0bf1..67a3d552b0 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 659cb837e9..4fbfd29e10 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 4ade08aeaa1d491149674587c0b134ce127cbbc5 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 15:59:09 +0000 Subject: [PATCH 10/27] 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 c90e4b0633..62c2288841 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 3e723793ba..88a2bc7fc8 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 67a3d552b0..d4b7407340 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 116193ca8d..20619dd33b 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 4fbfd29e10..493e72081a 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 d30c7f2370f79df6bffe36c1ea9dfafe853002ff Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 16:11:04 +0000 Subject: [PATCH 11/27] 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 d4b7407340..24906460fc 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 493e72081a..03298e98eb 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 22f48ecd699b53258db20645bee563a96998d6a1 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:01 +0000 Subject: [PATCH 12/27] 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 b4217eca63..cfa6acf222 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1041,7 +1041,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 + @@ -1052,12 +1053,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): @@ -1068,16 +1081,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 cdd1ffaa8a..5d88381200 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 a3b62de855f538d85f95bdd62c4a4055ed8435d8 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:06 +0000 Subject: [PATCH 13/27] 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 24906460fc..9bb68d165a 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 03298e98eb..a5a02952a3 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 3c10c7128e27eb9a481fce285e860a618f6a685e Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 20 Sep 2026 17:25:11 +0000 Subject: [PATCH 14/27] 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 62c2288841..94bcde0c32 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 15792ff9a05478da7739fa543d8c9d9a79a8e67a Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 22 Sep 2026 14:27:17 +0000 Subject: [PATCH 15/27] 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 20619dd33b..a1dcf851c3 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 e3186c26d965c6558cf28736d5a73a3fd19cd671 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 23 Sep 2026 20:32:17 +0000 Subject: [PATCH 16/27] 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 a1dcf851c3..2cd42c1c32 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 3f232cf5dbe565474ff876db0cc471fba57035c5 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 24 Sep 2026 02:56:26 +0000 Subject: [PATCH 17/27] 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 2c07b219fa..b1238d9d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -276,7 +276,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 9bb68d165a..fd316066d4 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 a5a02952a3..0a3f6d822f 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 5df93348365158397252018478c4a9690377dbca Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 24 Sep 2026 03:31:55 +0000 Subject: [PATCH 18/27] 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 5a984a3811..f8d5d46785 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -619,6 +619,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", @@ -1840,7 +1848,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) @@ -1873,7 +1881,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") @@ -2514,6 +2522,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 0000000000..b2a06b1af8 --- /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 f9ddf9a81b34f66ddc9fe1712c04ae3cfcb5547b Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Thu, 24 Sep 2026 03:48:20 +0000 Subject: [PATCH 19/27] 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 f8d5d46785..7792db8374 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -2072,6 +2072,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) @@ -2522,21 +2526,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 b2a06b1af8..2c804e23f1 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 3389659ea907ea8eb84efacb47d9dd2543bc916b Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 26 Sep 2026 12:09:41 +0000 Subject: [PATCH 20/27] 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 fd316066d4..4bdf0d69f7 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 2cb52b5a0456de3788163f3002f345e9f7f462cf Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 26 Sep 2026 13:01:01 +0000 Subject: [PATCH 21/27] 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 4bdf0d69f7..fd316066d4 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 5464518e52f9e6bf827ade5cbc1080cb6fa1d51d Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 28 Sep 2026 05:00:46 +0000 Subject: [PATCH 22/27] 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 fd316066d4..60b2657ce7 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 0a3f6d822f..da0259449b 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 e2df175fbb8414c6d31955a9186c1cb4ab846713 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 28 Sep 2026 05:01:50 +0000 Subject: [PATCH 23/27] 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 2cd42c1c32..f27427cc21 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 b8532688bc746a4d0ce193139db136b3bf74d9eb Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 28 Sep 2026 11:02:05 +0000 Subject: [PATCH 24/27] 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 f27427cc21..5dc0873511 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 c29a5f52fa9cd54fa78504e42e610b20ce2bb171 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 29 Sep 2026 12:04:26 +0000 Subject: [PATCH 25/27] 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 5dc0873511..2c4dd6cca8 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 02a50767fa4da9663c97a9c454be540612db20d0 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 21:52:33 +0000 Subject: [PATCH 26/27] 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 2c4dd6cca8..efb6cffced 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 9b7abad1bd230ad00110bfc5006669063c994520 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Wed, 7 Oct 2026 01:58:30 +0000 Subject: [PATCH 27/27] 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 efb6cffced..6b54283dac 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",