ai-lens 0.8.69 → 0.8.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.commithash +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +27 -6
- package/bin/ai-lens.js +2 -0
- package/cli/hooks.js +394 -20
- package/cli/init.js +71 -40
- package/cli/remove.js +25 -20
- package/cli/status.js +72 -72
- package/client/capture.js +135 -47
- package/client/config.js +0 -4
- package/client/token-usage.js +1 -1
- package/package.json +1 -1
- package/client/codex-watcher.js +0 -753
- package/client/codex.js +0 -625
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
78b5c4c
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
|
|
4
4
|
|
|
5
|
+
## 0.8.72 — 2026-05-29
|
|
6
|
+
- fix: `status` no longer reports hooks as "outdated" when they already capture reliably. A hook is now considered current if it's GUI-safe — either the per-machine launcher (`run.sh`/`run.cmd`, including the transitional `sh -c` wrapper) OR a `capture.js` command with an absolute node path baked in (e.g. `/opt/homebrew/bin/node`). Only PATH-dependent forms (bare `node`, `/usr/bin/env node`) — which break for GUI-launched Cursor/Claude on macOS — stay flagged outdated so `init` rewrites them.
|
|
7
|
+
- fix: `ai-lens status --report` now prints the full status to the screen just like plain `ai-lens status`, in addition to sending the report to the server. Previously it ran silently. The only difference from plain status is that it POSTs the report instead of writing the local `~/ai-lens-status.txt` file.
|
|
8
|
+
|
|
9
|
+
## 0.8.71 — 2026-05-29
|
|
10
|
+
- feat (ANL-837): Codex switched from the `codex-watcher` background process to native Codex hooks — the same hook-based capture path as Claude Code and Cursor. `init` writes hooks to the project `.codex/hooks.json` under `--project-hooks`; the user-layer `~/.codex/hooks.json` is silently ignored by Codex 0.130.x even though its source reads it, so the project layer is the reliable target.
|
|
11
|
+
- feat (ANL-837): all 10 native Codex events are covered — `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, `Stop`. `SubagentStart`/`SubagentStop` are Codex 0.135 additions. `capture.js` normalizes all 10 directly; the source is detected via `turn_id` / the `rollout-*.jsonl` transcript path, so Codex events are no longer misclassified as `claude_code` and dropped.
|
|
12
|
+
- feat (ANL-837): `init` pre-trusts every hook by injecting `[hooks.state."<path>:<event>:<group>:<handler>"]` blocks into `~/.codex/config.toml` with the exact `trusted_hash` Codex computes itself (bit-for-bit against `codex-rs/.../command_hook_hash`, bare-path key — no `file:` prefix). Hooks then fire from `codex exec` end-to-end with no manual step — verified live on Codex 0.135.
|
|
13
|
+
- feat (ANL-837): `ai-lens status` now verifies each Codex hook against `[hooks.state.*]` in `~/.codex/config.toml` (`verifyCodexHookTrust`) and surfaces `missing` / `disabled` / `mismatch` separately. A broken `trusted_hash` (e.g. after hand-editing config.toml or moving the repo) previously showed up only as "0 events captured".
|
|
14
|
+
- note: interactive `codex` (TUI) may show a one-time "N hooks need review" banner on first launch — a user-acknowledgement gate independent of the hash check. Codex 0.131+/0.135 offers a single "Trust all and continue" keystroke; when the full set is pre-trusted with matching hashes, Codex 0.135 shows no banner at all. `codex exec` needs no interactive step.
|
|
15
|
+
- cleanup: removed the watcher lifecycle from `init`/`remove`/`status` and the obsolete watcher tests.
|
|
16
|
+
|
|
17
|
+
## 0.8.70 — 2026-05-28
|
|
18
|
+
- diag: `sender-spawn-failed`, `codex-watcher-spawn-failed` and `queue-write-failed` entries in the capture log now record the OS `error.code` (e.g. EMFILE, EACCES). `ai-lens status` surfaces a per-category code breakdown so these failures can be diagnosed centrally instead of guessing from a bare count. The raw error message (which may contain local paths) stays on your machine — only the short code travels in the status report.
|
|
19
|
+
|
|
5
20
|
## 0.8.69 — 2026-05-27
|
|
6
21
|
- feat: per-machine launcher (`~/.ai-lens/client/run.sh` / `run.cmd`) now also accepts an optional script path as its first argument. With no args it still execs the sibling `capture.js` (default install), but `~/.ai-lens/client/run.sh path/to/some/capture.js` execs that script with the launcher's resolved node — letting bootstrap-style workflows (e.g. meta-cursor) route through the launcher for proper node resolution while keeping `capture.js` under git in the workspace repo.
|
|
7
22
|
- feat: new `--install-launcher` flag for `init`. Forces launcher installation even when `--no-hooks` is set, so the meta-cursor setup skill can wire up `~/.ai-lens/client/run.sh` without touching the static hook templates in the workspace repo.
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ This will:
|
|
|
20
20
|
1. Detect installed AI tools (Claude Code, Cursor, Codex)
|
|
21
21
|
2. Copy client files to `~/.ai-lens/client/`
|
|
22
22
|
3. Configure hooks in `~/.claude/settings.json` and/or `~/.cursor/hooks.json`
|
|
23
|
-
4.
|
|
23
|
+
4. Configure Codex native hooks in project `.codex/hooks.json` via `--project-hooks` (Codex 0.130.x ignores user-layer `~/.codex/hooks.json`)
|
|
24
24
|
5. Register the MCP server for in-editor analytics (optional)
|
|
25
25
|
|
|
26
26
|
Re-running is safe — it updates outdated hooks and skips current ones.
|
|
@@ -99,13 +99,34 @@ export AI_LENS_PROJECTS="~/meta/, ~/meta-cursor/" # optional, default: all
|
|
|
99
99
|
}
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
**Codex** —
|
|
102
|
+
**Codex** — `<project>/.codex/hooks.json` (use `npx -y ai-lens init --project-hooks`; user-layer `~/.codex/hooks.json` is silently ignored by Codex 0.130.x):
|
|
103
103
|
|
|
104
|
-
```
|
|
105
|
-
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"hooks": {
|
|
107
|
+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
108
|
+
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
109
|
+
"PreToolUse": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
110
|
+
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
111
|
+
"PermissionRequest": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
112
|
+
"PreCompact": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
113
|
+
"PostCompact": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
114
|
+
"SubagentStart": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
115
|
+
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }],
|
|
116
|
+
"Stop": [{ "hooks": [{ "type": "command", "command": "node ~/.ai-lens/client/capture.js" }] }]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
106
119
|
```
|
|
107
120
|
|
|
108
|
-
|
|
121
|
+
All 10 native Codex events are installed: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, `Stop` (the last two are 0.135 additions). Pre-trust hashes match Codex's `command_hook_hash` bit-for-bit for all 10 — verified against Codex 0.135: with the full set pre-trusted, no "needs review" banner appears and codex leaves every `[hooks.state.*]` entry untouched.
|
|
122
|
+
|
|
123
|
+
The `matcher` field is intentionally omitted — Codex coerces `matcher` to `None` for `UserPromptSubmit`/`Stop` before hashing, so writing `matcher: ""` leaves those entries stuck in `Modified` trust status.
|
|
124
|
+
|
|
125
|
+
Init also pre-trusts each hook by writing `[hooks.state."<hooks.json-path>:<event>:<group>:<handler>"]` blocks into `~/.codex/config.toml` with a `trusted_hash` matching what Codex computes. The state key is the **bare hooks.json path** (no `file:` scheme prefix) — that's the exact key `codex-rs/hooks/src/lib.rs::hook_key` looks up. (An earlier `file:` prefix never matched, so the pre-trust was silently ignored and hooks never fired.)
|
|
126
|
+
|
|
127
|
+
**`codex exec`** captures events end-to-end with no interactive step — the pre-trust is all it needs (verified on Codex 0.135).
|
|
128
|
+
|
|
129
|
+
**Interactive `codex` (TUI)** — when the full hook set is pre-trusted with matching hashes, Codex 0.135 shows no review banner and fires hooks immediately. If a banner does appear (e.g. partial/older trust state), pick **"Trust all and continue"** on Codex 0.131+/0.135 (one keystroke for all hooks); after that, every interactive session fires hooks automatically. Pre-trust still earns its keep: without it the hooks show as `Modified` and re-prompt on every command change.
|
|
109
130
|
|
|
110
131
|
</details>
|
|
111
132
|
|
|
@@ -261,7 +282,7 @@ Aggregate endpoints for dashboard charts: overview, teams, developers, tokens, M
|
|
|
261
282
|
|------|---------------|
|
|
262
283
|
| **Claude Code** | Hooks via `~/.claude/settings.json` |
|
|
263
284
|
| **Cursor** | Hooks via `~/.cursor/hooks.json` |
|
|
264
|
-
| **Codex** |
|
|
285
|
+
| **Codex** | Native hooks via project `.codex/hooks.json` (Codex 0.130.x ignores user-layer `~/.codex/hooks.json`) |
|
|
265
286
|
|
|
266
287
|
## Client Data
|
|
267
288
|
|
package/bin/ai-lens.js
CHANGED
|
@@ -42,6 +42,8 @@ switch (command) {
|
|
|
42
42
|
console.log(' --no-hooks Skip writing hooks and MCP (config + auth only)');
|
|
43
43
|
console.log(' --no-mcp Skip MCP server registration');
|
|
44
44
|
console.log(' --mcp-scope S MCP scope: user, local, or project (default: user)');
|
|
45
|
+
console.log(' --project-hooks Write Cursor/Claude hooks to project .cursor/ and .claude/ (not ~/.cursor)');
|
|
46
|
+
console.log(' --use-repo-path Run capture.js from this package; skip copy to ~/.ai-lens/client/');
|
|
45
47
|
console.log(' remove Remove AI Lens hooks and client files');
|
|
46
48
|
console.log(' status Run diagnostics and generate a status report');
|
|
47
49
|
console.log(' version Show package version and commit hash');
|
package/cli/hooks.js
CHANGED
|
@@ -527,6 +527,18 @@ const CURSOR_HOOK_NAMES = [
|
|
|
527
527
|
'afterAgentResponse', 'afterAgentThought', 'stop', 'sessionEnd',
|
|
528
528
|
];
|
|
529
529
|
|
|
530
|
+
// All 10 native Codex hook events (codex-rs/hooks/src/events/ plus the
|
|
531
|
+
// SubagentStart/SubagentStop events added in Codex 0.135). Codex uses the nested
|
|
532
|
+
// MatcherGroup format `{ hooks: [{ type:"command", command }] }` with NO matcher
|
|
533
|
+
// field — a flat `{ command }` parses as an empty MatcherGroup and never fires,
|
|
534
|
+
// and a `matcher: ""` would change the trust hash for UserPromptSubmit/Stop
|
|
535
|
+
// (codex coerces their matcher to None before hashing).
|
|
536
|
+
const CODEX_HOOK_NAMES = [
|
|
537
|
+
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
|
538
|
+
'PermissionRequest', 'PreCompact', 'PostCompact',
|
|
539
|
+
'SubagentStart', 'SubagentStop', 'Stop',
|
|
540
|
+
];
|
|
541
|
+
|
|
530
542
|
// Wrap captureCommand in a memoised getter so makeXxxHookDefs(null) does NOT
|
|
531
543
|
// trigger node-path resolution at builder time — the first .hookDefs[name]()
|
|
532
544
|
// call does it instead, and subsequent calls reuse the cached string.
|
|
@@ -576,6 +588,22 @@ export function makeCursorHookDefs(ctx = null, mode = 'global') {
|
|
|
576
588
|
return defs;
|
|
577
589
|
}
|
|
578
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Build Codex hookDefs bound to a ctx. mode: 'global' (default) | 'project'.
|
|
593
|
+
* Codex uses the nested MatcherGroup format with no matcher. Project-hooks bake a
|
|
594
|
+
* ~/.ai-lens/... path; Codex spawns hooks via `/bin/sh -lc`, which expands ~.
|
|
595
|
+
* captureCommand is deferred (see makeClaudeHookDefs).
|
|
596
|
+
*/
|
|
597
|
+
export function makeCodexHookDefs(ctx = null, mode = 'global') {
|
|
598
|
+
const useTilde = mode === 'project';
|
|
599
|
+
const getCmd = memoizeCmd(() => captureCommand({ useTilde, rawPath: true, ctx }));
|
|
600
|
+
const defs = {};
|
|
601
|
+
for (const name of CODEX_HOOK_NAMES) {
|
|
602
|
+
defs[name] = () => ({ hooks: [{ type: 'command', command: getCmd() }] });
|
|
603
|
+
}
|
|
604
|
+
return defs;
|
|
605
|
+
}
|
|
606
|
+
|
|
579
607
|
/**
|
|
580
608
|
* Claude Code hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
|
|
581
609
|
* @param {string} capturePath - Absolute path to client/capture.js.
|
|
@@ -604,6 +632,15 @@ export function getCursorHookDefsWithPath(capturePath, ctx = null) {
|
|
|
604
632
|
return defs;
|
|
605
633
|
}
|
|
606
634
|
|
|
635
|
+
export function getCodexHookDefsWithPath(capturePath, ctx = null) {
|
|
636
|
+
const getCmd = memoizeCmd(() => captureCommand({ rawPath: true, customPath: capturePath, ctx }));
|
|
637
|
+
const defs = {};
|
|
638
|
+
for (const name of CODEX_HOOK_NAMES) {
|
|
639
|
+
defs[name] = () => ({ hooks: [{ type: 'command', command: getCmd() }] });
|
|
640
|
+
}
|
|
641
|
+
return defs;
|
|
642
|
+
}
|
|
643
|
+
|
|
607
644
|
/**
|
|
608
645
|
* Build the array of TOOL_CONFIGS (Claude Code + Cursor at user-global paths).
|
|
609
646
|
* Pass ctx for production use; omit for the legacy TOOL_CONFIGS export
|
|
@@ -628,6 +665,13 @@ export function makeToolConfigs(ctx = null) {
|
|
|
628
665
|
hookDefs: makeCursorHookDefs(ctx, 'global'),
|
|
629
666
|
topLevelFields: { version: 1 },
|
|
630
667
|
},
|
|
668
|
+
{
|
|
669
|
+
name: 'Codex',
|
|
670
|
+
dirPath: join(homedir(), '.codex'),
|
|
671
|
+
configPath: join(homedir(), '.codex', 'hooks.json'),
|
|
672
|
+
hookDefs: makeCodexHookDefs(ctx, 'global'),
|
|
673
|
+
topLevelFields: {},
|
|
674
|
+
},
|
|
631
675
|
];
|
|
632
676
|
}
|
|
633
677
|
|
|
@@ -676,6 +720,18 @@ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (proje
|
|
|
676
720
|
};
|
|
677
721
|
}
|
|
678
722
|
|
|
723
|
+
export function getCodexToolConfig(projectRoot, label = 'Codex (project)', ctx = null) {
|
|
724
|
+
const base = TOOL_CONFIGS.find(t => t.name === 'Codex');
|
|
725
|
+
if (!base) return null;
|
|
726
|
+
return {
|
|
727
|
+
...base,
|
|
728
|
+
name: label,
|
|
729
|
+
dirPath: join(projectRoot, '.codex'),
|
|
730
|
+
configPath: join(projectRoot, '.codex', 'hooks.json'),
|
|
731
|
+
hookDefs: makeCodexHookDefs(ctx, 'project'),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
679
735
|
// ---------------------------------------------------------------------------
|
|
680
736
|
// AI Lens hook detection
|
|
681
737
|
// ---------------------------------------------------------------------------
|
|
@@ -798,35 +854,48 @@ function normalizePath(p) {
|
|
|
798
854
|
return (p || '').replace(/\\/g, '/');
|
|
799
855
|
}
|
|
800
856
|
|
|
857
|
+
// A hook command is "GUI-safe" — i.e. survives a GUI app's minimal launchd PATH —
|
|
858
|
+
// if it either routes through the per-machine launcher (run.sh / run.cmd, directly
|
|
859
|
+
// OR via the transitional wrapper that execs it) OR runs capture.js with an
|
|
860
|
+
// ABSOLUTE node path baked in. Bare `node` and `/usr/bin/env node` are NOT GUI-safe
|
|
861
|
+
// (they depend on node being on PATH, which GUI Cursor/Claude often lack).
|
|
862
|
+
//
|
|
863
|
+
// Both GUI-safe forms capture events reliably, so init produces one of them and we
|
|
864
|
+
// treat either as "current". Only the PATH-dependent forms get flagged outdated.
|
|
865
|
+
export function isGuiSafeHookCommand(cmd) {
|
|
866
|
+
if (!isAiLensCommand(cmd).isAiLens) return false;
|
|
867
|
+
const n = (cmd || '').replace(/\\/g, '/');
|
|
868
|
+
// Launcher: direct path, or the transitional wrapper that execs run.sh/run.cmd.
|
|
869
|
+
if (n.includes('.ai-lens/client/run.sh') || n.includes('.ai-lens/client/run.cmd')) return true;
|
|
870
|
+
// capture.js form: GUI-safe only when the node binary is an absolute path
|
|
871
|
+
// (e.g. /opt/homebrew/bin/node), not bare `node` or an env shim.
|
|
872
|
+
const p = _parseHookCommand(cmd);
|
|
873
|
+
if (p.kind === 'captureJs' && p.nodePrefix) {
|
|
874
|
+
const np = p.nodePrefix;
|
|
875
|
+
const isAbsolute = np.startsWith('/') || /^[a-zA-Z]:\//.test(np);
|
|
876
|
+
const isEnvShim = np === '/usr/bin/env' || np.endsWith('/env');
|
|
877
|
+
if (isAbsolute && !isEnvShim) return true;
|
|
878
|
+
}
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
|
|
801
882
|
function isCurrentAiLensHook(entry, expected) {
|
|
883
|
+
// "Current" = a GUI-safe install (launcher OR absolute-node capture.js). We do
|
|
884
|
+
// NOT require an exact match against the expected command form — both valid
|
|
885
|
+
// install methods capture reliably, so neither should be reported outdated.
|
|
886
|
+
// Only PATH-dependent forms (bare `node`, `/usr/bin/env node`) are outdated.
|
|
802
887
|
// Flat format (Cursor): single command per entry.
|
|
803
888
|
if (entry?.command != null) {
|
|
804
|
-
|
|
805
|
-
return commandsMatch(entry.command, expectedCmd);
|
|
889
|
+
return isGuiSafeHookCommand(entry.command);
|
|
806
890
|
}
|
|
807
|
-
// Nested format (Claude Code): { matcher, hooks: [{ command }] }
|
|
891
|
+
// Nested format (Claude Code / Codex): { matcher, hooks: [{ command }] }
|
|
808
892
|
if (Array.isArray(entry?.hooks)) {
|
|
809
|
-
const expectedCmd = expected?.hooks?.[0]?.command || '';
|
|
810
893
|
if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
|
|
811
|
-
return entry.hooks.some(h =>
|
|
894
|
+
return entry.hooks.some(h => isGuiSafeHookCommand(h?.command || ''));
|
|
812
895
|
}
|
|
813
896
|
return false;
|
|
814
897
|
}
|
|
815
898
|
|
|
816
|
-
function commandsMatch(entryCmd, expectedCmd) {
|
|
817
|
-
const e = _parseHookCommand(entryCmd);
|
|
818
|
-
const x = _parseHookCommand(expectedCmd);
|
|
819
|
-
if (x.kind === 'unknown') {
|
|
820
|
-
// Best-effort fallback: literal compare after slash-normalisation.
|
|
821
|
-
return normalizePath(entryCmd) === normalizePath(expectedCmd);
|
|
822
|
-
}
|
|
823
|
-
if (e.kind !== x.kind) return false;
|
|
824
|
-
if (e.prefix !== x.prefix) return false;
|
|
825
|
-
if (e.path !== x.path) return false;
|
|
826
|
-
if (x.kind === 'captureJs' && e.nodePrefix !== x.nodePrefix) return false;
|
|
827
|
-
return true;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
899
|
// ---------------------------------------------------------------------------
|
|
831
900
|
// Tool detection
|
|
832
901
|
// ---------------------------------------------------------------------------
|
|
@@ -1028,7 +1097,12 @@ export function buildStrippedConfig(tool, existingConfig) {
|
|
|
1028
1097
|
// ---------------------------------------------------------------------------
|
|
1029
1098
|
|
|
1030
1099
|
export function writeHooksConfig(tool, config) {
|
|
1031
|
-
|
|
1100
|
+
const parentDir = dirname(tool.configPath);
|
|
1101
|
+
if (existsSync(parentDir) && !lstatSync(parentDir).isDirectory()) {
|
|
1102
|
+
const bakPath = parentDir + '.bak';
|
|
1103
|
+
try { renameSync(parentDir, bakPath); } catch {}
|
|
1104
|
+
}
|
|
1105
|
+
mkdirSync(parentDir, { recursive: true });
|
|
1032
1106
|
const tmpPath = tool.configPath + '.tmp.' + process.pid;
|
|
1033
1107
|
writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n');
|
|
1034
1108
|
try {
|
|
@@ -1098,6 +1172,306 @@ export function cleanupLegacyHooks(tool) {
|
|
|
1098
1172
|
return results;
|
|
1099
1173
|
}
|
|
1100
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* Codex (>=0.130.0) marks every newly discovered hook as Untrusted and refuses
|
|
1177
|
+
* to fire it until the user trusts it via the interactive TUI hooks browser.
|
|
1178
|
+
* `codex exec` (non-interactive) has no UI to do that, so AI Lens hooks would
|
|
1179
|
+
* stay inert forever after install.
|
|
1180
|
+
*
|
|
1181
|
+
* The trust state lives under `[hooks.state."<key>"]` tables in the user
|
|
1182
|
+
* config.toml, where `<key>` is `<hooks.json-path>:<event_key>:<group_idx>:<handler_idx>`
|
|
1183
|
+
* (codex-rs/hooks/src/lib.rs::hook_key — the key is the bare hooks.json path as
|
|
1184
|
+
* rendered by AbsolutePathBuf::display(), with NO `file:` scheme prefix; an
|
|
1185
|
+
* earlier `file:` prefix here never matched Codex's lookup, so the pre-trust was
|
|
1186
|
+
* silently ignored and hooks stayed Untrusted) and the value is `{ enabled = true,
|
|
1187
|
+
* trusted_hash = "sha256:<hex>" }`. Codex re-checks the hash on each run; if
|
|
1188
|
+
* it doesn't match the live entry it falls back to Modified/Untrusted and
|
|
1189
|
+
* silently drops the hook. So the hash must mirror Codex's
|
|
1190
|
+
* `command_hook_hash` exactly: SHA-256 of the canonical-JSON encoding of a
|
|
1191
|
+
* NormalizedHookIdentity { event_name (snake_case), matcher, hooks:
|
|
1192
|
+
* [{ type:"command", command, command_windows:null, timeout:600,
|
|
1193
|
+
* async:false, status_message:null }] } (see
|
|
1194
|
+
* codex-rs/hooks/src/engine/discovery.rs and config/src/fingerprint.rs).
|
|
1195
|
+
*
|
|
1196
|
+
* The CLI bypass flag (--dangerously-bypass-hook-trust, shared_options.rs)
|
|
1197
|
+
* was added 2026-05-13, after codex-cli 0.130.0 shipped, so it's not a
|
|
1198
|
+
* universal substitute yet. Writing trust state directly works on every
|
|
1199
|
+
* version that supports hooks at all.
|
|
1200
|
+
*
|
|
1201
|
+
* Returns { changed, configPath, alreadyTrusted, missing, hooks }.
|
|
1202
|
+
* `hooks` is the list of injected keys (debug-friendly).
|
|
1203
|
+
*/
|
|
1204
|
+
import { createHash } from 'node:crypto';
|
|
1205
|
+
|
|
1206
|
+
const CODEX_HOOK_EVENT_KEYS = {
|
|
1207
|
+
SessionStart: 'session_start',
|
|
1208
|
+
UserPromptSubmit: 'user_prompt_submit',
|
|
1209
|
+
PreToolUse: 'pre_tool_use',
|
|
1210
|
+
PostToolUse: 'post_tool_use',
|
|
1211
|
+
PermissionRequest: 'permission_request',
|
|
1212
|
+
PreCompact: 'pre_compact',
|
|
1213
|
+
PostCompact: 'post_compact',
|
|
1214
|
+
SubagentStart: 'subagent_start',
|
|
1215
|
+
SubagentStop: 'subagent_stop',
|
|
1216
|
+
Stop: 'stop',
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
// Codex's matcher_pattern_for_event (codex-rs/hooks/src/events/common.rs):
|
|
1220
|
+
// UserPromptSubmit and Stop ignore any matcher in the source file and hash
|
|
1221
|
+
// with matcher=None. Every other event passes the matcher through verbatim.
|
|
1222
|
+
const CODEX_EVENTS_WITHOUT_MATCHER = new Set(['user_prompt_submit', 'stop']);
|
|
1223
|
+
|
|
1224
|
+
function canonicalJson(value) {
|
|
1225
|
+
// Mirrors codex-rs/config/src/fingerprint.rs::canonical_json — sort object
|
|
1226
|
+
// keys recursively; arrays preserve order; primitives unchanged.
|
|
1227
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
1228
|
+
if (value && typeof value === 'object') {
|
|
1229
|
+
const out = {};
|
|
1230
|
+
for (const k of Object.keys(value).sort()) out[k] = canonicalJson(value[k]);
|
|
1231
|
+
return out;
|
|
1232
|
+
}
|
|
1233
|
+
return value;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function codexCommandHookHash(eventKey, command, fileMatcher) {
|
|
1237
|
+
// Reproduces command_hook_hash (codex-rs/hooks/src/engine/discovery.rs):
|
|
1238
|
+
// struct → toml::Value → serde_json::to_value → canonical_json → sha256(JSON)
|
|
1239
|
+
// Codex first runs matcher_pattern_for_event(event, file_matcher) which
|
|
1240
|
+
// forces None for UserPromptSubmit/Stop regardless of what the file says,
|
|
1241
|
+
// then writes that back into group.matcher before hashing. TOML omits None
|
|
1242
|
+
// fields, so we drop matcher from the canonical JSON for those events.
|
|
1243
|
+
// The handler we install has command_windows=None and status_message=None,
|
|
1244
|
+
// so neither key appears in the hashed JSON either.
|
|
1245
|
+
// After canonical key sort: { async, command, timeout, type } in handler.
|
|
1246
|
+
const handler = {
|
|
1247
|
+
type: 'command',
|
|
1248
|
+
command,
|
|
1249
|
+
timeout: 600,
|
|
1250
|
+
async: false,
|
|
1251
|
+
};
|
|
1252
|
+
const identity = { event_name: eventKey, hooks: [handler] };
|
|
1253
|
+
const effectiveMatcher = CODEX_EVENTS_WITHOUT_MATCHER.has(eventKey)
|
|
1254
|
+
? null
|
|
1255
|
+
: (fileMatcher === undefined ? null : fileMatcher);
|
|
1256
|
+
if (effectiveMatcher !== null) identity.matcher = effectiveMatcher;
|
|
1257
|
+
const canonical = canonicalJson(identity);
|
|
1258
|
+
const serialized = JSON.stringify(canonical);
|
|
1259
|
+
return 'sha256:' + createHash('sha256').update(serialized).digest('hex');
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
export function enableCodexHookTrust(hooksFilePath, userConfigDir = join(homedir(), '.codex')) {
|
|
1263
|
+
// userConfigDir is always the user's ~/.codex/ — Codex reads trust state
|
|
1264
|
+
// only from the User config layer (codex-rs/hooks/src/config_rules.rs).
|
|
1265
|
+
// Project-scoped hooks still trust through the user's config.toml, keyed
|
|
1266
|
+
// by the absolute project hooks.json path.
|
|
1267
|
+
const codexDir = userConfigDir;
|
|
1268
|
+
const configPath = join(codexDir, 'config.toml');
|
|
1269
|
+
if (!existsSync(codexDir)) {
|
|
1270
|
+
return { changed: false, configPath, alreadyTrusted: false, missing: true, hooks: [] };
|
|
1271
|
+
}
|
|
1272
|
+
if (!existsSync(hooksFilePath)) {
|
|
1273
|
+
return { changed: false, configPath, alreadyTrusted: false, missing: true, hooks: [] };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
let hooksJson;
|
|
1277
|
+
try {
|
|
1278
|
+
hooksJson = JSON.parse(readFileSync(hooksFilePath, 'utf-8'));
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
return { changed: false, configPath, alreadyTrusted: false, missing: false, hooks: [], error: `unreadable ${hooksFilePath}: ${err.message}` };
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Build per-handler trust entries for every AI Lens hook in the file.
|
|
1284
|
+
const trustEntries = [];
|
|
1285
|
+
for (const [eventName, eventKey] of Object.entries(CODEX_HOOK_EVENT_KEYS)) {
|
|
1286
|
+
const groups = hooksJson?.hooks?.[eventName];
|
|
1287
|
+
if (!Array.isArray(groups)) continue;
|
|
1288
|
+
groups.forEach((group, groupIdx) => {
|
|
1289
|
+
const handlers = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
1290
|
+
handlers.forEach((handler, handlerIdx) => {
|
|
1291
|
+
const command = handler?.command;
|
|
1292
|
+
if (typeof command !== 'string' || !isAiLensCapturePath(command)) return;
|
|
1293
|
+
const key = `${hooksFilePath}:${eventKey}:${groupIdx}:${handlerIdx}`;
|
|
1294
|
+
// Pass the file's matcher value through — codex's matcher_pattern_for_event
|
|
1295
|
+
// strips it for UserPromptSubmit/Stop, mirrored inside codexCommandHookHash.
|
|
1296
|
+
const fileMatcher = typeof group?.matcher === 'string' ? group.matcher : undefined;
|
|
1297
|
+
const hash = codexCommandHookHash(eventKey, command, fileMatcher);
|
|
1298
|
+
trustEntries.push({ key, hash });
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
if (trustEntries.length === 0) {
|
|
1303
|
+
return { changed: false, configPath, alreadyTrusted: false, missing: false, hooks: [] };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
let original = '';
|
|
1307
|
+
let hadFile = false;
|
|
1308
|
+
try { original = readFileSync(configPath, 'utf-8'); hadFile = true; } catch {}
|
|
1309
|
+
|
|
1310
|
+
// Strip any existing AI Lens [hooks.state."<key>"] blocks (and the banner
|
|
1311
|
+
// comment that introduces them) before reinserting — repeated init runs
|
|
1312
|
+
// would otherwise accumulate duplicate banners and key collisions.
|
|
1313
|
+
let stripped = original;
|
|
1314
|
+
for (const { key } of trustEntries) {
|
|
1315
|
+
const headerRe = new RegExp(`(^|\\n)\\[hooks\\.state\\.${escapeRegex(JSON.stringify(key))}\\][^\\[]*`, 'g');
|
|
1316
|
+
stripped = stripped.replace(headerRe, '$1');
|
|
1317
|
+
// Also strip legacy `file:`-prefixed blocks left by pre-fix installs (the
|
|
1318
|
+
// prefix never matched Codex's lookup, so these are dead state).
|
|
1319
|
+
const legacyRe = new RegExp(`(^|\\n)\\[hooks\\.state\\.${escapeRegex(JSON.stringify('file:' + key))}\\][^\\[]*`, 'g');
|
|
1320
|
+
stripped = stripped.replace(legacyRe, '$1');
|
|
1321
|
+
}
|
|
1322
|
+
// Drop our own banner if present from a prior run (matches both the legacy
|
|
1323
|
+
// bypass_hook_trust banner and the new pre-trusted-hooks banner).
|
|
1324
|
+
stripped = stripped.replace(/(?:^|\n)# AI Lens:[^\n]*(?:\n#[^\n]*)*\n?/g, '\n');
|
|
1325
|
+
stripped = stripped.replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '\n');
|
|
1326
|
+
|
|
1327
|
+
const block = trustEntries.map(({ key, hash }) => (
|
|
1328
|
+
`[hooks.state.${JSON.stringify(key)}]\n` +
|
|
1329
|
+
`enabled = true\n` +
|
|
1330
|
+
`trusted_hash = "${hash}"\n`
|
|
1331
|
+
)).join('\n');
|
|
1332
|
+
|
|
1333
|
+
const banner = '\n# AI Lens: pre-trusted Codex hooks so they fire from `codex exec`\n# (the TUI trust flow is unreachable for non-interactive sessions).\n';
|
|
1334
|
+
const updated = (hadFile ? stripped.replace(/\n*$/, '\n') : '') + banner + block;
|
|
1335
|
+
|
|
1336
|
+
// If the file is unchanged after our rebuild, nothing to do.
|
|
1337
|
+
if (updated === original) {
|
|
1338
|
+
return { changed: false, configPath, alreadyTrusted: true, missing: false, hooks: trustEntries.map(e => e.key) };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
mkdirSync(codexDir, { recursive: true });
|
|
1342
|
+
const tmpPath = configPath + '.tmp.' + process.pid;
|
|
1343
|
+
writeFileSync(tmpPath, updated);
|
|
1344
|
+
try {
|
|
1345
|
+
renameSync(tmpPath, configPath);
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
try { unlinkSync(tmpPath); } catch {}
|
|
1348
|
+
throw err;
|
|
1349
|
+
}
|
|
1350
|
+
return { changed: true, configPath, alreadyTrusted: false, missing: false, hooks: trustEntries.map(e => e.key) };
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Uninstall counterpart to enableCodexHookTrust: strip the AI Lens
|
|
1355
|
+
* `[hooks.state."<hooksFilePath>:..."]` pre-trust blocks (plus legacy
|
|
1356
|
+
* `file:`-prefixed ones and the AI Lens banner) from the user's
|
|
1357
|
+
* ~/.codex/config.toml. Keyed purely by hooksFilePath, so it works even after
|
|
1358
|
+
* the hooks.json itself has already been deleted (the usual remove order).
|
|
1359
|
+
* Leaves all unrelated config.toml content intact. Returns
|
|
1360
|
+
* { changed, configPath, removed } where `removed` is the count of blocks dropped.
|
|
1361
|
+
*/
|
|
1362
|
+
export function removeCodexHookTrust(hooksFilePath, userConfigDir = join(homedir(), '.codex')) {
|
|
1363
|
+
const configPath = join(userConfigDir, 'config.toml');
|
|
1364
|
+
let original;
|
|
1365
|
+
try { original = readFileSync(configPath, 'utf-8'); }
|
|
1366
|
+
catch { return { changed: false, configPath, removed: 0, missing: true }; }
|
|
1367
|
+
|
|
1368
|
+
// Match every [hooks.state."<key>"] block whose key targets this hooks.json —
|
|
1369
|
+
// both the correct bare-path form and the legacy `file:`-prefixed form.
|
|
1370
|
+
const pathRe = escapeRegex(hooksFilePath);
|
|
1371
|
+
// Match the [hooks.state."..."] header then its value lines — any following
|
|
1372
|
+
// lines that do NOT start a new section ([...]). Using a line-based body (not
|
|
1373
|
+
// `[^[]*`) avoids consuming the newline before the next block, which would
|
|
1374
|
+
// make adjacent blocks match only every other one.
|
|
1375
|
+
const blockRe = new RegExp(
|
|
1376
|
+
`(^|\\n)\\[hooks\\.state\\."(?:file:)?${pathRe}:[^"]*"\\](?:\\n(?!\\[)[^\\n]*)*`,
|
|
1377
|
+
'g',
|
|
1378
|
+
);
|
|
1379
|
+
let removed = 0;
|
|
1380
|
+
let stripped = original.replace(blockRe, (m, lead) => { removed++; return lead; });
|
|
1381
|
+
|
|
1382
|
+
if (removed === 0) return { changed: false, configPath, removed: 0, missing: false };
|
|
1383
|
+
|
|
1384
|
+
// Drop our banner and any now-empty `[hooks.state]` header, then tidy blank runs.
|
|
1385
|
+
stripped = stripped.replace(/(?:^|\n)# AI Lens:[^\n]*(?:\n#[^\n]*)*\n?/g, '\n');
|
|
1386
|
+
stripped = stripped.replace(/(^|\n)\[hooks\.state\]\s*(?=\n\[|\n*$)/g, '$1');
|
|
1387
|
+
stripped = stripped.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n+$/, '\n');
|
|
1388
|
+
|
|
1389
|
+
if (stripped === original) return { changed: false, configPath, removed, missing: false };
|
|
1390
|
+
|
|
1391
|
+
const tmpPath = configPath + '.tmp.' + process.pid;
|
|
1392
|
+
writeFileSync(tmpPath, stripped);
|
|
1393
|
+
try { renameSync(tmpPath, configPath); }
|
|
1394
|
+
catch (err) { try { unlinkSync(tmpPath); } catch {} throw err; }
|
|
1395
|
+
return { changed: true, configPath, removed, missing: false };
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
export function verifyCodexHookTrust(hooksFilePath, userConfigDir = join(homedir(), '.codex')) {
|
|
1399
|
+
// Diagnostic counterpart to enableCodexHookTrust: for every AI Lens hook in
|
|
1400
|
+
// hooksFilePath, recompute the expected trusted_hash and check that the
|
|
1401
|
+
// matching [hooks.state."<key>"] block in ~/.codex/config.toml has it.
|
|
1402
|
+
// Returns { ok, configPath, entries: [{ key, expected, stored, status }] }
|
|
1403
|
+
// where status is one of:
|
|
1404
|
+
// 'ok' — block exists, enabled, hash matches
|
|
1405
|
+
// 'missing' — no [hooks.state."<key>"] block at all
|
|
1406
|
+
// 'mismatch' — block exists but hash differs (likely command/path drift)
|
|
1407
|
+
// 'disabled' — block exists but enabled = false
|
|
1408
|
+
const configPath = join(userConfigDir, 'config.toml');
|
|
1409
|
+
if (!existsSync(hooksFilePath)) {
|
|
1410
|
+
return { ok: null, configPath, entries: [], reason: 'hooks file missing' };
|
|
1411
|
+
}
|
|
1412
|
+
if (!existsSync(configPath)) {
|
|
1413
|
+
return { ok: false, configPath, entries: [], reason: 'config.toml missing — run init to pre-trust' };
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
let hooksJson;
|
|
1417
|
+
try {
|
|
1418
|
+
hooksJson = JSON.parse(readFileSync(hooksFilePath, 'utf-8'));
|
|
1419
|
+
} catch (err) {
|
|
1420
|
+
return { ok: false, configPath, entries: [], reason: `unreadable ${hooksFilePath}: ${err.message}` };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
let tomlText = '';
|
|
1424
|
+
try { tomlText = readFileSync(configPath, 'utf-8'); } catch {}
|
|
1425
|
+
|
|
1426
|
+
// Lightweight parse: we wrote each block as exactly three lines
|
|
1427
|
+
// ([header], enabled = true|false, trusted_hash = "..."), so a per-key regex
|
|
1428
|
+
// is sufficient and avoids pulling in a TOML lib for one diagnostic.
|
|
1429
|
+
function readBlock(key) {
|
|
1430
|
+
const headerLiteral = `[hooks.state.${JSON.stringify(key)}]`;
|
|
1431
|
+
const idx = tomlText.indexOf(headerLiteral);
|
|
1432
|
+
if (idx === -1) return null;
|
|
1433
|
+
const tail = tomlText.slice(idx + headerLiteral.length);
|
|
1434
|
+
const next = tail.search(/\n\[/);
|
|
1435
|
+
const body = next === -1 ? tail : tail.slice(0, next);
|
|
1436
|
+
const enabledMatch = body.match(/\benabled\s*=\s*(true|false)\b/);
|
|
1437
|
+
const hashMatch = body.match(/\btrusted_hash\s*=\s*"([^"]+)"/);
|
|
1438
|
+
return {
|
|
1439
|
+
enabled: enabledMatch ? enabledMatch[1] === 'true' : null,
|
|
1440
|
+
hash: hashMatch ? hashMatch[1] : null,
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const entries = [];
|
|
1445
|
+
for (const [eventName, eventKey] of Object.entries(CODEX_HOOK_EVENT_KEYS)) {
|
|
1446
|
+
const groups = hooksJson?.hooks?.[eventName];
|
|
1447
|
+
if (!Array.isArray(groups)) continue;
|
|
1448
|
+
groups.forEach((group, groupIdx) => {
|
|
1449
|
+
const handlers = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
1450
|
+
handlers.forEach((handler, handlerIdx) => {
|
|
1451
|
+
const command = handler?.command;
|
|
1452
|
+
if (typeof command !== 'string' || !isAiLensCapturePath(command)) return;
|
|
1453
|
+
const key = `${hooksFilePath}:${eventKey}:${groupIdx}:${handlerIdx}`;
|
|
1454
|
+
const fileMatcher = typeof group?.matcher === 'string' ? group.matcher : undefined;
|
|
1455
|
+
const expected = codexCommandHookHash(eventKey, command, fileMatcher);
|
|
1456
|
+
const block = readBlock(key);
|
|
1457
|
+
let status;
|
|
1458
|
+
if (!block) status = 'missing';
|
|
1459
|
+
else if (block.enabled === false) status = 'disabled';
|
|
1460
|
+
else if (block.hash !== expected) status = 'mismatch';
|
|
1461
|
+
else status = 'ok';
|
|
1462
|
+
entries.push({ key, eventName, expected, stored: block?.hash || null, status });
|
|
1463
|
+
});
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const ok = entries.length > 0 && entries.every(e => e.status === 'ok');
|
|
1468
|
+
return { ok, configPath, entries };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function escapeRegex(s) {
|
|
1472
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1101
1475
|
/**
|
|
1102
1476
|
* Delete .mcp.json in cwd if it was left with empty mcpServers by `claude mcp remove`.
|
|
1103
1477
|
*/
|