Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CLI/CMUXCLI+CodexFireAndForgetHooks.swift
Original file line number Diff line number Diff line change
@@ -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='''<ff>''',timeout=10000}]}]\0
/// -c\0hooks.UserPromptSubmit=...\0 ... (one `-c` pair per event)
/// where `<ff>` 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\""
Expand Down
36 changes: 25 additions & 11 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29587,14 +29587,15 @@ 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,
source: def.name,
subcommand: subcommand,
parsedInput: input,
workspaceId: workspaceId ?? workspaceArg(),
surfaceId: surfaceId,
socketPassword: socketPassword
)
}
Expand All @@ -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? {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -30692,7 +30693,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
env: env
)
#endif
sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId)
sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId)
print("{}")
return
}
Expand All @@ -30707,7 +30708,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
env: env
)
#endif
sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId)
sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId)
print("{}")
return
}
Expand Down Expand Up @@ -30854,15 +30855,15 @@ 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 {
retireCodexMonitorLeases(sessionId: sessionId, turnId: nil, env: env)
}
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,
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading
Loading