Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -108,6 +109,9 @@ codegraph install

<sub>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.)</sub>

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
Expand Down Expand Up @@ -149,6 +153,63 @@ Pass `--keep-cli` to remove only the agent configurations and keep the CLI insta

<sub>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.</sub>

## 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 `<codegraph_context>` 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
Expand Down Expand Up @@ -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 |
Expand Down
144 changes: 144 additions & 0 deletions __tests__/omp-extension.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<codegraph_context note="indexed source">\nfunction download() {}\n</codegraph_context>';
type HookContext = Pick<ExtensionContext, 'cwd' | 'isProjectTrusted'>;
type Handler = (event: unknown, ctx: HookContext) => unknown;

describe('native OMP prompt hook', () => {
let cwd: string;
let trusted: boolean;
let handlers: Map<string, Handler>;
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('<codegraph_context>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();
});
});
90 changes: 90 additions & 0 deletions omp/index.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>((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 (!/^<codegraph_context(?:\s[^>]*)?>[\s\S]*<\/codegraph_context>$/.test(content)) return;
return { message: { customType: 'codegraph-context', content, display: false } };
});
}
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
"bin": {
"codegraph": "./dist/bin/codegraph.js"
},
"omp": {
"extensions": ["./omp/index.ts"]
},
"files": [
"dist",
"scripts",
"omp",
"README.md"
],
"workspaces": [
Expand Down
4 changes: 3 additions & 1 deletion scripts/pack-npm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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");
Expand Down