From d0ab9ef2bb4e881f3af76620254a5c31ff626fc5 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 11 Sep 2026 20:33:18 +0000 Subject: [PATCH] feat: package native OMP prompt context hook --- CHANGELOG.md | 2 + README.md | 64 ++++++++++++++ __tests__/omp-extension.test.ts | 144 ++++++++++++++++++++++++++++++++ omp/index.ts | 90 ++++++++++++++++++++ package.json | 4 + scripts/pack-npm.sh | 4 +- 6 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 __tests__/omp-extension.test.ts create mode 100644 omp/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b68b091..b54f04fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **Oh My Pi can load CodeGraph context automatically before a new user run** through a native extension that reuses your existing index without installing tools or indexing code. + - **Codex and Astra read project guidance from `AGENTS.md`.** The canonical agent guide now lives in `AGENTS.md` (with a nested `docs/AGENTS.md` for long validation notes); `CLAUDE.md` is a thin `@AGENTS.md` wrapper for Claude Code. Codex/Astra no longer miss the old CLAUDE-only instructions. - **A big screen's picture stops wrapping into a column.** How wide a screen's lines run before they wrap was worked out with a formula, and the formula was wrong for the way these pictures are actually drawn: a part of a screen spends lines on its own structure — a step that fires things gets a line to itself, and what it fires starts another — so estimating the lines from the boxes alone badly undercounted them, and one screen's 98 boxes wrapped into a 4,356px column. Laying a picture out is cheap and exact, so the widths are now simply tried and the one that comes out closest to the shape of a window is kept. Across one app's 51 screens the tallest picture went from 4,356px to 3,796px, total height fell 8%, and — because a shorter picture is also a picture whose lines have less far to go — lines running over other boxes fell by a third and lines crossing each other went from 13 to 5. diff --git a/README.md b/README.md index 10a325ae2..ba79fe894 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. ## Contents - [Get Started](#get-started) +- [Oh My Pi (native extension)](#oh-my-pi-native-extension) - [Language Support](#language-support) - [Why CodeGraph?](#why-codegraph) - [Key Features](#key-features) @@ -108,6 +109,9 @@ codegraph install Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs) — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) +Oh My Pi uses a [native extension](#oh-my-pi-native-extension) installed through +OMP, not the interactive MCP installer. + ### 3. Initialize each project ```bash @@ -149,6 +153,63 @@ Pass `--keep-cli` to remove only the agent configurations and keep the CLI insta Reverses the installer — strips CodeGraph's MCP server config, instructions, and permissions from each configured agent. Your project indexes (`.codegraph/`) are left untouched; remove those per-project with `codegraph uninit`. Use `--target` to remove from specific agents, or `--yes` to run non-interactively. +## Oh My Pi (native extension) + +Install the package with OMP, then restart the session: + +```bash +omp plugin install @colbymchenry/codegraph +``` + +The package declares `omp.extensions` and ships the native extension alongside +the CLI. It uses the already-installed platform bundle, or an installed +`codegraph` executable on PATH when loading from a source checkout. It never +runs `npx`, installs a missing runtime, creates an index, or runs `init`/`sync`. +On Windows, install the npm package with its matching platform dependency; +shell-only `.cmd`/`.bat` launchers are not executed by the extension. + +In a project you have already indexed, ask a structural question such as +“Explain download and its callers.” Before each new user run, the extension +passes the prompt and working directory to the existing `codegraph prompt-hook`. +The hook's raw `` output becomes a hidden custom message, +not a replacement system prompt. Existing prompt selection, project discovery, +query logic, and Claude Code hooks are unchanged. This does not configure an +MCP server; an existing CodeGraph MCP connection can be used alongside it. + +Use an OMP version that fires `before_agent_start` for every new user run, +including a fresh steer and a follow-up promoted from the queue. The extension +does not replay input or regenerate context for each provider request. Session +switches, branches, tree navigation, shutdown, and newer runs cancel pending +work and discard its context. +The target host is OMP integration commit +`6aef0e8ad51b3bc5ea7a5f2a255c3d48e4c5af72`, containing the queue/input lifecycle +fix `ad3fb437d3`; the `18.1.17` version string alone does not guarantee those +fixes are present. + +The subprocess has a two-second deadline, a 256 KiB JSON input limit, and a +64 KiB limit for each output stream. Downloads are disabled with +`CODEGRAPH_NO_DOWNLOAD=1`. Missing executables/indexes, disabled hooks, failed +queries, malformed output, and limit violations simply add no context. +The existing `CODEGRAPH_NO_PROMPT_HOOK=1` or `CODEGRAPH_PROMPT_HOOK=0` switches +also disable this extension's automatic work; normal CodeGraph telemetry +preferences still apply. + +**Trust boundary:** the extension checks `ctx.isProjectTrusted()` before +execution and before accepting its result. Current OMP returns `true` +unconditionally: this API is a compatibility signal, **not a sandbox or an +enforced per-project approval gate**. Enable this extension only in workspaces +you trust, and use only a trusted installed CLI/PATH. It does not run commands +from project configuration. + +For a source checkout, install the CLI first and load the package directory: + +```bash +omp --extension /absolute/path/to/codegraph +``` + +Remove the extension with `omp plugin uninstall @colbymchenry/codegraph`. +This is separate from `codegraph uninstall`; existing indexes are preserved. + --- ## Language Support @@ -864,6 +925,9 @@ is written): - **Kiro** - **GitHub Copilot** — Copilot Chat in VS Code (`copilot-vscode`), the Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`) +**Oh My Pi (OMP)** has a separate [native prompt-context extension](#oh-my-pi-native-extension) +installed through `omp plugin install`, not through the interactive installer. + ## Supported Languages | Language | Extension | Status | diff --git a/__tests__/omp-extension.test.ts b/__tests__/omp-extension.test.ts new file mode 100644 index 000000000..b08681a94 --- /dev/null +++ b/__tests__/omp-extension.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { ExtensionAPI, ExtensionContext } from '@oh-my-pi/pi-coding-agent'; +import codegraph from '../omp/index'; + +const context = '\nfunction download() {}\n'; +type HookContext = Pick; +type Handler = (event: unknown, ctx: HookContext) => unknown; + +describe('native OMP prompt hook', () => { + let cwd: string; + let trusted: boolean; + let handlers: Map; + let which: Mock<() => string | null>; + const ctx = () => ({ cwd, isProjectTrusted: () => trusted }); + const run = (prompt: string) => handlers.get('before_agent_start')!({ + type: 'before_agent_start', prompt, systemPrompt: ['base policy'], + }, ctx()); + const load = () => { + // Only registration is needed; these handlers use the narrow context above. + const api = { on: (event: string, handler: Handler) => handlers.set(event, handler) } as unknown as ExtensionAPI; + codegraph(api); + }; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-omp-')); + trusted = true; + handlers = new Map(); + which = vi.fn(() => process.execPath); + vi.stubGlobal('Bun', { which }); + vi.stubEnv('CODEGRAPH_NO_PROMPT_HOOK', ''); + vi.stubEnv('CODEGRAPH_PROMPT_HOOK', ''); + // Real subprocess, no CodeGraph index or host dependency. Node treats the + // fixed `prompt-hook` argument as this fixture's entry point on every OS. + fs.writeFileSync(path.join(cwd, 'prompt-hook'), ` + const fs = require('node:fs'); + fs.appendFileSync('invocations', 'started\\n'); + let raw = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => raw += chunk); + process.stdin.on('end', () => { + const input = JSON.parse(raw); + if (process.env.CODEGRAPH_NO_DOWNLOAD !== '1') process.exit(2); + const context = ${JSON.stringify(context)}; + switch (input.prompt) { + // Real child deadline integration: parent fake timers cannot control + // the subprocess or prove that its open handles are terminated. + case 'hang': setInterval(() => {}, 100); break; + case 'overflow': process.stdout.write('x'.repeat(128 * 1024)); break; + case 'invalid': process.stdout.write('codegraph: missing bundle'); break; + case 'partial': process.stdout.write('partial'); break; + case 'error': process.stdout.write(context); process.exitCode = 3; break; + case 'empty': break; + default: process.stdout.write(context); + } + }); + `); + }); + + afterEach(() => { + handlers.get('session_shutdown')?.({}, ctx()); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it('delivers raw context as a hidden custom message', async () => { + load(); + expect(await run('Explain download and its callers')).toEqual({ + message: { customType: 'codegraph-context', content: context, display: false }, + }); + expect(fs.readFileSync(path.join(cwd, 'invocations'), 'utf8')).toBe('started\n'); + }); + + it('does not execute when the host reports an untrusted project', async () => { + trusted = false; + load(); + expect(await run('Explain download')).toBeUndefined(); + expect(fs.existsSync(path.join(cwd, 'invocations'))).toBe(false); + }); + + it('does not execute without an installed executable', async () => { + which.mockReturnValue(null); + load(); + expect(await run('Explain download')).toBeUndefined(); + expect(fs.existsSync(path.join(cwd, 'invocations'))).toBe(false); + }); + + it.each(['CODEGRAPH_NO_PROMPT_HOOK', 'CODEGRAPH_PROMPT_HOOK'])('honors %s before launching', async (name) => { + vi.stubEnv(name, name === 'CODEGRAPH_NO_PROMPT_HOOK' ? '1' : '0'); + load(); + expect(await run('Explain download')).toBeUndefined(); + expect(fs.existsSync(path.join(cwd, 'invocations'))).toBe(false); + }); + + it('rejects oversized serialized input, including escaping and multibyte text', async () => { + load(); + for (const prompt of ['x'.repeat(256 * 1024 + 1), '\u0000'.repeat(50 * 1024), '界'.repeat(90 * 1024)]) { + expect(await run(prompt)).toBeUndefined(); + } + expect(fs.existsSync(path.join(cwd, 'invocations'))).toBe(false); + }); + + it.each(['overflow', 'invalid', 'partial', 'error', 'empty'])('drops %s output without breaking the prompt', async (prompt) => { + load(); + expect(await run(prompt)).toBeUndefined(); + expect(fs.readFileSync(path.join(cwd, 'invocations'), 'utf8')).toBe('started\n'); + }); + + it('bounds execution time even when the hook does not finish', async () => { + load(); + expect(await run('hang')).toBeUndefined(); + expect(fs.readFileSync(path.join(cwd, 'invocations'), 'utf8')).toBe('started\n'); + }, 4000); + + it.each(['session_start', 'session_switch', 'session_branch', 'session_tree', 'session_shutdown'])('drops pending context on %s', async (event) => { + load(); + const pending = run('hang'); + handlers.get(event)!({}, ctx()); + expect(await pending).toBeUndefined(); + expect(await run('Explain download')).toEqual({ + message: { customType: 'codegraph-context', content: context, display: false }, + }); + }); + + it('does not attach an older run result to a newer run', async () => { + load(); + const old = run('hang'); + const current = run('Explain download'); + expect(await old).toBeUndefined(); + expect(await current).toEqual({ + message: { customType: 'codegraph-context', content: context, display: false }, + }); + }); + + it('drops context if trust is revoked while the hook is running', async () => { + load(); + const pending = run('Explain download'); + trusted = false; + expect(await pending).toBeUndefined(); + }); +}); diff --git a/omp/index.ts b/omp/index.ts new file mode 100644 index 000000000..04d6a98b6 --- /dev/null +++ b/omp/index.ts @@ -0,0 +1,90 @@ +import { execFile, type ChildProcess } from 'node:child_process'; +import { createRequire } from 'node:module'; +import type { ExtensionAPI } from '@oh-my-pi/pi-coding-agent'; + +const require = createRequire(import.meta.url); +const MAX_INPUT_BYTES = 256 * 1024; +const MAX_OUTPUT_BYTES = 64 * 1024; +const TIMEOUT_MS = 2000; + +// Prefer this package's installed runtime: no PATH setup, shell, or download. +// The standalone CLI remains usable when loading the extension from source. +function installedCommand(): { command: string; args: string[] } | undefined { + const pkg = `@colbymchenry/codegraph-${process.platform}-${process.arch}`; + try { + return { + command: require.resolve(`${pkg}/${process.platform === 'win32' ? 'node.exe' : 'node'}`), + args: ['--liftoff-only', '--disable-warning=ExperimentalWarning', + require.resolve(`${pkg}/lib/dist/bin/codegraph.js`), 'prompt-hook'], + }; + } catch { + const command = Bun.which('codegraph'); + // Windows npm/standalone .cmd launchers require a shell. Use the installed + // platform package above instead; never execute a project-supplied command. + if (!command || /\.(cmd|bat)$/i.test(command)) return undefined; + return { command, args: ['prompt-hook'] }; + } +} + +/** Native OMP integration; the existing CLI owns project discovery and queries. */ +export default function codegraph(omp: ExtensionAPI): void { + const installed = installedCommand(); + let generation = 0; + let cancel: (() => void) | undefined; + const reset = () => { + generation++; + cancel?.(); + cancel = undefined; + }; + for (const event of ['session_start', 'session_switch', 'session_branch', 'session_tree', 'session_shutdown'] as const) { + omp.on(event, reset); + } + + omp.on('before_agent_start', async (event, ctx) => { + reset(); + if (!ctx.isProjectTrusted() || !installed || process.env.CODEGRAPH_NO_PROMPT_HOOK === '1' || process.env.CODEGRAPH_PROMPT_HOOK === '0') return; + // Reject before serializing as well as after: JSON escaping can grow input. + if (event.prompt.length > MAX_INPUT_BYTES || ctx.cwd.length > MAX_INPUT_BYTES) return; + const input = JSON.stringify({ prompt: event.prompt, cwd: ctx.cwd }); + if (Buffer.byteLength(input) > MAX_INPUT_BYTES) return; + const current = generation; + const cwd = ctx.cwd; + const content = await new Promise((resolve) => { + let child: ChildProcess | undefined; + let timer: NodeJS.Timeout | undefined; + let settled = false; + const finish = (output?: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + // A PATH launcher can spawn the real CLI. Kill its process group too, + // including when a descendant keeps stdout open after its parent exits. + try { + if (process.platform !== 'win32' && child?.pid) process.kill(-child.pid, 'SIGKILL'); + else child?.kill('SIGKILL'); + } catch { /* Already exited. */ } + resolve(output); + }; + cancel = () => finish(); + try { + child = execFile(installed.command, installed.args, { + cwd, encoding: 'utf8', maxBuffer: MAX_OUTPUT_BYTES, killSignal: 'SIGKILL', + detached: process.platform !== 'win32', windowsHide: true, + env: { ...process.env, CLAUDE_PROJECT_DIR: cwd, CODEGRAPH_NO_DOWNLOAD: '1' }, + }, (error, stdout) => finish(error ? undefined : stdout.trim() || undefined)); + timer = setTimeout(() => finish(), TIMEOUT_MS); + child.stdin?.on('error', () => finish()); + child.stdin?.end(input); + } catch { + finish(); + } + }); + if (current !== generation) return; + cancel = undefined; + if (!ctx.isProjectTrusted() || ctx.cwd !== cwd || !content) return; + // prompt-hook emits raw tagged context, not Claude hook JSON. Never inject + // launcher diagnostics or a partial result as agent instructions. + if (!/^]*)?>[\s\S]*<\/codegraph_context>$/.test(content)) return; + return { message: { customType: 'codegraph-context', content, display: false } }; + }); +} diff --git a/package.json b/package.json index d5a742420..1918b97a9 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,13 @@ "bin": { "codegraph": "./dist/bin/codegraph.js" }, + "omp": { + "extensions": ["./omp/index.ts"] + }, "files": [ "dist", "scripts", + "omp", "README.md" ], "workspaces": [ diff --git a/scripts/pack-npm.sh b/scripts/pack-npm.sh index d2e1768dd..79d3022e1 100755 --- a/scripts/pack-npm.sh +++ b/scripts/pack-npm.sh @@ -86,6 +86,7 @@ done # per-platform bundle so its deps aren't duplicated here. cp "$ROOT/scripts/npm-shim.js" "$NPM/main/npm-shim.js" cp "$ROOT/scripts/npm-sdk.js" "$NPM/main/npm-sdk.js" +cp -R "$ROOT/omp" "$NPM/main/omp" [ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$NPM/main/README.md" # Ship the type declarations so `types`/`exports.types` resolve. Built from this @@ -112,12 +113,13 @@ VERSION="$VERSION" SCOPE="$SCOPE" TARGETS="${targets[*]}" \ bin: { codegraph: "npm-shim.js" }, main: "npm-sdk.js", types: "dist/index.d.ts", + omp: { extensions: ["./omp/index.ts"] }, exports: { ".": { types: "./dist/index.d.ts", default: "./npm-sdk.js" }, "./package.json": "./package.json" }, optionalDependencies: opt, - files: ["npm-shim.js","npm-sdk.js","dist","README.md"], + files: ["npm-shim.js","npm-sdk.js","dist","omp","README.md"], license: "MIT", repository: { type: "git", url: "git+https://github.com/colbymchenry/codegraph.git" } }, null, 2) + "\n");