Skip to content
Merged
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
64 changes: 62 additions & 2 deletions agent/claude-code/scripts/hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync, lstat
import { spawn, spawnSync } from 'node:child_process';
import { join, dirname, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import { tmpdir, homedir } from 'node:os';
import { createHash } from 'node:crypto';

import { binaryOf, checkoutOf as repositoryOf, childEnv as cutEnv, commandOf, pathKey } from './launcher.mjs';
Expand Down Expand Up @@ -547,20 +547,80 @@ const handlers = {
PostToolUse: (p, root, key) => (built(root) ? riskLine(p, root, key) : null),
};

const LEGACY_HOOK = 'zerograph-hook.mjs';

/**
* What a group's `matcher` is tested against for `event`, as both harnesses do: the tool for the
* tool events, how the session began for `SessionStart`, the subagent's type for `SubagentStart`.
*/
function matchSubject(event, payload) {
if (event === 'PreToolUse' || event === 'PostToolUse') return payload.tool_name ?? '';
if (event === 'SessionStart') return payload.source ?? '';
if (event === 'SubagentStart') return payload.agent_type ?? '';
return null;
}

/** A matcher absent, empty or `*` matches everything; any other is a regex over the whole subject. */
function matches(matcher, subject) {
if (subject === null || matcher === undefined || matcher === null || matcher === '' || matcher === '*') return true;
try { return new RegExp(`^(?:${matcher})$`).test(subject); } catch { return matcher === subject; }
}

/** The commands a harness hooks file runs for this payload's event, or none when the file is absent or unreadable. */
function hookCommands(file, event, payload) {
let parsed;
try { parsed = JSON.parse(readFileSync(file, 'utf8')); } catch { return []; }
const groups = parsed?.hooks?.[event];
if (!Array.isArray(groups)) return [];
const subject = matchSubject(event, payload);
return groups.filter((g) => matches(g?.matcher, subject))
.flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : []))
.map((h) => [h?.command, ...(Array.isArray(h?.args) ? h.args : [])].filter((w) => typeof w === 'string').join(' '));
}

/** `~`, `$HOME` and `${HOME}` as the shell running a hook command would expand them. */
function expandHome(path) {
return path.replace(/^(?:~|\$\{HOME\}|\$HOME)(?=\/|\\|$)/, homedir());
}

/**
* Whether a hook an earlier `install-agent` wired answers this event already, so this one stays
* silent rather than put the brief, the seeds and the risk line in context twice. In Claude Code
* that is a repository's committed `.claude/settings.json`, which `install-agent` leaves for the
* team to retire; in Codex it is the machine's `$CODEX_HOME/hooks.json`, which nothing a
* repository ships can touch. Either way the old script has to be there too: an entry whose
* script is gone answers nothing. The harness is named by the plugin's own hook command, since
* Codex sets `CLAUDE_PLUGIN_ROOT` as well and no variable tells the two apart.
*/
function legacyAnswers(event, payload, root, harness) {
if (harness === 'codex') {
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
return hookCommands(join(home, 'hooks.json'), event, payload).some((cmd) => {
const script = cmd.match(/(?:"([^"]*zerograph-hook\.mjs)"|'([^']*zerograph-hook\.mjs)'|(\S*zerograph-hook\.mjs))/);
const path = script && expandHome(script[1] ?? script[2] ?? script[3]);
return Boolean(path) && isAbsolute(path) && existsSync(path);
});
}
const project = process.env.CLAUDE_PROJECT_DIR || root;
return existsSync(join(project, '.claude', 'hooks', LEGACY_HOOK))
&& hookCommands(join(project, '.claude', 'settings.json'), event, payload).some((cmd) => cmd.includes(LEGACY_HOOK));
}

async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf8');
}

async function main() {
async function main({ harness = process.argv.includes('--codex') ? 'codex' : 'claude' } = {}) {
let payload;
try { payload = JSON.parse(await readStdin()); } catch { return; }
const event = payload.hook_event_name;
const handler = handlers[event];
if (!handler) return;
// The harness's cwd follows a `cd` into a subdirectory, which has no `.zerograph/` of its own.
const root = (payload.cwd && indexRoot(payload.cwd)) || payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
if (legacyAnswers(event, payload, root, harness)) { debug('legacy hook answers', event); return; }
// The subagent's own id is in the key: a subagent shares its parent's session_id, so a counter
// keyed by session alone finds the count already past zero on a subagent's first call and says
// nothing — which is a second reason 140 unprimed subagents went on grepping.
Expand Down
95 changes: 91 additions & 4 deletions agent/claude-code/scripts/hook.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Run: `node --test agent/claude-code/scripts/hook.test.mjs`, on every platform the hook is installed on.
import { test } from 'node:test';
import assert from 'node:assert';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, copyFileSync, symlinkSync, statSync, realpathSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, copyFileSync, symlinkSync, statSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, delimiter } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -58,12 +58,21 @@ function world({ store = true, answer = '', status = 0 } = {}) {
return { root, bin, launcher, argvLog, budgetLog, cwdLog, pathLog };
}

function fire(w, payload, env = {}, hook = HOOK) {
const r = spawnSync(process.execPath, [hook], {
/**
* The environment less what says which harness runs the hook and where its project is: a suite run
* from inside a Claude Code or Codex session would otherwise read that session's settings.
*/
function harnessless(env) {
const { CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT, PLUGIN_ROOT, CODEX_HOME, ...rest } = env;
return rest;
}

function fire(w, payload, env = {}, hook = HOOK, args = []) {
const r = spawnSync(process.execPath, [hook, ...args], {
input: JSON.stringify({ cwd: w.root, session_id: 'sess', ...payload }),
encoding: 'utf8',
env: {
...process.env,
...harnessless(process.env),
PATH: `${w.bin}${delimiter}${process.env.PATH}`,
// Windows resolves a bare name through `PATHEXT` only inside a shell, so `PATH` alone would
// never find `zerograph.cmd`; the variable the hook already honours names it instead.
Expand Down Expand Up @@ -535,6 +544,84 @@ test('every call the hook waits on refreshes in process for less than its timeou
}
});

/** A checkout carrying what a 0.6.x `install-agent` committed: the script and its entries for `events`. */
function legacyClaude(w, events, matchers = {}) {
mkdirSync(join(w.root, '.claude', 'hooks'), { recursive: true });
writeFileSync(join(w.root, '.claude', 'hooks', 'zerograph-hook.mjs'), '// 0.6.x\n');
const entry = { hooks: [{ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/zerograph-hook.mjs"', timeout: 10 }] };
const hooks = Object.fromEntries(events.map((e) => [e, [{ matcher: matchers[e] ?? '.*', ...entry }]]));
writeFileSync(join(w.root, '.claude', 'settings.json'), JSON.stringify({ hooks }));
}

test('a committed 0.6.x hook answers alone: the plugin is silent on the events it registers', () => {
const w = world({ answer: 'zerograph: 3076 doc nodes, 5240 code nodes' });
legacyClaude(w, ['SessionStart', 'PreToolUse', 'PostToolUse']);
const env = { CLAUDE_PROJECT_DIR: w.root, ZEROGRAPH_HOOK_SERVE: '0' };
assert.equal(fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, env), null);
assert.equal(fire(w, { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'rg -n cancellation' } }, env), null);
assert.deepEqual(argv(w), [], 'a silent hook asks the binary nothing either');
assert.equal(context(fire(w, { hook_event_name: 'SubagentStart', agent_id: 'a1' }, env)), RULE,
'an event the old entries never registered is still the plugin\'s');
});

test('entries whose script is gone leave the plugin answering', () => {
const w = world({ answer: 'zerograph: 3076 doc nodes, 5240 code nodes' });
legacyClaude(w, ['SessionStart']);
const env = { CLAUDE_PROJECT_DIR: w.root, ZEROGRAPH_HOOK_SERVE: '0' };
rmSync(join(w.root, '.claude', 'hooks', 'zerograph-hook.mjs'));
assert.ok(context(fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, env))?.startsWith('zerograph:'));
});

test('a legacy entry silences the plugin only where its matcher matches', () => {
const w = world({ answer: 'zerograph: 3076 doc nodes, 5240 code nodes' });
legacyClaude(w, ['SessionStart', 'PreToolUse'], { SessionStart: 'startup', PreToolUse: 'Grep|Glob|Agent' });
const env = { CLAUDE_PROJECT_DIR: w.root, ZEROGRAPH_HOOK_SERVE: '0' };
assert.equal(fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, env), null);
assert.ok(context(fire(w, { hook_event_name: 'SessionStart', source: 'resume', session_id: 's2' }, env))?.startsWith('zerograph:'),
'a resume the old entry does not match is the plugin\'s');
assert.equal(fire(w, { hook_event_name: 'PreToolUse', tool_name: 'Grep', tool_input: { pattern: 'cancellation' } }, env), null);
const w2 = world({ answer: 'FR-PAY-22 docs/a.md:1 cancellation' });
legacyClaude(w2, ['PreToolUse'], { PreToolUse: 'Grep|Glob|Agent' });
fire(w2, { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'rg -n cancellation' } }, { CLAUDE_PROJECT_DIR: w2.root, ZEROGRAPH_HOOK_SERVE: '0' });
assert.ok(argv(w2).some((a) => a.includes('ask')), 'Bash is not in Grep|Glob|Agent, so the plugin asks');
});

/** A machine `hooks.json` as 0.6.x printed it for Codex, running `script` as spelled. */
function legacyCodex(home, spelled, matcher = 'startup|resume|clear|compact') {
writeFileSync(join(home, 'hooks.json'), JSON.stringify({ hooks: { SessionStart: [{ matcher, hooks: [{ type: 'command', command: `node '${spelled}'` }] }] } }));
}

test('under Codex the machine hooks file decides, and a repository\'s .claude settings do not', () => {
const w = world({ answer: 'zerograph: 3076 doc nodes, 5240 code nodes' });
legacyClaude(w, ['SessionStart']);
const home = mkdtempSync(join(tmpdir(), 'zerograph-codex-home-'));
// What Codex really sets: both roots, so only the plugin's `--codex` argument names the harness.
const env = { PLUGIN_ROOT: join(home, 'plugin'), CLAUDE_PLUGIN_ROOT: join(home, 'plugin'), CODEX_HOME: home, ZEROGRAPH_HOOK_SERVE: '0' };
const codex = (payload) => fire(w, payload, env, HOOK, ['--codex']);
assert.ok(context(codex({ hook_event_name: 'SessionStart', source: 'startup' }))?.startsWith('zerograph:'),
'Codex never runs .claude/settings.json');

const script = join(w.root, '.codex', 'hooks', 'zerograph-hook.mjs');
mkdirSync(dirname(script), { recursive: true });
writeFileSync(script, '// 0.6.x\n');
legacyCodex(home, script);
assert.equal(codex({ hook_event_name: 'SessionStart', source: 'startup', session_id: 's2' }), null);
assert.equal(context(codex({ hook_event_name: 'SubagentStart', agent_id: 'a1' })), RULE);
});

test('under Codex a legacy entry spelled from the home directory is found', { skip: WIN && 'HOME is not the home directory there' }, () => {
const w = world({ answer: 'zerograph: 3076 doc nodes, 5240 code nodes' });
const home = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-home-')));
const codexHome = join(home, '.codex');
mkdirSync(join(codexHome, 'hooks'), { recursive: true });
writeFileSync(join(codexHome, 'hooks', 'zerograph-hook.mjs'), '// 0.6.x\n');
const env = { HOME: home, PLUGIN_ROOT: join(home, 'plugin'), CLAUDE_PLUGIN_ROOT: join(home, 'plugin'), ZEROGRAPH_HOOK_SERVE: '0' };
['~/.codex/hooks/zerograph-hook.mjs', '$HOME/.codex/hooks/zerograph-hook.mjs', '${HOME}/.codex/hooks/zerograph-hook.mjs'].forEach((spelled, i) => {
legacyCodex(codexHome, spelled);
assert.equal(fire(w, { hook_event_name: 'SessionStart', source: 'startup', session_id: `s${i}` }, env, HOOK, ['--codex']), null, spelled);
});
});

test('SubagentStart carries the rule', () => {
const w = world();
const c = context(fire(w, { hook_event_name: 'SubagentStart', agent_id: 'a1' }));
Expand Down
6 changes: 3 additions & 3 deletions agent/codex/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"hooks": {
"SessionStart": [{ "matcher": "startup|resume|clear|compact", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main())\"", "timeout": 10 }] }],
"SubagentStart": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main())\"", "timeout": 5 }] }],
"PostToolUse": [{ "matcher": "apply_patch|Edit|Write", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main())\"", "timeout": 10 }] }]
"SessionStart": [{ "matcher": "startup|resume|clear|compact", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\" --codex", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main({harness:'codex'}))\"", "timeout": 10 }] }],
"SubagentStart": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\" --codex", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main({harness:'codex'}))\"", "timeout": 5 }] }],
"PostToolUse": [{ "matcher": "apply_patch|Edit|Write", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\" --codex", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main({harness:'codex'}))\"", "timeout": 10 }] }]
}
}
4 changes: 3 additions & 1 deletion agent/codex/plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ test('the hooks run the shipped hook, and the marketplace points at this directo
assert.deepEqual(Object.keys(hooks).sort(), ['PostToolUse', 'SessionStart', 'SubagentStart']);
for (const groups of Object.values(hooks)) {
for (const h of groups.flatMap((g) => g.hooks)) {
assert.equal(h.command, 'node "${PLUGIN_ROOT}/scripts/hook.mjs"');
// Codex sets CLAUDE_PLUGIN_ROOT too, so the command is what tells the hook which harness runs it.
assert.equal(h.command, 'node "${PLUGIN_ROOT}/scripts/hook.mjs" --codex');
assert.match(h.commandWindows, /main\(\{harness:'codex'\}\)/);
// `${PLUGIN_ROOT}` is a POSIX shell's expansion; the Windows form reads the variable in node.
assert.doesNotMatch(h.commandWindows, /\$\{|%PLUGIN_ROOT%|\$env:/);
assert.match(h.commandWindows, /process\.env\.PLUGIN_ROOT/);
Expand Down
64 changes: 62 additions & 2 deletions agent/codex/scripts/hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync, lstat
import { spawn, spawnSync } from 'node:child_process';
import { join, dirname, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import { tmpdir, homedir } from 'node:os';
import { createHash } from 'node:crypto';

import { binaryOf, checkoutOf as repositoryOf, childEnv as cutEnv, commandOf, pathKey } from './launcher.mjs';
Expand Down Expand Up @@ -547,20 +547,80 @@ const handlers = {
PostToolUse: (p, root, key) => (built(root) ? riskLine(p, root, key) : null),
};

const LEGACY_HOOK = 'zerograph-hook.mjs';

/**
* What a group's `matcher` is tested against for `event`, as both harnesses do: the tool for the
* tool events, how the session began for `SessionStart`, the subagent's type for `SubagentStart`.
*/
function matchSubject(event, payload) {
if (event === 'PreToolUse' || event === 'PostToolUse') return payload.tool_name ?? '';
if (event === 'SessionStart') return payload.source ?? '';
if (event === 'SubagentStart') return payload.agent_type ?? '';
return null;
}

/** A matcher absent, empty or `*` matches everything; any other is a regex over the whole subject. */
function matches(matcher, subject) {
if (subject === null || matcher === undefined || matcher === null || matcher === '' || matcher === '*') return true;
try { return new RegExp(`^(?:${matcher})$`).test(subject); } catch { return matcher === subject; }
}

/** The commands a harness hooks file runs for this payload's event, or none when the file is absent or unreadable. */
function hookCommands(file, event, payload) {
let parsed;
try { parsed = JSON.parse(readFileSync(file, 'utf8')); } catch { return []; }
const groups = parsed?.hooks?.[event];
if (!Array.isArray(groups)) return [];
const subject = matchSubject(event, payload);
return groups.filter((g) => matches(g?.matcher, subject))
.flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : []))
.map((h) => [h?.command, ...(Array.isArray(h?.args) ? h.args : [])].filter((w) => typeof w === 'string').join(' '));
}

/** `~`, `$HOME` and `${HOME}` as the shell running a hook command would expand them. */
function expandHome(path) {
return path.replace(/^(?:~|\$\{HOME\}|\$HOME)(?=\/|\\|$)/, homedir());
}

/**
* Whether a hook an earlier `install-agent` wired answers this event already, so this one stays
* silent rather than put the brief, the seeds and the risk line in context twice. In Claude Code
* that is a repository's committed `.claude/settings.json`, which `install-agent` leaves for the
* team to retire; in Codex it is the machine's `$CODEX_HOME/hooks.json`, which nothing a
* repository ships can touch. Either way the old script has to be there too: an entry whose
* script is gone answers nothing. The harness is named by the plugin's own hook command, since
* Codex sets `CLAUDE_PLUGIN_ROOT` as well and no variable tells the two apart.
*/
function legacyAnswers(event, payload, root, harness) {
if (harness === 'codex') {
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
return hookCommands(join(home, 'hooks.json'), event, payload).some((cmd) => {
const script = cmd.match(/(?:"([^"]*zerograph-hook\.mjs)"|'([^']*zerograph-hook\.mjs)'|(\S*zerograph-hook\.mjs))/);
const path = script && expandHome(script[1] ?? script[2] ?? script[3]);
return Boolean(path) && isAbsolute(path) && existsSync(path);
});
}
const project = process.env.CLAUDE_PROJECT_DIR || root;
return existsSync(join(project, '.claude', 'hooks', LEGACY_HOOK))
&& hookCommands(join(project, '.claude', 'settings.json'), event, payload).some((cmd) => cmd.includes(LEGACY_HOOK));
}

async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf8');
}

async function main() {
async function main({ harness = process.argv.includes('--codex') ? 'codex' : 'claude' } = {}) {
let payload;
try { payload = JSON.parse(await readStdin()); } catch { return; }
const event = payload.hook_event_name;
const handler = handlers[event];
if (!handler) return;
// The harness's cwd follows a `cd` into a subdirectory, which has no `.zerograph/` of its own.
const root = (payload.cwd && indexRoot(payload.cwd)) || payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
if (legacyAnswers(event, payload, root, harness)) { debug('legacy hook answers', event); return; }
// The subagent's own id is in the key: a subagent shares its parent's session_id, so a counter
// keyed by session alone finds the count already past zero on a subagent's first call and says
// nothing — which is a second reason 140 unprimed subagents went on grepping.
Expand Down
Binary file modified docs/assets/comparison.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading