Files
AgentHub/docs/superpowers/specs/2026-09-29-codex-hooks-design.md
nntrivi2001 969b06253c feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

Settings persistence: dashboard-saved settings go to
~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin
cache an update wipes. Load order: real env > the checkout's .env > that file.
The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put.

C1: codex-usage.js reads Codex rollout journals into sessions (new column
tool) and token_usage, tracked per file in codex_rollouts; forks deduped by
total vector, sub-agent threads folded into the root; pricing-sync also
prices OpenAI models; PricingSettings with a vendor filter.

C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on
a click, through a stable forwarder; Codex trust is never written. The hook
route creates tool='codex' sessions and folds sub-agent hooks into the root.

D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens
from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js
installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never
PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab;
Gemini pricing.

Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing
on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=,
clear-data covering the new tables, Codex child-thread events moving no
status, hooks owning status only while current, EFOREIGN for a forwarder
that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE
honoured from .env.

Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
2026-09-29 16:09:19 +07:00

11 KiB

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": {"<Event>": [{"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."<hooks.json path>:<event_snake>:<group>:<hook>"] 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: '<CODEX_HOME>/agenthub-hook.sh' <Event>.
    • 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 <Event> 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: <description or command>. 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 <id>-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.