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 CHANGED
@@ -1 +1 @@
1
- fe949b0
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. Start the Codex watcher for user-level and project-local Codex sessions
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** — no hook file. Run `ai-lens init` to start the local watcher, or start it manually:
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
- ```bash
105
- node ~/.ai-lens/client/codex-watcher.js
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
- The watcher tails the user-level Codex directory (`~/.codex`) and any project-local `.codex` directories found under `AI_LENS_PROJECTS`. It respects `AI_LENS_PROJECTS` the same way as Claude Code and Cursor: only sessions whose `cwd` is inside a configured monitored root are sent.
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** | File watcher on `~/.codex` and project-local `.codex` directories |
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
- const expectedCmd = expected?.command || expected?.hooks?.[0]?.command || '';
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 => commandsMatch(h?.command || '', expectedCmd));
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
- mkdirSync(dirname(tool.configPath), { recursive: true });
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
  */