Add Codex CLI hooks integration (#2103)
* 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 commit is contained in:
parent
03355ca3fc
commit
09e31448c9
1 changed files with 606 additions and 0 deletions
606
CLI/cmux.swift
606
CLI/cmux.swift
|
|
@ -1413,6 +1413,7 @@ struct CMUXCLI {
|
|||
// so help text is available even when cmux is not running.
|
||||
if command != "__tmux-compat",
|
||||
command != "claude-teams",
|
||||
command != "codex",
|
||||
(commandArgs.contains("--help") || commandArgs.contains("-h")) {
|
||||
if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {
|
||||
return
|
||||
|
|
@ -1463,6 +1464,27 @@ struct CMUXCLI {
|
|||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Codex hook handler: gracefully no-op when not inside cmux
|
||||
// (before socket connection, so it doesn't fail when no socket exists)
|
||||
if command == "codex-hook" {
|
||||
guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] != nil else {
|
||||
print("{}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let client = SocketClient(path: resolvedSocketPath)
|
||||
if resolvedSocketPath != socketPath {
|
||||
cliTelemetry.breadcrumb(
|
||||
|
|
@ -2245,6 +2267,17 @@ struct CMUXCLI {
|
|||
throw error
|
||||
}
|
||||
|
||||
case "codex-hook":
|
||||
cliTelemetry.breadcrumb("codex-hook.dispatch")
|
||||
do {
|
||||
try runCodexHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry)
|
||||
cliTelemetry.breadcrumb("codex-hook.completed")
|
||||
} catch {
|
||||
cliTelemetry.breadcrumb("codex-hook.failure")
|
||||
cliTelemetry.captureError(stage: "codex_hook_dispatch", error: error)
|
||||
throw error
|
||||
}
|
||||
|
||||
case "set-app-focus":
|
||||
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
|
||||
let response = try sendV1Command("set_app_focus \(value)", client: client)
|
||||
|
|
@ -7047,6 +7080,32 @@ struct CMUXCLI {
|
|||
echo '{"session_id":"abc"}' | cmux claude-hook session-start
|
||||
echo '{}' | cmux claude-hook stop
|
||||
"""
|
||||
case "codex":
|
||||
return """
|
||||
Usage: cmux codex <install-hooks|uninstall-hooks>
|
||||
|
||||
Manage Codex CLI hooks integration.
|
||||
|
||||
Subcommands:
|
||||
install-hooks Install cmux hooks into ~/.codex/hooks.json
|
||||
uninstall-hooks Remove cmux hooks from ~/.codex/hooks.json
|
||||
"""
|
||||
case "codex-hook":
|
||||
return """
|
||||
Usage: cmux codex-hook <session-start|prompt-submit|stop> [flags]
|
||||
|
||||
Hook for Codex CLI integration. Reads JSON from stdin.
|
||||
Gracefully no-ops when not running inside cmux.
|
||||
|
||||
Subcommands:
|
||||
session-start Register a Codex session
|
||||
prompt-submit Set Running status on user prompt
|
||||
stop Send completion notification, set Idle
|
||||
|
||||
Flags:
|
||||
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
|
||||
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
|
||||
"""
|
||||
case "browser":
|
||||
return """
|
||||
Usage: cmux browser [--surface <id|ref|index> | <surface>] <subcommand> [args]
|
||||
|
|
@ -10982,6 +11041,552 @@ struct CMUXCLI {
|
|||
.replacingOccurrences(of: "|", with: "¦")
|
||||
}
|
||||
|
||||
// MARK: - Codex hooks
|
||||
|
||||
/// The hooks.json content that cmux installs into ~/.codex/.
|
||||
/// Each hook calls `cmux codex-hook <event>` which gracefully no-ops
|
||||
/// when not running inside cmux. The command checks for cmux on PATH
|
||||
/// first so it silently succeeds even when cmux is not installed
|
||||
/// (e.g. user opened codex in a non-cmux terminal).
|
||||
private static func codexHookCommand(_ event: String) -> String {
|
||||
"[ -n \"$CMUX_SURFACE_ID\" ] && command -v cmux >/dev/null 2>&1 && cmux codex-hook \(event) || echo '{}'"
|
||||
}
|
||||
|
||||
private static let codexHooksJSON: [String: Any] = [
|
||||
"hooks": [
|
||||
"SessionStart": [[
|
||||
"hooks": [[
|
||||
"type": "command",
|
||||
"command": codexHookCommand("session-start"),
|
||||
"timeout": 10
|
||||
] as [String: Any]]
|
||||
] as [String: Any]],
|
||||
"UserPromptSubmit": [[
|
||||
"hooks": [[
|
||||
"type": "command",
|
||||
"command": codexHookCommand("prompt-submit"),
|
||||
"timeout": 10
|
||||
] as [String: Any]]
|
||||
] as [String: Any]],
|
||||
"Stop": [[
|
||||
"hooks": [[
|
||||
"type": "command",
|
||||
"command": codexHookCommand("stop"),
|
||||
"timeout": 10
|
||||
] as [String: Any]]
|
||||
] as [String: Any]]
|
||||
] as [String: Any]
|
||||
]
|
||||
|
||||
/// Identifier used to detect cmux-owned hooks during uninstall.
|
||||
private static let codexHookCommandMarker = "cmux codex-hook"
|
||||
|
||||
private func runCodexInstallHooks() 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 configPath = (codexHome as NSString).appendingPathComponent("config.toml")
|
||||
let fm = FileManager.default
|
||||
|
||||
// Ensure ~/.codex/ exists
|
||||
try fm.createDirectory(atPath: codexHome, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
// Read existing state
|
||||
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
|
||||
}
|
||||
|
||||
var hooks = existing["hooks"] as? [String: Any] ?? [:]
|
||||
let cmuxHooks = Self.codexHooksJSON["hooks"] as! [String: Any]
|
||||
for (eventName, cmuxGroups) in cmuxHooks {
|
||||
guard let cmuxGroupArray = cmuxGroups as? [[String: Any]] else { continue }
|
||||
var eventGroups = hooks[eventName] as? [[String: Any]] ?? []
|
||||
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
|
||||
}
|
||||
}
|
||||
eventGroups.append(contentsOf: cmuxGroupArray)
|
||||
hooks[eventName] = eventGroups
|
||||
}
|
||||
existing["hooks"] = hooks
|
||||
let newJsonData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys])
|
||||
let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? ""
|
||||
|
||||
// Build new config.toml content
|
||||
let existingConfigContent: String = fm.fileExists(atPath: configPath)
|
||||
? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "")
|
||||
: ""
|
||||
let newConfigContent = buildConfigWithCodexHooks(existingConfigContent)
|
||||
|
||||
// Check if anything would change
|
||||
let hooksChanged = existingHooksContent != newHooksContent
|
||||
let configChanged = existingConfigContent != newConfigContent
|
||||
|
||||
if !hooksChanged && !configChanged {
|
||||
print("cmux hooks are already installed. Nothing to change.")
|
||||
return
|
||||
}
|
||||
|
||||
// Show diff and ask for confirmation
|
||||
if hooksChanged {
|
||||
print(" \(hooksPath):")
|
||||
if let existingHooksContent {
|
||||
printSimpleDiff(old: existingHooksContent, new: newHooksContent)
|
||||
} else {
|
||||
print(" (new file)")
|
||||
let lines = newHooksContent.components(separatedBy: "\n")
|
||||
for (i, line) in lines.enumerated() {
|
||||
let lineLabel = String(format: "%3d", i + 1)
|
||||
print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m")
|
||||
}
|
||||
}
|
||||
print("")
|
||||
}
|
||||
|
||||
if configChanged {
|
||||
print(" \(configPath):")
|
||||
if existingConfigContent.isEmpty {
|
||||
print(" (new file)")
|
||||
let lines = newConfigContent.components(separatedBy: "\n")
|
||||
for (i, line) in lines.enumerated() where !line.isEmpty {
|
||||
let lineLabel = String(format: "%3d", i + 1)
|
||||
print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m")
|
||||
}
|
||||
} else {
|
||||
printSimpleDiff(old: existingConfigContent, new: newConfigContent)
|
||||
}
|
||||
print("")
|
||||
}
|
||||
|
||||
if !skipConfirm {
|
||||
print("Apply these changes? [Y/n] ", terminator: "")
|
||||
if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
||||
!response.isEmpty && response != "y" && response != "yes" {
|
||||
print("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
if hooksChanged {
|
||||
try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic)
|
||||
}
|
||||
if configChanged {
|
||||
try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
print("")
|
||||
print("Installed. Hooks activate inside cmux and silently no-op elsewhere.")
|
||||
print("To remove: cmux codex uninstall-hooks")
|
||||
}
|
||||
|
||||
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 configPath = (codexHome as NSString).appendingPathComponent("config.toml")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Build config.toml without codex_hooks
|
||||
let existingConfigContent: String = fm.fileExists(atPath: configPath)
|
||||
? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "")
|
||||
: ""
|
||||
let newConfigContent = buildConfigWithoutCodexHooks(existingConfigContent)
|
||||
let configChanged = existingConfigContent != newConfigContent
|
||||
|
||||
if removedCount == 0 && !configChanged {
|
||||
print("No cmux hooks found.")
|
||||
return
|
||||
}
|
||||
|
||||
parsed["hooks"] = hooks
|
||||
let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys])
|
||||
let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? ""
|
||||
let oldHooksContent = String(data: data, encoding: .utf8) ?? ""
|
||||
|
||||
// Show diff and ask for confirmation
|
||||
if removedCount > 0 {
|
||||
print(" \(hooksPath):")
|
||||
printSimpleDiff(old: oldHooksContent, new: newHooksContent)
|
||||
print("")
|
||||
}
|
||||
|
||||
if configChanged {
|
||||
print(" \(configPath):")
|
||||
printSimpleDiff(old: existingConfigContent, new: newConfigContent)
|
||||
print("")
|
||||
}
|
||||
|
||||
if !skipConfirm {
|
||||
print("Apply these changes? [Y/n] ", terminator: "")
|
||||
if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
||||
!response.isEmpty && response != "y" && response != "yes" {
|
||||
print("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if removedCount > 0 {
|
||||
try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic)
|
||||
}
|
||||
if configChanged {
|
||||
try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8)
|
||||
}
|
||||
print("Removed cmux Codex hooks.")
|
||||
}
|
||||
|
||||
/// Print a unified-diff-style view with context lines and line numbers.
|
||||
private func printSimpleDiff(old: String, new: String, contextLines: Int = 2) {
|
||||
let red = "\u{001B}[31m"
|
||||
let green = "\u{001B}[32m"
|
||||
let dim = "\u{001B}[2m"
|
||||
let reset = "\u{001B}[0m"
|
||||
|
||||
let oldLines = old.components(separatedBy: "\n")
|
||||
let newLines = new.components(separatedBy: "\n")
|
||||
|
||||
// Simple LCS-based diff: find matching lines
|
||||
let lcs = longestCommonSubsequence(oldLines, newLines)
|
||||
var oldIdx = 0, newIdx = 0, lcsIdx = 0
|
||||
|
||||
struct DiffLine {
|
||||
enum Kind { case context, remove, add }
|
||||
let kind: Kind
|
||||
let lineNo: Int // 1-based, refers to old line for context/remove, new line for add
|
||||
let text: String
|
||||
}
|
||||
var allDiffs: [DiffLine] = []
|
||||
|
||||
while oldIdx < oldLines.count || newIdx < newLines.count {
|
||||
if lcsIdx < lcs.count && oldIdx < oldLines.count && newIdx < newLines.count
|
||||
&& oldLines[oldIdx] == lcs[lcsIdx] && newLines[newIdx] == lcs[lcsIdx] {
|
||||
allDiffs.append(DiffLine(kind: .context, lineNo: newIdx + 1, text: newLines[newIdx]))
|
||||
oldIdx += 1; newIdx += 1; lcsIdx += 1
|
||||
} else if oldIdx < oldLines.count && (lcsIdx >= lcs.count || oldLines[oldIdx] != lcs[lcsIdx]) {
|
||||
allDiffs.append(DiffLine(kind: .remove, lineNo: oldIdx + 1, text: oldLines[oldIdx]))
|
||||
oldIdx += 1
|
||||
} else if newIdx < newLines.count {
|
||||
allDiffs.append(DiffLine(kind: .add, lineNo: newIdx + 1, text: newLines[newIdx]))
|
||||
newIdx += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Find ranges with changes and expand by context
|
||||
var changedIndices = Set<Int>()
|
||||
for (i, d) in allDiffs.enumerated() where d.kind != .context {
|
||||
for j in max(0, i - contextLines)...min(allDiffs.count - 1, i + contextLines) {
|
||||
changedIndices.insert(j)
|
||||
}
|
||||
}
|
||||
|
||||
var lastPrinted = -1
|
||||
for i in changedIndices.sorted() {
|
||||
if lastPrinted >= 0 && i > lastPrinted + 1 {
|
||||
print(" \(dim)...\(reset)")
|
||||
}
|
||||
let d = allDiffs[i]
|
||||
let lineLabel = String(format: "%3d", d.lineNo)
|
||||
switch d.kind {
|
||||
case .context:
|
||||
print(" \(dim)\(lineLabel) \(d.text)\(reset)")
|
||||
case .remove:
|
||||
print(" \(red)\(lineLabel) -\(d.text)\(reset)")
|
||||
case .add:
|
||||
print(" \(green)\(lineLabel) +\(d.text)\(reset)")
|
||||
}
|
||||
lastPrinted = i
|
||||
}
|
||||
}
|
||||
|
||||
private func longestCommonSubsequence(_ a: [String], _ b: [String]) -> [String] {
|
||||
let m = a.count, n = b.count
|
||||
var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1)
|
||||
for i in 1...m {
|
||||
for j in 1...n {
|
||||
if a[i - 1] == b[j - 1] {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||
} else {
|
||||
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
var result: [String] = []
|
||||
var i = m, j = n
|
||||
while i > 0 && j > 0 {
|
||||
if a[i - 1] == b[j - 1] {
|
||||
result.append(a[i - 1])
|
||||
i -= 1; j -= 1
|
||||
} else if dp[i - 1][j] > dp[i][j - 1] {
|
||||
i -= 1
|
||||
} else {
|
||||
j -= 1
|
||||
}
|
||||
}
|
||||
return result.reversed()
|
||||
}
|
||||
|
||||
/// Returns config.toml content with codex_hooks = true under [features].
|
||||
private func buildConfigWithCodexHooks(_ content: String) -> String {
|
||||
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")
|
||||
}
|
||||
|
||||
// Find [features] section and insert after it (first occurrence only)
|
||||
if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
|
||||
lines.insert("codex_hooks = true", at: idx + 1)
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// No [features] section, append one
|
||||
var result = content
|
||||
if !result.isEmpty && !result.hasSuffix("\n") {
|
||||
result += "\n"
|
||||
}
|
||||
result += "\n[features]\ncodex_hooks = true\n"
|
||||
return result
|
||||
}
|
||||
|
||||
/// Returns config.toml content with codex_hooks removed from [features].
|
||||
private func buildConfigWithoutCodexHooks(_ content: String) -> String {
|
||||
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),
|
||||
// remove the header too
|
||||
if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
|
||||
let nextNonEmpty = lines[(idx + 1)...].firstIndex(where: {
|
||||
!$0.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
})
|
||||
let sectionEmpty = nextNonEmpty == nil || lines[nextNonEmpty!].trimmingCharacters(in: .whitespaces).hasPrefix("[")
|
||||
if sectionEmpty {
|
||||
lines.remove(at: idx)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Check if a TOML line sets a specific key (ignoring comments and whitespace).
|
||||
private func isTomlKey(_ line: String, key: String) -> Bool {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.hasPrefix("#") else { return false }
|
||||
guard trimmed.hasPrefix(key) else { return false }
|
||||
let rest = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces)
|
||||
return rest.hasPrefix("=")
|
||||
}
|
||||
|
||||
/// Codex hook handler. Gracefully no-ops when not running inside cmux.
|
||||
private func runCodexHook(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
telemetry: CLISocketSentryTelemetry
|
||||
) throws {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
|
||||
// Graceful no-op: if not inside cmux, exit silently with valid JSON
|
||||
guard env["CMUX_SURFACE_ID"] != nil else {
|
||||
print("{}")
|
||||
return
|
||||
}
|
||||
|
||||
let subcommand = commandArgs.first?.lowercased() ?? "help"
|
||||
let hookArgs = Array(commandArgs.dropFirst())
|
||||
let hookWsFlag = optionValue(hookArgs, name: "--workspace")
|
||||
let workspaceArg = hookWsFlag ?? env["CMUX_WORKSPACE_ID"]
|
||||
let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? env["CMUX_SURFACE_ID"] : nil)
|
||||
let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let parsedInput = parseClaudeHookInput(rawInput: rawInput)
|
||||
let sessionStore = ClaudeHookSessionStore(
|
||||
processEnv: env.merging(
|
||||
["CMUX_CLAUDE_HOOK_STATE_PATH": "~/.cmuxterm/codex-hook-sessions.json"],
|
||||
uniquingKeysWith: { _, new in new }
|
||||
)
|
||||
)
|
||||
telemetry.breadcrumb(
|
||||
"codex-hook.input",
|
||||
data: [
|
||||
"subcommand": subcommand,
|
||||
"has_session_id": parsedInput.sessionId != nil
|
||||
]
|
||||
)
|
||||
|
||||
switch subcommand {
|
||||
case "session-start":
|
||||
telemetry.breadcrumb("codex-hook.session-start")
|
||||
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
|
||||
preferred: nil,
|
||||
fallback: workspaceArg,
|
||||
client: client
|
||||
)
|
||||
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
|
||||
preferred: nil,
|
||||
fallback: surfaceArg,
|
||||
workspaceId: workspaceId,
|
||||
client: client
|
||||
)
|
||||
if let sessionId = parsedInput.sessionId {
|
||||
try? sessionStore.upsert(
|
||||
sessionId: sessionId,
|
||||
workspaceId: workspaceId,
|
||||
surfaceId: surfaceId,
|
||||
cwd: parsedInput.cwd
|
||||
)
|
||||
}
|
||||
print("{}")
|
||||
|
||||
case "prompt-submit":
|
||||
telemetry.breadcrumb("codex-hook.prompt-submit")
|
||||
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
|
||||
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
|
||||
preferred: mappedSession?.workspaceId,
|
||||
fallback: workspaceArg,
|
||||
client: client
|
||||
)
|
||||
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
|
||||
try setCodexStatus(
|
||||
client: client,
|
||||
workspaceId: workspaceId,
|
||||
value: "Running",
|
||||
icon: "bolt.fill",
|
||||
color: "#4C8DFF"
|
||||
)
|
||||
print("{}")
|
||||
|
||||
case "stop":
|
||||
telemetry.breadcrumb("codex-hook.stop")
|
||||
do {
|
||||
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
|
||||
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
|
||||
preferred: mappedSession?.workspaceId,
|
||||
fallback: workspaceArg,
|
||||
client: client
|
||||
)
|
||||
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
|
||||
preferred: mappedSession?.surfaceId,
|
||||
fallback: surfaceArg,
|
||||
workspaceId: workspaceId,
|
||||
client: client
|
||||
)
|
||||
|
||||
// Build completion notification from Codex stop payload
|
||||
let lastMessage = parsedInput.object?["last_assistant_message"] as? String
|
||||
?? parsedInput.object?["lastAssistantMessage"] as? String
|
||||
let cwd = parsedInput.cwd ?? mappedSession?.cwd
|
||||
let projectName: String? = {
|
||||
guard let cwd, !cwd.isEmpty else { return nil }
|
||||
return URL(fileURLWithPath: NSString(string: cwd).expandingTildeInPath).lastPathComponent
|
||||
}()
|
||||
|
||||
if let sessionId = parsedInput.sessionId {
|
||||
try? sessionStore.upsert(
|
||||
sessionId: sessionId,
|
||||
workspaceId: workspaceId,
|
||||
surfaceId: surfaceId,
|
||||
cwd: cwd,
|
||||
lastSubtitle: "Completed",
|
||||
lastBody: lastMessage.map { truncate($0, maxLength: 200) }
|
||||
)
|
||||
}
|
||||
|
||||
// Send completion notification
|
||||
var subtitle = "Completed"
|
||||
if let projectName, !projectName.isEmpty {
|
||||
subtitle = "Completed in \(projectName)"
|
||||
}
|
||||
let body = sanitizeNotificationField(
|
||||
lastMessage.map { truncate(normalizedSingleLine($0), maxLength: 200) }
|
||||
?? "Codex session completed"
|
||||
)
|
||||
let payload = "Codex|\(sanitizeNotificationField(subtitle))|\(body)"
|
||||
_ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
|
||||
|
||||
try? setCodexStatus(
|
||||
client: client,
|
||||
workspaceId: workspaceId,
|
||||
value: "Idle",
|
||||
icon: "pause.circle.fill",
|
||||
color: "#8E8E93"
|
||||
)
|
||||
print("{}")
|
||||
} catch {
|
||||
if shouldIgnoreClaudeHookTeardownError(error) {
|
||||
telemetry.breadcrumb("codex-hook.stop.ignored", data: ["error": String(describing: error)])
|
||||
print("{}")
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
case "help", "--help", "-h":
|
||||
print("cmux codex-hook <session-start|prompt-submit|stop> [--workspace <id>] [--surface <id>]")
|
||||
|
||||
default:
|
||||
throw CLIError(message: "Unknown codex-hook subcommand: \(subcommand)")
|
||||
}
|
||||
}
|
||||
|
||||
private func setCodexStatus(
|
||||
client: SocketClient,
|
||||
workspaceId: String,
|
||||
value: String,
|
||||
icon: String,
|
||||
color: String
|
||||
) throws {
|
||||
let cmd = "set_status codex \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
|
||||
_ = try client.send(command: cmd)
|
||||
}
|
||||
|
||||
private func versionSummary() -> String {
|
||||
let info = resolvedVersionInfo()
|
||||
let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }
|
||||
|
|
@ -11364,6 +11969,7 @@ struct CMUXCLI {
|
|||
feedback [--email <email> --body <text> [--image <path> ...]]
|
||||
themes [list|set|clear]
|
||||
claude-teams [claude-args...]
|
||||
codex <install-hooks|uninstall-hooks>
|
||||
ping
|
||||
version
|
||||
capabilities
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue