Skip to content

Add Codex CLI hooks integration - #2103

Merged
lawrencecchen merged 5 commits into
mainfrom
feat-codex-hooks
Mar 25, 2026
Merged

lawrencecchen merged 5 commits into
mainfrom
feat-codex-hooks

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds cmux codex install-hooks / uninstall-hooks to manage lifecycle hooks in ~/.codex/hooks.json
  • Adds cmux codex-hook handlers (session-start, prompt-submit, stop) for sidebar status and notifications
  • Shows diff and asks for confirmation before modifying user config
  • Hooks gracefully no-op outside cmux (checks CMUX_SURFACE_ID before invoking cmux, falls back to echo '{}')
  • Install merges with existing user hooks, uninstall removes only cmux-owned hooks

Test plan

  • Run cmux codex install-hooks, verify diff shown with line numbers and [Y/n] prompt
  • Verify ~/.codex/hooks.json and config.toml updated correctly
  • Run cmux codex install-hooks again, verify "already installed" message
  • Launch codex inside cmux, verify sidebar shows Running/Idle status
  • Launch codex outside cmux (e.g. default Terminal), verify no hook errors or junk output
  • Run cmux codex uninstall-hooks, verify only cmux hooks removed, user hooks preserved

Summary by cubic

Adds Codex CLI hooks integration to cmux to track sessions, show sidebar status, and send completion notifications. Install/uninstall safely manage ~/.codex/hooks.json and config.toml (including reverting codex_hooks on uninstall) and no-op outside cmux.

  • New Features

    • cmux codex install-hooks / uninstall-hooks manage hooks and toggle codex_hooks in config.toml; uninstall removes the flag and cleans an empty [features] section with exact TOML key matching.
    • Adds cmux codex-hook handlers: session-start, prompt-submit (sets Running), stop (notification + sets Idle).
    • Safe outside cmux: checks CMUX_SURFACE_ID and command -v cmux; prints {} and exits 0.
    • Install merges with existing user hooks; uninstall removes only cmux codex-hook entries; idempotent.
    • Shows unified diff with line numbers and context for both files; [Y/n] confirm; use --yes/-y to skip.
  • Migration

    • Run cmux codex install-hooks to enable Codex hooks.

Written for commit 811e806. Summary will update on new commits.

Summary by CodeRabbit

New Features

  • Added new CLI commands cmux codex install-hooks and cmux codex uninstall-hooks to manage Codex integration without requiring a cmux socket
  • Added cmux codex-hook command to handle Codex hook events during session and prompt workflows
  • Improved help output to recognize Codex commands without establishing socket connections
  • Enhanced configuration management for Codex integration settings

Adds `cmux codex install-hooks` to install lifecycle hooks into
~/.codex/hooks.json and enable the codex_hooks feature flag. The hooks
call `cmux codex-hook <event>` which gracefully no-ops (exit 0, prints
{}) when not running inside cmux, so they're safe to leave installed
globally.

Supported events: SessionStart (session tracking), UserPromptSubmit
(set Running status), Stop (completion notification + Idle status).

Install merges with existing user hooks and is idempotent. Uninstall
(`cmux codex uninstall-hooks`) removes only cmux-owned hooks,
identified by the `cmux codex-hook` command prefix.
install-hooks and uninstall-hooks now preview changes to hooks.json and
config.toml before applying, with a [Y/n] prompt. Pass --yes/-y to
skip confirmation.

Hook commands use `command -v cmux` guard so they silently no-op
(echo '{}') when cmux CLI is not on PATH (e.g. user runs codex in a
non-cmux terminal or after uninstalling cmux).
install-hooks and uninstall-hooks now show unified-diff-style output
with line numbers and surrounding context lines, making it easier to
see exactly what will change in hooks.json and config.toml.
The hook shell command now checks [ -n "$CMUX_SURFACE_ID" ] first,
so it short-circuits to echo '{}' without ever invoking cmux when
not inside a cmux terminal. Prevents usage text and socket errors
from leaking into Codex hook output.
@vercel

vercel Bot commented Mar 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 25, 2026 5:19am

@coderabbitai

coderabbitai Bot commented Mar 25, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This change adds new CLI commands to manage Codex integration and hook events: cmux codex for installing/uninstalling hooks and cmux codex-hook for handling hook events. It includes logic for reading and modifying hook configuration files, updating feature settings, and displaying diffs during hook operations.

Changes

Cohort / File(s) Summary
Codex Integration & Hook Management
CLI/cmux.swift
Added codex and codex-hook top-level CLI commands to manage Codex hook installation/uninstallation and event handling. Includes hook configuration file management (~/.codex/hooks.json), feature flag updates (~/.codex/config.toml), hook event routing with telemetry, and LCS-based diff display during installation prompts.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Hops of code in swift arrays,
Hooks now dance through Codex maze,
Config files get merged with care,
Diffs shown with flair so rare!
Integration hops complete! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding Codex CLI hooks integration to manage lifecycle hooks and handle Codex events.
Description check ✅ Passed The PR description covers the main changes and includes a test plan, but lacks manual testing verification and bot review requests in the checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-codex-hooks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds Codex CLI hooks integration to cmux: cmux codex install-hooks / uninstall-hooks manage ~/.codex/hooks.json and ~/.codex/config.toml, and a new cmux codex-hook command handles session-start, prompt-submit, and stop events from inside a running Codex session. The implementation closely mirrors the existing claude-hook subsystem, reusing ClaudeHookSessionStore, parseClaudeHookInput, and the resolution/notification helpers.

Key issues found:

  • runCodexUninstallHooks only removes entries from hooks.json but does not revert the codex_hooks = true line added to config.toml by install, leaving the config in a partially-restored state after uninstall.
  • buildConfigWithCodexHooks calls String.replacingOccurrences(of: "[features]", with: "[features]\ncodex_hooks = true"), which replaces every occurrence of the literal string in the file — including any that appear inside TOML comments — and can produce invalid TOML.
  • Minor: force-cast as! when reading the static codexHooksJSON["hooks"] dictionary; a guard let cast would be safer.

Confidence Score: 3/5

  • Two concrete bugs — asymmetric install/uninstall and a config-corrupting string replace — need to be fixed before merging.
  • The hook dispatch, session-store wiring, and graceful no-op logic are solid and mirror the proven claude-hook pattern. However the two P1 bugs are functional regressions: uninstall leaves stale config.toml state, and the replacingOccurrences path can silently corrupt a user's config if [features] appears anywhere other than as a standalone section header. Both are straightforward to fix but must be addressed before shipping.
  • CLI/cmux.swift — specifically runCodexUninstallHooks (missing config.toml revert) and buildConfigWithCodexHooks (replace-all bug)

Important Files Changed

Filename Overview
CLI/cmux.swift Adds ~560 lines implementing codex install-hooks/uninstall-hooks and the codex-hook dispatch handler. Two P1 issues: uninstall only reverts hooks.json but not the config.toml entry added by install (asymmetric), and buildConfigWithCodexHooks uses replacingOccurrences which can corrupt TOML if [features] appears in a comment. One P2: force cast in the hooks merge loop.

Sequence Diagram

sequenceDiagram
    participant User
    participant cmux CLI
    participant hooks.json
    participant config.toml
    participant Codex

    User->>cmux CLI: cmux codex install-hooks
    cmux CLI->>hooks.json: read existing (if any)
    cmux CLI->>config.toml: read existing (if any)
    cmux CLI-->>User: show diff [Y/n]
    User->>cmux CLI: confirm
    cmux CLI->>hooks.json: write merged hooks
    cmux CLI->>config.toml: write codex_hooks = true

    Note over User,Codex: Inside cmux session
    Codex->>cmux CLI: SessionStart → cmux codex-hook session-start
    cmux CLI->>cmux CLI: resolve workspaceId + surfaceId
    cmux CLI->>cmux CLI: sessionStore.upsert(sessionId)

    Codex->>cmux CLI: UserPromptSubmit → cmux codex-hook prompt-submit
    cmux CLI->>cmux CLI: clear_notifications
    cmux CLI->>cmux CLI: set_status codex Running

    Codex->>cmux CLI: Stop → cmux codex-hook stop
    cmux CLI->>cmux CLI: notify_target (completion)
    cmux CLI->>cmux CLI: set_status codex Idle

    User->>cmux CLI: cmux codex uninstall-hooks
    cmux CLI->>hooks.json: remove cmux hooks
    Note over config.toml: ⚠ config.toml NOT reverted
Loading

Reviews (1): Last reviewed commit: "Check CMUX_SURFACE_ID in shell guard bef..." | Re-trigger Greptile

Comment thread CLI/cmux.swift Outdated
Comment on lines +11195 to +11259
private func runCodexUninstallHooks() throws {
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"]
?? NSString(string: "~/.codex").expandingTildeInPath
let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json")
let fm = FileManager.default

guard fm.fileExists(atPath: hooksPath),
let data = try? Data(contentsOf: URL(fileURLWithPath: hooksPath)),
var parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("No hooks.json found at \(hooksPath)")
return
}

guard var hooks = parsed["hooks"] as? [String: Any] else {
print("No hooks section found in \(hooksPath)")
return
}

// Build the new state without cmux hooks
var removedCount = 0
for eventName in hooks.keys {
guard var eventGroups = hooks[eventName] as? [[String: Any]] else { continue }
let before = eventGroups.count
eventGroups.removeAll { group in
guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false }
return groupHooks.allSatisfy { hook in
(hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true
}
}
removedCount += before - eventGroups.count
if eventGroups.isEmpty {
hooks.removeValue(forKey: eventName)
} else {
hooks[eventName] = eventGroups
}
}

if removedCount == 0 {
print("No cmux hooks found in \(hooksPath)")
return
}

parsed["hooks"] = hooks
let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys])
let newContent = String(data: newJsonData, encoding: .utf8) ?? ""
let oldContent = String(data: data, encoding: .utf8) ?? ""

// Show diff and ask for confirmation
print(" \(hooksPath):")
printSimpleDiff(old: oldContent, new: newContent)
print("")

if !skipConfirm {
print("Remove \(removedCount) cmux hook(s)? [Y/n] ", terminator: "")
if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!response.isEmpty && response != "y" && response != "yes" {
print("Aborted.")
return
}
}

try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic)
print("Removed \(removedCount) cmux hook(s).")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Uninstall doesn't revert config.toml

runCodexInstallHooks modifies two files: hooks.json and config.toml (adding codex_hooks = true under [features]). But runCodexUninstallHooks only removes entries from hooks.json — the configPath variable isn't even read here. After an uninstall, config.toml retains the codex_hooks = true entry, leaving the user's config in a partially-reverted state.

The fix would be to also compute the configPath, strip the codex_hooks key (and potentially the [features] section if it was newly created by the install), include the config.toml diff in the confirmation prompt, and write it back after confirmation — symmetric with what runCodexInstallHooks does.

Comment thread CLI/cmux.swift Outdated
Comment on lines +11368 to +11372
} else if result.contains("[features]") {
result = result.replacingOccurrences(
of: "[features]",
with: "[features]\ncodex_hooks = true"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 replacingOccurrences replaces every [features] in the file

String.replacingOccurrences(of:with:) replaces all matches, not just the first one. If a user's config.toml has [features] appearing in a comment (e.g. # see [features] for options), this will inject codex_hooks = true after every such occurrence, producing invalid TOML and corrupting the user's config.

Use a line-by-line scan to replace only the first bare [features] section header (a line whose trimmed content equals exactly [features]), similar to how the codex_hooks replacement is already done:

} else if result.contains("[features]") {
    let lines = result.components(separatedBy: "\n")
    var output: [String] = []
    var inserted = false
    for line in lines {
        output.append(line)
        if !inserted && line.trimmingCharacters(in: .whitespaces) == "[features]" {
            output.append("codex_hooks = true")
            inserted = true
        }
    }
    result = output.joined(separator: "\n")
}

Comment thread CLI/cmux.swift
}

var hooks = existing["hooks"] as? [String: Any] ?? [:]
let cmuxHooks = Self.codexHooksJSON["hooks"] as! [String: Any]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Force cast on static dictionary

Self.codexHooksJSON["hooks"] as! [String: Any] will trap at runtime if codexHooksJSON is ever modified so that the "hooks" key is missing or holds a different type. While the property is private static and currently stable, the force cast is unnecessary — a guard let keeps the same invariant without the crash risk:

Suggested change
let cmuxHooks = Self.codexHooksJSON["hooks"] as! [String: Any]
guard let cmuxHooks = Self.codexHooksJSON["hooks"] as? [String: Any] else { return }

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4209ca6ce2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CLI/cmux.swift
// so help text is available even when cmux is not running.
if command != "__tmux-compat",
command != "claude-teams",
command != "codex",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route codex help flags through subcommand help

Excluding codex from the pre-socket help path means cmux codex --help (and cmux codex install-hooks --help) never reach dispatchSubcommandHelp; instead the command falls through to normal execution, which can attempt a socket connection or even start the install flow. This breaks expected CLI semantics for --help and can trigger side effects when the user only requested usage text.

Useful? React with 👍 / 👎.

Comment thread CLI/cmux.swift Outdated
Comment on lines +11356 to +11362
if result.contains("codex_hooks") {
let lines = result.components(separatedBy: "\n")
var output: [String] = []
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("codex_hooks") && trimmed.contains("=") {
output.append("codex_hooks = true")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse codex_hooks as a key, not any substring

Using result.contains("codex_hooks") as the gate is too broad: if config.toml only mentions that token in a comment or in another key name, this branch runs but no actual codex_hooks = ... line is added, so installation can leave hooks disabled. The same logic can also rewrite similarly-prefixed keys because hasPrefix("codex_hooks") matches more than the exact setting name.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 11195-11260: runCodexUninstallHooks only rewrites hooks.json but
doesn't revert the [features].codex_hooks flag set during install; update
runCodexUninstallHooks to also load the Codex config (codexHome/config.toml),
parse it (preserving formatting/comments if possible), check whether
features.codex_hooks is present/true, toggle it to false if appropriate (i.e.,
when removed cmux hooks count > 0 and no other integrations require it), produce
a simple diff of the old vs new config.toml, prompt the user with the same
skipConfirm logic, and write the updated config.toml atomically only after
confirmation (use the same readLine/skipConfirm flow and error handling used for
hooks.json); refer to runCodexUninstallHooks, Self.codexHookCommandMarker,
hooksPath and add handling for a new configPath variable for config.toml and use
the same diff/confirm/write pattern.
- Around line 11097-11106: The current read/parse logic silently treats an
existing but unreadable/invalid hooks.json as empty (see existingHooksContent,
data, parsed and resulting existing = [:]), which can overwrite user data;
change this to hard-fail: if fileExists(atPath: hooksPath) but reading or
JSONSerialization.jsonObject(with:) fails, return/throw/log an error and abort
the install rather than defaulting to an empty dictionary; apply the identical
pattern to the config.toml read logic (the config content/path variables used in
that block) and to the uninstall code path that currently treats unreadable
hooks.json as "not found" so it similarly reports an error instead of
proceeding.
- Around line 11081-11082: The uninstall logic currently uses string containment
with codexHookCommandMarker which can falsely match user hooks and fails when a
group is mixed; change it to match exact generated commands by creating a Set of
exact codex-owned command strings (use the same codexHookCommand(...) helper to
build them) and replace the current contains/allSatisfy logic with a function
(e.g., removingOwnedCodexHooks) that iterates groups, filters each group's
"hooks" array removing only hooks whose "command" exactly equals one of the
codexOwnedCommands, updates the group's "hooks" entry, and returns nil to drop
the group only when its hooks array becomes empty (leave mixed groups intact).
- Around line 1467-1477: The codex branch currently only handles
install-hooks/uninstall-hooks and then falls through causing the socket to open
for bare/unknown/help invocations and misroutes trailing args; update dispatch
to validate the codex subcommand early by extracting let subcommand =
commandArgs.first?.lowercased() ?? "help" and subArgs =
Array(commandArgs.dropFirst()), then switch on subcommand to: call
runCodexInstallHooks(commandArgs: subArgs) for "install-hooks", call
runCodexUninstallHooks(commandArgs: subArgs) for "uninstall-hooks", print usage
for "help"/"--help"/"-h", and throw a CLIError for unknown subcommands so the
branch returns without opening the socket and helpers receive the correct
subArgs instead of reading ProcessInfo.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 23f1ff08-e1b5-4da0-872e-7c27e7a73ba5

📥 Commits

Reviewing files that changed from the base of the PR and between 960006e and 4209ca6.

📒 Files selected for processing (1)
  • CLI/cmux.swift

Comment thread CLI/cmux.swift
Comment on lines +1467 to +1477
// Codex hooks management (no socket needed)
if command == "codex" {
let sub = commandArgs.first?.lowercased() ?? "help"
if sub == "install-hooks" {
try runCodexInstallHooks()
return
} else if sub == "uninstall-hooks" {
try runCodexUninstallHooks()
return
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate codex subcommands before this branch falls through.

cmux codex, cmux codex --help, and unknown subcommands currently fall past this block, try to open the socket, and then fail as an unknown top-level command. Because the install/uninstall helpers only inspect ProcessInfo.processInfo.arguments for --yes, unexpected trailing args are also ignored while still editing user config. Handle help/unknown subcommands here and pass validated subcommand args into the helpers.

Suggested dispatch shape
-        if command == "codex" {
-            let sub = commandArgs.first?.lowercased() ?? "help"
-            if sub == "install-hooks" {
-                try runCodexInstallHooks()
-                return
-            } else if sub == "uninstall-hooks" {
-                try runCodexUninstallHooks()
-                return
-            }
-        }
+        if command == "codex" {
+            try runCodex(commandArgs: commandArgs)
+            return
+        }
private func runCodex(commandArgs: [String]) throws {
    let subcommand = commandArgs.first?.lowercased() ?? "help"
    let subArgs = Array(commandArgs.dropFirst())

    switch subcommand {
    case "install-hooks":
        try runCodexInstallHooks(commandArgs: subArgs)
    case "uninstall-hooks":
        try runCodexUninstallHooks(commandArgs: subArgs)
    case "help", "--help", "-h":
        print(subcommandUsage("codex") ?? "")
    default:
        throw CLIError(message: "Unknown codex subcommand: \(subcommand)")
    }
}

Also applies to: 11085-11086, 11196-11197

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 1467 - 1477, The codex branch currently only
handles install-hooks/uninstall-hooks and then falls through causing the socket
to open for bare/unknown/help invocations and misroutes trailing args; update
dispatch to validate the codex subcommand early by extracting let subcommand =
commandArgs.first?.lowercased() ?? "help" and subArgs =
Array(commandArgs.dropFirst()), then switch on subcommand to: call
runCodexInstallHooks(commandArgs: subArgs) for "install-hooks", call
runCodexUninstallHooks(commandArgs: subArgs) for "uninstall-hooks", print usage
for "help"/"--help"/"-h", and throw a CLIError for unknown subcommands so the
branch returns without opening the socket and helpers receive the correct
subArgs instead of reading ProcessInfo.

Comment thread CLI/cmux.swift
Comment on lines +11081 to +11082
/// Identifier used to detect cmux-owned hooks during uninstall.
private static let codexHookCommandMarker = "cmux codex-hook"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Owned-hook matching needs to be exact and per hook entry.

The current contains("cmux codex-hook") + allSatisfy logic has two bad cases: a user hook that happens to invoke cmux codex-hook can be treated as cmux-owned, and a mixed group will keep the cmux hook forever. Match the exact commands this installer generates and remove owned hooks individually, then drop the group only when it becomes empty.

Suggested owned-hook filtering
private static let codexOwnedCommands: Set<String> = [
    codexHookCommand("session-start"),
    codexHookCommand("prompt-submit"),
    codexHookCommand("stop"),
]

private func removingOwnedCodexHooks(from groups: [[String: Any]]) -> [[String: Any]] {
    groups.compactMap { group in
        guard var hooks = group["hooks"] as? [[String: Any]] else { return group }
        hooks.removeAll { hook in
            guard let command = hook["command"] as? String else { return false }
            return Self.codexOwnedCommands.contains(command)
        }
        guard !hooks.isEmpty else { return nil }
        var updated = group
        updated["hooks"] = hooks
        return updated
    }
}

Also applies to: 11114-11120, 11220-11224

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 11081 - 11082, The uninstall logic currently
uses string containment with codexHookCommandMarker which can falsely match user
hooks and fails when a group is mixed; change it to match exact generated
commands by creating a Set of exact codex-owned command strings (use the same
codexHookCommand(...) helper to build them) and replace the current
contains/allSatisfy logic with a function (e.g., removingOwnedCodexHooks) that
iterates groups, filters each group's "hooks" array removing only hooks whose
"command" exactly equals one of the codexOwnedCommands, updates the group's
"hooks" entry, and returns nil to drop the group only when its hooks array
becomes empty (leave mixed groups intact).

Comment thread CLI/cmux.swift
Comment on lines +11097 to +11106
let existingHooksContent: String? = fm.fileExists(atPath: hooksPath)
? (try? String(contentsOfFile: hooksPath, encoding: .utf8))
: nil

// Build merged hooks
var existing: [String: Any] = [:]
if let existingHooksContent,
let data = existingHooksContent.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
existing = parsed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not coerce existing Codex files to empty state on read/parse failure.

If hooks.json or config.toml exists but can't be read or parsed, install silently treats it as empty and writes fresh content. That can overwrite a user's existing Codex setup after a malformed JSON file, encoding issue, or partial write. Uninstall should likewise report invalid hooks.json instead of "not found".

Suggested hard-fail on existing unreadable files
-        let existingHooksContent: String? = fm.fileExists(atPath: hooksPath)
-            ? (try? String(contentsOfFile: hooksPath, encoding: .utf8))
-            : nil
+        let existingHooksContent: String?
+        if fm.fileExists(atPath: hooksPath) {
+            existingHooksContent = try String(contentsOfFile: hooksPath, encoding: .utf8)
+        } else {
+            existingHooksContent = nil
+        }
...
-        if let existingHooksContent,
-           let data = existingHooksContent.data(using: .utf8),
-           let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
-            existing = parsed
-        }
+        if let existingHooksContent {
+            guard let data = existingHooksContent.data(using: .utf8),
+                  let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+                throw CLIError(message: "Invalid Codex hooks JSON at \(hooksPath)")
+            }
+            existing = parsed
+        }

Apply the same pattern to config.toml reads and to the uninstall path.

Also applies to: 11128-11130, 11203-11206

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 11097 - 11106, The current read/parse logic
silently treats an existing but unreadable/invalid hooks.json as empty (see
existingHooksContent, data, parsed and resulting existing = [:]), which can
overwrite user data; change this to hard-fail: if fileExists(atPath: hooksPath)
but reading or JSONSerialization.jsonObject(with:) fails, return/throw/log an
error and abort the install rather than defaulting to an empty dictionary; apply
the identical pattern to the config.toml read logic (the config content/path
variables used in that block) and to the uninstall code path that currently
treats unreadable hooks.json as "not found" so it similarly reports an error
instead of proceeding.

Comment thread CLI/cmux.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="CLI/cmux.swift">

<violation number="1" location="CLI/cmux.swift:1469">
P2: `codex` default/help paths fall through to socket dispatch, so `cmux codex` and `cmux codex --help` can fail instead of showing codex usage.</violation>

<violation number="2" location="CLI/cmux.swift:11117">
P2: Using substring ownership detection can delete user-defined hooks that mention `cmux codex-hook`, instead of preserving non-cmux hooks.</violation>

<violation number="3" location="CLI/cmux.swift:11356">
P2: Parse `codex_hooks` as an exact TOML key instead of using a substring gate. The current `result.contains("codex_hooks")` path can trigger on comments or unrelated keys and then skip adding `codex_hooks = true`, leaving hooks disabled.</violation>

<violation number="4" location="CLI/cmux.swift:11361">
P2: The config rewrite matches `codex_hooks*` prefixes, so similarly named TOML keys can be overwritten incorrectly.</violation>

<violation number="5" location="CLI/cmux.swift:11369">
P2: Avoid global `replacingOccurrences` for `[features]`; it can modify every occurrence (including comments) and insert duplicate `codex_hooks` entries. Insert once after the first real `[features]` section header line instead.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift Outdated
Comment thread CLI/cmux.swift
Comment on lines +1469 to +1476
let sub = commandArgs.first?.lowercased() ?? "help"
if sub == "install-hooks" {
try runCodexInstallHooks()
return
} else if sub == "uninstall-hooks" {
try runCodexUninstallHooks()
return
}

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: codex default/help paths fall through to socket dispatch, so cmux codex and cmux codex --help can fail instead of showing codex usage.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 1469:

<comment>`codex` default/help paths fall through to socket dispatch, so `cmux codex` and `cmux codex --help` can fail instead of showing codex usage.</comment>

<file context>
@@ -1463,6 +1464,27 @@ struct CMUXCLI {
 
+        // Codex hooks management (no socket needed)
+        if command == "codex" {
+            let sub = commandArgs.first?.lowercased() ?? "help"
+            if sub == "install-hooks" {
+                try runCodexInstallHooks()
</file context>
Suggested change
let sub = commandArgs.first?.lowercased() ?? "help"
if sub == "install-hooks" {
try runCodexInstallHooks()
return
} else if sub == "uninstall-hooks" {
try runCodexUninstallHooks()
return
}
let sub = commandArgs.first?.lowercased() ?? "help"
switch sub {
case "install-hooks":
try runCodexInstallHooks()
return
case "uninstall-hooks":
try runCodexUninstallHooks()
return
case "help", "--help", "-h":
if let text = subcommandUsage("codex") {
print("cmux codex")
print("")
print(text)
}
return
default:
throw CLIError(message: "Unknown codex subcommand: \(sub)")
}
Fix with Cubic

uninstall-hooks now also removes codex_hooks from config.toml and
shows the diff for both files before asking for confirmation.

buildConfigWithCodexHooks uses exact TOML key matching instead of
substring contains, and inserts after the first [features] header
only (not replacingOccurrences which hit all matches).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="CLI/cmux.swift">

<violation number="1" location="CLI/cmux.swift:11378">
P2: Scope the `codex_hooks` update to the `[features]` section; the current global first-match return can update the wrong table and skip enabling `[features].codex_hooks`.</violation>

<violation number="2" location="CLI/cmux.swift:11403">
P1: Restrict `codex_hooks` deletion to the `[features]` section; current global removal can erase unrelated user config.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread CLI/cmux.swift
var lines = content.components(separatedBy: "\n")

// Remove the codex_hooks line
lines.removeAll { isTomlKey($0, key: "codex_hooks") }

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Restrict codex_hooks deletion to the [features] section; current global removal can erase unrelated user config.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 11403:

<comment>Restrict `codex_hooks` deletion to the `[features]` section; current global removal can erase unrelated user config.</comment>

<file context>
@@ -11351,33 +11372,58 @@ struct CMUXCLI {
+        var lines = content.components(separatedBy: "\n")
+
+        // Remove the codex_hooks line
+        lines.removeAll { isTomlKey($0, key: "codex_hooks") }
+
+        // If [features] section is now empty (only has the header, nothing before next section or EOF),
</file context>
Suggested change
lines.removeAll { isTomlKey($0, key: "codex_hooks") }
if let featuresIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
let sectionEnd = lines[(featuresIdx + 1)...].firstIndex(where: {
$0.trimmingCharacters(in: .whitespaces).hasPrefix("[")
}) ?? lines.count
for i in stride(from: sectionEnd - 1, through: featuresIdx + 1, by: -1) {
if isTomlKey(lines[i], key: "codex_hooks") {
lines.remove(at: i)
}
}
}
Fix with Cubic

Comment thread CLI/cmux.swift
Comment on lines +11378 to +11381
if let idx = lines.firstIndex(where: { isTomlKey($0, key: "codex_hooks") }) {
lines[idx] = "codex_hooks = true"
return lines.joined(separator: "\n")
}

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Scope the codex_hooks update to the [features] section; the current global first-match return can update the wrong table and skip enabling [features].codex_hooks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 11378:

<comment>Scope the `codex_hooks` update to the `[features]` section; the current global first-match return can update the wrong table and skip enabling `[features].codex_hooks`.</comment>

<file context>
@@ -11351,33 +11372,58 @@ struct CMUXCLI {
+        var lines = content.components(separatedBy: "\n")
+
+        // Check if codex_hooks key already exists (exact key match at line start)
+        if let idx = lines.firstIndex(where: { isTomlKey($0, key: "codex_hooks") }) {
+            lines[idx] = "codex_hooks = true"
+            return lines.joined(separator: "\n")
</file context>
Suggested change
if let idx = lines.firstIndex(where: { isTomlKey($0, key: "codex_hooks") }) {
lines[idx] = "codex_hooks = true"
return lines.joined(separator: "\n")
}
if let featuresIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
let sectionEnd = lines[(featuresIdx + 1)...].firstIndex(where: {
$0.trimmingCharacters(in: .whitespaces).hasPrefix("[")
}) ?? lines.count
if let existingIdx = (featuresIdx + 1..<sectionEnd).first(where: { isTomlKey(lines[$0], key: "codex_hooks") }) {
lines[existingIdx] = "codex_hooks = true"
return lines.joined(separator: "\n")
}
}
Fix with Cubic

@lawrencecchen
lawrencecchen merged commit 09e3144 into main Mar 25, 2026
14 checks passed
@lawrencecchen
lawrencecchen deleted the feat-codex-hooks branch March 25, 2026 05:27
Jesssullivan added a commit to Jesssullivan/cmux that referenced this pull request Mar 26, 2026
Ingests all upstream fixes since 2026-03-22 including:
- Fix Cmd+N crash: retain snapshot workspaces (manaflow-ai#2183, manaflow-ai#2181, manaflow-ai#2178, manaflow-ai#2173)
- Fix browser pane restore after reopen (manaflow-ai#2141)
- Fix Ghostty resize_split keybind (manaflow-ai#1899)
- Reduce shell integration prompt latency (manaflow-ai#2109)
- Fix command palette focus after terminal find (manaflow-ai#2089)
- Add Codex CLI hooks (manaflow-ai#2103)
- Add cmux.json custom commands (manaflow-ai#2011)
- Fix window position restore on relaunch (manaflow-ai#2129)

Conflict resolution:
- BrowserPanel.swift: accepted upstream configureWebViewConfiguration()
  refactor (already includes our forMainFrameOnly:true CAPTCHA fix from PR manaflow-ai#1877)

Fork-specific files preserved:
- Sources/Panels/WebAuthn{Coordinator,BridgeJavaScript}.swift
- Sources/FIDO2/module.modulemap
- vendor/ctap2 submodule
- cmux.entitlements (with camera/audio-input removed)
- cmux.embedded.entitlements
- .github/workflows/fork-{ci,release}.yml
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
* Add Codex CLI hooks integration

Adds `cmux codex install-hooks` to install lifecycle hooks into
~/.codex/hooks.json and enable the codex_hooks feature flag. The hooks
call `cmux codex-hook <event>` which gracefully no-ops (exit 0, prints
{}) when not running inside cmux, so they're safe to leave installed
globally.

Supported events: SessionStart (session tracking), UserPromptSubmit
(set Running status), Stop (completion notification + Idle status).

Install merges with existing user hooks and is idempotent. Uninstall
(`cmux codex uninstall-hooks`) removes only cmux-owned hooks,
identified by the `cmux codex-hook` command prefix.

* Show diff and ask for confirmation before modifying user config

install-hooks and uninstall-hooks now preview changes to hooks.json and
config.toml before applying, with a [Y/n] prompt. Pass --yes/-y to
skip confirmation.

Hook commands use `command -v cmux` guard so they silently no-op
(echo '{}') when cmux CLI is not on PATH (e.g. user runs codex in a
non-cmux terminal or after uninstalling cmux).

* Improve diff output with line numbers and context

install-hooks and uninstall-hooks now show unified-diff-style output
with line numbers and surrounding context lines, making it easier to
see exactly what will change in hooks.json and config.toml.

* Check CMUX_SURFACE_ID in shell guard before calling cmux

The hook shell command now checks [ -n "$CMUX_SURFACE_ID" ] first,
so it short-circuits to echo '{}' without ever invoking cmux when
not inside a cmux terminal. Prevents usage text and socket errors
from leaking into Codex hook output.

* Uninstall reverts config.toml; fix [features] section handling

uninstall-hooks now also removes codex_hooks from config.toml and
shows the diff for both files before asking for confirmation.

buildConfigWithCodexHooks uses exact TOML key matching instead of
substring contains, and inserts after the first [features] header
only (not replacingOccurrences which hit all matches).

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — 811e806d Deployed Mar 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant