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
46 changes: 44 additions & 2 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10366,6 +10366,29 @@ struct CMUXCLI {
return ["--teammate-mode", "auto"] + commandArgs
}

/// Returns "xterm-ghostty" if its terminfo entry is available, otherwise
/// "xterm-256color" — which is universally supported. This avoids a race
/// where `cmux claude-teams` (etc.) could exec a child process that
/// inherits TERM=xterm-ghostty before cmux's terminfo overlay has
/// finished installing (matters for SSH'd agents on fresh remote hosts;
/// also belt-and-suspenders for local Macs without Ghostty installed
/// system-wide). Mirrors the Go-side `resolveDefaultTerm` in
/// daemon/remote/cmd/cmuxd-remote/agent_launch.go.
private static func resolveDefaultTerm() -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["infocmp", "xterm-ghostty"]
process.standardOutput = Pipe()
process.standardError = Pipe()
Comment on lines +10381 to +10382

@cubic-dev-ai cubic-dev-ai Bot Apr 19, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use FileHandle.nullDevice instead of Pipe() for stdout/stderr. These pipes are never drained before waitUntilExit(), which can deadlock if the subprocess writes enough to fill the pipe buffer (~64KB). Since the output of infocmp is discarded anyway, FileHandle.nullDevice is the correct choice — and matches the Go-side pattern (cmd.Stdout = nil).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 10381:

<comment>Use `FileHandle.nullDevice` instead of `Pipe()` for stdout/stderr. These pipes are never drained before `waitUntilExit()`, which can deadlock if the subprocess writes enough to fill the pipe buffer (~64KB). Since the output of `infocmp` is discarded anyway, `FileHandle.nullDevice` is the correct choice — and matches the Go-side pattern (`cmd.Stdout = nil`).</comment>

<file context>
@@ -10366,6 +10366,29 @@ struct CMUXCLI {
+        let process = Process()
+        process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+        process.arguments = ["infocmp", "xterm-ghostty"]
+        process.standardOutput = Pipe()
+        process.standardError = Pipe()
+        do {
</file context>
Suggested change
process.standardOutput = Pipe()
process.standardError = Pipe()
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
Fix with Cubic

do {
try process.run()
process.waitUntilExit()
Comment on lines +10381 to +10385

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python3 - <<'PY'
from pathlib import Path

for path in Path(".").rglob("cmux.swift"):
    text = path.read_text(errors="ignore")
    marker = "private static func resolveDefaultTerm()"
    start = text.find(marker)
    if start == -1:
        continue
    end = text.find("\n    }\n", start)
    body = text[start:end if end != -1 else start + 1200]
    print(path)
    print("uses Pipe():", "Pipe()" in body)
    print("drains pipe data:", "readDataToEndOfFile" in body or ".read(" in body)
    break
else:
    raise SystemExit("cmux.swift with resolveDefaultTerm() not found")
PY

Repository: manaflow-ai/cmux

Length of output: 116


🏁 Script executed:

#!/bin/bash

# View the exact code context at lines 10381-10385 and surrounding
sed -n '10369,10395p' CLI/cmux.swift

echo "---"
echo "Searching for FileHandle.nullDevice usage patterns in Swift files:"
rg "FileHandle\.nullDevice" -A 2 -B 2 CLI/cmux.swift | head -30

echo "---"
echo "Checking for other Pipe() + waitUntilExit patterns without draining:"
rg -A 5 'process\.standardOutput = Pipe\(\)' CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 1878


Discard probe output instead of piping it.

Lines 10382–10383 create pipes that are never drained before waitUntilExit(). Use FileHandle.nullDevice to discard output and avoid potential blocking, matching both the Go-side implementation and the pattern used elsewhere in this file.

🐛 Proposed fix
-        process.standardOutput = Pipe()
-        process.standardError = Pipe()
+        process.standardOutput = FileHandle.nullDevice
+        process.standardError = FileHandle.nullDevice
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
process.standardOutput = Pipe()
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 10381 - 10385, The code currently sets
process.standardOutput and process.standardError to Pipe() then calls try
process.run() and process.waitUntilExit(), which can block because the pipes are
never read; change those assignments to use FileHandle.nullDevice for both
standardOutput and standardError (replace the Pipe() uses), so output is
discarded instead of creating unread pipes before calling process.run() and
process.waitUntilExit(); update references around process.standardOutput,
process.standardError, process.run(), and process.waitUntilExit() accordingly.

return process.terminationStatus == 0 ? "xterm-ghostty" : "xterm-256color"
} catch {
return "xterm-256color"
}
}

private func configureTmuxCompatEnvironment(
processEnvironment: [String: String],
shimDirectory: URL,
Expand All @@ -10376,6 +10399,7 @@ struct CMUXCLI {
tmuxPathPrefix: String,
cmuxBinEnvVar: String,
termOverrideEnvVar: String,
preserveTermProgram: Bool = false,
extraEnvVars: [(key: String, value: String)] = []
) {
let updatedPath = prependPathEntries(
Expand All @@ -10392,20 +10416,37 @@ struct CMUXCLI {
let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" }
?? processEnvironment["TMUX_PANE"]
?? "%1"
let fakeTerm = processEnvironment[termOverrideEnvVar] ?? "screen-256color"
let fakeTerm = processEnvironment[termOverrideEnvVar] ?? Self.resolveDefaultTerm()

setenv(cmuxBinEnvVar, executablePath, 1)
setenv("PATH", updatedPath, 1)
setenv("TMUX", fakeTmuxValue, 1)
setenv("TMUX_PANE", fakeTmuxPane, 1)
setenv("TERM", fakeTerm, 1)
// Read COLORTERM from the live env (not the snapshot) so the fallback
// is robust against any future re-ordering that mutates COLORTERM
// earlier — and so it stays in sync with the Go daemon's behavior in
// daemon/remote/cmd/cmuxd-remote/agent_launch.go.
let liveColorTerm = getenv("COLORTERM").map { String(cString: $0) } ?? ""
if liveColorTerm.isEmpty {
setenv("COLORTERM", "truecolor", 1)
}
setenv("CMUX_SOCKET_PATH", socketPath, 1)
setenv("CMUX_SOCKET", socketPath, 1)
if let explicitPassword,
!explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1)
}
unsetenv("TERM_PROGRAM")
// opencode-family agents (omo/omx/omc) react to any non-empty
// TERM_PROGRAM by switching to a light theme — they treat the variable
// as an outer-host marker regardless of value (regression #2516).
// claude-teams must preserve TERM_PROGRAM since Claude Code v2.1.112+
// crashes during permission escalation when it's missing (issue #2947).
// The default (false) keeps the legacy "unset" behavior, so any new
// agent type defaults to the historically common case.
if !preserveTermProgram {
unsetenv("TERM_PROGRAM")
}
for envVar in extraEnvVars {
setenv(envVar.key, envVar.value, 1)
}
Expand Down Expand Up @@ -10446,6 +10487,7 @@ struct CMUXCLI {
tmuxPathPrefix: "cmux-claude-teams",
cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN",
termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM",
preserveTermProgram: true,
extraEnvVars: [
(key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"),
]
Expand Down
67 changes: 45 additions & 22 deletions daemon/remote/cmd/cmuxd-remote/agent_launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ func runClaudeTeamsRelay(socketPath string, args []string, refreshAddr func() st
focused := getFocusedContext(rc)

configureAgentEnvironment(agentConfig{
shimDir: shimDir,
socketPath: socketPath,
focused: focused,
tmuxPathPrefix: "cmux-claude-teams",
cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN",
termEnvVar: "CMUX_CLAUDE_TEAMS_TERM",
shimDir: shimDir,
socketPath: socketPath,
focused: focused,
tmuxPathPrefix: "cmux-claude-teams",
cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN",
termEnvVar: "CMUX_CLAUDE_TEAMS_TERM",
preserveTermProgram: true,
extraEnv: map[string]string{
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
},
Expand Down Expand Up @@ -430,14 +431,32 @@ func stringFromAny(values ...any) string {

// --- Environment configuration ---

// resolveDefaultTerm returns "xterm-ghostty" if its terminfo entry is
// available, otherwise "xterm-256color" — which is universally supported.
// This avoids a race on fresh remote SSH hosts where cmux's terminfo overlay
// installs asynchronously during shell bootstrap (see CLI/cmux.swift around
// the interactiveRemoteTerminalSetupLines block, and the existing test
// tests_v2/test_ssh_remote_shell_integration.py which documents that fresh
// containers don't have the entry preinstalled).
func resolveDefaultTerm() string {
cmd := exec.Command("infocmp", "xterm-ghostty")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Run(); err == nil {
return "xterm-ghostty"
}
return "xterm-256color"
}

type agentConfig struct {
shimDir string
socketPath string
focused *focusedContext
tmuxPathPrefix string
cmuxBinEnvVar string
termEnvVar string
extraEnv map[string]string
shimDir string
socketPath string
focused *focusedContext
tmuxPathPrefix string
cmuxBinEnvVar string
termEnvVar string
extraEnv map[string]string
preserveTermProgram bool
}

func configureAgentEnvironment(cfg agentConfig) {
Expand Down Expand Up @@ -470,22 +489,26 @@ func configureAgentEnvironment(cfg agentConfig) {
// Terminal settings
fakeTerm := os.Getenv(cfg.termEnvVar)
if fakeTerm == "" {
fakeTerm = "screen-256color"
fakeTerm = resolveDefaultTerm()
}
os.Setenv("TERM", fakeTerm)
if os.Getenv("COLORTERM") == "" {
os.Setenv("COLORTERM", "truecolor")
}

// Socket path
os.Setenv("CMUX_SOCKET_PATH", cfg.socketPath)
os.Setenv("CMUX_SOCKET", cfg.socketPath)

// Unset TERM_PROGRAM so apps don't detect the host terminal and
// override tmux-compatible behavior (e.g. opencode switches to
// light theme when it sees TERM_PROGRAM=ghostty).
os.Unsetenv("TERM_PROGRAM")

// Preserve COLORTERM for truecolor support in subagent panes.
if os.Getenv("COLORTERM") == "" {
os.Setenv("COLORTERM", "truecolor")
// opencode-family agents (omo/omx/omc) react to any non-empty TERM_PROGRAM
// by switching to a light theme — they treat the variable as an outer-host
// marker regardless of value. claude-teams must preserve TERM_PROGRAM since
// Claude Code v2.1.112+ crashes during permission escalation when it's
// missing (issue #2947). The zero value (false) keeps the legacy behavior
// of unsetting, so any new agent type defaults to the historically common
// case.
if !cfg.preserveTermProgram {
os.Unsetenv("TERM_PROGRAM")
}

// Set workspace/surface IDs from focused context
Expand Down
Loading