Skip to content

Commit fecf19a

Browse files
committed
Fix Codex hook compatibility and quiet advisory warnings
1 parent 28d9ed9 commit fecf19a

2 files changed

Lines changed: 107 additions & 18 deletions

File tree

bin/tl-hook.mjs

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,15 +124,21 @@ function loadSeen(p) {
124124
} catch { return {}; }
125125
}
126126

127-
function nudgeOnce(key, message, ttl = NUDGE_TTL_MS) {
127+
function codexWarningsEnabled() {
128+
const value = (process.env.TOKENLEAN_CODEX_WARNINGS || '').toLowerCase();
129+
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
130+
}
131+
132+
function nudgeOnce(key, message, ttl = NUDGE_TTL_MS, format = 'claude') {
133+
if (format === 'codex' && !codexWarningsEnabled()) return;
128134
const p = getSeenPath();
129135
if (p) {
130136
const seen = loadSeen(p);
131137
if (seen[key] && (Date.now() - seen[key]) < ttl) return;
132138
seen[key] = Date.now();
133139
writeFileSync(p, JSON.stringify(seen), 'utf8');
134140
}
135-
console.log(makeNudge(message));
141+
console.log(makeNudge(message, format));
136142
}
137143

138144
// --- Hook runner ---
@@ -154,13 +160,21 @@ function detectHookFormat(data) {
154160
if (process.env.PI_CODING_AGENT) return 'pi';
155161
if (process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_PROJECT_DIR) return 'claude';
156162
if (process.env.CODEX_THREAD_ID || process.env.CODEX_CI) return 'codex';
163+
if (data?.hook_event_name && data?.turn_id && data?.permission_mode && data?.session_id) {
164+
return 'codex';
165+
}
157166
// Detect codex from payload transcript path when env vars aren't set
158167
if (data?.transcript_path?.includes('/.codex/')) return 'codex';
159168
return 'claude';
160169
}
161170

162-
function makeNudge(message) {
163-
// Both Claude Code and Codex use the same hookSpecificOutput format
171+
function makeNudge(message, format = 'claude') {
172+
if (format === 'codex') {
173+
return JSON.stringify({
174+
systemMessage: message,
175+
});
176+
}
177+
164178
return JSON.stringify({
165179
hookSpecificOutput: {
166180
hookEventName: 'PreToolUse',
@@ -232,6 +246,7 @@ async function runHook() {
232246
const format = detectHookFormat(data);
233247
const toolName = data.tool_name;
234248
const toolInput = data.tool_input || {};
249+
const nudge = (key, message, ttl = NUDGE_TTL_MS) => nudgeOnce(key, message, ttl, format);
235250

236251
// --- Read on large files ---
237252
if (toolName === 'Read') {
@@ -245,15 +260,15 @@ async function runHook() {
245260

246261
// Large JSON/YAML — worth flagging even though they're data files
247262
if (size > LARGE_JSON_BYTES && (ext === 'json' || ext === 'yaml' || ext === 'yml')) {
248-
nudgeOnce('read-large-json', `[tl] ${Math.round(size / 1024)}KB ${ext} — use -j flag or tl-snippet to extract a specific path`, TTL_HIGH);
263+
nudge('read-large-json', `[tl] ${Math.round(size / 1024)}KB ${ext} — use -j flag or tl-snippet to extract a specific path`, TTL_HIGH);
249264
return;
250265
}
251266

252267
if (NON_CODE_EXTS.has(ext)) return;
253268

254269
if (size > LARGE_FILE_BYTES) {
255270
const savedKb = Math.round(size * 0.85 / 1024);
256-
nudgeOnce('read-large', `[tl] ${Math.round(size / 1024)}KB — tl-symbols + tl-snippet saves ~${savedKb}KB`, TTL_HIGH);
271+
nudge('read-large', `[tl] ${Math.round(size / 1024)}KB — tl-symbols + tl-snippet saves ~${savedKb}KB`, TTL_HIGH);
257272
}
258273
return;
259274
}
@@ -267,34 +282,34 @@ async function runHook() {
267282

268283
// Build/test commands
269284
if (BUILD_TEST_PATTERNS.some(p => p.test(cmd))) {
270-
nudgeOnce('bash-test', `[tl] wrap with tl-run`, TTL_LOW);
285+
nudge('bash-test', `[tl] wrap with tl-run`, TTL_LOW);
271286
return;
272287
}
273288

274289
// Tail commands
275290
if (TAIL_PATTERNS.some(p => p.test(cmd))) {
276-
nudgeOnce('bash-tail', `[tl] use tl-tail instead`, TTL_LOW);
291+
nudge('bash-tail', `[tl] use tl-tail instead`, TTL_LOW);
277292
return;
278293
}
279294

280295
// grep/rg/ag — nudge to built-in search tool
281296
if (/^\s*(grep|rg|ag)\s/.test(cmd)) {
282297
const grepTool = format === 'pi' ? 'grep tool' : 'Grep tool';
283-
nudgeOnce('bash-grep', `[tl] use ${grepTool}, not bash`, TTL_MEDIUM);
298+
nudge('bash-grep', `[tl] use ${grepTool}, not bash`, TTL_MEDIUM);
284299
return;
285300
}
286301

287302
// head — nudge to Read with limit
288303
if (/^\s*head\s/.test(cmd)) {
289304
const readTool = format === 'pi' ? 'read tool' : 'Read tool';
290-
nudgeOnce('bash-head', `[tl] use ${readTool} with offset/limit`, TTL_MEDIUM);
305+
nudge('bash-head', `[tl] use ${readTool} with offset/limit`, TTL_MEDIUM);
291306
return;
292307
}
293308

294309
// Writes via bash (heredocs, redirects) — nudge to Write tool
295310
if (WRITE_VIA_BASH_PATTERNS.some(p => p.test(cmd))) {
296311
const writeTool = format === 'pi' ? 'write tool' : 'Write tool';
297-
nudgeOnce('bash-write', `[tl] use ${writeTool} instead of writing via bash`, TTL_MEDIUM);
312+
nudge('bash-write', `[tl] use ${writeTool} instead of writing via bash`, TTL_MEDIUM);
298313
return;
299314
}
300315

@@ -308,39 +323,39 @@ async function runHook() {
308323
if (fp && !fp.startsWith('-')) {
309324
try { sizeHint = ` (${Math.round(statSync(fp).size / 1024)}KB)`; } catch {}
310325
}
311-
nudgeOnce('bash-cat', `[tl] use ${readTool}${sizeHint}, not cat`, TTL_HIGH);
326+
nudge('bash-cat', `[tl] use ${readTool}${sizeHint}, not cat`, TTL_HIGH);
312327
return;
313328
}
314329

315330
// find/fd — nudge to Glob/find tool
316331
if (/^\s*(find|fd)\s/.test(cmd)) {
317332
const findTool = format === 'pi' ? 'find tool' : 'Glob tool';
318-
nudgeOnce('bash-find', `[tl] use ${findTool}, not bash`, TTL_MEDIUM);
333+
nudge('bash-find', `[tl] use ${findTool}, not bash`, TTL_MEDIUM);
319334
return;
320335
}
321336

322337
// ls -R / ls -la / tree — verbose directory listing
323338
if (LS_TREE_PATTERNS.some(p => p.test(cmd))) {
324-
nudgeOnce('bash-ls-tree', `[tl] use tl-structure or Glob for directory exploration`, TTL_MEDIUM);
339+
nudge('bash-ls-tree', `[tl] use tl-structure or Glob for directory exploration`, TTL_MEDIUM);
325340
return;
326341
}
327342

328343
// git log / git diff / git show without truncation
329344
if (GIT_VERBOSE_PATTERNS.some(p => p.test(cmd))) {
330-
nudgeOnce('bash-git-verbose', `[tl] use tl-diff or tl-history for token-efficient git output`, TTL_MEDIUM);
345+
nudge('bash-git-verbose', `[tl] use tl-diff or tl-history for token-efficient git output`, TTL_MEDIUM);
331346
return;
332347
}
333348

334349
// sed -i / awk > file — in-place edits via bash
335350
if (SED_AWK_EDIT_PATTERNS.some(p => p.test(cmd))) {
336351
const editTool = format === 'pi' ? 'edit tool' : 'Edit tool';
337-
nudgeOnce('bash-sed-awk', `[tl] use ${editTool} for targeted edits`, TTL_MEDIUM);
352+
nudge('bash-sed-awk', `[tl] use ${editTool} for targeted edits`, TTL_MEDIUM);
338353
return;
339354
}
340355

341356
// curl on URLs (skip API calls with -X, -d, --data, -H with auth)
342357
if (/^\s*curl\s/.test(cmd) && !/(-X\s|--data|--header.*auth|-d\s)/i.test(cmd)) {
343-
nudgeOnce('bash-curl', `[tl] use tl-browse instead`, TTL_HIGH);
358+
nudge('bash-curl', `[tl] use tl-browse instead`, TTL_HIGH);
344359
return;
345360
}
346361
}
@@ -349,7 +364,7 @@ async function runHook() {
349364
if (toolName === 'WebFetch') {
350365
const url = toolInput.url || '';
351366
if (url) {
352-
nudgeOnce('webfetch', `[tl] use tl-browse instead`, TTL_HIGH);
367+
nudge('webfetch', `[tl] use tl-browse instead`, TTL_HIGH);
353368
}
354369
return;
355370
}

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)