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

122 lines
11 KiB
Markdown

# 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`.