969b06253c
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.
11 KiB
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'shooksblock:{"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.
- Events:
- The stdin payload is Claude's shape:
session_idequals the rollout's thread id, so it matches C1's session row.- Also:
transcript_path(the rollout),cwd,hook_event_name,model,permission_mode, andturn_idon turn-scoped events. - Tool events carry
tool_name(Bash),tool_inputandtool_use_id.PermissionRequestcarriestool_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.tomlas[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.
- Codex asks at startup ("Hooks need review → Trust all and continue"), or the user runs
Design
- 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'snode(falling back tonodeon PATH, like the CLI launcher); - it sets the server's
CLAUDE_HOME, so the handler finds the discovery file.
- it runs this build's
- 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.
- 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 withtimeout: 3. - Drops any earlier hook of ours first (matched by its exact command, never a group). Keeps every other entry.
- Refuses a
hooks.jsonthat is not a JSON object (EBADJSON, the router's rule forsettings.local.json). - Writes with temp + rename.
remove:- Takes our hooks out (a group left empty goes too) and deletes the script.
- Deletes
hooks.jsonwhen nothing else is left in it.
status:- Per event: whether our hook is present, and whether
config.tomlholds a trust entry for its position. - Whether the file is readable.
- Per event: whether our hook is present, and whether
- Win32 is refused (
EUNSUPPORTED): the command is a POSIX shell script. - Routes:
GET/POST/DELETE /api/settings/codex-hooks.
- Handler:
scripts/hook-handler.jssendstool: "codex"when its second argument iscodex. The route readstoolthrough an allowlist (codex, elseclaude). - 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_pathis a rollout under$CODEX_HOME/sessionswhosesession_meta.session_iddiffers from itsid, the hook is a sub-agent's. - Tool and permission events are attributed to the root session.
SessionStart/SessionEnd/Stop/UserPromptSubmitare dropped.- The meta read is cached per path.
- When
PermissionRequestbecomes theNotificationClaude uses for a permission prompt, with the messageCodex needs your permission: <description or command>. The awaiting flag, the lane'sneeds_action, the alert rules and the browser notification then work unchanged.ensureSessioncreates the row withtool = 'codex', namedCodex <8>(C1 renames it from the prompt or thread name). It stores notranscript_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.
touchLaneFromHookresolves 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 forclaudeonly: a non-claude lane carries no pipeline.
- 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.
- 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_atandupdated_at, as for Claude. - C1's poll still writes its tokens, model and name, and
settleActiveleaves 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
SessionEndstays active until the stale sweep (STALE_MINUTES, 3 h). The liveness reap probesclaudeprocesses only.
- A session that has hook events is hook-fed. While its hooks are current (see the whole-change review below), they own its status,
- 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, notStop. The lane stayedrunningwith a stale "needs your permission" until the next prompt.Interruptis now installed and becomesStop, since Codex is back at its prompt either way; a sub-agent thread'sInterruptis 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,
SessionStartfires 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_HOMEcould show "untrusted" forever and turn the sub-agent guard off, if Codex reports the resolved path. Trust andthreadOfnow accept the home as given or resolved (codexHomesincodex-home.js). - A session the journal poll imported first had no main agent, so its agent-level state stayed empty.
ensureSessionnow creates the missing<id>-mainfor a non-claude session. - Minor:
refreshScriptleaves a file that is not ours alone (foreign), restores a lost exec bit, and a failure is logged.- Writes go through a symlinked
hooks.jsonto 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 (
hooksCurrentincodex-usage.js: the newest event at or after the newest journal write minus 2 minutes); otherwise the journal decides again, insettleActivetoo. - A permission prompt could become a Compaction. The Notification →
Compactiontag matchescompact|compress|…in the message, and a CodexPermissionRequestquoting a command such astar --compressmatched it. The tag now applies totool = '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_codexChildand records work only: it no longer reactivates the session, clears its waiting flag, sets the main agent working, clears the lane'sneeds_actionor moves the lane's status. Its ownStopis 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 400EFOREIGN) or removed (remove skips it). - A rewritten
hooks.jsonkeeps 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
toolallowlist is nowcodex,antigravity(part D), elseclaude.
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 = codexsession with notranscript_pathand no Claude transcript read;PermissionRequeststamps awaiting and the codex lane'sneeds_actionwhile the claude lane at the same cwd is untouched; no stage detection on a codex lane; a sub-agent thread'sStopdropped and itsPreToolUseattributed 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 execagainst a fake model with the installed hooks trusted through the TUI prompt, then the session's timeline and a lane'sneeds_action.