Codex wrapper: one cmux hook group per event; Codex appends session-flag hooks to user layers
Closes https://github.com/manaflow-ai/cmux/issues/12081
Summary
The issue reports that cmux's per-event -c hooks.<event>=[...] injection replaces the user's configured hook arrays, so ~/.codex/hooks.json handlers stop running inside cmux. I reproduced the argv exactly as reported with the shipping 0.64.22 CLI, then measured what Codex actually does with it using the real installed binaries (codex-cli 0.153.4 from the ChatGPT app and 0.146.0 from npm) against a hermetic fake model provider, so a full codex exec turn runs with no credentials and no network.
Codex does not replace the user's hooks. Codex discovers hooks per configuration layer and appends them from lowest to highest precedence (codex-rs/hooks/src/engine/discovery.rs at rust-v0.153.4: for layer in config_layer_stack.layers_low_to_high() loads each layer's hooks.json and TOML [hooks] events and appends them; the first hooks engine from 2026-03-10 already iterated layers LowestPrecedenceFirst). cmux's -c hooks.<event>= values only define the session-flags layer. In every experiment the user handler and the cmux-style handler both fired:
| Run | codex | user hook source | cmux-style -c hooks.SessionStart= | user hook fired | override fired |
|---|---|---|---|---|---|
| A | 0.153.4 | $CODEX_HOME/hooks.json | none | 1× | – |
| B | 0.153.4 | $CODEX_HOME/hooks.json | cmux group only (today's shape) | 1× | 1× |
| C | 0.153.4 | $CODEX_HOME/hooks.json | user group copied + cmux group (this branch's inherited fix) | 2× | 1× |
| 146-A/B/C | 0.146.0 | same as A/B/C | same | 1× / 1× / 2× | – / 1× / 1× |
| D1/D2 | 0.153.4 | [hooks] in config.toml | none / cmux group only | 1× / 1× | – / 1× |
| E1/E2 | 0.153.4 | trusted project .codex/hooks.json | none / cmux group only | 1× / 1× | – / 1× |
So the inherited fix on this branch (20aab5c0ce, which copied every user group from hooks.json into cmux's value) would have made Codex run each user handler twice, and it also changed the injected shape that AgentLaunchSanitizerCodexLaunch strips from saved resume argv. This PR replaces it. Codex dispatches an event's handlers together (FuturesUnordered in dispatcher.rs) and only orders the results, so the docs describe registration order, not execution order.
What changed
CLI/CMUXCLI+CodexFireAndForgetHooks.swift: back to exactly one cmux group per injected event (removes the user-hook reader, the JSON→TOML re-encoder, and the combined branch; the file is 334 lines). The emitter's doc comment states the verified layering contract. Relative tomainthis is a comment-only change; runtime behavior is unchanged.docs/agent-hooks.md, new "Codex wrapper precedence" section next to the environment-override table: the flags are added only when at least one cmux event is not covered by a persistent handler (a completecmux hooks codex installmakes the wrapper add nothing, keepingfeatures.hooks = falseintact); per-layer append with user and project handlers registered before cmux's and dispatched together; nothing inhooks.json/config.tomlis replaced or rewritten; cmux deliberately does not copy handlers (double execution); the trust-bypass and same-layer-ctrade-offs; theCMUX_CODEX_HOOKS_DISABLED=1opt-out and its cost.tests/test_codex_wrapper_hook_append.py(behavior tests against the built CLI):test_injected_hooks_do_not_redeclare_user_hooks: userhooks.jsonhandlers on all twelve Codex hook events, the six issue events with awkward commands (quotes, backslashes,''', newline); asserts the activation flags are followed only by-c hooks.<event>=pairs, the injected event set equals the CLI's own baseline, each event carries exactly one cmux group, no user command leaks into the args, andhooks.jsonis byte-identical afterwards.test_persistent_cmux_hook_is_not_duplicated: a persistently installed cmux SessionStart handler plus a user Stop handler; asserts SessionStart is skipped, Stop still carries only cmux's group, andhooks.jsonis untouched.test_live_codex_runs_user_hooks_and_cmux_hook_once_each: runs the realcodexthroughResources/bin/cmux-codex-wrapperwith a throwawayCODEX_HOME/HOME,CMUX_AGENT_HOOK_STATE_DIR, a fake cmux socket, an argv-capturing codex shim, a logging shim in front of the cmux CLI, and an in-process fake Responses API server. User handlers live in every documented user layer:hooks.json(SessionStart/UserPromptSubmit/Stop), the[hooks]table inconfig.toml, and a trusted project's.codex/hooks.json(the temp path is canonicalized because Codex trusts a project by its resolved path). Asserts the live argv shape, that each of the five user handlers ran exactly once, and that cmux'sinject-args,session-start,prompt-submit, andstopCLI calls each happened exactly once, plus socket delivery and the hook ledger. Skips with an explicit message when no real codex is installed;CMUX_TEST_REQUIRE_REAL_CODEX=1turns that into a failure where Codex is provisioned. Python 3.9 compatible.
Before
Shipping 0.64.22 CLI, throwaway CODEX_HOME with user SessionStart/UserPromptSubmit/Stop hooks:
$ /Applications/cmux.app/Contents/Resources/bin/cmux version
cmux 0.64.22 (102) [ddd4a01bc]
$ env -i PATH="$PATH" HOME=$R/home CODEX_HOME=$R/codex-home /Applications/cmux.app/Contents/Resources/bin/cmux hooks codex inject-args | tr '\0' '\n'
--enable
hooks
--dangerously-bypass-hook-trust
-c
hooks.SessionStart=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-e06e72bed0843fff-session-start.sh''',timeout=10000}]}]
-c
hooks.UserPromptSubmit=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-8928b8abed1344dd-prompt-submit.sh''',timeout=10000}]}]
-c
hooks.Stop=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-0e69914d13c969d6-stop.sh''',timeout=10000}]}]
... (PreToolUse, PostToolUse, PermissionRequest)
That argv is what the issue calls a replacement. Feeding the same shape to the real codex (Run B) shows the user's hooks.json SessionStart handler still runs; the inherited copy approach (Run C) runs it twice:
--- B: user-hooks.log --- --- C: user-hooks.log ---
03:31:39 user-hook session-start pid=13882 03:31:43 user-hook session-start pid=15424
03:31:39 user-hook user-prompt-submit pid=14120 03:31:43 user-hook session-start pid=15414
03:31:40 user-hook stop pid=14205 03:31:44 user-hook user-prompt-submit pid=15712
--- B: override-hook.log --- 03:31:45 user-hook stop pid=16093
03:31:39 override-hook SessionStart pid=13884 --- C: override-hook.log ---
03:31:43 override-hook SessionStart pid=15427
Verification (tagged dev build issue-12081-codex-hook-append, HEAD 8ca951b4e8)
Build: CMUX_SKIP_ZIG_BUILD=1 /Users/austinwang/manaflow/cmuxterm-hq/scripts/reload-cloud.sh --tag issue-12081-codex-hook-append --no-dev-backend --launch (run id issue-12081-codex-hook-append-43628368be81, BUILD_OK).
1. Which binary was tested
$ ".../cmux DEV issue-12081-codex-hook-append.app/Contents/Resources/bin/cmux" version
cmux 0.64.22 (102) [8ca951b4e]
2. Headless: real codex 0.153.4 through the built app's own Resources/bin/cmux-codex-wrapper (throwaway CODEX_HOME with user SessionStart/UserPromptSubmit/Stop marker hooks, fake cmux socket, CMUX_AGENT_HOOK_STATE_DIR, argv captured by a CMUX_CUSTOM_CODEX_PATH shim):
== live codex argv ==
--enable / hooks / --dangerously-bypass-hook-trust
-c hooks.SessionStart=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-db803fdff268bdd9-session-start.sh''',timeout=10000}]}]
-c hooks.UserPromptSubmit=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-051e29da752a9030-prompt-submit.sh''',timeout=10000}]}]
-c hooks.Stop=[{hooks=[{type="command",command='''/Users/austinwang/.cmux/hooks/cmux-codex-hook-e3e89644cfb90c4f-stop.sh''',timeout=10000}]}]
-c hooks.PreToolUse=... -c hooks.PostToolUse=... -c hooks.PermissionRequest=... -c hooks.SubagentStart=... -c hooks.SubagentStop=...
exec --skip-git-repo-check -s read-only "reply with the word ok"
== per-event check: exactly one cmux group, no user handler copied: OK for all 8 events
== user hook markers (each exactly once) ==
SessionStart
UserPromptSubmit
Stop
== fake cmux socket requests from cmux's hooks == ['surface.list', 'surface.list', 'surface.list', 'agent.resolve_delivery_target', ...]
(With the fake socket, target resolution cannot succeed, so the throwaway ledger records the failure timestamps rather than a session; the real-app run below shows the full record.)
3. Before evidence: see "Before" above (0.64.22 argv, and Runs B/C against the real codex).
4. Full integration: real codex inside a terminal surface of the tagged dev app (tag-bound socket /tmp/cmux-debug-issue-12081-codex-hook-append.sock, CMUX_TAG=... scripts/cmux-debug-cli.sh):
$ identify -> bundle_identifier com.cmuxterm.app.debug.issue.12081.codex.hook.append
$ version -> cmux 0.64.22 (102) [8ca951b4e]
$ new-surface --workspace workspace:1 --working-directory $R/work --focus true -> OK surface:2 pane:1 workspace:1
$ send --workspace workspace:1 --surface surface:2 "env CODEX_HOME=$R/codex-home CMUX_AGENT_HOOK_STATE_DIR=$R/app-state CMUX_CUSTOM_CODEX_PATH=$R/app-argv-shim codex exec --skip-git-repo-check -s read-only 'reply with the word ok'; echo CODEX_EXIT=\$?\n"
$ capture-pane -> ... hook: Stop Completed / tokens used / 2 / CODEX_EXIT=0
== live argv inside the surface: same eight -c values as step 2 (one cmux group each), then exec ...
== user hook markers ==
05:07:46 user-hook session-start pid=37577 payload_bytes=535
05:07:46 user-hook user-prompt-submit pid=37589 payload_bytes=603
05:07:46 user-hook stop pid=37652 payload_bytes=642
== ledger (tagged CLI: sessions list --agent codex --state-dir $R/app-state --json) ==
session_id 01a080ea-c86c-7750-b4b0-3b41d88da429, workspace_id B3F20890-…, surface_id 2A192015-…, cwd $R/work, codex_transcript_found true
== tagged app debug log ==
agentHook.start agent=codex subcommand=session-start session=01a080ea-c86 … envWorkspace=1 envSurface=1
agentHook.target.resolved agent=codex subcommand=session-start … workspace=B3F20890-D6D surface=2A192015-F55
agentHook.start agent=codex subcommand=prompt-submit … agentHook.target.resolved … prompt-submit …
agentChat.hook session=01a080ea event=SessionStart source=codex
agentHook.start agent=codex subcommand=stop … agentHook.target.resolved … stop …
agentHook.stop.notify.sent agent=codex session=01a080ea-c86 response=OK
agentChat.hook session=01a080ea event=Stop source=codex agentChat.hook session=01a080ea event=UserPromptSubmit source=codex
The user's hooks ran, and cmux's lifecycle registration still bound the session to the dev app's workspace and surface, sent the Stop notification, and fed the agent chat events.
5. Python wrapper tests against the built CLI
$ cd tests && CMUX_CLI_BIN=".../cmux DEV issue-12081-codex-hook-append.app/Contents/Resources/bin/cmux" python3 test_codex_wrapper_hook_append.py
PASS: injected Codex hooks carry one cmux group per event and no user handlers
PASS: persistent cmux hooks are not duplicated and user hooks are not copied
PASS: real codex ran user hooks and cmux's hook exactly once each
$ ... python3 test_codex_wrapper_resume_hooks.py -> PASS: every cmux-owned Codex session entrypoint retains SessionStart and Stop hooks
$ ... python3 test_codex_wrapper_computer_use_mcp.py -> PASS: codex wrapper injects cmux-cua MCP
The same three tests were also run against the shipped 0.64.22 CLI as a harness check (argv and live tests pass; the persistent-dedup test fails there as expected, since that release predates main's persistent-producer skip from #10838).
CI
ci.yml is path-gated for pull requests, so on the final head the required ci-status comes from the fallback workflow, and Web complexity and the CLA checks pass. The full lane ran on 3bd392c137 (which touched ci.yml): its "Build for runtime regressions" step passed (compile evidence for the Swift change), then "Validate Swift warning budget" failed on main's own unbudgeted warnings in files this PR does not touch, exactly as on main's dispatched run (#12159). Earlier runs failed web-typecheck on main's import.meta.main error (#12147), fixed by #12154 and merged back here.
Trade-offs (stated, not absorbed)
- No merge for a user's own
-c hooks.<event>=argument. That is the one real replacement in this design: cmux prepends its flags, so a later user-c hooks.<event>=wins within the session-flags layer and cmux loses that event. Documented rather than implemented; merging would require the wrapper to parse arbitrary user TOML on the command line, andcmux hooks codex installalready covers users who need both. --dangerously-bypass-hook-truststays. Pre-existing and required for cmux's session-flag handlers to run at all; the docs now say plainly that it also skips review for user and project handlers.- CI wiring deferred. Running the new test in the
Run CLI no-socket regressionsstep requires touchingci.yml, which routes every CI area and currently turns the requiredci-statusred on main's warning-budget breakage (#12159). The one-line wiring is a follow-up once that lane is green; until then the test runs locally against the built CLI (outputs above). - The live test needs a real
codex. It skips with an explicit message on runners without one;CMUX_TEST_REQUIRE_REAL_CODEX=1makes the prerequisite mandatory where Codex is provisioned. The argv-level tests always run. - Branch history is additive. The inherited commits (
337b2c5198test,20aab5c0cefix) were already on the shared branch, so they are kept and superseded rather than rewritten.84a03c67d5(new test) is red against20aab5c0ceby construction and green fromfa909576a4on. The inherited fix was never built; its emitted value shape was reproduced by hand in Run C. - Sibling PR consolidated. #12141 (another agent session, opened a minute after this one) reached the same conclusion; its author converted it to a draft in favor of this PR, and its review points are folded in.
- Dev build ran with
--no-dev-backend. The default per-tag dev web backend host (cmux-dev-backend-1) does not resolve from this Mac; the backend serves the app's cloud features, which this verification does not touch. I also cleared an orphaned build-queue lock (created 03:05 by an xctest run that had exited; 97 minutes old) before re-queueing. - Follow-up, not fixed here (found on #12141): cmux writes
timeout=10000/120000in its-cvalues, but Codex parsestimeoutas seconds (timeout_secinhook_config.rs). cmux's hook scripts return immediately, so the wrong unit is latent; changing the value also changes the shapeAgentLaunchSanitizerCodexLaunchstrips from saved argv, so it needs its own schema step. - Localization audit: no user-facing strings changed (docs and code comments only); nothing to localize. No keyboard shortcuts involved. No iOS paths touched.
🤖 Generated with Claude Code