Skip to content
Closed
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ jobs:
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_autodiscovery.py
python3 tests/test_claude_wrapper_hooks.py
python3 tests/test_claude_wrapper_user_binary_resolution.py
python3 tests/test_codex_wrapper_notify.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omx_fallback_path.py
Expand Down
29 changes: 28 additions & 1 deletion Resources/bin/cmux-claude-wrapper
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,34 @@ fi
# PreToolUse denial as a side channel.
# - SubagentStop: feed telemetry only. It must not run the visible Stop
# hook because a subagent finishing should not notify like the parent.
HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}'
#
# Agent conversation live hook ingest (docs/agent-conversation-protocol.md,
# "Hook ingest"): when the app exported a staged cmuxd-remote emit relay
# (CMUX_AGENT_HOOK_EMIT_BIN) and this instance's ingest socket path
# (CMUX_AGENT_HOOK_SOCKET), add one extra hook entry per lifecycle event that
# pipes Claude's native hook payload to `cmuxd-remote agent-hook-emit`. The
# env vars are expanded by the shell Claude runs hook commands with (same
# convention as CMUX_CLAUDE_HOOK_CMUX_BIN), which keeps DerivedData paths
# with spaces safe without bash-side quoting. The emit verb always exits 0
# and is a no-op when no chat subscription is listening.
#
# Claude's --settings is last-wins across repeated flags (verified on
# 2.1.175: `claude --settings '{invalid' --settings '{}'` succeeds while the
# reversed order errors), so these entries are merged into the single
# settings payload this wrapper already owns instead of being passed as a
# second --settings. The high-frequency tool hooks are async so they never
# add latency to tool calls; the daemon tolerates a PostToolUse frame
# arriving before its PreToolUse.
AGENTCONV_SYNC_ENTRY=""
AGENTCONV_ASYNC_ENTRY=""
AGENTCONV_POST_TOOL_USE_KEY=""
if [[ -n "${CMUX_AGENT_HOOK_EMIT_BIN:-}" && -x "${CMUX_AGENT_HOOK_EMIT_BIN:-}" && -n "${CMUX_AGENT_HOOK_SOCKET:-}" ]]; then
AGENTCONV_COMMAND='\"$CMUX_AGENT_HOOK_EMIT_BIN\" agent-hook-emit --socket \"$CMUX_AGENT_HOOK_SOCKET\"'
AGENTCONV_SYNC_ENTRY=',{"matcher":"","hooks":[{"type":"command","command":"'"$AGENTCONV_COMMAND"'","timeout":5}]}'
AGENTCONV_ASYNC_ENTRY=',{"matcher":"","hooks":[{"type":"command","command":"'"$AGENTCONV_COMMAND"'","timeout":5,"async":true}]}'
AGENTCONV_POST_TOOL_USE_KEY='"PostToolUse":[{"matcher":"","hooks":[{"type":"command","command":"'"$AGENTCONV_COMMAND"'","timeout":5,"async":true}]}],'
fi
HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}'"$AGENTCONV_SYNC_ENTRY"'],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}'"$AGENTCONV_SYNC_ENTRY"'],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}'"$AGENTCONV_SYNC_ENTRY"'],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}'"$AGENTCONV_ASYNC_ENTRY"'],'"$AGENTCONV_POST_TOOL_USE_KEY"'"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}'"$AGENTCONV_SYNC_ENTRY"']}}'

if [[ "$SKIP_SESSION_ID" == true ]]; then
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"
Expand Down
96 changes: 96 additions & 0 deletions Resources/bin/cmux-codex-wrapper
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# cmux codex wrapper - injects the agent conversation notify hook
#
# When running inside a cmux terminal (CMUX_SURFACE_ID is set) and the app
# exported a staged cmuxd-remote emit relay (CMUX_AGENT_HOOK_EMIT_BIN) plus
# this instance's ingest socket path (CMUX_AGENT_HOOK_SOCKET), this wrapper
# adds a per-launch `notify` override so Codex turn completions flow into the
# agent chat surface (docs/agent-conversation-protocol.md, "Hook ingest").
# Codex invokes the notify program with the JSON payload as the final argv
# argument; `cmuxd-remote agent-hook-emit` translates it and always exits 0,
# so the hook can never slow down or break Codex.
#
# The override is per-launch only (`codex -c notify=[...]`); the user's
# ~/.codex/config.toml is never written. Injection is skipped when the user
# already has a notify program configured (their own -c override or an
# uncommented `notify` key in config.toml) so cmux never replaces a
# user-chosen notifier.
#
# Daemon-side decoding of Codex's agent-turn-complete payload ships with the
# stacked codex-hooks branch (manaflow-ai/cmux PR 5957). Until that lands,
# an emit relay without the decoder accepts the frame and drops it with exit
# 0, so this injection is safe to ship first and starts flowing on upgrade.

find_real_codex() {
local self_dir
self_dir="$(cd "$(dirname "$0")" && pwd)"
local IFS=:
for d in $PATH; do
[[ "$d" == "$self_dir" ]] && continue
local candidate="$d/codex"
if [[ -e "$candidate" && "$candidate" -ef "$0" ]]; then
continue
fi
case "$candidate" in
*/Contents/Resources/bin/codex|*/Resources/bin/codex)
continue
;;
esac
[[ -x "$candidate" ]] && printf '%s' "$candidate" && return 0
done
return 1
}

REAL_CODEX="$(find_real_codex)" || { echo "Error: codex not found in PATH" >&2; exit 127; }

# Pass through when not in a cmux terminal, when codex hooks are disabled, or
# when the app did not stage the emit relay for this terminal.
if [[ -z "${CMUX_SURFACE_ID:-}" ||
"${CMUX_CODEX_HOOKS_DISABLED:-}" == "1" ||
-z "${CMUX_AGENT_HOOK_EMIT_BIN:-}" ||
-z "${CMUX_AGENT_HOOK_SOCKET:-}" ]]; then
exec "$REAL_CODEX" "$@"
fi
if [[ ! -x "$CMUX_AGENT_HOOK_EMIT_BIN" ]]; then
exec "$REAL_CODEX" "$@"
fi

# The emit path and socket path are embedded literally in a TOML basic
# string; skip injection rather than risk malformed config for paths that
# would need escaping (never the case for DerivedData or /tmp paths).
case "$CMUX_AGENT_HOOK_EMIT_BIN$CMUX_AGENT_HOOK_SOCKET" in
*'"'*|*'\'*|*$'\n'*|*$'\t'*)
exec "$REAL_CODEX" "$@"
;;
esac

# Respect a user-provided notify override on the command line.
prev=""
for arg in "$@"; do
case "$arg" in
notify=*|notify.*)
if [[ "$prev" == "-c" || "$prev" == "--config" ]]; then
exec "$REAL_CODEX" "$@"
fi
;;
-c=notify=*|--config=notify=*)
exec "$REAL_CODEX" "$@"
;;
--help|-h|--version|-V)
exec "$REAL_CODEX" "$@"
;;
esac
prev="$arg"
done

# Respect a notify program configured in the user's config.toml (cmux's own
# codex integration never sets one, so a notify key is always user-chosen).
CODEX_CONFIG_TOML="${CODEX_HOME:-$HOME/.codex}/config.toml"
if [[ -f "$CODEX_CONFIG_TOML" ]] &&
grep -Eq '^[[:space:]]*notify[[:space:]]*=' "$CODEX_CONFIG_TOML" 2>/dev/null; then
exec "$REAL_CODEX" "$@"
fi

exec "$REAL_CODEX" \
-c "notify=[\"$CMUX_AGENT_HOOK_EMIT_BIN\",\"agent-hook-emit\",\"--socket\",\"$CMUX_AGENT_HOOK_SOCKET\",\"--provider\",\"codex\"]" \
"$@"
1 change: 1 addition & 0 deletions Resources/shell-integration/cmux-bash-integration.bash
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ _cmux_install_cli_wrapper() {
}
_cmux_install_cli_wrapper claude _CMUX_CLAUDE_WRAPPER cmux-claude-wrapper
_cmux_install_cli_wrapper grok _CMUX_GROK_WRAPPER
_cmux_install_cli_wrapper codex _CMUX_CODEX_WRAPPER cmux-codex-wrapper
_cmux_now() {
printf '%s\n' "${EPOCHSECONDS:-$SECONDS}"
}
Expand Down
16 changes: 14 additions & 2 deletions Resources/shell-integration/cmux-zsh-integration.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -255,24 +255,36 @@ _cmux_install_cli_wrapper() {
local wrapper_variable="$2"
local wrapper_file="${3:-$command_name}"
local integration_dir="${CMUX_SHELL_INTEGRATION_DIR:-}"
local existing_type=""
[[ -n "$integration_dir" ]] || return 0

integration_dir="${integration_dir%/}"
local bundle_dir="${integration_dir%/shell-integration}"
local wrapper_path="$bundle_dir/bin/$wrapper_file"
[[ -x "$wrapper_path" ]] || return 0

# Keep the bundled wrapper ahead of later PATH mutations. Install it
# via eval so an existing alias cannot break parsing.
existing_type="$(builtin whence -w "$command_name" 2>/dev/null || true)"
typeset -g "$wrapper_variable=$wrapper_path"
if [[ "$command_name" == "claude" ]]; then
_cmux_install_cli_command_shim "$command_name" "$wrapper_path"
fi
# A user-defined alias/function (binary selection, env injection,
# default args) wins over cmux's wrapper, matching the bash and fish
# integrations.
case "$existing_type" in
*": alias"|*": function")
return 0
;;
esac

# Keep the bundled wrapper ahead of later PATH mutations. Install it
# via eval so an existing alias cannot break parsing.
builtin unalias "$command_name" >/dev/null 2>&1 || true
eval "$command_name() { \"\${$wrapper_variable}\" \"\$@\"; }"
}
_cmux_install_cli_wrapper claude _CMUX_CLAUDE_WRAPPER cmux-claude-wrapper
_cmux_install_cli_wrapper grok _CMUX_GROK_WRAPPER
_cmux_install_cli_wrapper codex _CMUX_CODEX_WRAPPER cmux-codex-wrapper

_cmux_normalize_claude_config_dir() {
[[ -n "${CLAUDE_CONFIG_DIR:-}" && -n "${HOME:-}" ]] || return 0
Expand Down
5 changes: 5 additions & 0 deletions Resources/shell-integration/fish/config.fish
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,16 @@ if test "$_cmux_integration_enabled" != 0
function grok --wraps "$wrapper_path" --inherit-variable wrapper_path
"$wrapper_path" $argv
end
case codex
function codex --wraps "$wrapper_path" --inherit-variable wrapper_path
"$wrapper_path" $argv
end
end
end

_cmux_install_cli_wrapper claude cmux-claude-wrapper
_cmux_install_cli_wrapper grok grok
_cmux_install_cli_wrapper codex cmux-codex-wrapper

function _cmux_report_tty_once
test "$_CMUX_TTY_REPORTED" = 1; and return 0
Expand Down
7 changes: 5 additions & 2 deletions Sources/AgentChat/AgentChatWebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,14 +246,17 @@ final class AgentChatWebViewController: NSViewController, WKScriptMessageHandler
}
let binaryURL: URL
switch await Self.locateDaemonBinary() {
case .found(let url):
case .found(let url, _):
binaryURL = url
case .unavailable(let detail):
throw AgentDaemonClient.DaemonError(code: "daemon_unavailable", message: detail)
}

teardownDaemon()
let client = AgentDaemonClient(binaryURL: binaryURL)
let client = AgentDaemonClient(
binaryURL: binaryURL,
environment: AgentHookLaunchEnvironment.daemonChildEnvironment()
)
client.onEvent = { [weak self] frame in
Task { @MainActor [weak self] in
self?.handleDaemonEvent(frame)
Expand Down
32 changes: 23 additions & 9 deletions Sources/AgentChat/AgentDaemonBinaryLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,22 @@ struct AgentDaemonBinaryLocator {
/// status UI), so it must be localized and free of implementation
/// internals; technical specifics go to the debug log instead.
enum Outcome: Sendable {
case found(URL)
case found(URL, Provenance)
case unavailable(detail: String)
}

/// Where a found binary came from. Consumers that shell out to verbs the
/// `hello` capability handshake cannot vouch for (the launch-side
/// `agent-hook-emit` injection) gate on this: an old cached daemon
/// invoked with an unknown verb falls through to its CLI path and fails
/// hooks, so only binaries provably carrying the verb may be injected.
enum Provenance: Equatable, Sendable {
/// `CMUX_REMOTE_DAEMON_BINARY` dev override (explicit opt-in).
case explicitOverride
/// The checksum-verified `remote-daemons` cache, at `version`.
case cached(version: String)
}

private let fileManager: FileManager
private let environment: [String: String]

Expand All @@ -37,7 +49,7 @@ struct AgentDaemonBinaryLocator {
func locate() -> Outcome {
if let override = explicitOverrideURL() {
if isExecutableFile(override) {
return .found(override)
return .found(override, .explicitOverride)
}
#if DEBUG
cmuxDebugLog("agentChat.locator.overrideInvalid path=\(override.path)")
Expand All @@ -48,14 +60,14 @@ struct AgentDaemonBinaryLocator {
))
}
let (goOS, goArch) = hostPlatform()
let version = appVersionString()
let version = Self.appVersionString()
if let exact = try? Workspace.remoteDaemonCachedBinaryURL(
version: version, goOS: goOS, goArch: goArch, fileManager: fileManager
), isExecutableFile(exact) {
return .found(exact)
return .found(exact, .cached(version: version))
}
if let fallback = newestCachedBinary(goOS: goOS, goArch: goArch, excludingVersion: version) {
return .found(fallback)
if let (fallback, fallbackVersion) = newestCachedBinary(goOS: goOS, goArch: goArch, excludingVersion: version) {
return .found(fallback, .cached(version: fallbackVersion))
}
#if DEBUG
cmuxDebugLog("agentChat.locator.noCachedBinary platform=\(goOS)-\(goArch) version=\(version)")
Expand Down Expand Up @@ -96,7 +108,9 @@ struct AgentDaemonBinaryLocator {

/// Scans the cache root for the newest other version holding a runnable
/// binary for this platform.
private func newestCachedBinary(goOS: String, goArch: String, excludingVersion: String) -> URL? {
private func newestCachedBinary(
goOS: String, goArch: String, excludingVersion: String
) -> (url: URL, version: String)? {
guard let anyVersion = try? Workspace.remoteDaemonCachedBinaryURL(
version: "x", goOS: goOS, goArch: goArch, fileManager: fileManager
) else { return nil }
Expand All @@ -111,7 +125,7 @@ struct AgentDaemonBinaryLocator {
if let url = try? Workspace.remoteDaemonCachedBinaryURL(
version: version, goOS: goOS, goArch: goArch, fileManager: fileManager
), isExecutableFile(url) {
return url
return (url, version)
}
}
return nil
Expand All @@ -132,7 +146,7 @@ struct AgentDaemonBinaryLocator {
#endif
}

private func appVersionString() -> String {
static func appVersionString() -> String {
(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "dev"
}
}
11 changes: 10 additions & 1 deletion Sources/AgentChat/AgentDaemonClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ final class AgentDaemonClient: @unchecked Sendable {
var onTermination: (@Sendable (Int32) -> Void)?

private let binaryURL: URL
private let environment: [String: String]?
private let process = Process()
private let stdinPipe = Pipe()
private let stdoutPipe = Pipe()
Expand All @@ -31,8 +32,13 @@ final class AgentDaemonClient: @unchecked Sendable {
private var started = false
private var terminated = false

init(binaryURL: URL) {
/// - Parameter environment: The child's environment; `nil` inherits the
/// parent's. The chat surface passes the app environment with
/// `CMUX_AGENT_HOOK_SOCKET` pinned so the daemon's hook ingest listener
/// and the launch-injected hook emitters agree on one socket path.
init(binaryURL: URL, environment: [String: String]? = nil) {
self.binaryURL = binaryURL
self.environment = environment
}

deinit {
Expand All @@ -48,6 +54,9 @@ final class AgentDaemonClient: @unchecked Sendable {

process.executableURL = binaryURL
process.arguments = ["serve", "--stdio"]
if let environment {
process.environment = environment
}
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = FileHandle.nullDevice
Expand Down
Loading
Loading