# Codex parity, part C2: Codex hooks Issue: #40 part C. Date: 2026-09-29. Status: self-reviewed (the user asked for autonomous work with a self-review per part). Builds on C1 (`2026-09-29-codex-usage-design.md`). ## Problem C1 reads Codex sessions and tokens from the rollout journals, every 15 s. That leaves out what only a live signal gives: - a Codex session's tool calls in its timeline; - "Codex is waiting for you" when it asks permission, on the lane card and as a notification; - a Codex lane's running/idle without a 2-minute lag after a turn ends. ## What Codex offers (verified on 0.155.1, isolated `CODEX_HOME` + a fake model server) - **Where hooks live:** `$CODEX_HOME/hooks.json`, in the same shape as Claude Code's `hooks` block: `{"hooks": {"": [{"matcher"?, "hooks": [{"type": "command", "command", "timeout"}]}]}}`. - Events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `Stop`, `SessionEnd`, and others. - Timeouts above 3 s are clamped (with a startup warning) for `SessionEnd`/`Interrupt`. - **The stdin payload is Claude's shape:** - `session_id` equals the rollout's thread id, so it matches C1's session row. - Also: `transcript_path` (the rollout), `cwd`, `hook_event_name`, `model`, `permission_mode`, and `turn_id` on turn-scoped events. - Tool events carry `tool_name` (`Bash`), `tool_input` and `tool_use_id`. `PermissionRequest` carries `tool_input.command`/`.description`. - The hook inherits Codex's environment, including `AGENTHUB_LANE_ID`. - **Trust:** a hook runs only once the user trusts its exact definition. - Codex asks at startup ("Hooks need review → Trust all and continue"), or the user runs `/hooks`. - Trust is stored in `$CODEX_HOME/config.toml` as `[hooks.state.":::"] trusted_hash = "sha256:…"`. - An untrusted hook is silently skipped by `codex exec`. - AgentHub never writes that trust and never passes `--dangerously-bypass-hook-trust`: skipping the user's review is not ours to do. ## Design 1. **A stable command:** `'/agenthub-hook.sh' `. - Trust is keyed on the definition, so the command must not change when AgentHub updates. A plugin install moves to a new cache directory on every update. - The script is AgentHub's own: - it runs this build's `scripts/hook-handler.js codex`, with the server's `node` (falling back to `node` on PATH, like the CLI launcher); - it sets the server's `CLAUDE_HOME`, so the handler finds the discovery file. - Every server start rewrites the script's content when it is installed and stale. Only the content changes, so the trusted definition stays valid. - Every value is single-quoted. 2. **Install / remove** (`server/lib/codex-hooks.js`), only on an explicit click in **Settings → Codex → AgentHub → Codex hooks**: - `install`: - Writes the script (0755). - Merges one group per event into `hooks.json`: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `Stop`, `Interrupt`, `SessionEnd`, each with `timeout: 3`. - Drops any earlier hook of ours first (matched by its exact command, never a group). Keeps every other entry. - Refuses a `hooks.json` that is not a JSON object (`EBADJSON`, the router's rule for `settings.local.json`). - Writes with temp + rename. - `remove`: - Takes our hooks out (a group left empty goes too) and deletes the script. - Deletes `hooks.json` when nothing else is left in it. - `status`: - Per event: whether our hook is present, and whether `config.toml` holds a trust entry for its position. - Whether the file is readable. - Win32 is refused (`EUNSUPPORTED`): the command is a POSIX shell script. - Routes: `GET/POST/DELETE /api/settings/codex-hooks`. 3. **Handler:** `scripts/hook-handler.js` sends `tool: "codex"` when its second argument is `codex`. The route reads `tool` through an allowlist (`codex`, else `claude`). 4. **Hook route, for `tool = codex`:** - **Sub-agent threads.** A hook from one would describe a child thread, not the user's session. We cannot see whether Codex fires hooks there, so this is guarded, not assumed. - When `transcript_path` is a rollout under `$CODEX_HOME/sessions` whose `session_meta.session_id` differs from its `id`, the hook is a sub-agent's. - Tool and permission events are attributed to the root session. - `SessionStart`/`SessionEnd`/`Stop`/`UserPromptSubmit` are dropped. - The meta read is cached per path. - **`PermissionRequest`** becomes the `Notification` Claude uses for a permission prompt, with the message `Codex needs your permission: `. The awaiting flag, the lane's `needs_action`, the alert rules and the browser notification then work unchanged. - **`ensureSession`** creates the row with `tool = 'codex'`, named `Codex <8>` (C1 renames it from the prompt or thread name). It stores no `transcript_path`. - **The Claude transcript block** (tokens, titles, errors, compactions, turn durations), the SessionEnd transcript read, the sub-agent JSONL scan and the workflow ingest are skipped: they parse Claude's format, and C1 owns a Codex session's tokens. - **`touchLaneFromHook`** resolves the lane by `(cwd, tool)`, so a Codex hook reaches the Codex lane and never a Claude lane at the same directory. Stage detection runs for `claude` only: a non-claude lane carries no pipeline. 5. **Who owns a Codex session's status.** - A session that has hook events is **hook-fed**. While its hooks are current (see the whole-change review below), they own its status, `ended_at` and `updated_at`, as for Claude. - C1's poll still writes its tokens, model and name, and `settleActive` leaves it alone while its hooks are current. - A session with no events keeps C1's journal-activity status. - A hook-fed session whose Codex process dies without `SessionEnd` stays active until the stale sweep (`STALE_MINUTES`, 3 h). The liveness reap probes `claude` processes only. 6. **The lane mirror** (`codex-ingest.js`) skips a lane whose bound session got a hook event in the last 2 minutes: the hooks are current, and the mtime mirror would call a lane that just stopped "running" for up to 2 minutes. ## Found in the end-to-end check, fixed - **Declining a permission (or Esc) ends the turn with `Interrupt`, not `Stop`.** The lane stayed `running` with a stale "needs your permission" until the next prompt. `Interrupt` is now installed and becomes `Stop`, since Codex is back at its prompt either way; a sub-agent thread's `Interrupt` is dropped. Adding it to an install the user already trusted made Codex ask about that one hook only, and the other seven kept their trust. - In the TUI, `SessionStart` fires on the first prompt, when Codex creates the thread, not when the TUI opens. ## Found in the code review, fixed - **Remove/Reinstall deleted a user's hook along with ours** when it sat in our group, or when its command merely contained `agenthub-hook.sh`. Ours is now matched by its exact command shape, one hook at a time, and a group is dropped only when that leaves it empty. - **Install/remove had no `sameOriginGuard`.** Any web page could POST to install the hooks (Codex's trust prompt was the only stop). Both write routes now carry it. - **A symlinked `CODEX_HOME`** could show "untrusted" forever and turn the sub-agent guard off, if Codex reports the resolved path. Trust and `threadOf` now accept the home as given or resolved (`codexHomes` in `codex-home.js`). - **A session the journal poll imported first had no main agent**, so its agent-level state stayed empty. `ensureSession` now creates the missing `-main` for a non-claude session. - Minor: - `refreshScript` leaves a file that is not ours alone (`foreign`), restores a lost exec bit, and a failure is logged. - Writes go through a symlinked `hooks.json` to its target, and a failed write leaves no temp file behind. - The card has **Refresh**, shows Remove and the grid on a partial install, and gives each event screen-reader text. - Left as is: - Trust is matched by position, so a stale entry at the same position reads as trusted. - The mirror takes over again after 2 minutes of hook silence during a long tool call. - A sub-agent hook that arrives before its rollout's first line creates its own session. - Whichever AgentHub server started last owns the forwarder. ## Found in the whole-change review, fixed - **Hooks owned a session's status forever.** A session whose hooks were removed, or no longer trusted, stayed on its last hook-given status. Hooks now own it only while current (`hooksCurrent` in `codex-usage.js`: the newest event at or after the newest journal write minus 2 minutes); otherwise the journal decides again, in `settleActive` too. - **A permission prompt could become a Compaction.** The Notification → `Compaction` tag matches `compact|compress|…` in the message, and a Codex `PermissionRequest` quoting a command such as `tar --compress` matched it. The tag now applies to `tool = 'claude'` only. - **A sub-agent's tool call looked like the user resuming.** A child thread's `PreToolUse`/`PostToolUse`, folded onto the root session, is marked `_codexChild` and records work only: it no longer reactivates the session, clears its waiting flag, sets the main agent working, clears the lane's `needs_action` or moves the lane's status. Its own `Stop` is dropped, so anything it set would never be undone. - **A forwarder path that is not a file of ours** — other content, an unreadable file, a directory (`scriptIsOurs`) — is never overwritten (install answers 400 `EFOREIGN`) or removed (remove skips it). - **A rewritten `hooks.json` keeps the mode the user gave it**, and a symlinked one left with no hook is emptied through the link instead of being unlinked. - The route's `tool` allowlist is now `codex`, `antigravity` (part D), else `claude`. ## Not changed - Claude's hook path, apart from the parameter. - Hook installs for Claude. - `agenthub doctor`. - The notification title, which still says "Claude needs your confirmation"; the body names Codex. - No sub-agent rows for Codex. ## Tests - **Server:** - `codex-hooks.js`: install into an empty home, and next to a user's own hooks (kept); reinstall replaces ours; remove leaves theirs and deletes a file of ours only; a non-JSON file refused; status with and without a trust entry; the script refreshed and its command unchanged. - Route: a codex hook creates a `tool = codex` session with no `transcript_path` and no Claude transcript read; `PermissionRequest` stamps awaiting and the codex lane's `needs_action` while the claude lane at the same cwd is untouched; no stage detection on a codex lane; a sub-agent thread's `Stop` dropped and its `PreToolUse` attributed to the root. - C1 does not overwrite a hook-fed session's status. - The mirror skips a lane with a fresh hook. - The handler sends `tool`. - **Client:** the Codex hooks section (status, install, remove). - **Browser:** the isolated server, a real `codex exec` against a fake model with the installed hooks trusted through the TUI prompt, then the session's timeline and a lane's `needs_action`.