diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a0120002..6585353d 100644 --- a/CLI/cmux.swift +++ b/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 + + 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 [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 Target workspace (default: $CMUX_WORKSPACE_ID) + --surface Target surface (default: $CMUX_SURFACE_ID) + """ case "browser": return """ Usage: cmux browser [--surface | ] [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 ` 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() + 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 [--workspace ] [--surface ]") + + 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 --body [--image ...]] themes [list|set|clear] claude-teams [claude-args...] + codex ping version capabilities