Skip to content

Commit d5ca2b2

Browse files
committed
Fix Codex hook compatibility and quiet advisory warnings
1 parent 300c63e commit d5ca2b2

2 files changed

Lines changed: 94 additions & 5 deletions

File tree

bin/tl-hook.mjs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,21 @@ function statSyncSafe(path) {
7878
try { return statSync(path); } catch { return null; }
7979
}
8080

81-
function nudgeOnce(key, message, ttl = NUDGE_TTL_MS) {
81+
function codexWarningsEnabled() {
82+
const value = (process.env.TOKENLEAN_CODEX_WARNINGS || '').toLowerCase();
83+
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
84+
}
85+
86+
function nudgeOnce(key, message, ttl = NUDGE_TTL_MS, format = 'claude') {
87+
if (format === 'codex' && !codexWarningsEnabled()) return;
8288
const p = getSeenPath();
8389
if (p) {
8490
const seen = loadSeen(p);
8591
if (seen[key] && (Date.now() - seen[key]) < ttl) return;
8692
seen[key] = Date.now();
8793
writeFileSync(p, JSON.stringify(seen), 'utf8');
8894
}
89-
console.log(makeNudge(message));
95+
console.log(makeNudge(message, format));
9096
}
9197

9298
// --- Hook runner ---
@@ -108,13 +114,21 @@ function detectHookFormat(data) {
108114
if (process.env.PI_CODING_AGENT) return 'pi';
109115
if (process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_PROJECT_DIR) return 'claude';
110116
if (process.env.CODEX_THREAD_ID || process.env.CODEX_CI) return 'codex';
117+
if (data?.hook_event_name && data?.turn_id && data?.permission_mode && data?.session_id) {
118+
return 'codex';
119+
}
111120
// Detect codex from payload transcript path when env vars aren't set
112121
if (data?.transcript_path?.includes('/.codex/')) return 'codex';
113122
return 'claude';
114123
}
115124

116-
function makeNudge(message) {
117-
// Both Claude Code and Codex use the same hookSpecificOutput format
125+
function makeNudge(message, format = 'claude') {
126+
if (format === 'codex') {
127+
return JSON.stringify({
128+
systemMessage: message,
129+
});
130+
}
131+
118132
return JSON.stringify({
119133
hookSpecificOutput: {
120134
hookEventName: 'PreToolUse',
@@ -171,13 +185,14 @@ async function runHook({ json = false } = {}) {
171185
let data;
172186
try { data = JSON.parse(input); } catch { return; }
173187

188+
const format = detectHookFormat(data);
174189
const decision = evaluateToolCall(data);
175190
if (json) {
176191
console.log(JSON.stringify({ decision }, null, 2));
177192
return;
178193
}
179194

180-
if (decision) nudgeOnce(decision.id, decision.message);
195+
if (decision) nudgeOnce(decision.id, decision.message, NUDGE_TTL_MS, format);
181196
}
182197

183198
// --- Claude Code installer ---

src/tl-hook.test.mjs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { spawnSync } from 'node:child_process';
4+
import { dirname, resolve } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url));
8+
const repoRoot = resolve(__dirname, '..');
9+
10+
function runHook(payload, env = {}) {
11+
return spawnSync(process.execPath, ['bin/tl-hook.mjs', 'run'], {
12+
cwd: repoRoot,
13+
input: JSON.stringify(payload),
14+
encoding: 'utf8',
15+
env: {
16+
...process.env,
17+
...env,
18+
},
19+
});
20+
}
21+
22+
describe('tl-hook codex compatibility', () => {
23+
it('stays silent for Codex advisory nudges by default', () => {
24+
const result = runHook({
25+
session_id: 's1',
26+
turn_id: 't1',
27+
permission_mode: 'default',
28+
hook_event_name: 'PreToolUse',
29+
tool_name: 'Bash',
30+
tool_input: { command: 'rg foo .' },
31+
tool_use_id: 'u1',
32+
});
33+
34+
assert.strictEqual(result.status, 0);
35+
assert.strictEqual(result.stdout.trim(), '');
36+
});
37+
38+
it('can emit Codex systemMessage nudges when explicitly enabled', () => {
39+
const result = runHook({
40+
session_id: 's1',
41+
turn_id: 't1',
42+
permission_mode: 'default',
43+
hook_event_name: 'PreToolUse',
44+
tool_name: 'Bash',
45+
tool_input: { command: 'rg foo .' },
46+
tool_use_id: 'u1',
47+
}, {
48+
TOKENLEAN_CODEX_WARNINGS: '1',
49+
});
50+
51+
assert.strictEqual(result.status, 0);
52+
assert.deepStrictEqual(JSON.parse(result.stdout), {
53+
systemMessage: '[tl] use Grep tool, not bash',
54+
});
55+
});
56+
57+
it('preserves Claude hookSpecificOutput nudges', () => {
58+
const result = runHook({
59+
tool_name: 'Bash',
60+
tool_input: { command: 'rg foo .' },
61+
}, {
62+
TOKENLEAN_HOOK_FORMAT: 'claude',
63+
});
64+
65+
assert.strictEqual(result.status, 0);
66+
assert.deepStrictEqual(JSON.parse(result.stdout), {
67+
hookSpecificOutput: {
68+
hookEventName: 'PreToolUse',
69+
permissionDecision: 'allow',
70+
permissionDecisionReason: '[tl] use Grep tool, not bash',
71+
},
72+
});
73+
});
74+
});

0 commit comments

Comments
 (0)