diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index ec20e9f28a52..c1e0c61482c9 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -1,4 +1,62 @@ +import Foundation + extension CMUXCLI { + /// The per-invocation Codex hook events the wrapper injects, paired with the + /// cmux subcommand they call and the codex hook timeout (ms). Lifecycle + /// events are short; feed events (`PreToolUse`/`PermissionRequest`) are long + /// because the user may take time to approve. This is the single source of + /// truth for `cmux-codex-wrapper`'s injection, mirrored from the historic + /// hand-rolled `cmux_codex_add_hook` calls in the wrapper. + static let codexWrapperInjectionEvents: [(agentEvent: String, cmuxSubcommand: String, timeoutMs: Int)] = [ + ("SessionStart", "session-start", 10000), + ("UserPromptSubmit", "prompt-submit", 10000), + ("Stop", "stop", 10000), + ("PreToolUse", "pre-tool-use", 120000), + ("PostToolUse", "post-tool-use", 10000), + ("PermissionRequest", "notification", 120000), + ] + + /// Emit, NUL-separated to stdout, the exact codex arg list the wrapper must + /// splice ahead of the user's args to enable + inject cmux's fire-and-forget + /// hooks for one codex invocation. Returns the arg list: + /// --enable\0hooks\0--dangerously-bypass-hook-trust\0 + /// -c\0hooks.SessionStart=[{hooks=[{type="command",command='''''',timeout=10000}]}]\0 + /// -c\0hooks.UserPromptSubmit=...\0 ... (one `-c` pair per event) + /// where `` is `codexFireAndForgetAgentHookShellCommand(...)` so each + /// hook returns `{}` to codex instantly and backgrounds the real cmux call. + /// Requires no live socket: pure string construction from the agent def. + func emitCodexWrapperInjectArgs() throws { + guard let codexDef = Self.agentDef(named: "codex") else { + throw CLIError(message: "Codex hook integration is unavailable.") + } + var args: [String] = ["--enable", "hooks", "--dangerously-bypass-hook-trust"] + for event in Self.codexWrapperInjectionEvents { + let ff = Self.codexFireAndForgetAgentHookShellCommand( + "cmux hooks codex \(event.cmuxSubcommand)", for: codexDef + ) + // TOML multi-line literal string ('''...''') preserves bytes verbatim + // and may contain single quotes, so the embedded `echo '{}'` / `sh -c + // '...'` survive with no escaping. TOML forbids only a literal triple + // single quote inside; guard against it (the command never has one). + guard !ff.contains("'''") else { + throw CLIError(message: "Codex fire-and-forget hook command contains a triple single quote and cannot be TOML-encoded.") + } + let toml = "hooks.\(event.agentEvent)=[{hooks=[{type=\"command\",command='''\(ff)''',timeout=\(event.timeoutMs)}]}]" + args.append("-c") + args.append(toml) + } + // NUL-TERMINATE each arg (trailing NUL after the last too) so a bash + // `while IFS= read -r -d '' arg` loop captures every element including + // the final one — a separator-only stream drops the unterminated last + // arg at EOF. + var out = Data() + for arg in args { + out.append(Data(arg.utf8)) + out.append(0) + } + FileHandle.standardOutput.write(out) + } + static func codexFireAndForgetAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( sleep 30; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\"" diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fc8ecb9c0ef9..74c7efe022e5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -29587,7 +29587,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { client: client ) } - func sendAgentFeedTelemetry(workspaceId: String? = nil) { + func sendAgentFeedTelemetry(workspaceId: String? = nil, surfaceId: String? = nil) { didSendFeedTelemetry = true sendFeedTelemetry( client: client, @@ -29595,6 +29595,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { subcommand: subcommand, parsedInput: input, workspaceId: workspaceId ?? workspaceArg(), + surfaceId: surfaceId, socketPassword: socketPassword ) } @@ -29610,11 +29611,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } return def.feedHookEvents.contains(event) } - func sendAgentFeedTelemetryUnlessSuppressed(workspaceId: String? = nil) { + func sendAgentFeedTelemetryUnlessSuppressed(workspaceId: String? = nil, surfaceId: String? = nil) { if shouldSuppressGenericFeedTelemetry() { didSendFeedTelemetry = true } else { - sendAgentFeedTelemetry(workspaceId: workspaceId) + sendAgentFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) } } func notificationDedupeFingerprint(status: AgentHookNotificationStatus?) -> String? { @@ -29820,7 +29821,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { print("{}") return } - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) if !suppressVisibleMutations { if codexSessionStartWentStaleAfterAccept() { telemetry.breadcrumb("\(def.name)-hook.session-start.stale-after-turn") @@ -30083,7 +30084,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { stopStaleCodexPromptSubmit() return } - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) if !sessionId.isEmpty, !suppressVisibleMutations { let acceptedRunningUpdate: Bool if def.name == "codex" { @@ -30231,7 +30232,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let workspaceId = target.workspaceId let surfaceId = target.surfaceId - sendAgentFeedTelemetry(workspaceId: workspaceId) + sendAgentFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) let pid = mapped?.pid ?? inferredPID let codexFailure: CodexHookFailureSummary? let codexSubagentSignals: CodexTranscriptSubagentSignals @@ -30551,7 +30552,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let workspaceId = target.workspaceId let surfaceId = target.surfaceId - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) let pid = mapped?.pid ?? inferredPID let launchCommand = agentLaunchCommandFromEnvironment( env, @@ -30692,7 +30693,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { env: env ) #endif - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) print("{}") return } @@ -30707,7 +30708,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { env: env ) #endif - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) print("{}") return } @@ -30854,7 +30855,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case nil: break } - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) case .sessionEnd: if def.name == "codex", !sessionId.isEmpty { @@ -30862,7 +30863,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } if def.sessionEndIsTurnBoundary { if let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) { - sendAgentFeedTelemetry(workspaceId: mapped.workspaceId) + sendAgentFeedTelemetry(workspaceId: mapped.workspaceId, surfaceId: mapped.surfaceId) _ = try? store.recordPromptStop( sessionId: sessionId, workspaceId: mapped.workspaceId, @@ -30932,6 +30933,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { subcommand: String, parsedInput: ClaudeHookParsedInput, workspaceId: String? = nil, + surfaceId: String? = nil, socketPassword: String? = nil ) { let hookEventName = Self.feedEventName(forClaudeSubcommand: subcommand) @@ -30955,6 +30957,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if let workspaceId = feedWorkspaceId(rawObject: parsedInput.object, fallback: workspaceId) { event["workspace_id"] = workspaceId } + if let surfaceId, !surfaceId.isEmpty { + event["surface_id"] = surfaceId + } + if let transcriptPath = parsedInput.transcriptPath, !transcriptPath.isEmpty { + event["transcript_path"] = transcriptPath + } if let cwd = parsedInput.cwd { event["cwd"] = cwd } let toolName = parsedInput.object?["tool_name"] as? String if let toolName, !toolName.isEmpty { @@ -33538,6 +33546,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let actionArgs = Array(rest.dropFirst()) switch action { + case "inject-args" where def.name == "codex": + // Hidden: emit the NUL-separated codex arg list the wrapper + // (Resources/bin/cmux-codex-wrapper) splices to inject cmux's + // fire-and-forget hooks for one invocation. No socket required. + try emitCodexWrapperInjectArgs() + return true case "install": try installHooksForAgent(def, arguments: actionArgs) return true diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift index b3c1ee31cd79..dbdae3b388a8 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift @@ -12,6 +12,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { public let hookEventName: HookEventName public let source: String public let workspaceId: String? + public let surfaceId: String? + public let transcriptPath: String? public let cwd: String? public let toolName: String? public let toolInputJSON: String? @@ -26,6 +28,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { hookEventName: HookEventName, source: String, workspaceId: String? = nil, + surfaceId: String? = nil, + transcriptPath: String? = nil, cwd: String? = nil, toolName: String? = nil, toolInputJSON: String? = nil, @@ -39,6 +43,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { self.hookEventName = hookEventName self.source = source self.workspaceId = workspaceId + self.surfaceId = surfaceId + self.transcriptPath = transcriptPath self.cwd = cwd self.toolName = toolName self.toolInputJSON = toolInputJSON @@ -72,6 +78,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { case hookEventName = "hook_event_name" case source = "_source" case workspaceId = "workspace_id" + case surfaceId = "surface_id" + case transcriptPath = "transcript_path" case cwd case toolName = "tool_name" case toolInputJSON = "tool_input" @@ -87,6 +95,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { self.hookEventName = try c.decode(HookEventName.self, forKey: .hookEventName) self.source = try c.decode(String.self, forKey: .source) self.workspaceId = try c.decodeIfPresent(String.self, forKey: .workspaceId) + self.surfaceId = try c.decodeIfPresent(String.self, forKey: .surfaceId) + self.transcriptPath = try c.decodeIfPresent(String.self, forKey: .transcriptPath) self.cwd = try c.decodeIfPresent(String.self, forKey: .cwd) self.toolName = try c.decodeIfPresent(String.self, forKey: .toolName) self.context = try c.decodeIfPresent(WorkstreamContext.self, forKey: .context) @@ -117,6 +127,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { try c.encode(hookEventName, forKey: .hookEventName) try c.encode(source, forKey: .source) try c.encodeIfPresent(workspaceId, forKey: .workspaceId) + try c.encodeIfPresent(surfaceId, forKey: .surfaceId) + try c.encodeIfPresent(transcriptPath, forKey: .transcriptPath) try c.encodeIfPresent(cwd, forKey: .cwd) try c.encodeIfPresent(toolName, forKey: .toolName) try c.encodeIfPresent(context, forKey: .context) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift index bfdcbd4d3077..1955c100f979 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift @@ -53,6 +53,8 @@ extension ControlCommandCoordinator { return context?.controlMobileTerminalPaste(params: request.params) case "chat.sessions.dump": return context?.controlMobileChatSessionsDump() + case "mobile.chat.sessions": + return context?.controlMobileChatSessions(params: request.params) default: return nil } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift index ecec781834ac..4b185fd7439d 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift @@ -122,4 +122,12 @@ public protocol ControlMobileHostContext: AnyObject { /// /// - Returns: The fully-built command result. func controlMobileChatSessionsDump() -> ControlCallResult + + /// `mobile.chat.sessions` (local debug socket) — the chat-capable session + /// list as the phone would see it for one workspace, for diagnosing which + /// sessions surface a chat toggle. Same body the mobile data plane runs. + /// + /// - Parameter params: The decoded request params (`workspace_id`). + /// - Returns: The fully-built command result. + func controlMobileChatSessions(params: [String: JSONValue]) -> ControlCallResult } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift index 7be3730d5126..30c637c79fc5 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift @@ -160,6 +160,16 @@ extension TerminalSurface { """ try script.write(to: shimURL, atomically: true, encoding: .utf8) try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shimURL.path) + // Best-effort: write a sibling `codex` shim into the same per-surface + // dir so typed `codex` resolves to cmux-codex-wrapper through the + // PATH entry already prepended for the claude shim. Failure here + // never blocks the claude shim (codex detection degrades, claude is + // unaffected). + installCodexCommandShimIfPossible( + claudeWrapperURL: wrapperURL, + shimDirectory: shimDirectory, + fileManager: fileManager + ) return ClaudeCommandShim( directoryPath: shimDirectory.path, executablePath: shimURL.path @@ -169,6 +179,81 @@ extension TerminalSurface { } } + /// Writes the per-surface `codex` wrapper shim into `shimDirectory`, if the + /// bundled `cmux-codex-wrapper` exists alongside `cmux-claude-wrapper`. The + /// shim resolves and execs the codex wrapper; if the wrapper is gone it + /// strips every cmux shim dir from `PATH` and execs the real `codex`, so the + /// user's `codex` keeps working even when the app bundle is pruned. + /// + /// The directory is already prepended to the spawned shell's `PATH` for the + /// claude shim, so no extra `PATH` handling is required. + @discardableResult + public static func installCodexCommandShimIfPossible( + claudeWrapperURL: URL, + shimDirectory: URL, + fileManager: FileManager = .default + ) -> CodexCommandShim? { + let codexWrapperURL = claudeWrapperURL + .deletingLastPathComponent() + .appendingPathComponent("cmux-codex-wrapper", isDirectory: false) + .standardizedFileURL + guard fileManager.isExecutableFile(atPath: codexWrapperURL.path) else { + return nil + } + + let shimURL = shimDirectory.appendingPathComponent("codex", isDirectory: false) + do { + let script = """ + #!/usr/bin/env bash + cmux_wrapper=\(shellSingleQuoted(codexWrapperURL.path)) + if [[ ! -x "$cmux_wrapper" && -n "${CMUX_BUNDLED_CLI_PATH:-}" ]]; then + cmux_candidate="$(dirname "$CMUX_BUNDLED_CLI_PATH")/cmux-codex-wrapper" + if [[ -x "$cmux_candidate" ]]; then + cmux_wrapper="$cmux_candidate" + fi + fi + if [[ ! -x "$cmux_wrapper" ]]; then + cmux_cli="$(command -v cmux 2>/dev/null || true)" + if [[ -n "$cmux_cli" ]]; then + cmux_candidate="$(dirname "$cmux_cli")/cmux-codex-wrapper" + if [[ -x "$cmux_candidate" ]]; then + cmux_wrapper="$cmux_candidate" + fi + fi + fi + export CMUX_CODEX_WRAPPER_SHIM=\(shellSingleQuoted(shimURL.path)) + export CMUX_CODEX_WRAPPER_SHIM_ROOT=\(shellSingleQuoted(shimDirectory.path)) + if [[ -x "$cmux_wrapper" ]]; then + exec "$cmux_wrapper" "$@" + fi + cmux_path_without_shim="" + cmux_old_ifs="$IFS" + IFS=: + for cmux_entry in ${PATH:-}; do + if [[ "$cmux_entry" == "$CMUX_CODEX_WRAPPER_SHIM_ROOT" || "$cmux_entry" == */cmux-cli-shims/* || "$cmux_entry" == */cmux-cli-shims ]]; then + continue + fi + if [[ -z "$cmux_path_without_shim" ]]; then + cmux_path_without_shim="$cmux_entry" + else + cmux_path_without_shim="$cmux_path_without_shim:$cmux_entry" + fi + done + IFS="$cmux_old_ifs" + export PATH="$cmux_path_without_shim" + exec codex "$@" + """ + try script.write(to: shimURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shimURL.path) + return CodexCommandShim( + directoryPath: shimDirectory.path, + executablePath: shimURL.path + ) + } catch { + return nil + } + } + /// Merges base, additional, and override environments with key /// protection, Claude auth-selection inheritance, and config-dir /// normalization. diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 9b06b7924e01..f2af096a63d1 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -56,6 +56,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { public typealias NamedKeySendResult = CmuxTerminalCore.NamedKeySendResult public typealias InputSendResult = CmuxTerminalCore.InputSendResult public typealias ClaudeCommandShim = TerminalSurfaceClaudeCommandShim + public typealias CodexCommandShim = TerminalSurfaceCodexCommandShim public typealias CmuxContextEnvironment = TerminalSurfaceCmuxContextEnvironment /// The live runtime surface pointer, or nil before creation/after teardown. public internal(set) var surface: ghostty_surface_t? diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCodexCommandShim.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCodexCommandShim.swift new file mode 100644 index 000000000000..7134038dd1b5 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCodexCommandShim.swift @@ -0,0 +1,23 @@ +/// The on-disk `codex` wrapper shim installed for one terminal surface. +/// +/// The shim lives in the same per-surface directory as the `claude` shim, which +/// is already prepended to the spawned shell's `PATH`, so `codex` resolves to +/// the cmux codex wrapper; both paths are exported to the shell as +/// `CMUX_CODEX_WRAPPER_SHIM` / `CMUX_CODEX_WRAPPER_SHIM_ROOT`. +public struct TerminalSurfaceCodexCommandShim: Equatable, Sendable { + /// The per-surface shim directory (shared with the claude shim). + public let directoryPath: String + + /// The executable shim script inside ``directoryPath``. + public let executablePath: String + + /// Creates a shim descriptor. + /// + /// - Parameters: + /// - directoryPath: The per-surface shim directory. + /// - executablePath: The executable shim script inside the directory. + public init(directoryPath: String, executablePath: String) { + self.directoryPath = directoryPath + self.executablePath = executablePath + } +} diff --git a/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper new file mode 100755 index 000000000000..f93565f3faed --- /dev/null +++ b/Resources/bin/cmux-codex-wrapper @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +# cmux codex wrapper - per-invocation Codex hook injection + launch detection. +# +# When running inside a cmux terminal (CMUX_SURFACE_ID is set), this wrapper +# makes `codex` carry cmux's hooks for THIS invocation only (nothing is written +# to ~/.codex), so Codex's own SessionStart/UserPromptSubmit/Stop/PreToolUse/ +# PostToolUse/PermissionRequest fire back into cmux with Codex's real session_id. +# It also fires a best-effort one-way `cmux hooks codex session-start` BEFORE +# exec so a session is detected at launch even if Codex's own SessionStart is +# delayed; the registry dedups by session id so the two are idempotent. +# +# Outside cmux (no CMUX_SURFACE_ID / live socket), or when the user opts out +# (CMUX_CODEX_HOOKS_DISABLED=1), the wrapper does NOTHING but exec the real +# codex. Every failure path below also execs the real codex, so installing the +# wrapper can never break `codex`. + +# Deliberately NOT using `set -e`/`set -u`: a wrapper that aborts on any error +# would break the user's `codex`. Each helper handles its own failures and the +# script always reaches an `exec "$REAL_CODEX"`. + +cmux_codex_wrapper_is_self_or_shim() { + local candidate="$1" + [[ -n "$candidate" ]] || return 1 + if [[ -e "$candidate" && "$candidate" -ef "$0" ]]; then + return 0 + fi + if [[ -n "${CMUX_CODEX_WRAPPER_SHIM:-}" && + -e "$candidate" && + -e "$CMUX_CODEX_WRAPPER_SHIM" && + "$candidate" -ef "$CMUX_CODEX_WRAPPER_SHIM" ]]; then + return 0 + fi + if [[ -n "${CMUX_CODEX_WRAPPER_SHIM_ROOT:-}" && + "$candidate" == "${CMUX_CODEX_WRAPPER_SHIM_ROOT%/}/codex" ]]; then + return 0 + fi + case "$candidate" in + */cmux-cli-shims/*/codex|*/cmux-cli-shims/codex) + return 0 + ;; + */Contents/Resources/bin/codex|*/Resources/bin/codex) + return 0 + ;; + esac + return 1 +} + +# Find the real codex binary, skipping this wrapper and cmux's shell shims. +find_real_codex() { + local custom="${CMUX_CUSTOM_CODEX_PATH:-}" + custom="${custom#"${custom%%[![:space:]]*}"}" # trim leading whitespace + custom="${custom%"${custom##*[![:space:]]}"}" # trim trailing whitespace + if [[ -n "$custom" && -f "$custom" && -x "$custom" ]]; then + if ! cmux_codex_wrapper_is_self_or_shim "$custom"; then + printf '%s' "$custom" + return 0 + fi + fi + local self_dir + self_dir="$(cd "$(dirname "$0")" && pwd)" + local IFS=: + for d in $PATH; do + [[ "$d" == "$self_dir" ]] && continue + local candidate="$d/codex" + cmux_codex_wrapper_is_self_or_shim "$candidate" && continue + [[ -x "$candidate" ]] && printf '%s' "$candidate" && return 0 + done + return 1 +} + +# Return 0 only when CMUX_SOCKET_PATH points to a live cmux socket. +cmux_socket_available() { + local socket="${CMUX_SOCKET_PATH:-}" + [[ -n "$socket" && -S "$socket" ]] || return 1 + + local cmux_bin + cmux_bin="$(resolve_hook_cmux_bin)" + [[ -n "$cmux_bin" ]] || return 1 + + CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=0.75 \ + "$cmux_bin" --socket "$socket" ping >/dev/null 2>&1 +} + +resolve_hook_cmux_bin() { + local self_dir bundled_cli + bundled_cli="${CMUX_BUNDLED_CLI_PATH:-}" + if [[ -n "$bundled_cli" && -x "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + self_dir="$(cd "$(dirname "$0")" && pwd)" + bundled_cli="$self_dir/cmux" + if [[ -x "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + bundled_cli="$(command -v cmux 2>/dev/null || true)" + if [[ -n "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + printf '%s' "cmux" +} + +# Only the interactive session and `exec`/`e` entrypoints start a Codex session; +# everything else (resume, review, login, mcp, doctor, --help, --version, ...) +# must pass through untouched so it never receives session/hook flags. Mirrors +# should_inject_claude_hooks: a leading non-option token that is a known Codex +# subcommand means "not a session" unless it is the exec alias. +codex_subcommand_starts_session() { + case "$1" in + exec|e) return 0 ;; + *) return 1 ;; + esac +} + +codex_known_subcommand() { + case "$1" in + exec|e|review|login|logout|mcp|plugin|mcp-server|app-server|\ + remote-control|app|completion|update|doctor|sandbox|debug|apply|a|\ + resume|archive|delete|unarchive|fork|cloud|exec-server|features|help) + return 0 + ;; + esac + return 1 +} + +# Options that take a value, so a following non-option token is the value and +# not the subcommand. Conservative: unknown `--flag value` is handled by the +# "first bare token decides" rule below, which is safe because Codex session +# launches take an optional prompt, not a subcommand. +codex_option_consumes_value() { + case "$1" in + -c|--config|-m|--model|-p|--profile|-C|--cd|--remote|-a|--ask-for-approval|\ + -s|--sandbox|--output-last-message|--enable|--disable) + return 0 + ;; + esac + return 1 +} + +codex_passthrough_option_flag() { + case "$1" in + --help|-h|-V|--version) + return 0 + ;; + esac + return 1 +} + +# Decide whether this invocation is a Codex SESSION entrypoint we should inject +# hooks into. Bare `codex` (no args) and `codex [prompt]` are interactive +# sessions; `codex exec ...` is a non-interactive session; any other leading +# subcommand is not. +should_inject_codex_hooks() { + (( $# == 0 )) && return 0 + + local arg + local skip_next=false + for arg in "$@"; do + if [[ "$skip_next" == true ]]; then + skip_next=false + continue + fi + case "$arg" in + --) + return 0 + ;; + -*) + if codex_passthrough_option_flag "$arg"; then + return 1 + fi + if [[ "$arg" != *=* ]] && codex_option_consumes_value "$arg"; then + skip_next=true + fi + continue + ;; + *) + if codex_known_subcommand "$arg"; then + codex_subcommand_starts_session "$arg" && return 0 + return 1 + fi + # First bare token is a prompt -> interactive session. + return 0 + ;; + esac + done + + return 0 +} + +# Whether the invocation uses the non-interactive `exec`/`e` subcommand, which +# requires --skip-git-repo-check tolerance differently; we never add flags codex +# would reject, so this only gates the best-effort wrapper-fired session-start +# transcript hint. (Currently informational; kept for clarity/extension.) + +IN_CMUX=0 +if [[ -n "${CMUX_SURFACE_ID:-}" ]]; then + IN_CMUX=1 +fi + +# Resolve real codex up front; if it cannot be found we error like a missing +# binary would (same as the claude wrapper) rather than silently succeeding. +REAL_CODEX="$(find_real_codex)" || { echo "Error: codex not found in PATH" >&2; exit 127; } + +exec_real_codex_passthrough() { + if [[ "$IN_CMUX" == "1" ]]; then + local cmux_key + for cmux_key in "${!CMUX_@}"; do + unset "$cmux_key" + done + unset TERMINFO + fi + exec "$REAL_CODEX" "$@" +} + +# Opt-out, outside cmux, or stale/dead socket: pass straight through. +if [[ "${CMUX_CODEX_HOOKS_DISABLED:-}" == "1" ]]; then + exec_real_codex_passthrough "$@" +fi +if [[ "$IN_CMUX" == "0" ]] || ! cmux_socket_available; then + exec_real_codex_passthrough "$@" +fi + +# Not a session entrypoint (resume/doctor/--help/...): pass through unchanged. +if ! should_inject_codex_hooks "$@"; then + exec_real_codex_passthrough "$@" +fi + +# Export launch identity so the hook subprocesses and cmux's agent-pid / +# ended-detection can bind this Codex to its surface and pid. Because we exec +# codex below, $$ becomes the real codex pid. +export CMUX_CODEX_PID=$$ +export CMUX_CODEX_HOOK_CMUX_BIN="$(resolve_hook_cmux_bin)" +export CMUX_AGENT_LAUNCH_KIND="codex" +export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CODEX" +export CMUX_AGENT_LAUNCH_CWD="$PWD" +{ + cmux_codex_argv_b64="$( + { + printf '%s\0' "$REAL_CODEX" + if (( $# > 0 )); then + printf '%s\0' "$@" + fi + } | base64 | tr -d '\n' + )" + [[ -n "$cmux_codex_argv_b64" ]] && export CMUX_AGENT_LAUNCH_ARGV_B64="$cmux_codex_argv_b64" +} + +# NOTE: no wrapper-fired launch session-start. Codex's own injected SessionStart +# hook (below) fires within ~1s carrying Codex's REAL session_id, so it is the +# single authoritative signal. A pre-exec wrapper-fired session-start has no +# session id and made the handler mint a junk `fallback-*` record alongside the +# real one (a phantom duplicate in the GUI), so it is intentionally omitted. + +# Build the per-invocation [hooks] injection. The cmux CLI emits the exact arg +# list (NUL-separated) that enables hooks and injects cmux's FIRE-AND-FORGET hook +# command for each event. Fire-and-forget is critical: codex runs hooks +# synchronously and BLOCKS until they return, so a synchronous `cmux hooks codex +# ` made every launch hang ~35s on "Running SessionStart hook". The emitted +# command captures codex's stdin payload to a temp file, nohup-backgrounds the +# real cmux call with a 30s watchdog, and `echo '{}'` returns to codex instantly, +# so detection still binds the real session id while codex never blocks. +# +# Resolving the args via the CLI (instead of re-deriving fragile shell here) +# avoids quoting bugs in the fire-and-forget command (it contains single quotes +# like `echo '{}'` and `sh -c '...'`). If the emit fails or yields nothing, we +# fall back to plain passthrough so installing the wrapper can never break codex. +CMUX_INJECT_CLI="$CMUX_CODEX_HOOK_CMUX_BIN" +cmux_codex_injected_args=() +cmux_codex_read_inject_args() { + [[ -n "$CMUX_INJECT_CLI" && -x "$CMUX_INJECT_CLI" ]] || return 1 + local arg + # Read the NUL-TERMINATED arg stream DIRECTLY via process substitution. A + # `raw="$(... )"` command substitution must NOT be used: bash discards every + # NUL byte from command-substitution output (with a warning), which would + # collapse the whole stream into one token and silently drop the injection. + if [[ -n "${CMUX_SOCKET_PATH:-}" ]]; then + while IFS= read -r -d '' arg; do + cmux_codex_injected_args+=("$arg") + done < <("$CMUX_INJECT_CLI" --socket "$CMUX_SOCKET_PATH" hooks codex inject-args 2>/dev/null) + else + while IFS= read -r -d '' arg; do + cmux_codex_injected_args+=("$arg") + done < <("$CMUX_INJECT_CLI" hooks codex inject-args 2>/dev/null) + fi + (( ${#cmux_codex_injected_args[@]} > 0 )) || return 1 + return 0 +} + +if cmux_codex_read_inject_args; then + exec "$REAL_CODEX" "${cmux_codex_injected_args[@]}" "$@" +fi + +# Emit failed or returned nothing: never break codex, just pass through. +exec_real_codex_passthrough "$@" diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 3f1f322182c4..3c5c299d68ff 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -278,9 +278,15 @@ final class AgentChatSessionRegistry { if let workspaceID = event.workspaceId, !workspaceID.isEmpty { record.workspaceID = workspaceID } + if let surfaceID = event.surfaceId, !surfaceID.isEmpty { + record.surfaceID = surfaceID + } if let cwd = event.cwd, !cwd.isEmpty { record.workingDirectory = cwd } + if let transcriptPath = event.transcriptPath, !transcriptPath.isEmpty { + record.transcriptPath = transcriptPath + } record.lastActivityAt = event.receivedAt let previous = records[sessionID] diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index aa0b4a12ce44..2dedff377ae1 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -107,6 +107,21 @@ final class AgentChatTranscriptService { await registry.refreshBindingsFromHookStore(sessionID: sessionID) } + /// Re-stamps a session's stored workspace id to the workspace its surface + /// currently lives in. cmux workspace ids regenerate on every Mac relaunch + /// while surface ids are stable, so a session created before the last + /// relaunch carries a stale `workspaceID`. The caller resolves the session's + /// live surface to its current workspace and calls this so the seed and the + /// live `descriptorChanged` pushes both scope to that workspace (the iOS + /// reducer is workspace-scoped and rejects stale-workspace live updates). + /// + /// - Parameters: + /// - sessionID: The session to re-stamp. + /// - workspaceID: The surface's current workspace UUID string. + func updateSessionWorkspace(sessionID: String, workspaceID: String) { + registry.update(sessionID: sessionID) { $0.workspaceID = workspaceID } + } + /// Serves one history page, starting the session's tailer on demand. /// /// - Parameters: diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index f550acde844b..d08f3ce4d86b 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -65,6 +65,10 @@ extension TerminalController: ControlMobileHostContext { bridgeMobileResult(v2ChatSessionsDump()) } + func controlMobileChatSessions(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileChatSessions(params: foundationParams(params))) + } + /// Reconstructs the legacy `[String: Any]` params from the coordinator's /// typed params. This is the exact inverse of the dispatcher's /// `request.params.mapValues { $0.foundationObject }`, so the legacy body diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index ad59c970aeb0..6d5f80e38ddb 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -62,15 +62,63 @@ extension TerminalController { /// `mobile.chat.sessions`: list chat-capable coding-agent sessions, /// optionally scoped to one workspace. + /// + /// When a `workspace_id` W is given, sessions are scoped by the SURFACE'S + /// CURRENT workspace, never the record's stored `workspaceID`. cmux + /// workspace ids regenerate on every Mac relaunch while surface ids are + /// stable, so a session created before the last relaunch carries a stale + /// stored `workspaceID` and would otherwise be dropped from its terminal's + /// current workspace. We resolve W once, collect W's live terminal surface + /// ids once, then return every session whose surface is one of them and that + /// still matches its agent against THAT workspace+panel. Each returned + /// session is re-stamped to W so its seed and live `descriptorChanged` + /// pushes both scope to the current workspace. func v2MobileChatSessions(params: [String: Any]) -> V2CallResult { let workspaceID = v2String(params, "workspace_id") guard let service = agentChatTranscriptService else { return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) } - let descriptors = service.sessionRecords(workspaceID: workspaceID) - .filter { mobileChatBindingIsCurrentAgent($0) } - .map(\.descriptor) - let encoded = descriptors.compactMap { service.wirePayload($0) } + guard let workspaceID else { + // No filter: return all current-agent sessions across workspaces, + // resolving each via its stored binding as before. + let descriptors = service.sessionRecords(workspaceID: nil) + .filter { mobileChatBindingIsCurrentAgent($0) } + .map(\.descriptor) + let encoded = descriptors.compactMap { service.wirePayload($0) } + return .ok(["sessions": encoded]) + } + // Resolve W to its live Workspace once; build the set of its live + // terminal surface ids once, then filter sessions against that set. + guard let resolved = mobileResolveWorkspaceAndSurface( + params: ["workspace_id": workspaceID], + requireTerminal: false + ) else { + return .ok(["sessions": []]) + } + let workspace = resolved.workspace + var encoded: [[String: Any]] = [] + for record in service.sessionRecords(workspaceID: nil) { + guard let surfaceID = record.surfaceID, + let surfaceUUID = UUID(uuidString: surfaceID), + let terminalPanel = workspace.terminalPanel(for: surfaceUUID), + mobileChatRecordMatchesAgent( + record: record, + workspace: workspace, + terminalPanel: terminalPanel + ) else { + continue + } + // Re-stamp stale-workspace records to W so the seed and live pushes + // both scope to the current workspace, then encode the re-stamped + // descriptor. + if record.workspaceID != workspaceID { + service.updateSessionWorkspace(sessionID: record.sessionID, workspaceID: workspaceID) + } + let descriptor = service.sessionRecord(sessionID: record.sessionID)?.descriptor ?? record.descriptor + if let payload = service.wirePayload(descriptor) { + encoded.append(payload) + } + } return .ok(["sessions": encoded]) } @@ -324,6 +372,13 @@ extension TerminalController { /// represents. This prevents a stale registry surface id from exposing a /// chat toggle or routing prompts into a plain shell after a terminal was /// restored/reused. + /// + /// Resolves the terminal via the record's STORED `workspaceID`, which is + /// the very value that goes stale after a Mac relaunch — so use this only + /// for the no-filter path. The workspace-filtered path + /// (``v2MobileChatSessions``) resolves the surface to its CURRENT workspace + /// and calls ``mobileChatRecordMatchesAgent(record:workspace:terminalPanel:)`` + /// directly. private func mobileChatBindingIsCurrentAgent(_ record: AgentChatSessionRecord) -> Bool { guard let workspaceID = record.workspaceID, let surfaceID = record.surfaceID, @@ -335,9 +390,25 @@ extension TerminalController { let terminalPanel = resolved.workspace.terminalPanel(for: surfaceId) else { return false } - let title = resolved.workspace.panelTitle(panelId: terminalPanel.id) ?? terminalPanel.displayTitle + return mobileChatRecordMatchesAgent( + record: record, + workspace: resolved.workspace, + terminalPanel: terminalPanel + ) + } + + /// Agent-match core: whether an already-resolved `(workspace, terminalPanel)` + /// still looks like the agent the record represents. Resolution-free so the + /// workspace-filtered listing path can call it with the surface's CURRENT + /// workspace rather than the record's stale stored one. + private func mobileChatRecordMatchesAgent( + record: AgentChatSessionRecord, + workspace: Workspace, + terminalPanel: TerminalPanel + ) -> Bool { + let title = workspace.panelTitle(panelId: terminalPanel.id) ?? terminalPanel.displayTitle let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let context = WorkspaceContentView.terminalAgentContext(panel: terminalPanel, workspace: resolved.workspace) + let context = WorkspaceContentView.terminalAgentContext(panel: terminalPanel, workspace: workspace) switch record.agentKind { case .claude: return TextBoxAgentDetection.isClaudeCode(context: context) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 524be4fc0464..67b5e576fa7d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -186,6 +186,7 @@ C10D00010000000000000001 /* CloudVMActionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D00020000000000000002 /* CloudVMActionLauncher.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; C1ADE20002A1B2C3D4E5F719 /* cmux-claude-wrapper in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE20001A1B2C3D4E5F719 /* cmux-claude-wrapper */; }; + C1ADE30002A1B2C3D4E5F719 /* cmux-codex-wrapper in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE30001A1B2C3D4E5F719 /* cmux-codex-wrapper */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* cmux_open.swift */; }; @@ -1111,6 +1112,7 @@ files = ( B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE20002A1B2C3D4E5F719 /* cmux-claude-wrapper in Copy CLI */, + C1ADE30002A1B2C3D4E5F719 /* cmux-codex-wrapper in Copy CLI */, C1ADE10002A1B2C3D4E5F719 /* grok in Copy CLI */, D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, C0DE70500000000000000002 /* start-cmux-profiling in Copy CLI */, @@ -1311,6 +1313,7 @@ B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; C1ADE20001A1B2C3D4E5F719 /* cmux-claude-wrapper */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/cmux-claude-wrapper"; sourceTree = SOURCE_ROOT; }; + C1ADE30001A1B2C3D4E5F719 /* cmux-codex-wrapper */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/cmux-codex-wrapper"; sourceTree = SOURCE_ROOT; }; A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; @@ -2226,6 +2229,7 @@ B2E7294509CC42FE9191870E /* xterm-ghostty */, A5002001 /* THIRD_PARTY_LICENSES.md */, C1ADE20001A1B2C3D4E5F719 /* cmux-claude-wrapper */, + C1ADE30001A1B2C3D4E5F719 /* cmux-codex-wrapper */, C1ADE10001A1B2C3D4E5F719 /* grok */, C0DE70500000000000000001 /* start-cmux-profiling */, C0DE70530000000000000001 /* submit-cmux-profile */, diff --git a/docs/codex-agent-detection-plan.md b/docs/codex-agent-detection-plan.md new file mode 100644 index 000000000000..593f003aa362 --- /dev/null +++ b/docs/codex-agent-detection-plan.md @@ -0,0 +1,149 @@ +# Codex Agent Detection Plan + +Status: IMPLEMENTED + live-verified (macOS/daemon side). Owner: Aziz. Last +updated: 2026-06-22. Branch `feat-codex-detection`, PR #6655 (do-not-merge +pending on-device dogfood). Base/return point: tag `agent-session-sot-landmark`. + +Done: `cmux-codex-wrapper` (PATH shim, Claude-parity per-invocation `[hooks]` +injection via `--enable hooks --dangerously-bypass-hook-trust -c hooks.=...`), +codex PATH-shim install sibling to the claude shim, `WorkstreamEvent` + +`feed.push` + `noteHookEvent` now carry `surface_id`/`transcript_path`. Two +preflight-caught bugs fixed: phantom `fallback-*` duplicate (removed the +wrapper-fired empty-stdin launch signal) and unbound-surface on live +session-start. Live debug-socket proof: a real `codex exec` produced exactly one +codex session, surface + transcript bound, `idle -> ended` on exit. + +Remaining: on-device iOS GUI dogfood (the iPhone renders the registry; macOS +side proven). Codex `needsInput` is hook-driven via `PermissionRequest`. + +Expands Slice F of `agent-session-tracking-spec.md`: make Codex sessions track in +the iOS GUI as reliably as Claude, without forcing users to install anything and +without silently editing their `~/.codex` config. + +## Problem + +Codex agents are not reliably detected in the GUI. Empirical root cause on a real +machine (Aziz's, 2026-06-22): + +- cmux's Codex hooks (`~/.codex/hooks.json`) are NOT installed. +- Codex has a single legacy `notify` slot, and it is taken by Computer Use: + `notify = [".../SkyComputerUseClient", "turn-ended"]`. cmux's notify-based + events cannot fire. +- Slice D removed the terminal-title / newest-jsonl-by-mtime fallback + (intentionally — no unreliable fallback), so a hook-less Codex is invisible. +- The 13 "codex" entries in `~/.cmuxterm/codex-hook-sessions.json` are stale, + not live-updating. + +Why Claude works, for contrast: Claude Code has a `SessionStart` hook. cmux's +`cmux-claude-wrapper` is a PATH shim that injects cmux's hooks per-invocation +(`--settings`) and execs the real claude, so `SessionStart` fires the instant a +claude session starts. Transparent, per-launch, nothing written to `~/.claude`, +works for hand-typed `claude`. + +## Principle + +cmux owns the terminal environment, so it can mediate any agent the user launches +in its terminal, per-invocation, without touching global config. Detection must +depend only on what cmux controls — the wrapper, the injected env, the pid it +parents — never on the agent cooperating. This is the same primitive that makes +Claude reliable, generalized to Codex. + +## Design: PATH-shim wrapper that emits its own session-start + +A `cmux-codex-wrapper`, mirroring `cmux-claude-wrapper`: + +1. cmux prepends a shim dir to `PATH` when it spawns its terminal shells (the + same mechanism Claude already uses). Typing `codex` resolves to the shim, not + the real binary. +2. Before exec'ing the real codex, the wrapper emits the launch signal itself: + `cmux hooks codex session-start` carrying `CMUX_SURFACE_ID`, the cwd, and its + child pid. THIS is the reliable detection signal, and it needs nothing from + Codex — the wrapper, which cmux controls, is the source. (More robust than the + Claude path, where the signal comes from Claude's own hook.) +3. The wrapper execs the real codex (resolved by skipping the shim on `PATH`). +4. Outside cmux (no `CMUX_SURFACE_ID` / socket), the wrapper no-ops and execs the + real codex, so it is invisible to non-cmux usage. + +Session lifecycle, with NO Codex hooks required: + +- Presence + terminal binding: from the wrapper's session-start (surface + pid, + deterministic). +- Transcript: resolve the Codex rollout JSONL the pid is writing, anchored to the + pid (the process's open file descriptors, or a launch-time-bounded match + confirmed against the pid). This is an identity, NOT the deleted "newest jsonl + by mtime in the cwd" guess. Then tail it. +- State (working / idle): derived from the transcript tail. `CodexTranscriptParser` + already exists. +- ended: the Slice-B `DispatchSourceProcess(.exit)` watcher on the pid. +- Codex's own hooks: optional enhancement for finer / faster state, never a + requirement. + +## The `notify` coexistence wrinkle + +This is the only Codex-specific complication, and it is bounded, not fundamental. +Codex has one legacy `notify` slot, frequently already taken (Computer Use). If we +want Codex's own notify events too, do NOT clobber it. Options, simplest first: + +- Skip `notify` entirely and rely on transcript-derived state. If the rollout + covers the states we need, the wrapper's session-start + transcript tail + + process-exit are sufficient and `notify` is unnecessary. +- Chain: read the user's existing `notify` from `config.toml`; cmux's injected + notify handler forwards to the original after handling. Decorator pattern, + preserves Computer Use. +- Use Codex's newer multi-hook system if the current CLI accepts a per-invocation + config override that adds a hook alongside existing ones (no chaining needed). + +## Reliability assessment (honest) + +Super reliable: +- Detection of any Codex that cmux launches OR the user types in a cmux terminal. + Anchored to the wrapper-emitted session-start (surface + pid cmux owns). +- `ended`, via the process-exit watcher. + +Bounded edges, acceptable under the no-unreliable-fallback stance (Claude has the +identical limits): +- Deliberate bypass is not caught: absolute path (`/usr/local/bin/codex`), an + alias/function that skips the shim, `env -i`, a PATH reset, or Codex over ssh on + a host cmux does not own. Not surfacing a truly-unmediated agent is correct + behavior, not a bug. +- Fine-grained `needsInput` (Codex paused on an approval/answer) is + transcript-paced without Codex's own hooks. Reliable IF the rollout records + approval/needs-input events; coarse if it does not. + +## Open questions to verify before building + +1. Does a `cmux-claude-wrapper`-style PATH shim already exist, and where is the + shim dir injected into terminal `PATH`? (Reuse the same machinery for codex.) +2. Does the Codex rollout JSONL expose approval / needs-input events, for reliable + `needsInput` without Codex hooks? +3. The current Codex CLI per-invocation config surface (`-c` overrides; multi-hook + support), to decide notify-chaining vs. skip vs. multi-hook. +4. Does cmux's existing Codex launch path (`codex-teams` / + `upsertCodexSessionStartIfFresh`) already register into the iOS chat registry + (`AgentChatSessionRegistry`), or only write the stale hook store? Determines + how much is wiring vs. new. + +## Implementation steps + +1. Verify the four open questions above. +2. Add `cmux-codex-wrapper` to the same shim dir + PATH injection Claude uses. +3. Wrapper emits `cmux hooks codex session-start` (surface / pid / cwd) before + exec, and no-ops cleanly outside cmux. +4. Wire the Codex session-start into `AgentChatSessionRegistry` (the iOS chat + registry), if it does not already land there. +5. pid-anchored Codex transcript resolution (open-fd / launch-bounded), replacing + any reliance on session-id-from-hook for the typed case. +6. Confirm `CodexTranscriptParser` yields working / idle (/ needsInput) from the + rollout; fill gaps. +7. `notify` coexistence: skip, chain, or multi-hook per Q3. +8. Build + dogfood: type `codex` in a cmux terminal, confirm it appears in the + GUI, state tracks, and it ends on exit; confirm Computer Use's `notify` still + fires. + +## Relationship to the main spec + +This is `agent-session-tracking-spec.md` Slice F, expanded. It replaces that +slice's "deferred — needs a product decision" note: the wrapper approach needs no +global install and edits no user config, so there is no product decision to gate +on. Detection does not depend on Codex's hook support, which is what makes it +principled rather than a per-agent hack.